Fix form keyboard issues and sticky footers in agent screens

This commit is contained in:
Abin Toppo
2026-05-26 16:34:05 +05:30
commit 9ac5e95d52
410 changed files with 86650 additions and 0 deletions
@@ -0,0 +1,80 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface AgentEmptyStateProps {
title: string;
description: string;
icon?: string;
actionTitle?: string;
onAction?: () => void;
}
export const AgentEmptyState = ({
title,
description,
icon = 'document-text-outline',
actionTitle,
onAction,
}: AgentEmptyStateProps) => {
const theme = useTheme();
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>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
<Text style={[styles.description, { color: theme.colors.textMuted }]}>{description}</Text>
{actionTitle && onAction && (
<Pressable
style={[styles.button, { backgroundColor: theme.colors.primary }]}
onPress={onAction}
>
<Text style={[styles.buttonText, { color: theme.colors.white }]}>{actionTitle}</Text>
</Pressable>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 32,
minHeight: 300,
},
iconContainer: {
width: 100,
height: 100,
borderRadius: 50,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
title: {
fontSize: 18,
fontWeight: '700',
marginBottom: 8,
textAlign: 'center',
},
description: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
marginBottom: 24,
},
button: {
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,105 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
interface AgentMiniChartProps {
data: number[];
color?: string;
height?: number;
labels?: string[];
type?: 'bar' | 'line';
}
export const AgentMiniChart = ({
data,
color,
height = 100,
labels,
type = 'bar',
}: AgentMiniChartProps) => {
const theme = useTheme();
const chartColor = color || theme.colors.primary;
const maxValue = Math.max(...data, 1);
const animationProgress = useSharedValue(0);
useEffect(() => {
animationProgress.value = 0;
animationProgress.value = withSpring(1, { damping: 15 });
}, [data]);
return (
<View style={styles.container}>
<View style={[styles.chartArea, { height }]}>
{data.map((val, i) => {
const barHeight = (val / maxValue) * height;
const animatedStyle = useAnimatedStyle(() => {
return {
height: barHeight * animationProgress.value,
opacity: animationProgress.value,
};
});
return (
<View key={`bar-${i}`} style={styles.barContainer}>
<Animated.View
style={[
styles.bar,
{ backgroundColor: chartColor },
animatedStyle,
]}
/>
</View>
);
})}
</View>
{labels && (
<View style={styles.labelsArea}>
{labels.map((label, i) => (
<Text key={`label-${i}`} style={[styles.label, { color: theme.colors.textMuted }]}>
{label}
</Text>
))}
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
width: '100%',
},
chartArea: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 4,
},
barContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'flex-end',
height: '100%',
},
bar: {
width: '100%',
maxWidth: 24,
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
},
labelsArea: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
},
label: {
flex: 1,
textAlign: 'center',
fontSize: 10,
fontWeight: '600',
},
});
@@ -0,0 +1,113 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, Text } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import Animated, {
useAnimatedProps,
useSharedValue,
withTiming,
withDelay,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
interface AgentProgressRingProps {
progress: number; // 0 to 100
size?: number;
strokeWidth?: number;
color?: string;
label?: string;
value?: string;
}
export const AgentProgressRing = ({
progress,
size = 120,
strokeWidth = 10,
color,
label,
value,
}: AgentProgressRingProps) => {
const theme = useTheme();
const ringColor = color || theme.colors.primary;
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const animatedProgress = useSharedValue(0);
useEffect(() => {
animatedProgress.value = 0;
animatedProgress.value = withDelay(
300,
withTiming(progress, {
duration: 1500,
easing: Easing.out(Easing.cubic),
})
);
}, [progress]);
const animatedProps = useAnimatedProps(() => {
const strokeDashoffset = circumference - (animatedProgress.value / 100) * circumference;
return {
strokeDashoffset,
};
});
return (
<View style={[styles.container, { width: size, height: size }]}>
<Svg width={size} height={size}>
{/* Background Circle */}
<Circle
stroke={`${ringColor}20`}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
/>
{/* Progress Circle */}
<AnimatedCircle
stroke={ringColor}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
animatedProps={animatedProps}
strokeLinecap="round"
rotation="-90"
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
<View style={styles.content}>
{value && <Text style={[styles.value, { color: theme.colors.textPrimary, fontSize: size * 0.25 }]}>{value}</Text>}
{label && <Text style={[styles.label, { color: theme.colors.textMuted, fontSize: size * 0.1 }]}>{label}</Text>}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
content: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
value: {
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
},
label: {
fontSize: 10,
fontWeight: '600',
marginTop: 2,
},
});
@@ -0,0 +1,59 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useTheme } from '../../../theme';
interface AgentSectionHeaderProps {
title: string;
subtitle?: string;
actionTitle?: string;
onAction?: () => void;
}
export const AgentSectionHeader = ({
title,
subtitle,
actionTitle,
onAction,
}: AgentSectionHeaderProps) => {
const theme = useTheme();
return (
<View style={styles.container}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{subtitle && (
<Text style={[styles.subtitle, { color: theme.colors.textMuted }]}>{subtitle}</Text>
)}
</View>
{actionTitle && onAction && (
<Pressable onPress={onAction}>
<Text style={[styles.action, { color: theme.colors.primary }]}>{actionTitle}</Text>
</Pressable>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginBottom: 16,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
},
subtitle: {
fontSize: 13,
marginTop: 2,
},
action: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,104 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, DimensionValue } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
withSequence,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
interface AgentSkeletonLoaderProps {
type: 'card' | 'list' | 'dashboard';
}
export const AgentSkeletonLoader = ({ type }: AgentSkeletonLoaderProps) => {
const theme = useTheme();
const opacity = useSharedValue(0.5);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 800 }),
withTiming(0.5, { duration: 800 })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const SkeletonBlock = ({ width = '100%', height = 20, borderRadius = 4, mb = 0 }: { width?: DimensionValue; height?: number; borderRadius?: number; mb?: number }) => (
<Animated.View
style={[
{
width,
height,
borderRadius,
marginBottom: mb,
backgroundColor: theme.colors.cardBorder,
},
animatedStyle,
]}
/>
);
if (type === 'dashboard') {
return (
<View style={styles.container}>
<View style={styles.row}>
<SkeletonBlock width="48%" height={120} borderRadius={16} />
<SkeletonBlock width="48%" height={120} borderRadius={16} />
</View>
<SkeletonBlock height={24} width="40%" mb={16} mt={24} />
<SkeletonBlock height={180} borderRadius={16} mb={16} />
</View>
);
}
if (type === 'list') {
return (
<View style={styles.container}>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={[styles.card, { borderColor: theme.colors.cardBorder }]}>
<View style={styles.row}>
<SkeletonBlock width={40} height={40} borderRadius={20} />
<View style={{ flex: 1, marginLeft: 12 }}>
<SkeletonBlock width="60%" mb={8} />
<SkeletonBlock width="40%" height={14} />
</View>
</View>
</View>
))}
</View>
);
}
return (
<View style={[styles.card, { borderColor: theme.colors.cardBorder }]}>
<SkeletonBlock width="40%" height={24} mb={16} />
<SkeletonBlock height={60} mb={12} />
<SkeletonBlock height={60} />
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
},
card: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
});
@@ -0,0 +1,185 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../../theme';
interface AgentStatCardProps {
title: string;
value: string;
icon: string;
trend?: number;
trendText?: string;
color?: string;
index?: number;
gradient?: [string, string];
onPress?: () => void;
}
export const AgentStatCard = ({
title,
value,
icon,
trend,
trendText,
color,
index = 0,
gradient,
onPress,
}: AgentStatCardProps) => {
const theme = useTheme();
const baseColor = color || theme.colors.primary;
const isPositive = trend && trend > 0;
const isNegative = trend && trend < 0;
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>
{trend !== undefined && (
<View style={[styles.trendBadge, { backgroundColor: isPositive ? `${theme.colors.success}20` : isNegative ? `${theme.colors.danger}20` : `${theme.colors.textMuted}20` }]}>
<Ionicons
name={isPositive ? 'trending-up' : isNegative ? 'trending-down' : 'remove'}
size={12}
color={isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted}
/>
<Text style={[styles.trendText, { color: isPositive ? theme.colors.success : isNegative ? theme.colors.danger : theme.colors.textMuted }]}>
{Math.abs(trend)}%
</Text>
</View>
)}
</View>
<View style={styles.body}>
<Text style={[styles.value, { color: gradient ? theme.colors.white : theme.colors.textPrimary }]}>{value}</Text>
<Text style={[styles.title, { color: gradient ? 'rgba(255,255,255,0.8)' : theme.colors.textMuted }]}>{title}</Text>
{trendText && (
<Text style={[styles.trendDesc, { color: gradient ? 'rgba(255,255,255,0.6)' : theme.colors.textMuted }]}>{trendText}</Text>
)}
</View>
{/* 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} />
</View>
)}
</View>
);
const cardInner = gradient ? (
<LinearGradient
colors={gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
{content}
</LinearGradient>
) : content;
return (
<Animated.View
entering={FadeInUp.delay(index * 100).springify()}
style={[
styles.card,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
}
]}
>
{onPress ? (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.pressable,
pressed && styles.pressed,
]}
>
{cardInner}
</Pressable>
) : cardInner}
</Animated.View>
);
};
const styles = StyleSheet.create({
card: {
flex: 1,
borderRadius: 16,
borderWidth: 1,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
minWidth: 140,
},
pressable: {
flex: 1,
},
pressed: {
opacity: 0.75,
transform: [{ scale: 0.97 }],
},
gradient: {
flex: 1,
},
content: {
padding: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
},
iconFrame: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
trendBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 4,
borderRadius: 6,
gap: 4,
},
trendText: {
fontSize: 10,
fontWeight: '700',
},
body: {
gap: 4,
},
value: {
fontSize: 22,
fontWeight: '800',
letterSpacing: -0.5,
},
title: {
fontSize: 12,
fontWeight: '600',
},
trendDesc: {
fontSize: 10,
marginTop: 4,
},
tapIndicator: {
position: 'absolute',
bottom: 16,
right: 16,
},
});
@@ -0,0 +1,119 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { CollectionEntry } from '../types/agentTypes';
import { formatAgentCurrency, formatTime } from '../utils/agentFormatters';
interface CollectionEntryCardProps {
entry: CollectionEntry;
onPress?: () => void;
}
export const CollectionEntryCard = ({ entry, onPress }: CollectionEntryCardProps) => {
const theme = useTheme();
const isCollected = entry.status === 'collected';
const isPartial = entry.status === 'partial';
const isPending = entry.status === 'pending';
const statusColor = isCollected
? theme.colors.success
: isPartial
? theme.colors.warning
: theme.colors.textMuted;
return (
<Pressable
style={({ pressed }) => [
styles.card,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
opacity: pressed ? 0.9 : 1,
},
]}
onPress={onPress}
>
<View style={styles.leftContent}>
<View style={[styles.iconContainer, { backgroundColor: `${statusColor}15` }]}>
<Ionicons
name={isCollected ? 'checkmark-circle' : isPartial ? 'pie-chart' : 'time'}
size={20}
color={statusColor}
/>
</View>
<View style={styles.details}>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{entry.customerName}</Text>
<View style={styles.metaRow}>
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>{entry.accountType} • </Text>
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>
{isPending ? 'Pending' : entry.collectedAt ? formatTime(entry.collectedAt) : ''}
</Text>
</View>
</View>
</View>
<View style={styles.rightContent}>
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>
{formatAgentCurrency(isPending ? entry.emiAmount : entry.collectedAmount)}
</Text>
{!isPending && (
<Text style={[styles.mode, { color: theme.colors.textMuted }]}>
via {entry.mode.toUpperCase()}
</Text>
)}
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
leftContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
details: {
gap: 4,
},
name: {
fontSize: 15,
fontWeight: '700',
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
},
meta: {
fontSize: 12,
},
rightContent: {
alignItems: 'flex-end',
gap: 4,
},
amount: {
fontSize: 15,
fontWeight: '700',
},
mode: {
fontSize: 10,
fontWeight: '600',
},
});
@@ -0,0 +1,131 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { CommissionSummary } from '../types/agentTypes';
import { formatAgentCurrency } from '../utils/agentFormatters';
import { AgentProgressRing } from './AgentProgressRing';
interface CommissionSummaryCardProps {
summary: CommissionSummary;
}
export const CommissionSummaryCard = ({ summary }: CommissionSummaryCardProps) => {
const theme = useTheme();
const progress = summary.currentMonthExpected > 0
? (summary.currentMonthActual / summary.currentMonthExpected) * 100
: 0;
return (
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.header}>
<View>
<Text style={[styles.title, { color: theme.colors.textMuted }]}>Current Month</Text>
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>
{formatAgentCurrency(summary.currentMonthActual)}
</Text>
</View>
<AgentProgressRing
progress={progress}
size={60}
strokeWidth={6}
color={theme.colors.success}
value={`${Math.round(progress)}%`}
/>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Expected</Text>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
{formatAgentCurrency(summary.currentMonthExpected)}
</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Loss</Text>
<Text style={[styles.statValue, { color: theme.colors.danger }]}>
{formatAgentCurrency(summary.currentMonthLoss)}
</Text>
</View>
</View>
<View style={[styles.rateBadge, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="information-circle" size={14} color={theme.colors.primary} />
<Text style={[styles.rateText, { color: theme.colors.primary }]}>
Calculated at {summary.commissionRate}% commission rate
</Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
card: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
amount: {
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
divider: {
height: 1,
width: '100%',
marginVertical: 16,
},
statsRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statItem: {
flex: 1,
},
statLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
statValue: {
fontSize: 16,
fontWeight: '700',
},
vDivider: {
width: 1,
height: 30,
marginHorizontal: 16,
},
rateBadge: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderRadius: 8,
gap: 6,
justifyContent: 'center',
},
rateText: {
fontSize: 12,
fontWeight: '600',
},
});
@@ -0,0 +1,203 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { AgentCustomer } from '../types/agentTypes';
import { formatAgentCurrency, maskPhone } from '../utils/agentFormatters';
interface CustomerCardProps {
customer: AgentCustomer;
onPress?: () => void;
onAction?: () => void;
actionIcon?: string;
actionColor?: string;
}
export const CustomerCard = ({
customer,
onPress,
onAction,
actionIcon,
actionColor,
}: CustomerCardProps) => {
const theme = useTheme();
const isOverdue = customer.status === 'overdue';
const isPaid = customer.dueAmount === 0;
const statusColor = isPaid
? theme.colors.success
: isOverdue
? theme.colors.danger
: theme.colors.warning;
return (
<Pressable
style={({ pressed }) => [
styles.card,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
onPress={onPress}
>
<View style={styles.header}>
<View style={styles.profileInfo}>
<View style={[styles.avatar, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>
{customer.name.charAt(0).toUpperCase()}
</Text>
</View>
<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} />
<Text style={[styles.phone, { color: theme.colors.textMuted }]}>
{maskPhone(customer.phone)}
</Text>
</View>
</View>
</View>
<View style={[styles.statusBadge, { backgroundColor: `${statusColor}15` }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>
{isPaid ? 'Paid' : isOverdue ? 'Overdue' : 'Pending'}
</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.detailsRow}>
<View style={styles.detailItem}>
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Total Due</Text>
<Text style={[styles.detailValue, { color: isOverdue ? theme.colors.danger : theme.colors.textPrimary }]}>
{formatAgentCurrency(customer.dueAmount)}
</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.detailItem}>
<Text style={[styles.detailLabel, { color: theme.colors.textMuted }]}>Total Collected</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatAgentCurrency(customer.totalCollected)}
</Text>
</View>
{onAction && (
<Pressable
style={[styles.actionBtn, { backgroundColor: `${actionColor || theme.colors.primary}15` }]}
onPress={onAction}
>
<Ionicons name={actionIcon || 'chevron-forward'} size={18} color={actionColor || theme.colors.primary} />
</Pressable>
)}
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
profileInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatar: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 16,
fontWeight: '700',
},
name: {
fontSize: 15,
fontWeight: '700',
marginBottom: 2,
},
phoneRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
phone: {
fontSize: 12,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
gap: 4,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
},
statusText: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
detailsRow: {
flexDirection: 'row',
alignItems: 'center',
},
detailItem: {
flex: 1,
},
detailLabel: {
fontSize: 11,
fontWeight: '500',
marginBottom: 4,
},
detailValue: {
fontSize: 14,
fontWeight: '700',
},
vDivider: {
width: 1,
height: 30,
marginHorizontal: 16,
},
actionBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 16,
},
});
@@ -0,0 +1,54 @@
import type { AgentLoanCategory, CollectionMode, NewAccountType, SmsType } from '../types/agentTypes';
export const COLLECTION_MODES: { id: CollectionMode; label: string; icon: string }[] = [
{ id: 'cash', label: 'Cash', icon: 'cash-outline' },
{ id: 'upi', label: 'UPI', icon: 'phone-portrait-outline' },
{ id: 'bank_transfer', label: 'Bank Transfer', icon: 'swap-horizontal-outline' },
];
export const ACCOUNT_TYPES: { id: NewAccountType; label: string; description: string; icon: string }[] = [
{ id: 'DDS', label: 'DDS Account', description: 'Daily Deposit Scheme for regular savings', icon: 'calendar-outline' },
{ id: 'RD', label: 'RD Account', description: 'Recurring Deposit with fixed monthly installments', icon: 'trending-up-outline' },
{ id: 'Savings', label: 'Savings Account', description: 'Standard savings account with interest', icon: 'wallet-outline' },
];
export const LOAN_CATEGORIES: { id: AgentLoanCategory; label: string; icon: string; color: string }[] = [
{ id: 'Personal', label: 'Personal Loan', icon: 'person-outline', color: '#3B82F6' },
{ id: 'Gold', label: 'Gold Loan', icon: 'diamond-outline', color: '#F59E0B' },
{ id: 'Vehicle', label: 'Vehicle Loan', icon: 'car-outline', color: '#10B981' },
{ id: 'Housing', label: 'Housing Loan', icon: 'home-outline', color: '#6366F1' },
{ id: 'Agriculture', label: 'Agriculture Loan', icon: 'leaf-outline', color: '#22C55E' },
{ id: 'Business', label: 'Business Loan', icon: 'briefcase-outline', color: '#EF4444' },
];
export const SMS_TYPES: { id: SmsType; label: string; icon: string }[] = [
{ id: 'balance', label: 'Balance Confirmation', icon: 'wallet-outline' },
{ id: 'payment_success', label: 'Payment Success', icon: 'checkmark-circle-outline' },
{ id: 'due_reminder', label: 'Due Reminder', icon: 'alarm-outline' },
{ id: 'loan_status', label: 'Loan Status', icon: 'document-text-outline' },
{ id: 'account_created', label: 'Account Created', icon: 'person-add-outline' },
];
export const CUSTOMER_FILTERS = [
{ id: 'all', label: 'All' },
{ id: 'paid', label: 'Paid' },
{ id: 'pending', label: 'Pending' },
{ id: 'overdue', label: 'Overdue' },
] as const;
export const DEFAULT_COMMISSION_RATE = 2.5; // percentage
export const ACCOUNT_OPENING_STEPS: { id: string; label: string }[] = [
{ id: 'type', label: 'Account Type' },
{ id: 'personal', label: 'Personal Info' },
{ id: 'kyc', label: 'KYC Details' },
{ id: 'deposit', label: 'Initial Deposit' },
{ id: 'review', label: 'Review' },
];
export const LOAN_REQUEST_STEPS: { id: string; label: string }[] = [
{ id: 'customer', label: 'Customer' },
{ id: 'details', label: 'Loan Details' },
{ id: 'documents', label: 'Documents' },
{ id: 'review', label: 'Review' },
];
@@ -0,0 +1,98 @@
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
import { selectAccountDraft, selectAccountStep } from '../store/agentSelectors';
import { useSubmitAccountOpeningMutation } from '../services/agentApi';
import type { AccountOpeningStep, AccountOpeningDraft, NewAccountType } from '../types/agentTypes';
import { validateRequired, validateAadhaar, validatePan, validatePhone, validateAmount } from '../utils/agentValidation';
export const useAccountOpeningForm = () => {
const dispatch = useAppDispatch();
const draft = useAppSelector(selectAccountDraft);
const step = useAppSelector(selectAccountStep);
const [submitOpening, { isLoading }] = useSubmitAccountOpeningMutation();
const setStep = useCallback((newStep: AccountOpeningStep) => {
dispatch(agentActions.setAccountOpeningStep(newStep));
}, [dispatch]);
const updateField = useCallback((field: keyof AccountOpeningDraft, value: string) => {
dispatch(agentActions.updateAccountDraftField({ field, value }));
}, [dispatch]);
const setAccountType = useCallback((type: NewAccountType) => {
dispatch(agentActions.setAccountType(type));
}, [dispatch]);
const reset = useCallback(() => {
dispatch(agentActions.resetAccountDraft());
}, [dispatch]);
const validateStep = (currentStep: AccountOpeningStep): boolean => {
// Simple validation for the multi-step form
if (currentStep === 'type') {
return !!draft.accountType;
}
if (currentStep === 'personal') {
return (
!validateRequired(draft.customerName, 'Name') &&
!validatePhone(draft.phone) &&
!validateRequired(draft.address, 'Address')
);
}
if (currentStep === 'kyc') {
return (
!validateAadhaar(draft.aadhaarNumber) &&
!validatePan(draft.panNumber)
);
}
if (currentStep === 'deposit') {
return !validateAmount(draft.initialDeposit, 500);
}
return true;
};
const nextStep = useCallback(() => {
if (!validateStep(step)) return;
if (step === 'type') setStep('personal');
else if (step === 'personal') setStep('kyc');
else if (step === 'kyc') setStep('deposit');
else if (step === 'deposit') setStep('review');
}, [step, validateStep, setStep]);
const prevStep = useCallback(() => {
if (step === 'review') setStep('deposit');
else if (step === 'deposit') setStep('kyc');
else if (step === 'kyc') setStep('personal');
else if (step === 'personal') setStep('type');
}, [step, setStep]);
const submit = useCallback(async () => {
try {
const result = await submitOpening(draft).unwrap();
return result;
} catch (error) {
console.error('Failed to submit account opening', error);
return null;
}
}, [draft, submitOpening]);
return {
draft,
step,
setStep,
nextStep,
prevStep,
updateField,
setAccountType,
reset,
submit,
isSubmitting: isLoading,
};
};
@@ -0,0 +1,35 @@
import { useCallback, useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
import { selectDashboardStats, selectIsRefreshing } from '../store/agentSelectors';
import { useGetAgentDashboardQuery } from '../services/agentApi';
export const useAgentDashboard = () => {
const dispatch = useAppDispatch();
const stats = useAppSelector(selectDashboardStats);
const isRefreshing = useAppSelector(selectIsRefreshing);
const { data, isLoading, isFetching, refetch, error } = useGetAgentDashboardQuery(undefined, {
refetchOnMountOrArgChange: true,
});
useEffect(() => {
if (data) {
dispatch(agentActions.setDashboardStats(data));
dispatch(agentActions.setRefreshing(false));
}
}, [data, dispatch]);
const onRefresh = useCallback(() => {
dispatch(agentActions.setRefreshing(true));
refetch();
}, [dispatch, refetch]);
return {
stats,
isLoading: isLoading && !stats,
isRefreshing,
error,
onRefresh,
};
};
@@ -0,0 +1,81 @@
import { useState, useCallback } from 'react';
import type { CollectionMode } from '../types/agentTypes';
import { validateCollectionAmount } from '../utils/agentValidation';
import { useSubmitCollectionMutation } from '../services/agentApi';
import { useAppDispatch } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
export const useCollectionForm = (entryId: string, emiAmount: number) => {
const dispatch = useAppDispatch();
const [amount, setAmount] = useState<string>(emiAmount.toString());
const [mode, setMode] = useState<CollectionMode>('cash');
const [error, setError] = useState<string | null>(null);
const [submitCollection, { isLoading }] = useSubmitCollectionMutation();
const validate = useCallback(() => {
const err = validateCollectionAmount(amount, emiAmount);
setError(err);
return err === null;
}, [amount, emiAmount]);
const submit = useCallback(async () => {
if (!validate()) return null;
try {
const receipt = await submitCollection({
entryId,
amount: Number(amount),
mode,
}).unwrap();
// Update local state optimisticly
dispatch(agentActions.updateCollectionEntry({
id: entryId,
customerId: '',
customerName: '',
customerPhone: '',
accountId: '',
accountType: '',
emiAmount,
collectedAmount: Number(amount),
pendingAmount: Math.max(0, emiAmount - Number(amount)),
status: Number(amount) >= emiAmount ? 'collected' : 'partial',
mode,
collectedAt: new Date().toISOString(),
synced: true,
} as any));
return receipt;
} catch (err) {
console.error('Failed to submit collection', err);
// Fallback: Add to offline queue
dispatch(agentActions.addToOfflineQueue({
id: entryId,
customerId: '',
customerName: '',
customerPhone: '',
accountId: '',
accountType: '',
emiAmount,
collectedAmount: Number(amount),
pendingAmount: Math.max(0, emiAmount - Number(amount)),
status: Number(amount) >= emiAmount ? 'collected' : 'partial',
mode,
collectedAt: new Date().toISOString(),
synced: false,
} as any));
return null;
}
}, [amount, mode, entryId, validate, submitCollection, dispatch, emiAmount]);
return {
amount,
setAmount,
mode,
setMode,
error,
submit,
isLoading,
};
};
+109
View File
@@ -0,0 +1,109 @@
import { useState, useCallback } from 'react';
import type { AgentCustomer } from '../types/agentTypes';
import { validateCustomerForm } from '../utils/agentValidation';
import { useAddAgentCustomerMutation, useUpdateAgentCustomerMutation } from '../services/agentApi';
import { useAppDispatch } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
export interface CustomerFormData {
name: string;
phone: string;
email: string;
address: string;
city: string;
state: string;
pincode: string;
aadhaarNumber: string;
panNumber: string;
}
const initialData: CustomerFormData = {
name: '',
phone: '',
email: '',
address: '',
city: '',
state: '',
pincode: '',
aadhaarNumber: '',
panNumber: '',
};
export const useCustomerForm = (existingCustomer?: AgentCustomer) => {
const dispatch = useAppDispatch();
const [data, setData] = useState<CustomerFormData>(
existingCustomer
? {
name: existingCustomer.name,
phone: existingCustomer.phone,
email: existingCustomer.email || '',
address: existingCustomer.address,
city: existingCustomer.city,
state: existingCustomer.state,
pincode: existingCustomer.pincode,
aadhaarNumber: existingCustomer.kyc.aadhaarNumber,
panNumber: existingCustomer.kyc.panNumber,
}
: initialData
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [addCustomer, { isLoading: isAdding }] = useAddAgentCustomerMutation();
const [updateCustomer, { isLoading: isUpdating }] = useUpdateAgentCustomerMutation();
const updateField = useCallback((field: keyof CustomerFormData, value: string) => {
setData((prev) => ({ ...prev, [field]: value }));
// Clear error when user types
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
}, [errors]);
const validate = useCallback(() => {
const result = validateCustomerForm(data);
setErrors(result.errors);
return result.isValid;
}, [data]);
const submit = useCallback(async () => {
if (!validate()) return null;
try {
if (existingCustomer) {
const result = await updateCustomer({
...existingCustomer,
...data,
kyc: {
...existingCustomer.kyc,
aadhaarNumber: data.aadhaarNumber,
panNumber: data.panNumber,
},
}).unwrap();
// Update the customer in the Redux store so the list reflects changes
dispatch(agentActions.updateCustomer(result));
return result;
} else {
const result = await addCustomer(data).unwrap();
// Add the new customer to the Redux store so it appears in the list immediately
dispatch(agentActions.addCustomer(result));
return result;
}
} catch (err) {
console.error('Failed to submit customer form', err);
return null;
}
}, [data, existingCustomer, validate, addCustomer, updateCustomer, dispatch]);
return {
data,
errors,
updateField,
submit,
isSubmitting: isAdding || isUpdating,
};
};
@@ -0,0 +1,86 @@
import { useState, useCallback } from 'react';
import type { AgentLoanCategory } from '../types/agentTypes';
import { validateLoanRequest } from '../utils/agentValidation';
import { useSubmitAgentLoanRequestMutation } from '../services/agentApi';
import { useAppDispatch } from '../../../app/hooks';
import { agentActions } from '../store/agentSlice';
export interface LoanFormData {
customerId: string;
customerName: string;
customerPhone: string;
category: AgentLoanCategory | null;
amount: string;
tenureMonths: string;
purpose: string;
monthlyIncome: string;
}
const initialData: LoanFormData = {
customerId: '',
customerName: '',
customerPhone: '',
category: null,
amount: '',
tenureMonths: '12',
purpose: '',
monthlyIncome: '',
};
export const useLoanRequestForm = (initialCategory?: AgentLoanCategory, customerId?: string) => {
const [data, setData] = useState<LoanFormData>({
...initialData,
category: initialCategory || null,
customerId: customerId || '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitRequest, { isLoading }] = useSubmitAgentLoanRequestMutation();
const dispatch = useAppDispatch();
const updateField = useCallback((field: keyof LoanFormData, value: string) => {
setData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
}, [errors]);
const validate = useCallback(() => {
const result = validateLoanRequest(data);
setErrors(result.errors);
return result.isValid;
}, [data]);
const submit = useCallback(async () => {
if (!validate()) return null;
try {
const result = await submitRequest({
...data,
amount: Number(data.amount),
tenureMonths: Number(data.tenureMonths),
monthlyIncome: Number(data.monthlyIncome),
category: data.category!,
}).unwrap();
dispatch(agentActions.addLoanRequest(result));
return result;
} catch (err) {
console.error('Failed to submit loan request', err);
return null;
}
}, [data, validate, submitRequest, dispatch]);
return {
data,
errors,
updateField,
submit,
isSubmitting: isLoading,
validate,
};
};
@@ -0,0 +1,42 @@
import type { NavigatorScreenParams } from '@react-navigation/native';
import type { AgentLoanCategory } from '../types/agentTypes';
export type AgentDashboardStackParamList = {
AgentDashboardMain: undefined;
AgentRoute: undefined;
};
export type AgentCustomersStackParamList = {
CustomerList: undefined;
CustomerDetail: { customerId: string };
AddEditCustomer: { customerId?: string };
CustomerInstallmentHistory: { customerId: string };
};
export type AgentCollectionsStackParamList = {
DailyCollection: undefined;
CollectionHistory: undefined;
CollectionReceipt: { entryId: string };
};
export type AgentLoansStackParamList = {
AgentLoansMain: { initialTab?: 'loans' | 'commission' | 'account' };
CreateLoanRequest: { category?: AgentLoanCategory; customerId?: string };
LoanRequestDetail: { requestId: string };
AccountOpening: undefined;
CommissionDashboard: undefined;
};
export type AgentProfileStackParamList = {
AgentProfileMain: undefined;
SmsHistory: undefined;
AgentChangePassword: undefined;
};
export type AgentTabParamList = {
AgentDashboard: NavigatorScreenParams<AgentDashboardStackParamList>;
AgentCustomers: NavigatorScreenParams<AgentCustomersStackParamList>;
AgentCollections: NavigatorScreenParams<AgentCollectionsStackParamList>;
AgentLoans: NavigatorScreenParams<AgentLoansStackParamList>;
AgentProfile: NavigatorScreenParams<AgentProfileStackParamList>;
};
@@ -0,0 +1,544 @@
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 Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated';
import { Input, Button, DatePicker, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useAccountOpeningForm } from '../hooks/useAccountOpeningForm';
import { ACCOUNT_TYPES } from '../constants/agentConstants';
import { formatAgentCurrency } from '../utils/agentFormatters';
const StepIndicator = ({ steps, currentStepIndex }: { steps: any[], currentStepIndex: number }) => {
const theme = useTheme();
return (
<View style={styles.stepContainer}>
<View style={styles.stepRow}>
{steps.map((s, i) => {
const isPassed = i <= currentStepIndex;
const isActive = i === currentStepIndex;
return (
<View key={s.id} style={styles.stepWrapper}>
<View style={[
styles.stepDot,
{
backgroundColor: isPassed ? theme.colors.primary : theme.colors.surface,
borderColor: isPassed ? theme.colors.primary : theme.colors.cardBorder,
transform: [{ scale: isActive ? 1.15 : 1 }]
}
]}>
{i < currentStepIndex ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{ color: isPassed ? theme.colors.white : theme.colors.textMuted, fontSize: 12, fontWeight: '700' }}>{i + 1}</Text>
)}
</View>
{i < steps.length - 1 && <View style={[styles.stepLine, { backgroundColor: i < currentStepIndex ? theme.colors.primary : theme.colors.cardBorder }]} />}
</View>
);
})}
</View>
<View style={styles.stepLabelRow}>
{steps.map((s, i) => (
<Text
key={s.id}
style={[
styles.stepLabel,
{ color: i <= currentStepIndex ? theme.colors.primary : theme.colors.textMuted },
i === currentStepIndex && { fontWeight: '700' },
]}
>
{s.label}
</Text>
))}
</View>
</View>
);
};
export default function AccountOpeningScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
const theme = useTheme();
const { draft, step, nextStep, prevStep, updateField, setAccountType, submit, isSubmitting } = useAccountOpeningForm();
const handleNext = () => nextStep();
const handleBack = () => prevStep();
const handleSubmit = async () => {
const res = await submit();
if (res) {
Alert.alert(
'Account Created',
`Account Number: ${res.accountNumber}\nInitial Deposit collected.`,
[{ text: 'OK', onPress: () => navigation.navigate('AgentLoansMain', { initialTab: 'loans' }) }]
);
}
};
const steps = [
{ id: 'type', label: 'Type' },
{ id: 'personal', label: 'Personal' },
{ id: 'kyc', label: 'KYC' },
{ id: 'deposit', label: 'Deposit' },
{ id: 'review', label: 'Review' },
];
const currentStepIndex = steps.findIndex(s => s.id === step);
return (
<View style={styles.flexContainer}>
{/* 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} />
</Pressable>
<Text style={[styles.localHeaderTitle, { color: theme.colors.textPrimary }]}>NEW ACCOUNT</Text>
</View>
<StepIndicator steps={steps} currentStepIndex={currentStepIndex} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
{step === 'type' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Select Account Type</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Choose the type of account you want to open for the customer</Text>
{ACCOUNT_TYPES.map(type => (
<Pressable
key={type.id}
style={[
styles.typeCard,
{
backgroundColor: theme.colors.surface,
borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder,
borderWidth: draft.accountType === type.id ? 2 : 1,
}
]}
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>
<View style={{ flex: 1 }}>
<Text style={[styles.typeLabel, { color: theme.colors.textPrimary }]}>{type.label}</Text>
<Text style={[styles.typeDesc, { color: theme.colors.textMuted }]}>{type.description}</Text>
</View>
<View style={[styles.radio, { borderColor: draft.accountType === type.id ? theme.colors.primary : theme.colors.cardBorder }]}>
{draft.accountType === type.id && <View style={[styles.radioFill, { backgroundColor: theme.colors.primary }]} />}
</View>
</Pressable>
))}
</Animated.View>
)}
{step === 'personal' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Personal Information</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Enter customer's basic details</Text>
<View style={styles.formGroup}>
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={draft.customerName}
onChangeText={v => updateField('customerName', v)}
/>
<Input
label="Phone Number"
placeholder="10-digit mobile number"
value={draft.phone}
onChangeText={v => updateField('phone', v)}
keyboardType="number-pad"
maxLength={10}
/>
<DatePicker
label="Date of Birth"
placeholder="DD/MM/YYYY"
value={draft.dateOfBirth}
onDateChange={v => updateField('dateOfBirth', v)}
maxDate={new Date()}
/>
<Input
label="Address"
placeholder="House/Flat No, Street, Area"
value={draft.address}
onChangeText={v => updateField('address', v)}
multiline
/>
</View>
</Animated.View>
)}
{step === 'kyc' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>KYC & Nominee</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Identity verification & nominee details</Text>
<View style={styles.formGroup}>
<Input
label="Aadhaar Number"
placeholder="12-digit Aadhaar number"
value={draft.aadhaarNumber}
onChangeText={v => updateField('aadhaarNumber', v)}
keyboardType="number-pad"
maxLength={12}
/>
<Input
label="PAN Number"
placeholder="e.g. ABCDE1234F"
value={draft.panNumber}
onChangeText={v => updateField('panNumber', v)}
autoCapitalize="characters"
maxLength={10}
/>
</View>
<View style={[styles.sectionDivider, { borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.sectionDividerText, { color: theme.colors.textMuted, backgroundColor: theme.colors.background }]}>Nominee</Text>
</View>
<View style={styles.formGroup}>
<Input
label="Nominee Name"
placeholder="e.g. Sunita Sharma"
value={draft.nomineeName}
onChangeText={v => updateField('nomineeName', v)}
/>
<Input
label="Relationship"
placeholder="e.g. Spouse, Parent, Child"
value={draft.nomineeRelation}
onChangeText={v => updateField('nomineeRelation', v)}
/>
</View>
</Animated.View>
)}
{step === 'deposit' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Initial Deposit</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Collect the opening deposit from customer</Text>
<View style={styles.formGroup}>
<Input
label="Initial Deposit Amount"
placeholder="e.g. 1000"
value={draft.initialDeposit}
onChangeText={v => updateField('initialDeposit', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
{(draft.accountType === 'RD' || draft.accountType === 'DDS') && (
<Input
label={`${draft.accountType === 'RD' ? 'Monthly' : 'Daily'} Installment`}
placeholder={draft.accountType === 'RD' ? 'e.g. 500' : 'e.g. 100'}
value={draft.monthlyInstallment}
onChangeText={v => updateField('monthlyInstallment', v)}
keyboardType="number-pad"
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
)}
</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} />
<Text style={[styles.warningText, { color: theme.colors.info }]}>
Please collect the initial deposit amount from the customer in cash before proceeding.
</Text>
</View>
</Animated.View>
)}
{step === 'review' && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft} style={styles.stepView}>
<Text style={[styles.stepTitle, { color: theme.colors.textPrimary }]}>Review Application</Text>
<Text style={[styles.stepSubtitle, { color: theme.colors.textMuted }]}>Please verify all details before submitting</Text>
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Account Type</Text>
<Text style={[styles.reviewValue, { color: theme.colors.primary }]}>{draft.accountType} Account</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Name</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.customerName}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Phone</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.phone}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>DOB</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.dateOfBirth}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Aadhaar</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.aadhaarNumber}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>PAN</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.panNumber}</Text>
</View>
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Nominee</Text>
<Text style={[styles.reviewValue, { color: theme.colors.textPrimary }]}>{draft.nomineeName} ({draft.nomineeRelation})</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>Initial Deposit</Text>
<Text style={[styles.reviewValue, { color: theme.colors.success, fontWeight: '800', fontSize: 16 }]}>
{formatAgentCurrency(Number(draft.initialDeposit))}
</Text>
</View>
</View>
</Animated.View>
)}
</KeyboardAwareScrollView>
</KeyboardAvoidingView>
{/* Fixed footer buttons */}
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
<View style={styles.footerRow}>
<View style={styles.col}>
{step !== 'type' && (
<Button
title="Back"
variant="secondary"
onPress={handleBack}
/>
)}
</View>
{step !== 'type' && <View style={{ width: 16 }} />}
<View style={styles.col}>
{step !== 'review' ? (
<Button
title="Next"
onPress={handleNext}
/>
) : (
<Button
title="Submit & Open"
onPress={handleSubmit}
loading={isSubmitting}
/>
)}
</View>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
localHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 12,
gap: 8,
},
backBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
localHeaderTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: 0.5,
},
// ── Step indicator ─────────────────────────
stepContainer: {
paddingTop: 8,
paddingBottom: 16,
},
stepRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
},
stepWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
stepDot: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stepLine: {
width: 24,
height: 2,
marginHorizontal: 4,
borderRadius: 1,
},
stepLabelRow: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 8,
marginTop: 8,
},
stepLabel: {
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.3,
width: 54,
textAlign: 'center',
},
// ── Form content ──────────────────────────
scrollContent: {
padding: 16,
paddingBottom: 24,
},
stepView: {
gap: 4,
},
stepTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 4,
},
stepSubtitle: {
fontSize: 13,
lineHeight: 18,
marginBottom: 20,
},
formGroup: {
gap: 16,
},
// ── Account type cards ────────────────────
typeCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 16,
marginBottom: 12,
gap: 16,
},
typeIcon: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
typeLabel: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
typeDesc: {
fontSize: 12,
lineHeight: 18,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
radioFill: {
width: 10,
height: 10,
borderRadius: 5,
},
// ── Section divider ───────────────────────
sectionDivider: {
borderTopWidth: 1,
marginVertical: 20,
alignItems: 'center',
},
sectionDividerText: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
paddingHorizontal: 12,
marginTop: -8,
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
// ── Info box ──────────────────────────────
warningBox: {
flexDirection: 'row',
padding: 16,
borderRadius: 12,
borderWidth: 1,
marginTop: 20,
gap: 12,
},
warningText: {
flex: 1,
fontSize: 13,
lineHeight: 18,
fontWeight: '500',
},
// ── Review card ───────────────────────────
reviewCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
reviewRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
flexShrink: 1,
marginLeft: 12,
},
// ── Footer ────────────────────────────────
footer: {
padding: 16,
borderTopWidth: 1,
paddingBottom: TAB_BAR_HEIGHT + 10,
},
footerRow: {
flexDirection: 'row',
},
col: {
flex: 1,
},
});
@@ -0,0 +1,216 @@
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 Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { useCustomerForm } from '../hooks/useCustomerForm';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'AddEditCustomer'>;
const FormSection = ({ title, icon, children }: { title: string, icon: string, children: React.ReactNode }) => {
const theme = useTheme();
return (
<Animated.View entering={FadeInUp} style={[styles.section, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.sectionHeader}>
<View style={[styles.sectionIcon, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
</View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
</View>
<View style={styles.sectionContent}>
{children}
</View>
</Animated.View>
);
};
export default function AddEditCustomerScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const existingCustomer = useAppSelector(state =>
customerId ? selectCustomerById(customerId)(state) : null
);
const { data, errors, updateField, submit, isSubmitting } = useCustomerForm(existingCustomer || undefined);
const isEdit = !!existingCustomer;
const handleSubmit = async () => {
const res = await submit();
if (res) {
navigation.goBack();
}
};
return (
<ScreenShell title={isEdit ? 'Edit Customer' : 'Add New Customer'} scrollable={false} keyboardAware={false} withTabBar={false}>
<View style={styles.flexContainer}>
<KeyboardAwareScrollView contentContainerStyle={styles.scrollContent} style={styles.flex}>
<FormSection title="Personal Information" icon="person-outline">
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={data.name}
onChangeText={(v) => updateField('name', v)}
error={errors.name}
/>
<Input
label="Phone Number"
placeholder="10-digit number"
value={data.phone}
onChangeText={(v) => updateField('phone', v)}
keyboardType="number-pad"
maxLength={10}
error={errors.phone}
leftAccessory={<Text style={{ color: theme.colors.textPrimary, fontWeight: 'bold' }}>+91</Text>}
/>
<Input
label="Email Address (Optional)"
placeholder="e.g. amit@example.com"
value={data.email}
onChangeText={(v) => updateField('email', v)}
keyboardType="email-address"
autoCapitalize="none"
error={errors.email}
/>
</FormSection>
<FormSection title="KYC Details" icon="finger-print-outline">
<Input
label="Aadhaar Number"
placeholder="12-digit number"
value={data.aadhaarNumber}
onChangeText={(v) => updateField('aadhaarNumber', v)}
keyboardType="number-pad"
maxLength={12}
error={errors.aadhaarNumber}
/>
<Input
label="PAN Number"
placeholder="e.g. ABCDE1234F"
value={data.panNumber}
onChangeText={(v) => updateField('panNumber', v)}
autoCapitalize="characters"
maxLength={10}
error={errors.panNumber}
/>
</FormSection>
<FormSection title="Address" icon="location-outline">
<Input
label="Street Address"
placeholder="House/Flat No, Area"
value={data.address}
onChangeText={(v) => updateField('address', v)}
error={errors.address}
/>
<View style={styles.row}>
<View style={styles.col}>
<Input
label="City"
placeholder="e.g. Kolkata"
value={data.city}
onChangeText={(v) => updateField('city', v)}
error={errors.city}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
<Input
label="State"
placeholder="e.g. West Bengal"
value={data.state}
onChangeText={(v) => updateField('state', v)}
error={errors.state}
/>
</View>
</View>
<Input
label="Pincode"
placeholder="6-digit pincode"
value={data.pincode}
onChangeText={(v) => updateField('pincode', v)}
keyboardType="number-pad"
maxLength={6}
error={errors.pincode}
/>
</FormSection>
</KeyboardAwareScrollView>
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
<Button
title={isEdit ? 'Save Changes' : 'Add Customer'}
onPress={handleSubmit}
loading={isSubmitting}
/>
</View>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 24,
},
section: {
borderRadius: 16,
borderWidth: 1,
marginBottom: 24,
overflow: 'hidden',
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
sectionIcon: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
sectionTitle: {
fontSize: 15,
fontWeight: '700',
},
sectionContent: {
padding: 16,
paddingBottom: 16,
gap: 16,
},
row: {
flexDirection: 'row',
},
col: {
flex: 1,
},
footer: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 10,
borderTopWidth: 1,
},
});
@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, Alert, Platform, KeyboardAvoidingView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenShell, Input, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'AgentChangePassword'>;
export default function AgentChangePasswordScreen({ navigation }: Props) {
const theme = useTheme();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = () => {
if (!currentPassword || !newPassword || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields.');
return;
}
if (newPassword !== confirmPassword) {
Alert.alert('Error', 'New passwords do not match.');
return;
}
setLoading(true);
// Simulate API call
setTimeout(() => {
setLoading(false);
Alert.alert('Success', 'Your password has been changed successfully.', [
{ text: 'OK', onPress: () => navigation.goBack() }
]);
}, 1500);
};
return (
<ScreenShell title="Change Password" scrollable={false} keyboardAware={false}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 20}
>
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}>
<View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Input
label="Current Password"
value={currentPassword}
onChangeText={setCurrentPassword}
secureTextEntry
/>
<Input
label="New Password"
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
<Input
label="Confirm New Password"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<Button
title="Update Password"
onPress={handleSubmit}
loading={loading}
style={{ marginTop: 24 }}
/>
</ScrollView>
</KeyboardAvoidingView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
});
@@ -0,0 +1,316 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl, Pressable } from 'react-native';
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 Animated, { FadeIn } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector, useAppDispatch } from '../../../app/hooks';
import type { AgentTabParamList, AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
import { useAgentDashboard } from '../hooks/useAgentDashboard';
import { formatAgentCurrency, getGreeting } from '../utils/agentFormatters';
import { agentActions } from '../store/agentSlice';
import type { CustomerFilter } from '../types/agentTypes';
import { AgentStatCard } from '../components/AgentStatCard';
import { AgentMiniChart } from '../components/AgentMiniChart';
import { AgentProgressRing } from '../components/AgentProgressRing';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type AgentDashboardNavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentDashboardStackParamList, 'AgentDashboardMain'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function AgentDashboardScreen({ navigation }: { navigation: AgentDashboardNavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const profile = useAppSelector(state => state.user.profile);
const { stats, isLoading, isRefreshing, error, onRefresh } = useAgentDashboard();
// ── Navigation helpers ──────────────────────────────────────
const goToCustomers = (filter: CustomerFilter = 'all') => {
dispatch(agentActions.setCustomerFilter(filter));
navigation.navigate('AgentCustomers', { screen: 'CustomerList' });
};
const goToCollections = () => {
navigation.navigate('AgentCollections', { screen: 'DailyCollection' });
};
const goToCollectionHistory = () => {
navigation.navigate('AgentCollections', { screen: 'CollectionHistory' });
};
const goToCommission = () => {
navigation.navigate('AgentLoans', { screen: 'CommissionDashboard' });
};
const goToRoute = () => {
navigation.navigate('AgentRoute');
};
if (isLoading) {
return (
<ScreenShell scrollable={false}>
<View style={styles.header}>
<Text style={[styles.greeting, { color: theme.colors.textMuted }]}>{getGreeting()},</Text>
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{profile?.firstName || 'Agent'}</Text>
</View>
<AgentSkeletonLoader type="dashboard" />
</ScreenShell>
);
}
if (error || !stats) {
return (
<ScreenShell scrollable={false}>
<AgentEmptyState
title="Dashboard Unavailable"
description="We couldn't load your dashboard data. Please try again."
icon="alert-circle-outline"
actionTitle="Retry"
onAction={onRefresh}
/>
</ScreenShell>
);
}
const collectionProgress = stats.possibleCollection > 0
? (stats.actualCollection / stats.possibleCollection) * 100
: 0;
return (
<ScreenShell scrollable={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} colors={[theme.colors.primary]} />
}
>
<View style={styles.header}>
<View>
<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>
</View>
{/* Customer Stats Row */}
<Animated.View entering={FadeIn.duration(400)} style={styles.section}>
<AgentSectionHeader
title="Customer Overview"
actionTitle="View All"
onAction={() => goToCustomers('all')}
/>
<View style={styles.row}>
<AgentStatCard
index={0}
title="Total Customers"
value={stats.totalCustomers.toString()}
icon="people-outline"
color={theme.colors.info}
onPress={() => goToCustomers('all')}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={1}
title="Paid Customers"
value={stats.paidCustomers.toString()}
icon="checkmark-circle-outline"
color={theme.colors.success}
trend={12}
onPress={() => goToCustomers('paid')}
/>
</View>
<View style={[styles.row, { marginTop: 12 }]}>
<AgentStatCard
index={2}
title="Pending Customers"
value={stats.pendingCustomers.toString()}
icon="time-outline"
color={theme.colors.warning}
onPress={() => goToCustomers('pending')}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={3}
title="Pending Amount"
value={formatAgentCurrency(stats.pendingAmount)}
icon="alert-circle-outline"
color={theme.colors.danger}
onPress={() => goToCustomers('pending')}
/>
</View>
</Animated.View>
{/* Collection Section */}
<Animated.View entering={FadeIn.duration(500)} style={styles.section}>
<AgentSectionHeader
title="Collection Performance"
actionTitle="View All"
onAction={goToCollections}
/>
<View style={styles.row}>
<AgentStatCard
index={4}
title="Today's Collection"
value={formatAgentCurrency(stats.todayCollection)}
icon="wallet-outline"
gradient={['#2563EB', '#3B82F6']}
onPress={goToCollections}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={5}
title="This Week"
value={formatAgentCurrency(stats.weeklyCollection)}
icon="trending-up-outline"
gradient={['#059669', '#10B981']}
onPress={goToCollectionHistory}
/>
</View>
<Pressable onPress={goToCollectionHistory}>
<View style={[styles.chartCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.chartHeaderRow}>
<View>
<Text style={[styles.chartTitle, { color: theme.colors.textMuted }]}>Monthly Collection</Text>
<Text style={[styles.chartValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(stats.monthlyCollection)}</Text>
</View>
<AgentProgressRing
progress={collectionProgress}
size={56}
strokeWidth={5}
color={theme.colors.primary}
value={`${Math.round(collectionProgress)}%`}
/>
</View>
<View style={styles.chartDivider} />
<Text style={[styles.chartSubtitle, { color: theme.colors.textMuted }]}>Last 7 Days Trend</Text>
<AgentMiniChart
data={stats.weeklyCollectionTrend}
height={80}
color={theme.colors.primary}
/>
</View>
</Pressable>
</Animated.View>
{/* Commission & Routing */}
<Animated.View entering={FadeIn.duration(600)} style={styles.section}>
<AgentSectionHeader title="Insights" />
<View style={styles.row}>
<AgentStatCard
index={6}
title="Commission Earned"
value={formatAgentCurrency(stats.commissionEarned)}
icon="star-outline"
color="#F59E0B"
onPress={goToCommission}
/>
<View style={{ width: 12 }} />
<AgentStatCard
index={7}
title="Route Completion"
value={`${stats.completedVisits}/${stats.todayVisits}`}
icon="map-outline"
color="#8B5CF6"
trendText={`${Math.round((stats.completedVisits / stats.todayVisits) * 100)}% done`}
onPress={goToRoute}
/>
</View>
</Animated.View>
<View style={styles.footerSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
paddingBottom: 100, // accommodate bottom tab
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
marginTop: 8,
},
greeting: {
fontSize: 14,
fontWeight: '500',
marginBottom: 4,
},
name: {
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
},
avatarFrame: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
section: {
marginBottom: 32,
},
row: {
flexDirection: 'row',
},
chartCard: {
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginTop: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
chartHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
chartTitle: {
fontSize: 12,
fontWeight: '600',
marginBottom: 4,
},
chartValue: {
fontSize: 22,
fontWeight: '800',
letterSpacing: -0.5,
},
chartDivider: {
height: 1,
backgroundColor: 'rgba(0,0,0,0.05)',
marginVertical: 16,
},
chartSubtitle: {
fontSize: 11,
fontWeight: '500',
marginBottom: 12,
},
footerSpacer: {
height: 40,
},
});
@@ -0,0 +1,61 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ScreenShell, Card } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch } from '../../../app/hooks';
import { logoutSession } from '../../auth/store/authThunk';
import { Button } from '../../../components/Button';
export default function AgentHomeScreen() {
const theme = useTheme();
const dispatch = useAppDispatch();
const handleLogout = () => {
dispatch(logoutSession());
};
return (
<ScreenShell>
<View style={styles.container}>
<Card style={styles.card}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Agent Portal</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Welcome to the CBS Agent dashboard. Manage your clients and commissions here.
</Text>
<Button
title="Secure Logout"
onPress={handleLogout}
style={styles.logoutButton}
variant="secondary"
/>
</Card>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
justifyContent: 'center',
},
card: {
padding: 24,
alignItems: 'center',
gap: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
fontSize: 16,
textAlign: 'center',
},
logoutButton: {
marginTop: 24,
width: '100%',
},
});
@@ -0,0 +1,123 @@
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 Animated, { FadeIn, FadeInRight, FadeInLeft } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import LoanRequestListScreen from './LoanRequestListScreen';
import CommissionDashboardScreen from './CommissionDashboardScreen';
import AccountOpeningScreen from './AccountOpeningScreen';
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'AgentLoansMain'>;
type TabKey = 'loans' | 'commission' | 'account';
export default function AgentLoansScreen({ route, navigation }: Props) {
const theme = useTheme();
const [activeTab, setActiveTab] = useState<TabKey>(route.params?.initialTab || 'loans');
const tabs: { key: TabKey; label: string; icon: string }[] = [
{ key: 'loans', label: 'Loans', icon: 'document-text-outline' },
{ key: 'commission', label: 'Commission', icon: 'star-outline' },
{ key: 'account', label: 'New A/C', icon: 'person-add-outline' },
];
return (
<ScreenShell scrollable={false} withTabBar={false} keyboardAware={false}>
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.colors.textPrimary }]}>Agent Workspace</Text>
</View>
<View style={styles.tabContainer}>
{tabs.map((tab) => {
const isActive = activeTab === tab.key;
return (
<Pressable
key={tab.key}
style={[
styles.tab,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setActiveTab(tab.key)}
>
<Ionicons
name={tab.icon as any}
size={16}
color={isActive ? theme.colors.white : theme.colors.textMuted}
/>
<Text style={[
styles.tabLabel,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
]}>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
<View style={styles.contentArea}>
{activeTab === 'loans' && (
<Animated.View entering={FadeInLeft} style={{ flex: 1 }}>
<LoanRequestListScreen navigation={navigation as any} />
</Animated.View>
)}
{activeTab === 'commission' && (
<Animated.View entering={FadeIn} style={{ flex: 1 }}>
<CommissionDashboardScreen />
</Animated.View>
)}
{activeTab === 'account' && (
<Animated.View entering={FadeInRight} style={{ flex: 1 }}>
<AccountOpeningScreen navigation={navigation as any} />
</Animated.View>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
headerTitle: {
fontSize: 22,
fontWeight: '800',
letterSpacing: -0.5,
},
tabContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 12,
gap: 8,
},
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
gap: 6,
},
tabLabel: {
fontSize: 12,
fontWeight: '700',
},
contentArea: {
flex: 1,
},
});
@@ -0,0 +1,231 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Pressable, Alert } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { logoutSession } from '../../auth/store/authThunk';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentProfileStackParamList, 'AgentProfileMain'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function AgentProfileScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const profile = useAppSelector(state => state.user.profile);
const handleLogout = () => {
Alert.alert(
'Log Out',
'Are you sure you want to log out of your agent account?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Log Out',
style: 'destructive',
onPress: () => {
dispatch(logoutSession());
}
}
]
);
};
const SettingsRow = ({ icon, label, onPress, destructive = false }: { icon: string, label: string, onPress: () => void, destructive?: boolean }) => (
<Pressable
style={styles.settingsRow}
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>
<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} />
</Pressable>
);
return (
<ScreenShell title="Agent Profile" scrollable={false}>
<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` }]}>
<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.statusDot, { backgroundColor: theme.colors.success }]} />
<Text style={[styles.badgeText, { color: theme.colors.success }]}>Active Agent</Text>
</View>
</Animated.View>
<Animated.View entering={FadeInUp.delay(100)}>
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted }]}>Account Settings</Text>
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<SettingsRow
icon="key-outline"
label="Change Password"
onPress={() => navigation.navigate('AgentChangePassword')}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="chatbubble-ellipses-outline"
label="SMS Log"
onPress={() => navigation.navigate('SmsHistory')}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="notifications-outline"
label="Notification Settings"
onPress={() => {}}
/>
</View>
</Animated.View>
<Animated.View entering={FadeInUp.delay(200)}>
<Text style={[styles.sectionTitle, { color: theme.colors.textMuted, marginTop: 24 }]}>Support & Legal</Text>
<View style={[styles.settingsGroup, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<SettingsRow
icon="help-circle-outline"
label="Help & Support"
onPress={() => {}}
/>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<SettingsRow
icon="document-text-outline"
label="Terms & Conditions"
onPress={() => {}}
/>
</View>
</Animated.View>
<View style={{ marginTop: 32 }}>
<Button
title="Log Out"
variant="secondary"
onPress={handleLogout}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
textStyle={{ color: theme.colors.danger }}
/>
</View>
<View style={styles.footerInfo}>
<Text style={[styles.version, { color: theme.colors.textMuted }]}>App Version 1.0.0</Text>
<Text style={[styles.version, { color: theme.colors.textMuted }]}>Agent Module v1</Text>
</View>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
profileCard: {
alignItems: 'center',
padding: 24,
borderRadius: 16,
borderWidth: 1,
marginBottom: 24,
},
avatarBig: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
avatarText: {
fontSize: 32,
fontWeight: '800',
},
name: {
fontSize: 20,
fontWeight: '800',
marginBottom: 4,
},
role: {
fontSize: 14,
marginBottom: 12,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
gap: 6,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
marginLeft: 8,
},
settingsGroup: {
borderRadius: 16,
borderWidth: 1,
overflow: 'hidden',
},
settingsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
},
settingsRowLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
settingsIconFrame: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
settingsLabel: {
fontSize: 15,
fontWeight: '600',
},
divider: {
height: 1,
marginLeft: 64, // Align with text
},
footerInfo: {
alignItems: 'center',
marginTop: 32,
gap: 4,
},
version: {
fontSize: 12,
},
});
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentDashboardStackParamList } from '../navigation/agentNavigationTypes';
type Props = NativeStackScreenProps<AgentDashboardStackParamList, 'AgentRoute'>;
export default function AgentRouteScreen({ navigation }: Props) {
const theme = useTheme();
return (
<ScreenShell title="My Route" scrollable={false}>
<View style={styles.container}>
<View style={styles.mapPlaceholder}>
<Ionicons name="map-outline" size={64} color={theme.colors.textMuted} />
<Text style={[styles.mapText, { color: theme.colors.textMuted }]}>Map Integration Coming Soon</Text>
</View>
<View style={[styles.infoCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.infoTitle, { color: theme.colors.textPrimary }]}>Route Summary</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Total Stops</Text>
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>12</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Completed</Text>
<Text style={[styles.statValue, { color: theme.colors.success }]}>5</Text>
</View>
<View style={styles.statRow}>
<Text style={[styles.statLabel, { color: theme.colors.textMuted }]}>Remaining</Text>
<Text style={[styles.statValue, { color: theme.colors.warning }]}>7</Text>
</View>
</View>
<Button
title="Start Navigation"
onPress={() => {}}
style={{ marginTop: 'auto', marginBottom: 24 }}
/>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
mapPlaceholder: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.02)',
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(0,0,0,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
},
mapText: {
marginTop: 16,
fontSize: 16,
fontWeight: '600',
},
infoCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
statRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
statLabel: {
fontSize: 14,
},
statValue: {
fontSize: 16,
fontWeight: '700',
},
});
@@ -0,0 +1,115 @@
import React, { useEffect } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, Text } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetCollectionHistoryQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectCollectionHistory } from '../store/agentSelectors';
import { CollectionEntryCard } from '../components/CollectionEntryCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionHistory'>;
export default function CollectionHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const history = useAppSelector(selectCollectionHistory);
const { data, isLoading, refetch, isFetching } = useGetCollectionHistoryQuery({});
useEffect(() => {
if (data) {
dispatch(agentActions.setCollectionHistory(data));
}
}, [data, dispatch]);
const totalCollected = history.reduce((sum, item) => sum + item.collectedAmount, 0);
return (
<ScreenShell title="Collection History" scrollable={false}>
{/* Summary Header */}
<View style={[styles.summaryHeader, { backgroundColor: theme.colors.surface, borderBottomColor: theme.colors.cardBorder }]}>
<View style={styles.summaryItem}>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Total Entries</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>{history.length}</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.summaryItem}>
<Text style={[styles.summaryLabel, { color: theme.colors.textMuted }]}>Amount Collected</Text>
<Text style={[styles.summaryValue, { color: theme.colors.primary }]}>{formatAgentCurrency(totalCollected)}</Text>
</View>
</View>
<View style={styles.listArea}>
{isLoading && history.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={history}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No history found"
description="There are no past collection records."
/>
)}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInUp.delay(index * 50)}>
<CollectionEntryCard
entry={item}
onPress={() => item.status !== 'pending' && item.receiptId ? navigation.navigate('CollectionReceipt', { entryId: item.receiptId }) : null}
/>
</Animated.View>
)}
/>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
summaryHeader: {
flexDirection: 'row',
padding: 16,
borderBottomWidth: 1,
},
summaryItem: {
flex: 1,
},
summaryLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
summaryValue: {
fontSize: 20,
fontWeight: '800',
},
vDivider: {
width: 1,
height: '100%',
marginHorizontal: 16,
},
listArea: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: 40,
},
});
@@ -0,0 +1,204 @@
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 Animated, { FadeIn, SlideInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import type { AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { formatFullCurrency, formatDateTime } from '../utils/agentFormatters';
// Since this is a mock receipt screen, we will just simulate receipt data based on the entry ID
// In a real app, we would fetch the receipt details from the API
type Props = NativeStackScreenProps<AgentCollectionsStackParamList, 'CollectionReceipt'>;
export default function CollectionReceiptScreen({ route, navigation }: Props) {
const { entryId } = route.params;
const theme = useTheme();
const receipt = {
receiptNo: entryId,
customerName: 'Customer',
accountNumber: 'CBS00000000',
amount: 500,
date: new Date().toISOString(),
mode: 'cash',
agentName: 'Rajesh Kumar',
agentCode: 'AGT-2024-0147',
};
const InfoRow = ({ label, value, strong = false }: { label: string, value: string, strong?: boolean }) => (
<View style={styles.infoRow}>
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
<Text style={[
styles.infoValue,
{
color: strong ? theme.colors.textPrimary : theme.colors.textSecondary,
fontWeight: strong ? '700' : '500'
}
]}>
{value}
</Text>
</View>
);
return (
<ScreenShell title="Receipt" scrollable={false}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<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>
<Text style={[styles.successTitle, { color: theme.colors.success }]}>Payment Successful</Text>
<Text style={[styles.successAmount, { color: theme.colors.textPrimary }]}>{formatFullCurrency(receipt.amount)}</Text>
</View>
<View style={styles.dividerContainer}>
<View style={[styles.circleCutout, styles.leftCutout, { backgroundColor: theme.colors.background }]} />
<View style={[styles.dashedLine, { borderColor: theme.colors.cardBorder }]} />
<View style={[styles.circleCutout, styles.rightCutout, { backgroundColor: theme.colors.background }]} />
</View>
<View style={styles.detailsSection}>
<InfoRow label="Receipt No" value={receipt.receiptNo} strong />
<InfoRow label="Date & Time" value={formatDateTime(receipt.date)} />
<InfoRow label="Customer Name" value={receipt.customerName} strong />
<InfoRow label="Account Number" value={receipt.accountNumber} />
<InfoRow label="Payment Mode" value={receipt.mode.toUpperCase()} />
<View style={[styles.solidDivider, { backgroundColor: theme.colors.cardBorder }]} />
<InfoRow label="Agent Name" value={receipt.agentName} />
<InfoRow label="Agent Code" value={receipt.agentCode} />
</View>
</Animated.View>
<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} />
<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} />
<Text style={[styles.actionBtnText, { color: theme.colors.primary }]}>Print</Text>
</Pressable>
</Animated.View>
<Button
title="Done"
onPress={() => navigation.navigate('DailyCollection')}
style={{ marginTop: 24 }}
/>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
},
receiptCard: {
borderRadius: 16,
borderWidth: 1,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 12,
elevation: 3,
},
successHeader: {
alignItems: 'center',
padding: 24,
},
successIcon: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
successTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 8,
},
successAmount: {
fontSize: 32,
fontWeight: '800',
},
dividerContainer: {
flexDirection: 'row',
alignItems: 'center',
height: 24,
position: 'relative',
},
dashedLine: {
flex: 1,
height: 1,
borderWidth: 1,
borderStyle: 'dashed',
marginHorizontal: 12,
},
circleCutout: {
width: 24,
height: 24,
borderRadius: 12,
position: 'absolute',
top: 0,
},
leftCutout: {
left: -12,
},
rightCutout: {
right: -12,
},
detailsSection: {
padding: 24,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
infoLabel: {
fontSize: 13,
},
infoValue: {
fontSize: 14,
},
solidDivider: {
height: 1,
width: '100%',
marginVertical: 16,
},
actionsRow: {
flexDirection: 'row',
gap: 16,
marginTop: 24,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
gap: 8,
},
actionBtnText: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,215 @@
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 { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { useGetCommissionSummaryQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectCommissionSummary } from '../store/agentSelectors';
import { formatAgentCurrency, formatPercentage } from '../utils/agentFormatters';
import { CommissionSummaryCard } from '../components/CommissionSummaryCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
export default function CommissionDashboardScreen() {
const theme = useTheme();
const dispatch = useAppDispatch();
const summary = useAppSelector(selectCommissionSummary);
const { data, isLoading, refetch, isFetching } = useGetCommissionSummaryQuery();
useEffect(() => {
if (data) dispatch(agentActions.setCommissionSummary(data));
}, [data, dispatch]);
if (isLoading && !summary) {
return <AgentSkeletonLoader type="dashboard" />;
}
if (!summary) {
return <AgentEmptyState title="Unavailable" description="Could not load commission data" />;
}
return (
<View style={styles.container}>
<FlatList
data={summary.history}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListHeaderComponent={() => (
<View style={styles.headerArea}>
<View style={[styles.totalCard, { backgroundColor: theme.colors.primary }]}>
<View style={styles.totalRow}>
<View>
<Text style={styles.totalLabel}>Total Earned Till Date</Text>
<Text style={styles.totalValue}>{formatAgentCurrency(summary.totalEarned)}</Text>
</View>
<View style={styles.iconCircle}>
<Ionicons name="star" size={24} color={theme.colors.primary} />
</View>
</View>
<View style={styles.totalDivider} />
<View style={styles.totalRow}>
<Text style={styles.lastPaidLabel}>Last Paid: {summary.lastPaidDate}</Text>
<Text style={styles.lastPaidValue}>{formatAgentCurrency(summary.lastPaidAmount)}</Text>
</View>
</View>
<View style={{ marginTop: 16, marginBottom: 24 }}>
<CommissionSummaryCard summary={summary} />
</View>
<Text style={[styles.historyTitle, { color: theme.colors.textPrimary }]}>History</Text>
</View>
)}
renderItem={({ item, index }) => (
<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` }]}>
<Text style={[styles.statusText, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
{item.status.toUpperCase()}
</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.historyBody}>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Collection</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.actualCollection)}</Text>
</View>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Commission</Text>
<Text style={[styles.value, { color: theme.colors.primary }]}>{formatAgentCurrency(item.actualCommission)}</Text>
</View>
<View style={styles.historyCol}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Loss</Text>
<Text style={[styles.value, { color: item.loss > 0 ? theme.colors.danger : theme.colors.textPrimary }]}>
{formatAgentCurrency(item.loss)}
</Text>
</View>
</View>
</Animated.View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
headerArea: {
marginBottom: 8,
},
totalCard: {
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
totalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
totalLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 13,
fontWeight: '500',
marginBottom: 4,
},
totalValue: {
color: '#fff',
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
iconCircle: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
totalDivider: {
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
marginVertical: 16,
},
lastPaidLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
lastPaidValue: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
historyTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
},
historyCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
historyMonth: {
fontSize: 16,
fontWeight: '700',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
historyBody: {
flexDirection: 'row',
justifyContent: 'space-between',
},
historyCol: {
flex: 1,
},
label: {
fontSize: 11,
marginBottom: 4,
},
value: {
fontSize: 15,
fontWeight: '700',
},
});
@@ -0,0 +1,613 @@
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 Animated, { FadeInRight, FadeOutLeft, FadeIn } from 'react-native-reanimated';
import { ScreenShell, Input, Button, KeyboardAwareScrollView } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useLoanRequestForm } from '../hooks/useLoanRequestForm';
import { LOAN_CATEGORIES } from '../constants/agentConstants';
import { useAppSelector } from '../../../app/hooks';
import { selectFilteredCustomers } from '../store/agentSelectors';
type Props = NativeStackScreenProps<AgentLoansStackParamList, 'CreateLoanRequest'>;
const STEPS = [
{ id: 1, label: 'Category', icon: 'grid-outline' },
{ id: 2, label: 'Details', icon: 'document-text-outline' },
{ id: 3, label: 'Review', icon: 'checkmark-circle-outline' },
];
// ── Step Indicator ──────────────────────────
const StepIndicator = ({ step }: { step: number }) => {
const theme = useTheme();
return (
<View style={styles.stepContainer}>
{STEPS.map((s, i) => {
const isCompleted = step > s.id;
const isActive = step === s.id;
const isPending = step < s.id;
return (
<React.Fragment key={s.id}>
<View style={styles.stepItem}>
<View style={[
styles.stepDot,
{
backgroundColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isCompleted ? theme.colors.success : isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}>
{isCompleted ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : (
<Text style={{
color: isActive ? theme.colors.white : theme.colors.textMuted,
fontSize: 11,
fontWeight: '800',
}}>{s.id}</Text>
)}
</View>
<Text style={[
styles.stepLabel,
{
color: isActive ? theme.colors.primary : isCompleted ? theme.colors.success : theme.colors.textMuted,
fontWeight: isActive ? '700' : '500',
},
]}>{s.label}</Text>
</View>
{i < STEPS.length - 1 && (
<View style={[
styles.stepLine,
{ backgroundColor: isCompleted ? theme.colors.success : theme.colors.cardBorder },
]} />
)}
</React.Fragment>
);
})}
</View>
);
};
// ── Section Card Wrapper ────────────────────
const SectionCard = ({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) => {
const theme = useTheme();
return (
<View style={[styles.sectionCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>{title}</Text>
{subtitle && <Text style={[styles.sectionSubtitle, { color: theme.colors.textMuted }]}>{subtitle}</Text>}
<View style={styles.sectionBody}>{children}</View>
</View>
);
};
// ── Review Row ──────────────────────────────
const ReviewRow = ({ label, value, accent }: { label: string; value: string; accent?: boolean }) => {
const theme = useTheme();
return (
<View style={styles.reviewRow}>
<Text style={[styles.reviewLabel, { color: theme.colors.textMuted }]}>{label}</Text>
<Text style={[styles.reviewValue, { color: accent ? theme.colors.primary : theme.colors.textPrimary }]}>{value || '—'}</Text>
</View>
);
};
export default function CreateLoanRequestScreen({ route, navigation }: Props) {
const theme = useTheme();
const { category, customerId } = route.params;
const customers = useAppSelector(selectFilteredCustomers);
const { data, errors, updateField, submit, isSubmitting, validate } = useLoanRequestForm(category, customerId);
const [step, setStep] = useState(1);
const selectedCategory = LOAN_CATEGORIES.find(c => c.id === data.category);
const handleNext = () => {
if (step === 1 && !data.category) return;
if (step === 1 && !data.customerName) return;
if (step === 2) {
if (!validate()) return;
}
setStep(s => Math.min(3, s + 1));
};
const handleBack = () => {
if (step === 1) navigation.goBack();
else setStep(s => Math.max(1, s - 1));
};
const handleSubmit = async () => {
const res = await submit();
if (res) {
navigation.goBack();
}
};
return (
<ScreenShell
title="New Loan Request"
scrollable={false}
withTabBar={false}
fixedFooter={
<View style={[styles.footer, { backgroundColor: theme.colors.background, borderTopColor: theme.colors.cardBorder }]}>
{/* Progress indicator */}
<View style={styles.progressRow}>
<Text style={[styles.progressText, { color: theme.colors.textMuted }]}>
Step {step} of {STEPS.length}
</Text>
<View style={[styles.progressTrack, { backgroundColor: theme.colors.cardBorder }]}>
<View style={[styles.progressFill, { width: `${(step / STEPS.length) * 100}%`, backgroundColor: theme.colors.primary }]} />
</View>
</View>
<View style={styles.row}>
<View style={styles.col}>
<Button
title={step === 1 ? 'Cancel' : 'Back'}
variant="secondary"
onPress={handleBack}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
{step < 3 ? (
<Button
title="Continue"
onPress={handleNext}
/>
) : (
<Button
title="Submit Request"
onPress={handleSubmit}
loading={isSubmitting}
/>
)}
</View>
</View>
</View>
}
>
<View style={styles.flexContainer}>
<StepIndicator step={step} />
<KeyboardAwareScrollView
contentContainerStyle={styles.scrollContent}
style={styles.flex}
>
{/* ═══════════ STEP 1: Category & Customer ═══════════ */}
{step === 1 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
<SectionCard title="Loan Type" subtitle="Choose the category that best fits">
<View style={styles.categoryList}>
{LOAN_CATEGORIES.map(cat => {
const isSelected = data.category === cat.id;
return (
<Pressable
key={cat.id}
style={[
styles.catCard,
{
backgroundColor: isSelected ? `${cat.color}12` : theme.colors.surfaceMuted,
borderColor: isSelected ? cat.color : 'transparent',
borderWidth: isSelected ? 1.5 : 1,
},
]}
onPress={() => updateField('category', cat.id as any)}
>
<View style={[styles.catIcon, { backgroundColor: `${cat.color}20` }]}>
<Ionicons name={cat.icon as any} size={22} color={cat.color} />
</View>
<Text style={[
styles.catLabel,
{ color: isSelected ? cat.color : theme.colors.textPrimary },
]}>{cat.label}</Text>
{isSelected && (
<View style={[styles.catCheck, { backgroundColor: cat.color }]}>
<Ionicons name="checkmark" size={12} color={theme.colors.white} />
</View>
)}
</Pressable>
);
})}
</View>
{errors.category && (
<Text style={[styles.errorText, { color: theme.colors.danger }]}>{errors.category}</Text>
)}
</SectionCard>
<SectionCard title="Customer Details" subtitle="Who is applying for the loan?">
<Input
label="Full Name"
placeholder="e.g. Amit Sharma"
value={data.customerName}
onChangeText={v => updateField('customerName', v)}
error={errors.customerName}
/>
<Input
label="Phone Number"
placeholder="10-digit mobile number"
value={data.customerPhone}
onChangeText={v => updateField('customerPhone', v)}
keyboardType="number-pad"
maxLength={10}
leftAccessory={<Text style={{ color: theme.colors.textMuted, fontWeight: '600', fontSize: 13 }}>+91</Text>}
/>
</SectionCard>
</Animated.View>
)}
{/* ═══════════ STEP 2: Loan Details ═══════════ */}
{step === 2 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{selectedCategory && (
<Animated.View entering={FadeIn} style={[styles.selectedBanner, { backgroundColor: `${selectedCategory.color}12`, borderColor: `${selectedCategory.color}30` }]}>
<View style={[styles.bannerIcon, { backgroundColor: `${selectedCategory.color}20` }]}>
<Ionicons name={selectedCategory.icon as any} size={20} color={selectedCategory.color} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.bannerTitle, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
<Text style={[styles.bannerSub, { color: theme.colors.textMuted }]}>for {data.customerName}</Text>
</View>
</Animated.View>
)}
<SectionCard title="Loan Amount & Tenure">
<Input
label="Loan Amount"
placeholder="Min ₹5,000"
value={data.amount}
onChangeText={v => updateField('amount', v)}
keyboardType="number-pad"
error={errors.amount}
leftAccessory={<Text style={{ fontSize: 16, fontWeight: '800', color: theme.colors.primary }}>₹</Text>}
/>
<View style={styles.row}>
<View style={styles.col}>
<Input
label="Tenure"
placeholder="e.g. 12"
value={data.tenureMonths}
onChangeText={v => updateField('tenureMonths', v)}
keyboardType="number-pad"
error={errors.tenureMonths}
rightAccessory={<Text style={{ fontSize: 12, color: theme.colors.textMuted, fontWeight: '600' }}>months</Text>}
/>
</View>
<View style={{ width: 12 }} />
<View style={styles.col}>
<Input
label="Monthly Income"
placeholder="Approx"
value={data.monthlyIncome}
onChangeText={v => updateField('monthlyIncome', v)}
keyboardType="number-pad"
error={errors.monthlyIncome}
leftAccessory={<Text style={{ fontSize: 14, fontWeight: '700', color: theme.colors.textMuted }}>₹</Text>}
/>
</View>
</View>
</SectionCard>
<SectionCard title="Purpose of Loan">
<Input
placeholder="e.g. Home renovation, medical expenses..."
value={data.purpose}
onChangeText={v => updateField('purpose', v)}
error={errors.purpose}
multiline
/>
</SectionCard>
</Animated.View>
)}
{/* ═══════════ STEP 3: Review & Submit ═══════════ */}
{step === 3 && (
<Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
{/* Summary Card */}
<View style={[styles.reviewCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
{/* Header badge */}
{selectedCategory && (
<View style={[styles.reviewBadge, { backgroundColor: `${selectedCategory.color}12` }]}>
<Ionicons name={selectedCategory.icon as any} size={18} color={selectedCategory.color} />
<Text style={[styles.reviewBadgeText, { color: selectedCategory.color }]}>{selectedCategory.label}</Text>
</View>
)}
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Customer" value={data.customerName} />
<ReviewRow label="Phone" value={data.customerPhone ? `+91 ${data.customerPhone}` : ''} />
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Amount" value={data.amount ? `₹ ${Number(data.amount).toLocaleString('en-IN')}` : ''} accent />
<ReviewRow label="Tenure" value={data.tenureMonths ? `${data.tenureMonths} months` : ''} />
<ReviewRow label="Monthly Income" value={data.monthlyIncome ? `₹ ${Number(data.monthlyIncome).toLocaleString('en-IN')}` : ''} />
<View style={[styles.reviewDivider, { backgroundColor: theme.colors.cardBorder }]} />
<ReviewRow label="Purpose" value={data.purpose} />
</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={{ flex: 1 }}>
<Text style={[styles.docsTitle, { color: theme.colors.info }]}>Documents can be added later</Text>
<Text style={[styles.docsText, { color: theme.colors.textMuted }]}>
You can upload Aadhaar, PAN, income proof & photo from the request details page after submission.
</Text>
</View>
</View>
{/* Consent */}
<View style={[styles.consentBox, { backgroundColor: theme.colors.surfaceMuted }]}>
<Ionicons 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>
</View>
</Animated.View>
)}
</KeyboardAwareScrollView>
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
flexContainer: {
flex: 1,
},
// ── Step Indicator ────────────────────────
stepContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 24,
},
stepItem: {
alignItems: 'center',
gap: 6,
},
stepDot: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
stepLabel: {
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
stepLine: {
flex: 1,
height: 2,
marginHorizontal: 8,
marginBottom: 18,
borderRadius: 1,
},
// ── Scroll Content ────────────────────────
scrollContent: {
padding: 16,
paddingBottom: 24,
gap: 14,
},
// ── Section Cards ─────────────────────────
sectionCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
sectionTitle: {
fontSize: 17,
fontWeight: '700',
marginBottom: 2,
},
sectionSubtitle: {
fontSize: 12,
lineHeight: 16,
marginBottom: 4,
},
sectionBody: {
marginTop: 12,
gap: 14,
},
// ── Category Cards ────────────────────────
categoryList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
catCard: {
width: '31%',
borderRadius: 14,
paddingVertical: 14,
paddingHorizontal: 8,
alignItems: 'center',
gap: 8,
position: 'relative',
},
catIcon: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
},
catLabel: {
fontSize: 11,
fontWeight: '600',
textAlign: 'center',
lineHeight: 14,
},
catCheck: {
position: 'absolute',
top: 6,
right: 6,
width: 18,
height: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
},
// ── Selected Banner ───────────────────────
selectedBanner: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
borderWidth: 1,
gap: 12,
},
bannerIcon: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
bannerTitle: {
fontSize: 14,
fontWeight: '700',
},
bannerSub: {
fontSize: 12,
marginTop: 1,
},
// ── Form Layout ───────────────────────────
row: {
flexDirection: 'row',
},
col: {
flex: 1,
},
errorText: {
fontSize: 12,
marginTop: 6,
fontWeight: '500',
},
// ── Review Card ───────────────────────────
reviewCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
reviewBadge: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
gap: 6,
marginBottom: 12,
},
reviewBadgeText: {
fontSize: 13,
fontWeight: '700',
},
reviewDivider: {
height: 1,
marginVertical: 10,
},
reviewRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: 6,
},
reviewLabel: {
fontSize: 13,
fontWeight: '500',
minWidth: 80,
},
reviewValue: {
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
flexShrink: 1,
marginLeft: 16,
},
// ── Docs Info ─────────────────────────────
docsCard: {
flexDirection: 'row',
padding: 14,
borderRadius: 12,
borderWidth: 1,
gap: 12,
alignItems: 'flex-start',
},
docsTitle: {
fontSize: 13,
fontWeight: '700',
marginBottom: 3,
},
docsText: {
fontSize: 12,
lineHeight: 17,
},
// ── Consent ───────────────────────────────
consentBox: {
flexDirection: 'row',
padding: 14,
borderRadius: 12,
gap: 10,
alignItems: 'flex-start',
},
consentText: {
flex: 1,
fontSize: 11,
lineHeight: 16,
},
// ── Footer ────────────────────────────────
footer: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: TAB_BAR_HEIGHT + 10,
borderTopWidth: 1,
},
progressRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 10,
},
progressText: {
fontSize: 11,
fontWeight: '600',
minWidth: 60,
},
progressTrack: {
flex: 1,
height: 3,
borderRadius: 2,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 2,
},
});
@@ -0,0 +1,327 @@
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 Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell, Button } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import { useDeleteAgentCustomerMutation } from '../services/agentApi';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { formatAgentCurrency, formatDate, maskAadhaar, maskPhone } from '../utils/agentFormatters';
import { AgentSectionHeader } from '../components/AgentSectionHeader';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerDetail'>;
export default function CustomerDetailScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const customer = useAppSelector(selectCustomerById(customerId));
const [deleteCustomer, { isLoading: isDeleting }] = useDeleteAgentCustomerMutation();
if (!customer) {
return (
<ScreenShell title="Customer Detail">
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.textMuted }}>Customer not found</Text>
</View>
</ScreenShell>
);
}
const handleDelete = () => {
Alert.alert(
'Delete Customer',
'Are you sure you want to remove this customer? This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
await deleteCustomer(customer.id).unwrap();
navigation.goBack();
} catch (error) {
Alert.alert('Error', 'Failed to delete customer');
}
}
}
]
);
};
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 }} />}
<Text style={[styles.infoLabel, { color: theme.colors.textMuted }]}>{label}</Text>
</View>
<Text style={[styles.infoValue, { color: theme.colors.textPrimary }]}>
{masked ? '•••• •••• ' + value.slice(-4) : value}
</Text>
</View>
);
return (
<ScreenShell title="Customer Profile" scrollable={false}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
{/* Profile Header */}
<Animated.View entering={FadeInUp.delay(100)} style={styles.headerCard}>
<View style={styles.profileTopRow}>
<View style={[styles.avatarBig, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarBigText, { color: theme.colors.primary }]}>
{customer.name.charAt(0).toUpperCase()}
</Text>
</View>
<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` }]}>
<Text style={[styles.statusText, { color: customer.status === 'active' ? theme.colors.success : theme.colors.danger }]}>
{customer.status.toUpperCase()}
</Text>
</View>
</View>
</View>
<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>
<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>
<Text style={[styles.iconLabel, { color: theme.colors.success }]}>SMS</Text>
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => navigation.navigate('AddEditCustomer', { customerId: customer.id })}>
<View style={[styles.iconFrame, { backgroundColor: `${theme.colors.primary}15` }]}>
<Ionicons name="create" size={20} color={theme.colors.primary} />
</View>
<Text style={[styles.iconLabel, { color: theme.colors.primary }]}>Edit</Text>
</Pressable>
</View>
</Animated.View>
{/* Account Details */}
<Animated.View entering={FadeInUp.delay(200)} style={styles.section}>
<AgentSectionHeader title="Account Details" />
{customer.accounts.map(acc => (
<View key={acc.id} style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.accHeaderRow}>
<Text style={[styles.accType, { color: theme.colors.textPrimary }]}>{acc.type} A/C</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>{acc.accountNumber}</Text>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.financialRow}>
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Monthly EMI</Text>
<Text style={[styles.finValue, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(acc.emiAmount)}</Text>
</View>
<View style={[styles.vDivider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Balance</Text>
<Text style={[styles.finValue, { color: theme.colors.primary }]}>{formatAgentCurrency(acc.balance)}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.financialRow}>
<View style={styles.finItem}>
<Text style={[styles.finLabel, { color: theme.colors.textMuted }]}>Total Due</Text>
<Text style={[styles.finValue, { color: acc.totalDue > 0 ? theme.colors.danger : theme.colors.success }]}>
{formatAgentCurrency(acc.totalDue)}
</Text>
</View>
<Button
title="View History"
variant="ghost"
fullWidth={false}
onPress={() => navigation.navigate('CustomerInstallmentHistory', { customerId: customer.id })}
/>
</View>
</View>
))}
</Animated.View>
{/* KYC & Personal Details */}
<Animated.View entering={FadeInUp.delay(300)} style={styles.section}>
<AgentSectionHeader title="KYC & Information" />
<View style={[styles.dataCard, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<InfoRow label="Aadhaar" value={customer.kyc.aadhaarNumber} icon="finger-print-outline" masked />
<InfoRow label="PAN Number" value={customer.kyc.panNumber} icon="card-outline" masked />
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<InfoRow label="Joined Date" value={formatDate(customer.joinedDate)} icon="calendar-outline" />
<InfoRow label="Address" value={`${customer.address}, ${customer.city}, ${customer.state} - ${customer.pincode}`} icon="location-outline" />
</View>
</Animated.View>
{/* Delete Area */}
<View style={styles.deleteArea}>
<Button
title="Remove Customer"
variant="ghost"
loading={isDeleting}
onPress={handleDelete}
leftAccessory={<Ionicons name="trash-outline" size={18} color={theme.colors.danger} />}
style={{ borderColor: theme.colors.danger, borderWidth: 1 }}
/>
</View>
<View style={styles.footerSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
padding: 16,
},
headerCard: {
marginBottom: 32,
alignItems: 'center',
},
profileTopRow: {
alignItems: 'center',
marginBottom: 24,
},
avatarBig: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
avatarBigText: {
fontSize: 32,
fontWeight: '800',
},
headerInfo: {
alignItems: 'center',
},
headerName: {
fontSize: 22,
fontWeight: '800',
marginBottom: 4,
},
headerPhone: {
fontSize: 15,
marginBottom: 8,
},
statusChip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
actionButtonsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 32,
},
iconBtn: {
alignItems: 'center',
},
iconFrame: {
width: 50,
height: 50,
borderRadius: 25,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 8,
},
iconLabel: {
fontSize: 12,
fontWeight: '600',
},
section: {
marginBottom: 24,
},
dataCard: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
},
accHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
accType: {
fontSize: 16,
fontWeight: '700',
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
financialRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
finItem: {
flex: 1,
},
finLabel: {
fontSize: 12,
marginBottom: 4,
},
finValue: {
fontSize: 18,
fontWeight: '800',
},
vDivider: {
width: 1,
height: 40,
marginHorizontal: 16,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 8,
},
infoLabelArea: {
flexDirection: 'row',
alignItems: 'center',
width: '40%',
},
infoLabel: {
fontSize: 13,
},
infoValue: {
fontSize: 14,
fontWeight: '600',
flex: 1,
textAlign: 'right',
},
deleteArea: {
marginTop: 16,
},
footerSpacer: {
height: 40,
},
});
@@ -0,0 +1,154 @@
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 Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppSelector } from '../../../app/hooks';
import { selectCustomerById } from '../store/agentSelectors';
import type { AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { formatAgentCurrency, formatDateTime } from '../utils/agentFormatters';
import { AgentEmptyState } from '../components/AgentEmptyState';
type Props = NativeStackScreenProps<AgentCustomersStackParamList, 'CustomerInstallmentHistory'>;
export default function CustomerInstallmentHistoryScreen({ route, navigation }: Props) {
const { customerId } = route.params;
const theme = useTheme();
const customer = useAppSelector(selectCustomerById(customerId));
if (!customer) {
return (
<ScreenShell title="Installment History">
<AgentEmptyState title="Error" description="Customer not found" />
</ScreenShell>
);
}
// Generate mock history based on totalCollected for demo purposes
const history = Array.from({ length: 10 }).map((_, i) => {
const isPaid = i < (customer.totalCollected / 500);
const date = new Date();
date.setDate(date.getDate() - (i * 30));
return {
id: `hist-${i}`,
amount: 500,
date: date.toISOString(),
status: isPaid ? 'paid' : 'pending',
receiptNo: isPaid ? `RCP-${date.getFullYear()}${String(date.getMonth()+1).padStart(2,'0')}${String(date.getDate()).padStart(2,'0')}-${1000+i}` : null,
};
});
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
name={item.status === 'paid' ? 'checkmark-circle' : 'time'}
size={20}
color={item.status === 'paid' ? theme.colors.success : theme.colors.warning}
/>
</View>
<View>
<Text style={[styles.date, { color: theme.colors.textPrimary }]}>{formatDateTime(item.date)}</Text>
{item.receiptNo ? (
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>{item.receiptNo}</Text>
) : (
<Text style={[styles.receipt, { color: theme.colors.textMuted }]}>Pending Installment</Text>
)}
</View>
</View>
<View style={styles.rightSide}>
<Text style={[styles.amount, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
<Text style={[styles.status, { color: item.status === 'paid' ? theme.colors.success : theme.colors.warning }]}>
{item.status.toUpperCase()}
</Text>
</View>
</Animated.View>
);
return (
<ScreenShell title="Installment History" scrollable={false}>
<View style={styles.header}>
<Text style={[styles.customerName, { color: theme.colors.textPrimary }]}>{customer.name}</Text>
<Text style={[styles.accNumber, { color: theme.colors.textMuted }]}>
{customer.accounts[0]?.type} A/C: {customer.accounts[0]?.accountNumber}
</Text>
</View>
<FlatList
data={history}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
padding: 16,
paddingTop: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
customerName: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
accNumber: {
fontSize: 14,
fontFamily: 'monospace',
},
listContent: {
padding: 16,
},
card: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
leftSide: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconFrame: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
date: {
fontSize: 14,
fontWeight: '600',
marginBottom: 2,
},
receipt: {
fontSize: 11,
},
rightSide: {
alignItems: 'flex-end',
},
amount: {
fontSize: 15,
fontWeight: '700',
marginBottom: 2,
},
status: {
fontSize: 10,
fontWeight: '700',
},
});
@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, FlatList, RefreshControl, TextInput, Pressable, Text } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCustomersStackParamList } from '../navigation/agentNavigationTypes';
import { useGetAgentCustomersQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectFilteredCustomers, selectCustomerFilter, selectCustomerSearch } from '../store/agentSelectors';
import { CUSTOMER_FILTERS } from '../constants/agentConstants';
import type { CustomerFilter } from '../types/agentTypes';
import { CustomerCard } from '../components/CustomerCard';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCustomersStackParamList, 'CustomerList'>,
BottomTabNavigationProp<AgentTabParamList>
>;
export default function CustomerListScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const customers = useAppSelector(selectFilteredCustomers);
const activeFilter = useAppSelector(selectCustomerFilter);
const searchQuery = useAppSelector(selectCustomerSearch);
const { data, isLoading, isFetching, refetch, error } = useGetAgentCustomersQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (data) {
dispatch(agentActions.setCustomers(data));
}
}, [data, dispatch]);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleSearch = (text: string) => {
dispatch(agentActions.setCustomerSearch(text));
};
const handleFilter = (filter: CustomerFilter) => {
dispatch(agentActions.setCustomerFilter(filter));
};
return (
<ScreenShell title="Customers" scrollable={false}>
{/* 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} />
<TextInput
style={[styles.searchInput, { color: theme.colors.textPrimary }]}
placeholder="Search by name or phone..."
placeholderTextColor={theme.colors.textMuted}
value={searchQuery}
onChangeText={handleSearch}
/>
{searchQuery.length > 0 && (
<Pressable onPress={() => handleSearch('')} style={styles.clearBtn}>
<Ionicons name="close-circle" size={16} color={theme.colors.textMuted} />
</Pressable>
)}
</View>
{/* Filters */}
<View style={styles.filterArea}>
<FlatList
data={CUSTOMER_FILTERS}
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item.id}
contentContainerStyle={styles.filterList}
renderItem={({ item }) => {
const isActive = activeFilter === item.id;
return (
<Pressable
style={[
styles.filterChip,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surface,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => handleFilter(item.id)}
>
<Text style={[
styles.filterText,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary }
]}>
{item.label}
</Text>
</Pressable>
);
}}
/>
</View>
</View>
{/* List Area */}
<View style={styles.listArea}>
{isLoading && customers.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : error && customers.length === 0 ? (
<AgentEmptyState
title="Unable to load customers"
description="We encountered an error while fetching your customer list."
actionTitle="Retry"
onAction={handleRefresh}
/>
) : (
<FlatList
data={customers}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No customers found"
description={searchQuery ? `No matches for "${searchQuery}"` : "You don't have any customers in this category yet."}
icon="people-outline"
actionTitle={searchQuery ? "Clear Search" : undefined}
onAction={searchQuery ? () => handleSearch('') : undefined}
/>
)}
renderItem={({ item, index }) => (
<Animated.View entering={FadeIn.delay(Math.min(index * 50, 500))} exiting={FadeOut}>
<CustomerCard
customer={item}
onPress={() => navigation.navigate('CustomerDetail', { customerId: item.id })}
/>
</Animated.View>
)}
/>
)}
</View>
{/* FAB to add new customer */}
<Pressable
style={[styles.fab, { backgroundColor: theme.colors.primary }]}
onPress={() => navigation.navigate('AddEditCustomer', {})}
>
<Ionicons name="add" size={24} color={theme.colors.white} />
</Pressable>
</ScreenShell>
);
}
const styles = StyleSheet.create({
headerArea: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
zIndex: 10,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 24,
borderWidth: 1,
paddingHorizontal: 16,
marginBottom: 16,
},
searchInput: {
flex: 1,
height: '100%',
marginLeft: 8,
fontSize: 15,
},
clearBtn: {
padding: 4,
},
filterArea: {
marginBottom: 8,
},
filterList: {
gap: 8,
},
filterChip: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 16,
borderWidth: 1,
},
filterText: {
fontSize: 13,
fontWeight: '600',
},
listArea: {
flex: 1,
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
fab: {
position: 'absolute',
right: 20,
bottom: TAB_BAR_HEIGHT + 20, // above tab bar
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 5,
},
});
@@ -0,0 +1,498 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, RefreshControl, Pressable } from 'react-native';
import { CompositeNavigationProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
import { ScreenShell, Button, Input } from '../../../components';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentTabParamList, AgentCollectionsStackParamList } from '../navigation/agentNavigationTypes';
import { useGetTodayCollectionsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectTodayCollections, selectTodayCollectionSummary, selectOfflineQueue } from '../store/agentSelectors';
import { formatAgentCurrency } from '../utils/agentFormatters';
import { useCollectionForm } from '../hooks/useCollectionForm';
import { COLLECTION_MODES } from '../constants/agentConstants';
import type { CollectionEntry } from '../types/agentTypes';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type NavigationProp = CompositeNavigationProp<
NativeStackNavigationProp<AgentCollectionsStackParamList, 'DailyCollection'>,
BottomTabNavigationProp<AgentTabParamList>
>;
const CollectionForm = ({ selectedEntry, navigation, bottomSheetRef }: { selectedEntry: CollectionEntry | null, navigation: any, bottomSheetRef: any }) => {
const theme = useTheme();
if (!selectedEntry) return null;
const { amount, setAmount, mode, setMode, error: formError, submit, isLoading: isSubmitting } = useCollectionForm(selectedEntry.id, selectedEntry.emiAmount);
const handleSubmit = async () => {
const receipt = await submit();
if (receipt) {
bottomSheetRef.current?.close();
navigation.navigate('CollectionReceipt', { entryId: receipt.id });
// In real app, we'd pass the full receipt object or fetch it there.
// For mock, we just pass ID.
} else {
// Fallback or error handled in hook
bottomSheetRef.current?.close();
}
};
return (
<View style={styles.sheetContent}>
<Text style={[styles.sheetTitle, { color: theme.colors.textPrimary }]}>Collect Payment</Text>
<View style={styles.sheetCustomerInfo}>
<Text style={[styles.sheetCustomerName, { color: theme.colors.textPrimary }]}>{selectedEntry.customerName}</Text>
<Text style={[styles.sheetCustomerMeta, { color: theme.colors.textMuted }]}>
{selectedEntry.accountType} • EMI: {formatAgentCurrency(selectedEntry.emiAmount)}
</Text>
</View>
<Input
label="Collection Amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
error={formError || undefined}
leftAccessory={<Text style={{ fontSize: 18, fontWeight: '700', color: theme.colors.textPrimary }}>₹</Text>}
/>
<Text style={[styles.modeLabel, { color: theme.colors.textMuted }]}>Payment Mode</Text>
<View style={styles.modeRow}>
{COLLECTION_MODES.map(m => (
<Pressable
key={m.id}
style={[
styles.modeChip,
{
backgroundColor: mode === m.id ? `${theme.colors.primary}15` : 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} />
<Text style={[styles.modeText, { color: mode === m.id ? theme.colors.primary : theme.colors.textMuted }]}>{m.label}</Text>
</Pressable>
))}
</View>
<Button
title="Confirm Collection"
onPress={handleSubmit}
loading={isSubmitting}
style={{ marginTop: 24 }}
/>
</View>
);
};
export default function DailyCollectionScreen({ navigation }: { navigation: NavigationProp }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const entries = useAppSelector(selectTodayCollections);
const summary = useAppSelector(selectTodayCollectionSummary);
const offlineQueue = useAppSelector(selectOfflineQueue);
const { data, isLoading, isFetching, refetch, error } = useGetTodayCollectionsQuery(undefined, {
refetchOnMountOrArgChange: true,
});
const [refreshing, setRefreshing] = useState(false);
const [selectedEntry, setSelectedEntry] = useState<CollectionEntry | null>(null);
const [filter, setFilter] = useState<'all' | 'pending' | 'collected'>('all');
const filteredEntries = entries.filter(entry => {
if (filter === 'all') return true;
if (filter === 'collected') return entry.status === 'collected' || entry.status === 'partial';
if (filter === 'pending') return entry.status === 'pending';
return true;
});
const bottomSheetRef = React.useRef<BottomSheet>(null);
useEffect(() => {
if (data) {
dispatch(agentActions.setTodayCollections(data));
}
}, [data, dispatch]);
const handleRefresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const handleEntryPress = (entry: CollectionEntry) => {
if (entry.status !== 'pending') return; // Already collected
setSelectedEntry(entry);
bottomSheetRef.current?.expand();
};
return (
<ScreenShell title="Daily Collection" scrollable={false}>
<View style={styles.header}>
{/* Summary Dashboard */}
<Animated.View entering={FadeIn} style={[styles.summaryCard, { backgroundColor: theme.colors.primary }]}>
<View style={styles.summaryRow}>
<View>
<Text style={styles.summaryLabel}>Total Collected Today</Text>
<Text style={styles.summaryValue}>{formatAgentCurrency(summary.totalCollected)}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.summaryLabel}>Completion</Text>
<Text style={styles.summaryValue}>{summary.collectedCount}/{summary.total}</Text>
</View>
</View>
<View style={styles.progressTrack}>
<View style={[styles.progressBar, { width: `${summary.total > 0 ? (summary.collectedCount / summary.total) * 100 : 0}%` }]} />
</View>
</Animated.View>
{offlineQueue.length > 0 && (
<View style={[styles.offlineBanner, { backgroundColor: theme.colors.warning }]}>
<Ionicons name="cloud-offline" size={16} color="#fff" />
<Text style={styles.offlineText}>{offlineQueue.length} entries waiting to sync</Text>
</View>
)}
</View>
<View style={styles.listArea}>
<View style={styles.listHeader}>
<Text style={[styles.listTitle, { color: theme.colors.textPrimary }]}>Today's Schedule</Text>
<Pressable onPress={() => navigation.navigate('CollectionHistory')}>
<Text style={[styles.historyLink, { color: theme.colors.primary }]}>History</Text>
</Pressable>
</View>
<View style={styles.filterRow}>
{(['all', 'pending', 'collected'] as const).map(f => (
<Pressable
key={f}
style={[
styles.filterChip,
{
backgroundColor: filter === f ? theme.colors.primary : theme.colors.surface,
borderColor: filter === f ? theme.colors.primary : theme.colors.cardBorder,
}
]}
onPress={() => setFilter(f)}
>
<Text style={[
styles.filterText,
{ color: filter === f ? '#fff' : theme.colors.textSecondary }
]}>
{f === 'all' ? 'All' : f === 'pending' ? 'Pending' : 'Collected'}
</Text>
</Pressable>
))}
</View>
{isLoading && entries.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : error && entries.length === 0 ? (
<AgentEmptyState
title="Unable to load schedule"
description="We encountered an error while fetching your daily collections."
actionTitle="Retry"
onAction={handleRefresh}
/>
) : (
<FlatList
data={filteredEntries}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} colors={[theme.colors.primary]} />
}
ListEmptyComponent={() => (
<AgentEmptyState
title="No collections today"
description="Your schedule for today is empty."
icon="calendar-outline"
/>
)}
renderItem={({ item, index }) => {
const isCollected = item.status === 'collected';
const isPartial = item.status === 'partial';
return (
<Animated.View entering={SlideInDown.delay(index * 50)}>
<Pressable
style={[
styles.entryCard,
{
backgroundColor: theme.colors.surface,
borderColor: theme.colors.cardBorder,
opacity: isCollected ? 0.7 : 1,
}
]}
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryLeft}>
<View style={[styles.avatarBox, { backgroundColor: `${theme.colors.primary}15` }]}>
<Text style={[styles.avatarText, { color: theme.colors.primary }]}>{item.customerName.charAt(0)}</Text>
</View>
<View>
<Text style={[styles.entryName, { color: theme.colors.textPrimary }]}>{item.customerName}</Text>
<Text style={[styles.entryMeta, { color: theme.colors.textMuted }]}>{item.accountType} • EMI: {formatAgentCurrency(item.emiAmount)}</Text>
</View>
</View>
<View style={styles.entryRight}>
{isCollected || isPartial ? (
<>
<Text style={[styles.entryAmount, { color: theme.colors.success }]}>{formatAgentCurrency(item.collectedAmount)}</Text>
<View style={[styles.statusBadge, { backgroundColor: `${theme.colors.success}15` }]}>
<Text style={[styles.statusText, { color: theme.colors.success }]}>Collected</Text>
</View>
</>
) : (
<View style={[styles.collectBtn, { backgroundColor: theme.colors.primary }]}>
<Text style={styles.collectBtnText}>Collect</Text>
</View>
)}
</View>
</Pressable>
</Animated.View>
);
}}
/>
)}
</View>
<BottomSheet
ref={bottomSheetRef}
index={-1}
snapPoints={['50%', '65%']}
enablePanDownToClose
backgroundStyle={{ backgroundColor: theme.colors.surface }}
handleIndicatorStyle={{ backgroundColor: theme.colors.cardBorder }}
>
<BottomSheetView style={styles.sheetContainer}>
<CollectionForm selectedEntry={selectedEntry} navigation={navigation} bottomSheetRef={bottomSheetRef} />
</BottomSheetView>
</BottomSheet>
</ScreenShell>
);
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: 16,
paddingTop: 16,
zIndex: 10,
},
summaryCard: {
borderRadius: 16,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
},
summaryLabel: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
summaryValue: {
color: '#fff',
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
},
progressTrack: {
height: 6,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 3,
overflow: 'hidden',
},
progressBar: {
height: '100%',
backgroundColor: '#fff',
borderRadius: 3,
},
offlineBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
borderRadius: 8,
marginTop: 12,
gap: 8,
},
offlineText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
listArea: {
flex: 1,
marginTop: 16,
},
listHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
listTitle: {
fontSize: 16,
fontWeight: '700',
},
historyLink: {
fontSize: 14,
fontWeight: '600',
},
filterRow: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 16,
gap: 8,
},
filterChip: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
},
filterText: {
fontSize: 12,
fontWeight: '600',
},
listContent: {
padding: 16,
paddingTop: 0,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
entryCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderRadius: 16,
borderWidth: 1,
marginBottom: 12,
},
entryLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
avatarBox: {
width: 44,
height: 44,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 18,
fontWeight: '700',
},
entryName: {
fontSize: 15,
fontWeight: '700',
marginBottom: 2,
},
entryMeta: {
fontSize: 12,
},
entryRight: {
alignItems: 'flex-end',
gap: 4,
},
collectBtn: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
collectBtnText: {
color: '#fff',
fontSize: 13,
fontWeight: '600',
},
entryAmount: {
fontSize: 15,
fontWeight: '800',
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
sheetContainer: {
flex: 1,
padding: 24,
},
sheetContent: {
flex: 1,
},
sheetTitle: {
fontSize: 20,
fontWeight: '800',
marginBottom: 24,
},
sheetCustomerInfo: {
marginBottom: 24,
},
sheetCustomerName: {
fontSize: 18,
fontWeight: '700',
marginBottom: 4,
},
sheetCustomerMeta: {
fontSize: 14,
},
modeLabel: {
fontSize: 13,
fontWeight: '600',
marginBottom: 12,
marginTop: 16,
},
modeRow: {
flexDirection: 'row',
gap: 12,
},
modeChip: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
gap: 6,
},
modeText: {
fontSize: 13,
fontWeight: '600',
},
});
@@ -0,0 +1,183 @@
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 Animated, { SlideInRight } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { TAB_BAR_HEIGHT } from '../../../utils/layoutConstants';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentLoansStackParamList } from '../navigation/agentNavigationTypes';
import { useGetAgentLoanRequestsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectLoanRequests } from '../store/agentSelectors';
import { formatAgentCurrency, formatDate } from '../utils/agentFormatters';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
export default function LoanRequestListScreen({ navigation }: { navigation: NativeStackNavigationProp<AgentLoansStackParamList> }) {
const theme = useTheme();
const dispatch = useAppDispatch();
const requests = useAppSelector(selectLoanRequests);
const { data, isLoading, refetch, isFetching } = useGetAgentLoanRequestsQuery();
useEffect(() => {
if (data) dispatch(agentActions.setLoanRequests(data));
}, [data, dispatch]);
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Recent Requests</Text>
<Pressable
style={[styles.newBtn, { backgroundColor: `${theme.colors.primary}15` }]}
onPress={() => navigation.navigate('CreateLoanRequest', {})}
>
<Ionicons name="add" size={16} color={theme.colors.primary} />
<Text style={[styles.newBtnText, { color: theme.colors.primary }]}>New Request</Text>
</Pressable>
</View>
{isLoading && requests.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={requests}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListEmptyComponent={() => (
<AgentEmptyState
title="No Loan Requests"
description="You haven't submitted any loan requests yet."
icon="document-text-outline"
/>
)}
renderItem={({ item, index }) => {
const isApproved = item.status === 'approved' || item.status === 'disbursed';
const isRejected = item.status === 'rejected';
const statusColor = isApproved ? theme.colors.success : isRejected ? theme.colors.danger : theme.colors.warning;
return (
<Animated.View entering={SlideInRight.delay(index * 50)}>
<Pressable style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.cardBorder }]}>
<View style={styles.cardHeader}>
<View style={styles.customerInfo}>
<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` }]}>
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.replace('_', ' ').toUpperCase()}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<View style={styles.cardBody}>
<View>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Amount</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatAgentCurrency(item.amount)}</Text>
</View>
<View>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Tenure</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{item.tenureMonths} mos</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={[styles.label, { color: theme.colors.textMuted }]}>Applied On</Text>
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>{formatDate(item.appliedDate)}</Text>
</View>
</View>
</Pressable>
</Animated.View>
);
}}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
title: {
fontSize: 16,
fontWeight: '700',
},
newBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
gap: 4,
},
newBtnText: {
fontSize: 12,
fontWeight: '700',
},
listContent: {
padding: 16,
paddingBottom: TAB_BAR_HEIGHT + 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
customerInfo: {
flex: 1,
},
customerName: {
fontSize: 16,
fontWeight: '700',
marginBottom: 2,
},
category: {
fontSize: 12,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginVertical: 12,
},
cardBody: {
flexDirection: 'row',
justifyContent: 'space-between',
},
label: {
fontSize: 11,
marginBottom: 4,
},
value: {
fontSize: 14,
fontWeight: '600',
},
});
@@ -0,0 +1,134 @@
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 Animated, { FadeInUp } from 'react-native-reanimated';
import { ScreenShell } from '../../../components';
import { useTheme } from '../../../theme';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import type { AgentProfileStackParamList } from '../navigation/agentNavigationTypes';
import { useGetSmsLogsQuery } from '../services/agentApi';
import { agentActions } from '../store/agentSlice';
import { selectSmsLogs } from '../store/agentSelectors';
import { formatDateTime } from '../utils/agentFormatters';
import { AgentSkeletonLoader } from '../components/AgentSkeletonLoader';
import { AgentEmptyState } from '../components/AgentEmptyState';
type Props = NativeStackScreenProps<AgentProfileStackParamList, 'SmsHistory'>;
export default function SmsHistoryScreen({ navigation }: Props) {
const theme = useTheme();
const dispatch = useAppDispatch();
const logs = useAppSelector(selectSmsLogs);
const { data, isLoading, refetch, isFetching } = useGetSmsLogsQuery();
useEffect(() => {
if (data) dispatch(agentActions.setSmsLogs(data));
}, [data, dispatch]);
return (
<ScreenShell title="SMS Logs" scrollable={false}>
<View style={styles.container}>
{isLoading && logs.length === 0 ? (
<AgentSkeletonLoader type="list" />
) : (
<FlatList
data={logs}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isFetching} onRefresh={refetch} colors={[theme.colors.primary]} />}
ListEmptyComponent={() => (
<AgentEmptyState
title="No SMS Logs"
description="There is no record of SMS messages sent."
icon="chatbubble-outline"
/>
)}
renderItem={({ item, index }) => {
const isDelivered = item.status === 'delivered';
const isFailed = item.status === 'failed';
const statusColor = isDelivered ? theme.colors.success : isFailed ? theme.colors.danger : theme.colors.warning;
const statusIcon = isDelivered ? 'checkmark-done' : isFailed ? 'close-circle' : 'time';
return (
<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} />
<Text style={[styles.phone, { color: theme.colors.textPrimary }]}>{item.customerPhone}</Text>
</View>
<View style={styles.headerRight}>
<Ionicons name={statusIcon} size={14} color={statusColor} />
<Text style={[styles.statusText, { color: statusColor }]}>{item.status.toUpperCase()}</Text>
</View>
</View>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.message, { color: theme.colors.textSecondary }]}>{item.message}</Text>
<Text style={[styles.date, { color: theme.colors.textMuted }]}>{formatDateTime(item.sentAt)}</Text>
</Animated.View>
);
}}
/>
)}
</View>
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
padding: 16,
},
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
marginBottom: 12,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
phone: {
fontSize: 14,
fontWeight: '700',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
statusText: {
fontSize: 10,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 12,
},
message: {
fontSize: 13,
lineHeight: 18,
marginBottom: 8,
},
date: {
fontSize: 11,
textAlign: 'right',
},
});
+239
View File
@@ -0,0 +1,239 @@
import { baseApi } from '../../../services/api/baseApi';
import type {
AgentDashboardStats,
AgentCustomer,
CollectionEntry,
CommissionSummary,
AgentLoanRequest,
AgentRoute,
SmsLog,
CollectionReceipt,
} from '../types/agentTypes';
import {
MOCK_DASHBOARD_STATS,
MOCK_CUSTOMERS,
MOCK_TODAY_COLLECTIONS,
MOCK_COMMISSION_SUMMARY,
MOCK_LOAN_REQUESTS,
MOCK_ROUTE,
MOCK_SMS_LOGS,
} from '../utils/agentMockData';
import { generateReceiptNumber } from '../utils/agentFormatters';
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
export const agentApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
// ── Dashboard ──────────────────────────────────────────
getAgentDashboard: builder.query<AgentDashboardStats, void>({
queryFn: async () => {
await delay(600);
return { data: MOCK_DASHBOARD_STATS };
},
providesTags: ['AgentDashboard'],
}),
// ── Customers ──────────────────────────────────────────
getAgentCustomers: builder.query<AgentCustomer[], void>({
queryFn: async () => {
await delay(500);
return { data: MOCK_CUSTOMERS };
},
providesTags: ['AgentCustomers'],
}),
addAgentCustomer: builder.mutation<AgentCustomer, Partial<AgentCustomer>>({
queryFn: async (customer) => {
await delay(400);
const newCustomer: AgentCustomer = {
id: `c${Date.now()}`,
name: customer.name ?? '',
phone: customer.phone ?? '',
email: customer.email,
address: customer.address ?? '',
city: customer.city ?? '',
state: customer.state ?? '',
pincode: customer.pincode ?? '',
status: 'active',
kyc: customer.kyc ?? {
aadhaarNumber: '',
panNumber: '',
aadhaarVerified: false,
panVerified: false,
status: 'pending',
},
accounts: [],
dueAmount: 0,
totalCollected: 0,
joinedDate: new Date().toISOString(),
};
return { data: newCustomer };
},
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
}),
updateAgentCustomer: builder.mutation<AgentCustomer, AgentCustomer>({
queryFn: async (customer) => {
await delay(300);
return { data: customer };
},
invalidatesTags: ['AgentCustomers'],
}),
deleteAgentCustomer: builder.mutation<{ id: string }, string>({
queryFn: async (id) => {
await delay(300);
return { data: { id } };
},
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
}),
// ── Collections ────────────────────────────────────────
getTodayCollections: builder.query<CollectionEntry[], void>({
queryFn: async () => {
await delay(500);
return { data: MOCK_TODAY_COLLECTIONS };
},
providesTags: ['AgentCollections'],
}),
submitCollection: builder.mutation<CollectionReceipt, { entryId: string; amount: number; mode: string }>({
queryFn: async (params) => {
await delay(500);
const receipt: CollectionReceipt = {
id: `rcp-${Date.now()}`,
collectionId: params.entryId,
customerName: 'Customer',
customerPhone: '9876543000',
accountNumber: 'CBS00000000',
amount: params.amount,
date: new Date().toISOString(),
mode: params.mode as any,
agentName: 'Rajesh Kumar',
agentCode: 'AGT-2024-0147',
referenceNumber: generateReceiptNumber(),
};
return { data: receipt };
},
invalidatesTags: ['AgentCollections', 'AgentDashboard'],
}),
getCollectionHistory: builder.query<CollectionEntry[], { from?: string; to?: string }>({
queryFn: async () => {
await delay(400);
const history = MOCK_TODAY_COLLECTIONS.map(e => ({ ...e, collectedAt: '2026-05-17T10:00:00Z' }));
return { data: history };
},
}),
// ── Commission ─────────────────────────────────────────
getCommissionSummary: builder.query<CommissionSummary, void>({
queryFn: async () => {
await delay(500);
return { data: MOCK_COMMISSION_SUMMARY };
},
providesTags: ['AgentCommission'],
}),
// ── Loan Requests ──────────────────────────────────────
getAgentLoanRequests: builder.query<AgentLoanRequest[], void>({
queryFn: async () => {
await delay(500);
return { data: MOCK_LOAN_REQUESTS };
},
providesTags: ['AgentLoans'],
}),
submitAgentLoanRequest: builder.mutation<AgentLoanRequest, Partial<AgentLoanRequest>>({
queryFn: async (req) => {
await delay(600);
const newRequest: AgentLoanRequest = {
id: `lr-${Date.now()}`,
customerId: req.customerId ?? '',
customerName: req.customerName ?? '',
customerPhone: req.customerPhone ?? '',
category: req.category ?? 'Personal',
amount: req.amount ?? 0,
tenureMonths: req.tenureMonths ?? 12,
purpose: req.purpose ?? '',
monthlyIncome: req.monthlyIncome ?? 0,
status: 'submitted',
appliedDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
documents: req.documents ?? [],
timeline: [{ stage: 'Submitted', timestamp: new Date().toISOString(), active: true }],
};
return { data: newRequest };
},
invalidatesTags: ['AgentLoans', 'AgentDashboard'],
}),
// ── Route ──────────────────────────────────────────────
getAgentRoute: builder.query<AgentRoute, void>({
queryFn: async () => {
await delay(400);
return { data: MOCK_ROUTE };
},
}),
// ── SMS ────────────────────────────────────────────────
getSmsLogs: builder.query<SmsLog[], void>({
queryFn: async () => {
await delay(400);
return { data: MOCK_SMS_LOGS };
},
providesTags: ['AgentSms'],
}),
sendSms: builder.mutation<SmsLog, { customerId: string; type: string; message: string }>({
queryFn: async (params) => {
await delay(500);
const log: SmsLog = {
id: `sms-${Date.now()}`,
customerId: params.customerId,
customerName: 'Customer',
customerPhone: '9876543000',
type: params.type as any,
message: params.message,
status: 'sent',
sentAt: new Date().toISOString(),
retryCount: 0,
};
return { data: log };
},
invalidatesTags: ['AgentSms'],
}),
// ── Account Opening ────────────────────────────────────
submitAccountOpening: builder.mutation<{ accountId: string; accountNumber: string }, any>({
queryFn: async () => {
await delay(800);
return {
data: {
accountId: `acc-${Date.now()}`,
accountNumber: `CBS${Date.now().toString().slice(-8)}`,
},
};
},
invalidatesTags: ['AgentCustomers', 'AgentDashboard'],
}),
}),
});
export const {
useGetAgentDashboardQuery,
useGetAgentCustomersQuery,
useAddAgentCustomerMutation,
useUpdateAgentCustomerMutation,
useDeleteAgentCustomerMutation,
useGetTodayCollectionsQuery,
useSubmitCollectionMutation,
useGetCollectionHistoryQuery,
useGetCommissionSummaryQuery,
useGetAgentLoanRequestsQuery,
useSubmitAgentLoanRequestMutation,
useGetAgentRouteQuery,
useGetSmsLogsQuery,
useSendSmsMutation,
useSubmitAccountOpeningMutation,
} = agentApi;
+73
View File
@@ -0,0 +1,73 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../../app/store';
import type { CustomerFilter } from '../types/agentTypes';
const selectAgent = (state: RootState) => state.agent;
export const selectDashboardStats = createSelector(selectAgent, a => a.dashboardStats);
export const selectIsRefreshing = createSelector(selectAgent, a => a.isRefreshing);
// Customers
export const selectAllCustomers = createSelector(selectAgent, a => a.customers);
export const selectCustomerFilter = createSelector(selectAgent, a => a.customerFilter);
export const selectCustomerSearch = createSelector(selectAgent, a => a.customerSearch);
export const selectFilteredCustomers = createSelector(
[selectAllCustomers, selectCustomerFilter, selectCustomerSearch],
(customers, filter, search) => {
let filtered = customers;
if (filter !== 'all') {
filtered = filtered.filter(c => {
if (filter === 'paid') return c.dueAmount === 0;
if (filter === 'pending') return c.dueAmount > 0 && c.status !== 'overdue';
if (filter === 'overdue') return c.status === 'overdue';
return true;
});
}
if (search.trim()) {
const q = search.toLowerCase();
filtered = filtered.filter(
c => c.name.toLowerCase().includes(q) || c.phone.includes(q),
);
}
return filtered;
},
);
export const selectCustomerById = (customerId: string) =>
createSelector(selectAllCustomers, customers => customers.find(c => c.id === customerId) ?? null);
// Collections
export const selectTodayCollections = createSelector(selectAgent, a => a.todayCollections);
export const selectCollectionHistory = createSelector(selectAgent, a => a.collectionHistory);
export const selectOfflineQueue = createSelector(selectAgent, a => a.offlineQueue);
export const selectTodayCollectionSummary = createSelector(selectTodayCollections, entries => {
const totalCollected = entries.reduce((sum, e) => sum + e.collectedAmount, 0);
const totalPending = entries.reduce((sum, e) => sum + e.pendingAmount, 0);
const collectedCount = entries.filter(e => e.status === 'collected').length;
const pendingCount = entries.filter(e => e.status === 'pending').length;
return { totalCollected, totalPending, collectedCount, pendingCount, total: entries.length };
});
// Commission
export const selectCommissionSummary = createSelector(selectAgent, a => a.commissionSummary);
// Loan Requests
export const selectLoanRequests = createSelector(selectAgent, a => a.loanRequests);
export const selectPendingLoanRequests = createSelector(selectLoanRequests, lr =>
lr.filter(l => l.status === 'submitted' || l.status === 'under_review'),
);
// Route
export const selectRoute = createSelector(selectAgent, a => a.route);
// SMS
export const selectSmsLogs = createSelector(selectAgent, a => a.smsLogs);
// Account Opening
export const selectAccountDraft = createSelector(selectAgent, a => a.accountOpeningDraft);
export const selectAccountStep = createSelector(selectAgent, a => a.accountOpeningStep);
+163
View File
@@ -0,0 +1,163 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type {
AgentCustomer,
CollectionEntry,
CustomerFilter,
AgentDashboardStats,
CommissionSummary,
AgentLoanRequest,
AgentRoute,
SmsLog,
AccountOpeningDraft,
AccountOpeningStep,
NewAccountType,
} from '../types/agentTypes';
interface AgentState {
dashboardStats: AgentDashboardStats | null;
customers: AgentCustomer[];
customerFilter: CustomerFilter;
customerSearch: string;
todayCollections: CollectionEntry[];
collectionHistory: CollectionEntry[];
offlineQueue: CollectionEntry[];
commissionSummary: CommissionSummary | null;
loanRequests: AgentLoanRequest[];
route: AgentRoute | null;
smsLogs: SmsLog[];
accountOpeningDraft: AccountOpeningDraft;
accountOpeningStep: AccountOpeningStep;
isRefreshing: boolean;
}
const emptyAccountDraft: AccountOpeningDraft = {
accountType: null,
customerName: '',
phone: '',
email: '',
address: '',
city: '',
state: '',
pincode: '',
dateOfBirth: '',
gender: '',
aadhaarNumber: '',
panNumber: '',
nomineeName: '',
nomineeRelation: '',
initialDeposit: '',
monthlyInstallment: '',
};
const initialState: AgentState = {
dashboardStats: null,
customers: [],
customerFilter: 'all',
customerSearch: '',
todayCollections: [],
collectionHistory: [],
offlineQueue: [],
commissionSummary: null,
loanRequests: [],
route: null,
smsLogs: [],
accountOpeningDraft: emptyAccountDraft,
accountOpeningStep: 'type',
isRefreshing: false,
};
const agentSlice = createSlice({
name: 'agent',
initialState,
reducers: {
setDashboardStats(state, action: PayloadAction<AgentDashboardStats>) {
state.dashboardStats = action.payload;
},
setCustomers(state, action: PayloadAction<AgentCustomer[]>) {
// Merge: keep any locally-added customers not present in the API response
const incomingIds = new Set(action.payload.map(c => c.id));
const localOnly = state.customers.filter(c => !incomingIds.has(c.id));
state.customers = [...localOnly, ...action.payload];
},
addCustomer(state, action: PayloadAction<AgentCustomer>) {
state.customers.unshift(action.payload);
},
updateCustomer(state, action: PayloadAction<AgentCustomer>) {
const idx = state.customers.findIndex(c => c.id === action.payload.id);
if (idx !== -1) state.customers[idx] = action.payload;
},
removeCustomer(state, action: PayloadAction<string>) {
state.customers = state.customers.filter(c => c.id !== action.payload);
},
setCustomerFilter(state, action: PayloadAction<CustomerFilter>) {
state.customerFilter = action.payload;
},
setCustomerSearch(state, action: PayloadAction<string>) {
state.customerSearch = action.payload;
},
setTodayCollections(state, action: PayloadAction<CollectionEntry[]>) {
state.todayCollections = action.payload;
},
updateCollectionEntry(state, action: PayloadAction<CollectionEntry>) {
const idx = state.todayCollections.findIndex(c => c.id === action.payload.id);
if (idx !== -1) state.todayCollections[idx] = action.payload;
},
addToOfflineQueue(state, action: PayloadAction<CollectionEntry>) {
state.offlineQueue.push(action.payload);
},
clearOfflineQueue(state) {
state.offlineQueue = [];
},
setCollectionHistory(state, action: PayloadAction<CollectionEntry[]>) {
state.collectionHistory = action.payload;
},
setCommissionSummary(state, action: PayloadAction<CommissionSummary>) {
state.commissionSummary = action.payload;
},
setLoanRequests(state, action: PayloadAction<AgentLoanRequest[]>) {
state.loanRequests = action.payload;
},
addLoanRequest(state, action: PayloadAction<AgentLoanRequest>) {
state.loanRequests.unshift(action.payload);
},
setRoute(state, action: PayloadAction<AgentRoute>) {
state.route = action.payload;
},
updateRoutePointStatus(state, action: PayloadAction<{ pointId: string; status: 'visited' | 'skipped' }>) {
if (!state.route) return;
const point = state.route.points.find(p => p.id === action.payload.pointId);
if (point) {
point.visitStatus = action.payload.status;
point.visitedAt = new Date().toISOString();
const total = state.route.points.length;
const done = state.route.points.filter(p => p.visitStatus !== 'pending').length;
state.route.completionPercent = Math.round((done / total) * 100);
}
},
setSmsLogs(state, action: PayloadAction<SmsLog[]>) {
state.smsLogs = action.payload;
},
addSmsLog(state, action: PayloadAction<SmsLog>) {
state.smsLogs.unshift(action.payload);
},
setAccountOpeningStep(state, action: PayloadAction<AccountOpeningStep>) {
state.accountOpeningStep = action.payload;
},
updateAccountDraftField(state, action: PayloadAction<{ field: keyof AccountOpeningDraft; value: string }>) {
(state.accountOpeningDraft as any)[action.payload.field] = action.payload.value;
},
setAccountType(state, action: PayloadAction<NewAccountType>) {
state.accountOpeningDraft.accountType = action.payload;
},
resetAccountDraft(state) {
state.accountOpeningDraft = emptyAccountDraft;
state.accountOpeningStep = 'type';
},
setRefreshing(state, action: PayloadAction<boolean>) {
state.isRefreshing = action.payload;
},
},
});
export const agentActions = agentSlice.actions;
export const agentReducer = agentSlice.reducer;
+262
View File
@@ -0,0 +1,262 @@
// ─── Agent Profile ───────────────────────────────────────────────────────────
export interface AgentProfile {
id: string;
name: string;
phone: string;
email: string;
avatarUrl?: string;
agentCode: string;
branch: string;
region: string;
joinedDate: string;
commissionRate: number; // percentage
biometricEnabled: boolean;
language: 'en' | 'hi' | 'bn';
}
// ─── Dashboard ───────────────────────────────────────────────────────────────
export interface AgentDashboardStats {
totalCustomers: number;
paidCustomers: number;
pendingCustomers: number;
todayCollection: number;
weeklyCollection: number;
monthlyCollection: number;
totalCollectionTillDate: number;
commissionEarned: number;
lastCommissionPaidDate: string;
possibleCollection: number;
actualCollection: number;
commissionLoss: number;
pendingAmount: number;
pendingLoanRequests: number;
approvedLoanRequests: number;
todayVisits: number;
completedVisits: number;
weeklyCollectionTrend: number[];
monthlyCollectionTrend: number[];
}
// ─── Customer ────────────────────────────────────────────────────────────────
export type CustomerStatus = 'active' | 'inactive' | 'overdue';
export type CustomerFilter = 'all' | 'paid' | 'pending' | 'overdue';
export type KycStatus = 'verified' | 'pending' | 'rejected';
export interface CustomerKyc {
aadhaarNumber: string;
panNumber: string;
aadhaarVerified: boolean;
panVerified: boolean;
aadhaarDocUrl?: string;
panDocUrl?: string;
photoUrl?: string;
status: KycStatus;
}
export interface CustomerAccount {
id: string;
type: 'DDS' | 'RD' | 'Savings';
accountNumber: string;
balance: number;
emiAmount: number;
totalPaid: number;
totalDue: number;
startDate: string;
maturityDate?: string;
status: 'active' | 'closed' | 'matured';
}
export interface AgentCustomer {
id: string;
name: string;
phone: string;
email?: string;
address: string;
city: string;
state: string;
pincode: string;
photoUrl?: string;
status: CustomerStatus;
kyc: CustomerKyc;
accounts: CustomerAccount[];
dueAmount: number;
lastPaymentDate?: string;
lastPaymentAmount?: number;
totalCollected: number;
joinedDate: string;
}
// ─── Collection ──────────────────────────────────────────────────────────────
export type CollectionStatus = 'collected' | 'pending' | 'partial' | 'skipped';
export type CollectionMode = 'cash' | 'upi' | 'bank_transfer';
export interface CollectionEntry {
id: string;
customerId: string;
customerName: string;
customerPhone: string;
accountId: string;
accountType: string;
emiAmount: number;
collectedAmount: number;
pendingAmount: number;
status: CollectionStatus;
mode: CollectionMode;
collectedAt?: string;
latitude?: number;
longitude?: number;
remarks?: string;
receiptId?: string;
synced: boolean;
}
export interface CollectionReceipt {
id: string;
collectionId: string;
customerName: string;
customerPhone: string;
accountNumber: string;
amount: number;
date: string;
mode: CollectionMode;
agentName: string;
agentCode: string;
referenceNumber: string;
}
export interface CollectionDaySummary {
date: string;
totalCollected: number;
totalPending: number;
entriesCount: number;
collectedCount: number;
pendingCount: number;
}
// ─── Commission ──────────────────────────────────────────────────────────────
export interface CommissionRecord {
id: string;
month: string;
year: number;
possibleCollection: number;
actualCollection: number;
commissionRate: number;
possibleCommission: number;
actualCommission: number;
loss: number;
paidDate?: string;
status: 'paid' | 'pending' | 'processing';
}
export interface CommissionSummary {
totalEarned: number;
lastPaidAmount: number;
lastPaidDate: string;
currentMonthExpected: number;
currentMonthActual: number;
currentMonthLoss: number;
commissionRate: number;
history: CommissionRecord[];
}
// ─── Loan Request ────────────────────────────────────────────────────────────
export type LoanRequestStatus = 'draft' | 'submitted' | 'under_review' | 'approved' | 'rejected' | 'disbursed';
export type AgentLoanCategory = 'Personal' | 'Gold' | 'Vehicle' | 'Housing' | 'Agriculture' | 'Business';
export interface AgentLoanRequest {
id: string;
customerId: string;
customerName: string;
customerPhone: string;
category: AgentLoanCategory;
amount: number;
tenureMonths: number;
purpose: string;
monthlyIncome: number;
status: LoanRequestStatus;
appliedDate: string;
lastUpdated: string;
documents: LoanRequestDocument[];
remarks?: string;
rejectionReason?: string;
approvedAmount?: number;
timeline: { stage: string; timestamp: string; active: boolean }[];
}
export interface LoanRequestDocument {
id: string;
name: string;
type: 'aadhaar' | 'pan' | 'income_proof' | 'address_proof' | 'photo' | 'other';
status: 'uploaded' | 'verified' | 'rejected';
uploadedAt: string;
}
// ─── Account Opening ─────────────────────────────────────────────────────────
export type NewAccountType = 'DDS' | 'RD' | 'Savings';
export type AccountOpeningStep = 'type' | 'personal' | 'kyc' | 'deposit' | 'review';
export interface AccountOpeningDraft {
accountType: NewAccountType | null;
customerName: string;
phone: string;
email: string;
address: string;
city: string;
state: string;
pincode: string;
dateOfBirth: string;
gender: string;
aadhaarNumber: string;
panNumber: string;
nomineeName: string;
nomineeRelation: string;
initialDeposit: string;
monthlyInstallment: string;
photoUrl?: string;
aadhaarDocUrl?: string;
panDocUrl?: string;
}
// ─── Route ───────────────────────────────────────────────────────────────────
export type VisitStatus = 'pending' | 'visited' | 'skipped';
export interface RoutePoint {
id: string;
customerId: string;
customerName: string;
customerPhone: string;
address: string;
latitude: number;
longitude: number;
dueAmount: number;
visitStatus: VisitStatus;
visitedAt?: string;
distance?: string;
estimatedTime?: string;
}
export interface AgentRoute {
id: string;
date: string;
points: RoutePoint[];
totalDistance: string;
estimatedDuration: string;
completionPercent: number;
}
// ─── SMS ─────────────────────────────────────────────────────────────────────
export type SmsType = 'balance' | 'payment_success' | 'due_reminder' | 'loan_status' | 'account_created';
export type SmsStatus = 'sent' | 'failed' | 'pending' | 'delivered';
export interface SmsLog {
id: string;
customerId: string;
customerName: string;
customerPhone: string;
type: SmsType;
message: string;
status: SmsStatus;
sentAt: string;
deliveredAt?: string;
retryCount: number;
}
@@ -0,0 +1,56 @@
export const formatAgentCurrency = (value: number): string => {
if (value >= 10000000) {
return `₹${(value / 10000000).toFixed(2)} Cr`;
}
if (value >= 100000) {
return `₹${(value / 100000).toFixed(2)} L`;
}
if (value >= 1000) {
return `₹${(value / 1000).toFixed(1)}K`;
}
return `₹${value.toLocaleString('en-IN')}`;
};
export const formatFullCurrency = (value: number): string =>
`₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
};
export const formatTime = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' });
};
export const formatDateTime = (dateString: string): string =>
`${formatDate(dateString)}, ${formatTime(dateString)}`;
export const maskPhone = (phone: string): string => {
if (phone.length < 10) return phone;
return `${phone.slice(0, 2)}****${phone.slice(-4)}`;
};
export const maskAadhaar = (aadhaar: string): string => {
const digits = aadhaar.replace(/\D/g, '');
if (digits.length < 12) return aadhaar;
return `XXXX XXXX ${digits.slice(-4)}`;
};
export const formatPercentage = (value: number, decimals = 1): string =>
`${value.toFixed(decimals)}%`;
export const getGreeting = (): string => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
};
export const generateReceiptNumber = (): string => {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
const random = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
return `RCP-${dateStr}-${random}`;
};
+220
View File
@@ -0,0 +1,220 @@
import type {
AgentProfile,
AgentDashboardStats,
AgentCustomer,
CollectionEntry,
CommissionSummary,
CommissionRecord,
AgentLoanRequest,
AgentRoute,
RoutePoint,
SmsLog,
} from '../types/agentTypes';
export const MOCK_AGENT_PROFILE: AgentProfile = {
id: 'agent-001',
name: 'Rajesh Kumar',
phone: '9876543210',
email: 'rajesh.kumar@cbsbank.in',
agentCode: 'AGT-2024-0147',
branch: 'Kolkata Main Branch',
region: 'West Bengal',
joinedDate: '2023-03-15',
commissionRate: 2.5,
biometricEnabled: true,
language: 'en',
};
export const MOCK_DASHBOARD_STATS: AgentDashboardStats = {
totalCustomers: 248,
paidCustomers: 189,
pendingCustomers: 59,
todayCollection: 34500,
weeklyCollection: 187500,
monthlyCollection: 756000,
totalCollectionTillDate: 8945000,
commissionEarned: 18900,
lastCommissionPaidDate: '2026-04-30',
possibleCollection: 875000,
actualCollection: 756000,
commissionLoss: 2975,
pendingAmount: 119000,
pendingLoanRequests: 7,
approvedLoanRequests: 23,
todayVisits: 18,
completedVisits: 11,
weeklyCollectionTrend: [28500, 31200, 25800, 34500, 29000, 37800, 34500],
monthlyCollectionTrend: [680000, 720000, 695000, 756000],
};
const makeCustomer = (
id: string, name: string, phone: string, status: 'active' | 'inactive' | 'overdue',
dueAmount: number, totalCollected: number, lastPayment?: number,
): AgentCustomer => ({
id,
name,
phone,
email: `${name.toLowerCase().replace(/\s/g, '.')}@email.com`,
address: '123, Main Road',
city: 'Kolkata',
state: 'West Bengal',
pincode: '700001',
status,
kyc: {
aadhaarNumber: '123456789012',
panNumber: 'ABCDE1234F',
aadhaarVerified: true,
panVerified: status !== 'overdue',
status: status === 'overdue' ? 'pending' : 'verified',
},
accounts: [
{
id: `acc-${id}`,
type: 'DDS',
accountNumber: `CBS${id.padStart(8, '0')}`,
balance: totalCollected,
emiAmount: 500,
totalPaid: totalCollected,
totalDue: dueAmount,
startDate: '2025-01-01',
status: 'active',
},
],
dueAmount,
lastPaymentDate: lastPayment ? '2026-05-17' : undefined,
lastPaymentAmount: lastPayment,
totalCollected,
joinedDate: '2025-01-15',
});
export const MOCK_CUSTOMERS: AgentCustomer[] = [
makeCustomer('c001', 'Amit Sharma', '9876543001', 'active', 0, 15000, 500),
makeCustomer('c002', 'Priya Devi', '9876543002', 'active', 500, 12000, 500),
makeCustomer('c003', 'Ravi Prasad', '9876543003', 'overdue', 2500, 8500),
makeCustomer('c004', 'Sunita Kumari', '9876543004', 'active', 0, 18000, 500),
makeCustomer('c005', 'Manoj Singh', '9876543005', 'overdue', 1500, 6000),
makeCustomer('c006', 'Deepa Gupta', '9876543006', 'active', 500, 14500, 500),
makeCustomer('c007', 'Arun Verma', '9876543007', 'active', 0, 16000, 500),
makeCustomer('c008', 'Kavita Rani', '9876543008', 'inactive', 3000, 3000),
makeCustomer('c009', 'Suresh Yadav', '9876543009', 'active', 0, 20000, 500),
makeCustomer('c010', 'Meena Devi', '9876543010', 'overdue', 2000, 7000),
makeCustomer('c011', 'Vikas Jha', '9876543011', 'active', 500, 11500, 500),
makeCustomer('c012', 'Anita Patel', '9876543012', 'active', 0, 19000, 500),
];
const makeCollectionEntry = (
id: string, customerId: string, customerName: string, phone: string,
emiAmount: number, collected: number, status: 'collected' | 'pending' | 'partial',
): CollectionEntry => ({
id,
customerId,
customerName,
customerPhone: phone,
accountId: `acc-${customerId}`,
accountType: 'DDS',
emiAmount,
collectedAmount: collected,
pendingAmount: Math.max(0, emiAmount - collected),
status,
mode: 'cash',
collectedAt: status !== 'pending' ? new Date().toISOString() : undefined,
latitude: 22.5726 + Math.random() * 0.01,
longitude: 88.3639 + Math.random() * 0.01,
synced: true,
});
export const MOCK_TODAY_COLLECTIONS: CollectionEntry[] = [
makeCollectionEntry('col-001', 'c001', 'Amit Sharma', '9876543001', 500, 500, 'collected'),
makeCollectionEntry('col-002', 'c002', 'Priya Devi', '9876543002', 500, 500, 'collected'),
makeCollectionEntry('col-003', 'c003', 'Ravi Prasad', '9876543003', 500, 0, 'pending'),
makeCollectionEntry('col-004', 'c004', 'Sunita Kumari', '9876543004', 500, 500, 'collected'),
makeCollectionEntry('col-005', 'c005', 'Manoj Singh', '9876543005', 500, 200, 'partial'),
makeCollectionEntry('col-006', 'c006', 'Deepa Gupta', '9876543006', 500, 0, 'pending'),
makeCollectionEntry('col-007', 'c007', 'Arun Verma', '9876543007', 500, 500, 'collected'),
makeCollectionEntry('col-008', 'c009', 'Suresh Yadav', '9876543009', 500, 0, 'pending'),
];
export const MOCK_COMMISSION_SUMMARY: CommissionSummary = {
totalEarned: 18900,
lastPaidAmount: 4750,
lastPaidDate: '2026-04-30',
currentMonthExpected: 21875,
currentMonthActual: 18900,
currentMonthLoss: 2975,
commissionRate: 2.5,
history: [
{ id: 'com-01', month: 'January', year: 2026, possibleCollection: 800000, actualCollection: 750000, commissionRate: 2.5, possibleCommission: 20000, actualCommission: 18750, loss: 1250, paidDate: '2026-01-31', status: 'paid' },
{ id: 'com-02', month: 'February', year: 2026, possibleCollection: 820000, actualCollection: 790000, commissionRate: 2.5, possibleCommission: 20500, actualCommission: 19750, loss: 750, paidDate: '2026-02-28', status: 'paid' },
{ id: 'com-03', month: 'March', year: 2026, possibleCollection: 850000, actualCollection: 810000, commissionRate: 2.5, possibleCommission: 21250, actualCommission: 20250, loss: 1000, paidDate: '2026-03-31', status: 'paid' },
{ id: 'com-04', month: 'April', year: 2026, possibleCollection: 870000, actualCollection: 760000, commissionRate: 2.5, possibleCommission: 21750, actualCommission: 19000, loss: 2750, paidDate: '2026-04-30', status: 'paid' },
{ id: 'com-05', month: 'May', year: 2026, possibleCollection: 875000, actualCollection: 756000, commissionRate: 2.5, possibleCommission: 21875, actualCommission: 18900, loss: 2975, status: 'pending' },
] as CommissionRecord[],
};
export const MOCK_LOAN_REQUESTS: AgentLoanRequest[] = [
{
id: 'lr-001', customerId: 'c001', customerName: 'Amit Sharma', customerPhone: '9876543001',
category: 'Personal', amount: 200000, tenureMonths: 24, purpose: 'Home renovation',
monthlyIncome: 35000, status: 'approved', appliedDate: '2026-04-20', lastUpdated: '2026-05-10',
documents: [
{ id: 'd1', name: 'Aadhaar Card', type: 'aadhaar', status: 'verified', uploadedAt: '2026-04-20' },
{ id: 'd2', name: 'PAN Card', type: 'pan', status: 'verified', uploadedAt: '2026-04-20' },
],
approvedAmount: 180000,
timeline: [
{ stage: 'Submitted', timestamp: '2026-04-20T10:00:00Z', active: false },
{ stage: 'Under Review', timestamp: '2026-04-25T14:00:00Z', active: false },
{ stage: 'Approved', timestamp: '2026-05-10T11:00:00Z', active: true },
],
},
{
id: 'lr-002', customerId: 'c003', customerName: 'Ravi Prasad', customerPhone: '9876543003',
category: 'Gold', amount: 100000, tenureMonths: 12, purpose: 'Business expansion',
monthlyIncome: 28000, status: 'under_review', appliedDate: '2026-05-12', lastUpdated: '2026-05-15',
documents: [
{ id: 'd3', name: 'Aadhaar Card', type: 'aadhaar', status: 'uploaded', uploadedAt: '2026-05-12' },
],
timeline: [
{ stage: 'Submitted', timestamp: '2026-05-12T09:00:00Z', active: false },
{ stage: 'Under Review', timestamp: '2026-05-15T10:00:00Z', active: true },
],
},
{
id: 'lr-003', customerId: 'c005', customerName: 'Manoj Singh', customerPhone: '9876543005',
category: 'Vehicle', amount: 350000, tenureMonths: 48, purpose: 'Two-wheeler purchase',
monthlyIncome: 22000, status: 'submitted', appliedDate: '2026-05-17', lastUpdated: '2026-05-17',
documents: [],
timeline: [
{ stage: 'Submitted', timestamp: '2026-05-17T16:00:00Z', active: true },
],
},
];
export const MOCK_ROUTE: AgentRoute = {
id: 'route-today',
date: new Date().toISOString().split('T')[0],
totalDistance: '12.5 km',
estimatedDuration: '2h 30m',
completionPercent: 61,
points: MOCK_CUSTOMERS.slice(0, 8).map((c, i): RoutePoint => ({
id: `rp-${i}`,
customerId: c.id,
customerName: c.name,
customerPhone: c.phone,
address: `${c.address}, ${c.city}`,
latitude: 22.5726 + i * 0.002,
longitude: 88.3639 + i * 0.003,
dueAmount: c.dueAmount,
visitStatus: i < 5 ? 'visited' : 'pending',
visitedAt: i < 5 ? new Date(Date.now() - (5 - i) * 3600000).toISOString() : undefined,
distance: `${(1.2 + i * 0.3).toFixed(1)} km`,
estimatedTime: `${8 + i * 3} min`,
})),
};
export const MOCK_SMS_LOGS: SmsLog[] = [
{ id: 'sms-001', customerId: 'c001', customerName: 'Amit Sharma', customerPhone: '9876543001', type: 'payment_success', message: 'Dear Amit, your DDS payment of ₹500 has been received. Ref: RCP-20260518-0001. Thank you.', status: 'delivered', sentAt: '2026-05-18T10:30:00Z', deliveredAt: '2026-05-18T10:30:05Z', retryCount: 0 },
{ id: 'sms-002', customerId: 'c003', customerName: 'Ravi Prasad', customerPhone: '9876543003', type: 'due_reminder', message: 'Dear Ravi, your DDS installment of ₹500 is overdue. Please pay at the earliest. Contact your agent.', status: 'sent', sentAt: '2026-05-18T09:00:00Z', retryCount: 0 },
{ id: 'sms-003', customerId: 'c005', customerName: 'Manoj Singh', customerPhone: '9876543005', type: 'loan_status', message: 'Dear Manoj, your vehicle loan application (₹3,50,000) has been submitted. Ref: LR-003.', status: 'failed', sentAt: '2026-05-17T16:30:00Z', retryCount: 2 },
{ id: 'sms-004', customerId: 'c002', customerName: 'Priya Devi', customerPhone: '9876543002', type: 'balance', message: 'Dear Priya, your DDS account balance is ₹12,000. Next installment due: 19-May-2026.', status: 'delivered', sentAt: '2026-05-17T08:00:00Z', deliveredAt: '2026-05-17T08:00:03Z', retryCount: 0 },
];
+134
View File
@@ -0,0 +1,134 @@
export const validatePhone = (phone: string): string | null => {
const digits = phone.replace(/\D/g, '');
if (!digits) return 'Phone number is required';
if (digits.length !== 10) return 'Enter a valid 10-digit phone number';
if (!/^[6-9]/.test(digits)) return 'Phone must start with 6, 7, 8, or 9';
return null;
};
export const validateAadhaar = (aadhaar: string): string | null => {
const digits = aadhaar.replace(/\D/g, '');
if (!digits) return 'Aadhaar number is required';
if (digits.length !== 12) return 'Enter a valid 12-digit Aadhaar number';
return null;
};
export const validatePan = (pan: string): string | null => {
if (!pan.trim()) return 'PAN number is required';
const panRegex = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
if (!panRegex.test(pan.toUpperCase())) return 'Enter a valid PAN (e.g. ABCDE1234F)';
return null;
};
export const validateEmail = (email: string): string | null => {
if (!email.trim()) return null; // optional
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) return 'Enter a valid email address';
return null;
};
export const validateRequired = (value: string, fieldName: string): string | null => {
if (!value.trim()) return `${fieldName} is required`;
return null;
};
export const validateAmount = (amount: string, min = 1): string | null => {
const num = Number(amount);
if (!amount.trim() || isNaN(num)) return 'Enter a valid amount';
if (num < min) return `Amount must be at least ₹${min}`;
return null;
};
export const validatePincode = (pincode: string): string | null => {
const digits = pincode.replace(/\D/g, '');
if (!digits) return 'Pincode is required';
if (digits.length !== 6) return 'Enter a valid 6-digit pincode';
return null;
};
export interface ValidationResult {
isValid: boolean;
errors: Record<string, string>;
}
export const validateCustomerForm = (data: {
name: string;
phone: string;
address: string;
city: string;
state: string;
pincode: string;
aadhaarNumber: string;
panNumber: string;
email?: string;
}): ValidationResult => {
const errors: Record<string, string> = {};
const nameErr = validateRequired(data.name, 'Name');
if (nameErr) errors.name = nameErr;
const phoneErr = validatePhone(data.phone);
if (phoneErr) errors.phone = phoneErr;
const addressErr = validateRequired(data.address, 'Address');
if (addressErr) errors.address = addressErr;
const cityErr = validateRequired(data.city, 'City');
if (cityErr) errors.city = cityErr;
const stateErr = validateRequired(data.state, 'State');
if (stateErr) errors.state = stateErr;
const pincodeErr = validatePincode(data.pincode);
if (pincodeErr) errors.pincode = pincodeErr;
const aadhaarErr = validateAadhaar(data.aadhaarNumber);
if (aadhaarErr) errors.aadhaarNumber = aadhaarErr;
const panErr = validatePan(data.panNumber);
if (panErr) errors.panNumber = panErr;
if (data.email) {
const emailErr = validateEmail(data.email);
if (emailErr) errors.email = emailErr;
}
return { isValid: Object.keys(errors).length === 0, errors };
};
export const validateCollectionAmount = (collected: string, emi: number): string | null => {
const num = Number(collected);
if (!collected.trim() || isNaN(num)) return 'Enter a valid amount';
if (num <= 0) return 'Amount must be greater than 0';
if (num > emi * 3) return 'Amount exceeds 3x EMI limit';
return null;
};
export const validateLoanRequest = (data: {
customerName: string;
amount: string;
tenureMonths: string;
purpose: string;
monthlyIncome: string;
category: string | null;
}): ValidationResult => {
const errors: Record<string, string> = {};
if (!data.category) errors.category = 'Select a loan category';
if (!data.customerName.trim()) errors.customerName = 'Select a customer';
const amountErr = validateAmount(data.amount, 5000);
if (amountErr) errors.amount = amountErr;
const tenureNum = Number(data.tenureMonths);
if (!data.tenureMonths || isNaN(tenureNum) || tenureNum < 3 || tenureNum > 360) {
errors.tenureMonths = 'Tenure must be between 3 and 360 months';
}
if (!data.purpose.trim()) errors.purpose = 'Enter the loan purpose';
const incomeErr = validateAmount(data.monthlyIncome, 1000);
if (incomeErr) errors.monthlyIncome = incomeErr;
return { isValid: Object.keys(errors).length === 0, errors };
};
@@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { StyleSheet, View, useWindowDimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
interpolate
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
const Orb = ({ size, color, delay, duration, startPos }: {
size: number;
color: string;
delay: number;
duration: number;
startPos: { x: number; y: number };
}) => {
const tx = useSharedValue(0);
const ty = useSharedValue(0);
useEffect(() => {
tx.value = withRepeat(
withTiming(1, { duration, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
ty.value = withRepeat(
withTiming(1, { duration: duration * 1.2, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateX: interpolate(tx.value, [0, 1], [-20, 20]) },
{ translateY: interpolate(ty.value, [0, 1], [-30, 30]) },
],
};
});
return (
<Animated.View
style={[
styles.orb,
animatedStyle,
{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
left: startPos.x,
top: startPos.y,
}
]}
/>
);
};
export const BackgroundOrbs = () => {
const { width, height } = useWindowDimensions();
const theme = useTheme();
return (
<View style={[StyleSheet.absoluteFill, styles.container]}>
<Orb
size={width * 0.8}
color={theme.colors.primary}
delay={0}
duration={8000}
startPos={{ x: -50, y: height * 0.1 }}
/>
<Orb
size={width * 0.6}
color="#3B82F6"
delay={1000}
duration={10000}
startPos={{ x: width * 0.4, y: height * 0.5 }}
/>
<Orb
size={width * 0.5}
color="#6366F1"
delay={2000}
duration={12000}
startPos={{ x: width * 0.1, y: height * 0.7 }}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#0A0F1E',
overflow: 'hidden',
},
orb: {
position: 'absolute',
opacity: 0.15,
},
});
@@ -0,0 +1,81 @@
import React, { useEffect } from 'react';
import { StyleSheet, Text, View, Pressable, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
interpolate
} from 'react-native-reanimated';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
interface BiometricNodeProps {
onPress: () => void;
}
export const BiometricNode = ({ onPress }: BiometricNodeProps) => {
const theme = useTheme();
const scale = useSharedValue(1);
useEffect(() => {
scale.value = withRepeat(
withTiming(1.05, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<View style={styles.container}>
<Pressable onPress={onPress} style={styles.pressable}>
<Animated.View style={[styles.node, { backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder }, animatedStyle]}>
<Ionicons name="finger-print-outline" size={32} color={theme.colors.primary} />
</Animated.View>
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Use Biometrics</Text>
</Pressable>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
marginTop: 8,
},
pressable: {
alignItems: 'center',
gap: 10,
},
node: {
width: 64,
height: 64,
borderRadius: 32,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 2,
},
}),
},
label: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.5,
},
});
+106
View File
@@ -0,0 +1,106 @@
import React, { memo, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
FadeInDown,
Easing,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
const CBSNexoraLogo = () => {
return (
<View style={styles.logoGrid}>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#1E40AF' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#3B82F6' }]} />
</View>
<View style={styles.logoRow}>
<View style={[styles.logoSquare, { backgroundColor: '#10B981' }]} />
<View style={[styles.logoSquare, { backgroundColor: '#CBD5E1' }]} />
</View>
</View>
);
};
const LoginHeroComponent = () => {
const theme = useTheme();
return (
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(800).springify()}
style={styles.logoWrapper}
>
<CBSNexoraLogo />
<Text style={[styles.brandName, { color: theme.colors.textPrimary }]}>
CBS<Text style={{ color: theme.colors.primary }}>Nexora</Text>
</Text>
</Animated.View>
<Animated.View entering={FadeInDown.delay(200).duration(800).springify()} style={styles.textContainer}>
<Text style={[styles.welcome, { color: theme.colors.textPrimary }]}>Secure Banking</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
Enter your credentials to access your global accounts.
</Text>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 32,
gap: 24,
},
logoWrapper: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
logoGrid: {
gap: 3,
},
logoRow: {
flexDirection: 'row',
gap: 3,
},
logoSquare: {
width: 16,
height: 16,
borderRadius: 3,
},
brandName: {
fontSize: 28,
fontWeight: '800',
letterSpacing: -0.5,
},
textContainer: {
alignItems: 'center',
gap: 6,
paddingHorizontal: 40,
},
welcome: {
fontSize: 24,
fontWeight: '700',
textAlign: 'center',
},
subtitle: {
fontSize: 15,
textAlign: 'center',
fontWeight: '400',
lineHeight: 22,
},
});
export const LoginHero = memo(LoginHeroComponent);
@@ -0,0 +1,45 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../../theme';
export function LoginHero() {
const theme = useTheme();
return (
<View style={styles.container}>
<View style={[styles.logoFrame, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="shield-checkmark" size={28} color={theme.colors.primary} />
</View>
<Text style={[styles.heading, { color: theme.colors.textPrimary }]}>BankCBS</Text>
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>Banking made effortless for your everyday money management.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
marginBottom: 22,
gap: 12,
},
logoFrame: {
width: 72,
height: 72,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
heading: {
fontSize: 28,
fontWeight: '800',
textAlign: 'center',
},
description: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
maxWidth: 320,
},
});
@@ -0,0 +1,98 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated';
import { useTheme } from '../../../theme';
import { haptics } from '../../../services/haptics/haptics';
import type { LoginRole } from '../../../types/app';
interface LoginRoleTabsProps {
activeRole: LoginRole;
onRoleChange: (role: LoginRole) => void;
}
const ROLES: LoginRole[] = ['User', 'Employee', 'Agent'];
const ActiveTabBackground = () => (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
layout={Layout.springify().damping(15)}
style={StyleSheet.absoluteFill}
>
<Svg width="100%" height="100%" viewBox="0 0 110 36" preserveAspectRatio="none">
<Path d="M 0,36 L 0,12 Q 0,0 12,0 L 80,0 Q 88,0 92,6 L 110,36 Z" fill="#143e6c" />
</Svg>
</Animated.View>
);
export const LoginRoleTabs: React.FC<LoginRoleTabsProps> = ({ activeRole, onRoleChange }) => {
const theme = useTheme();
return (
<View style={styles.container}>
{ROLES.map((role, index) => {
const isActive = activeRole === role;
return (
<Pressable
key={role}
onPress={() => {
if (!isActive) {
haptics.selection();
onRoleChange(role);
}
}}
style={({ pressed }) => [
styles.tab,
index > 0 && { marginLeft: isActive ? -10 : 0 },
isActive && { zIndex: 10 },
pressed && !isActive && { opacity: 0.6 },
pressed && isActive && { transform: [{ scale: 0.98 }] }
]}
>
{isActive && <ActiveTabBackground />}
<Animated.Text
layout={Layout.springify().damping(15)}
style={[
styles.tabText,
isActive ? styles.activeText : [styles.inactiveText, { color: theme.colors.textSecondary }],
]}
>
{role}
</Animated.Text>
</Pressable>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: -1,
zIndex: 10,
marginLeft: 0,
},
tab: {
justifyContent: 'center',
alignItems: 'center',
height: 38,
width: 110,
},
tabText: {
fontSize: 14,
fontWeight: '700',
zIndex: 2,
transform: [{ translateX: -4 }],
},
activeText: {
color: '#FFFFFF',
},
inactiveText: {
fontWeight: '600',
},
});
+179
View File
@@ -0,0 +1,179 @@
import React, { useMemo } from 'react';
import { StyleSheet, View, useWindowDimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
withDelay,
withSequence,
} from 'react-native-reanimated';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../../theme';
const Node = ({ x, y, delay, color }: { x: number; y: number; delay: number; color: string }) => {
const opacity = useSharedValue(0.1);
const scale = useSharedValue(0.8);
React.useEffect(() => {
opacity.value = withRepeat(
withSequence(
withDelay(delay, withTiming(0.4, { duration: 1500, easing: Easing.inOut(Easing.sin) })),
withTiming(0.1, { duration: 1500, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
scale.value = withRepeat(
withSequence(
withDelay(delay, withTiming(1.2, { duration: 1500, easing: Easing.inOut(Easing.sin) })),
withTiming(0.8, { duration: 1500, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, [delay]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
return (
<Animated.View
style={[
styles.node,
{
left: x,
top: y,
backgroundColor: color,
shadowColor: color,
},
animatedStyle
]}
/>
);
};
export const NeuralGrid = () => {
const { width, height } = useWindowDimensions();
const theme = useTheme();
const nodes = useMemo(() => {
const items = [];
const cols = 8;
const rows = 16;
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
items.push({
id: `${i}-${j}`,
x: (width / (cols - 1)) * i,
y: (height / (rows - 1)) * j,
delay: Math.random() * 3000,
});
}
}
return items;
}, [width, height]);
return (
<View style={StyleSheet.absoluteFill}>
<LinearGradient
colors={[theme.colors.backgroundElevated, theme.colors.background]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Radial Background Glows */}
<View style={[styles.glow, {
backgroundColor: theme.colors.cyberCyan,
opacity: 0.05,
left: -width * 0.3,
top: height * 0.1,
width: width * 1.5,
height: width * 1.5
}]} />
<View style={[styles.glow, {
backgroundColor: theme.colors.cyberPurple,
opacity: 0.03,
right: -width * 0.4,
bottom: -height * 0.2,
width: width * 1.5,
height: width * 1.5
}]} />
{/* Grid Lines */}
<View style={styles.gridContainer}>
{nodes.map((node) => (
<Node key={node.id} x={node.x} y={node.y} delay={node.delay} color={theme.colors.primary} />
))}
</View>
<ScanLine height={height} width={width} color={theme.colors.primary} />
</View>
);
};
const ScanLine = ({ width, height, color }: { width: number; height: number; color: string }) => {
const ty = useSharedValue(-100);
React.useEffect(() => {
ty.value = withRepeat(
withTiming(height + 100, { duration: 6000, easing: Easing.linear }),
-1,
false
);
}, [height]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: ty.value }],
}));
return (
<Animated.View
style={[
styles.scanLine,
{
width,
backgroundColor: color,
shadowColor: color,
},
animatedStyle
]}
/>
);
};
const styles = StyleSheet.create({
gridContainer: {
...StyleSheet.absoluteFillObject,
opacity: 0.4,
},
node: {
position: 'absolute',
width: 2,
height: 2,
borderRadius: 1,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 2,
elevation: 2,
},
glow: {
position: 'absolute',
borderRadius: 9999,
},
scanLine: {
position: 'absolute',
height: 1,
opacity: 0.15,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 10,
elevation: 10,
},
});
@@ -0,0 +1,25 @@
import { useCallback } from 'react';
import { useAppDispatch } from '../../../app/hooks';
import { biometrics } from '../../../services/security/biometrics';
import { authActions } from '../store/authSlice';
export const useBiometricPrompt = () => {
const dispatch = useAppDispatch();
return useCallback(async () => {
const available = await biometrics.isAvailable();
if (!available) {
return false;
}
const result = await biometrics.prompt();
if (result.success) {
dispatch(authActions.setAuthMethod('biometric'));
}
return result.success;
}, [dispatch]);
};
+248
View File
@@ -0,0 +1,248 @@
import React, { 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 Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppDispatch } from '../../../app/hooks';
import { useTheme } from '../../../theme';
import { useToast } from '../../../hooks/useToast';
import { secureStorage } from '../../../services/storage/secureStorage';
import { authActions } from '../store/authSlice';
import { useLoginMutation } from '../services/authApi';
import { isValidPhone } from '../utils/authValidation';
import { LoginHero } from '../components/LoginHero';
import { NeuralGrid } from '../components/NeuralGrid';
import { BiometricNode } from '../components/BiometricNode';
import { useBiometricPrompt } from '../hooks/useBiometricPrompt';
import { haptics } from '../../../services/haptics/haptics';
import type { AuthStackParamList } from '../../../navigation/types';
import type { LoginRole } from '../../../types/app';
import { LoginRoleTabs } from '../components/LoginRoleTabs';
type Props = Readonly<NativeStackScreenProps<AuthStackParamList, 'Login'>>;
export default function LoginScreen(props: Readonly<Props>) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const promptBiometric = useBiometricPrompt();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [activeRole, setActiveRole] = useState<LoginRole>('User');
const [showPassword, setShowPassword] = useState(false);
const [login, loginState] = useLoginMutation();
const handleForgotPassword = () => {
haptics.selection();
toast('Redirecting to secure reset channel...', 'info');
};
const handleSignUp = () => {
haptics.selection();
toast('Initializing onboarding sequence...', 'info');
};
const handleContinue = async () => {
try {
const tokens = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Neural bridge established', 'success');
return;
} catch {
haptics.error();
toast('Access Denied', 'error', 'Authorization failed. Retry handshake.');
}
};
const handleBiometricLogin = async () => {
haptics.selection();
const success = await promptBiometric();
if (!success) {
toast('Biometric scan aborted', 'info');
return;
}
const tokens = {
accessToken: 'biometric-access-token',
refreshToken: 'biometric-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
role: activeRole,
};
await secureStorage.saveSession(tokens);
dispatch(authActions.authenticate(tokens));
haptics.success();
toast('Synapse link verified', 'success');
};
return (
<View style={styles.flex}>
<ScreenShell scrollable={true} contentContainerStyle={styles.shellContainer}>
<View style={styles.content}>
<View style={styles.heroWrapper}>
<LoginHero />
</View>
<Animated.View entering={FadeInUp.delay(400).duration(800).springify()}>
<LoginRoleTabs activeRole={activeRole} onRoleChange={setActiveRole} />
<Card
style={[
styles.formCard,
activeRole === 'User' ? { borderTopLeftRadius: 0 } : {}
]}
>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>EMAIL OR PHONE</Text>
<Input
keyboardType="default"
value={phone}
onChangeText={setPhone}
placeholder="name@example.com"
/>
</View>
<View style={styles.inputGap}>
<Text style={[styles.inputLabel, { color: theme.colors.textSecondary }]}>PASSWORD</Text>
<Input
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
placeholder="••••••••"
rightAccessory={
<Pressable onPress={() => setShowPassword(!showPassword)} hitSlop={12}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={theme.colors.textMuted}
/>
</Pressable>
}
/>
</View>
<View style={styles.forgotPasswordRow}>
<Pressable onPress={handleForgotPassword} hitSlop={12}>
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Forgot password?</Text>
</Pressable>
</View>
<Button
title="Sign In"
loading={loginState.isLoading}
onPress={handleContinue}
style={styles.mainButton}
/>
<View style={styles.dividerRow}>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
<Text style={[styles.dividerText, { color: theme.colors.textMuted }]}>OR SIGN IN WITH</Text>
<View style={[styles.divider, { backgroundColor: theme.colors.cardBorder }]} />
</View>
<BiometricNode onPress={handleBiometricLogin} />
</Card>
</Animated.View>
<Animated.View
entering={FadeInDown.delay(600).duration(800)}
style={styles.bottomLinks}
>
<Text style={[styles.signUpText, { color: theme.colors.textSecondary }]}>
New to BankCBS?{' '}
<Text style={[styles.signUpLink, { color: theme.colors.primary }]} onPress={handleSignUp}>Create account</Text>
</Text>
</Animated.View>
</View>
</ScreenShell>
</View>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
shellContainer: {
flexGrow: 1,
paddingHorizontal: 20,
},
content: {
flex: 1,
justifyContent: 'center',
paddingBottom: 40,
},
heroWrapper: {
marginBottom: 8,
},
formCard: {
gap: 20,
padding: 24,
borderRadius: 24,
backgroundColor: '#FFFFFF',
// We'll rely on the Card component's internal variant/shadow or override
},
inputGap: {
gap: 8,
},
inputLabel: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
},
forgotPasswordRow: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: -8,
},
linkText: {
fontSize: 14,
fontWeight: '600',
},
mainButton: {
height: 54,
borderRadius: 12,
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
marginVertical: 4,
},
divider: {
flex: 1,
height: 1,
},
dividerText: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
},
bottomLinks: {
gap: 16,
alignItems: 'center',
marginTop: 32,
},
signUpText: {
fontSize: 15,
fontWeight: '500',
},
signUpLink: {
fontWeight: '700',
},
});
+96
View File
@@ -0,0 +1,96 @@
import type { AuthTokens, UserProfile } from '../../../types/app';
import { demoUser } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
interface LoginRequest {
phone: string;
password: string;
}
interface LoginResponse {
challengeId: string;
phone: string;
}
interface VerifyOtpRequest {
challengeId: string;
otp: string;
phone: string;
}
interface VerifyOtpResponse {
verified: true;
phone: string;
}
interface SetupMpinRequest {
phone: string;
mpin: string;
}
interface SetupMpinResponse {
tokens: AuthTokens;
user: UserProfile;
}
const createDemoTokens = (): AuthTokens => ({
accessToken: 'demo-access-token',
refreshToken: 'demo-refresh-token',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
});
export const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginResponse, LoginRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
challengeId: 'otp_challenge_001',
phone,
},
request: async () => {
const response = await apiClient.post<LoginResponse>(endpoints.auth.login, { phone });
return response.data;
},
}),
}),
verifyOtp: builder.mutation<VerifyOtpResponse, VerifyOtpRequest>({
queryFn: ({ phone }) =>
resolveWithMock({
mockData: {
verified: true,
phone,
},
request: async () => {
const response = await apiClient.post<VerifyOtpResponse>(endpoints.auth.verifyOtp, {
phone,
});
return response.data;
},
}),
}),
setupMpin: builder.mutation<SetupMpinResponse, SetupMpinRequest>({
queryFn: () =>
resolveWithMock({
mockData: () => ({
tokens: createDemoTokens(),
user: demoUser,
}),
request: async () => {
const response = await apiClient.post<SetupMpinResponse>(endpoints.auth.setupMpin);
return response.data;
},
}),
invalidatesTags: ['Auth', 'User'],
}),
}),
});
export const {
useLoginMutation,
useVerifyOtpMutation,
useSetupMpinMutation,
} = authApi;
+8
View File
@@ -0,0 +1,8 @@
import type { RootState } from '../../../app/store';
export const selectAuthState = (state: RootState) => state.auth;
export const selectIsAuthenticated = (state: RootState) => state.auth.isAuthenticated;
export const selectAuthHydrating = (state: RootState) => state.auth.isHydrating;
export const selectPendingPhone = (state: RootState) => state.auth.pendingPhone;
export const selectChallengeId = (state: RootState) => state.auth.challengeId;
export const selectAuthRole = (state: RootState) => state.auth.role;
+80
View File
@@ -0,0 +1,80 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthTokens } from '../../../types/app';
import { bootstrapAuthSession, logoutSession } from './authThunk';
import type { LoginRole } from '../../../types/app';
interface AuthState {
isHydrating: boolean;
isAuthenticated: boolean;
pendingPhone: string;
challengeId: string | null;
tokens: AuthTokens | null;
authMethod: 'otp' | 'biometric' | null;
role: LoginRole;
}
const initialState: AuthState = {
isHydrating: true,
isAuthenticated: false,
pendingPhone: '',
challengeId: null,
tokens: null,
authMethod: null,
role: 'User',
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setPendingPhone(state, action: PayloadAction<string>) {
state.pendingPhone = action.payload;
},
setChallengeId(state, action: PayloadAction<string>) {
state.challengeId = action.payload;
},
authenticate(state, action: PayloadAction<AuthTokens>) {
state.tokens = action.payload;
state.isAuthenticated = true;
state.isHydrating = false;
state.role = action.payload.role || 'User';
},
setAuthMethod(state, action: PayloadAction<'otp' | 'biometric'>) {
state.authMethod = action.payload;
},
resetAuthFlow(state) {
state.challengeId = null;
state.pendingPhone = '';
},
},
extraReducers: (builder) => {
builder
.addCase(bootstrapAuthSession.pending, (state) => {
state.isHydrating = true;
})
.addCase(bootstrapAuthSession.fulfilled, (state, action) => {
state.tokens = action.payload;
state.isAuthenticated = Boolean(action.payload);
state.isHydrating = false;
state.role = action.payload?.role || 'User';
})
.addCase(bootstrapAuthSession.rejected, (state) => {
state.isHydrating = false;
state.isAuthenticated = false;
state.role = 'User';
})
.addCase(logoutSession.fulfilled, (state) => {
state.tokens = null;
state.isAuthenticated = false;
state.pendingPhone = '';
state.challengeId = null;
state.authMethod = null;
state.role = 'User';
});
},
});
export const authActions = authSlice.actions;
export const authReducer = authSlice.reducer;
+13
View File
@@ -0,0 +1,13 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { secureStorage } from '../../../services/storage/secureStorage';
export const bootstrapAuthSession = createAsyncThunk('auth/bootstrapSession', async () => {
const tokens = await secureStorage.getSession();
return tokens;
});
export const logoutSession = createAsyncThunk('auth/logoutSession', async () => {
await secureStorage.clearSession();
return true;
});
+1
View File
@@ -0,0 +1 @@
export type { AuthTokens, UserProfile } from '../../../types/app';
+5
View File
@@ -0,0 +1,5 @@
export {
isValidMpin,
isValidOtp,
isValidPhone,
} from '../../../utils/validation';
@@ -0,0 +1,157 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import { AwarenessArticle } from '../types';
interface AwarenessCardProps {
article: AwarenessArticle;
isBookmarked: boolean;
onToggleBookmark: () => void;
onPress: () => void;
}
export const AwarenessCard = ({
article,
isBookmarked,
onToggleBookmark,
onPress,
}: AwarenessCardProps) => {
const theme = useTheme();
return (
<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>
<View style={styles.headerInfo}>
<View style={styles.titleRow}>
<Text style={[styles.category, { color: theme.colors.primary }]}>
{article.category}
</Text>
{article.isUrgent && (
<View style={[styles.urgentBadge, { backgroundColor: `${theme.colors.error}15` }]}>
<Text style={[styles.urgentText, { color: theme.colors.error }]}>URGENT</Text>
</View>
)}
</View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{article.title}
</Text>
</View>
<Pressable hitSlop={10} onPress={onToggleBookmark} style={styles.bookmarkBtn}>
<Ionicons
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
size={22}
color={isBookmarked ? theme.colors.primary : theme.colors.textMuted}
/>
</Pressable>
</View>
<Text style={[styles.description, { color: theme.colors.textSecondary }]} numberOfLines={2}>
{article.description}
</Text>
<View style={styles.footer}>
<View style={styles.tags}>
{article.tags.slice(0, 2).map((tag) => (
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.tagText, { color: theme.colors.textSecondary }]}>#{tag}</Text>
</View>
))}
</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} />
</View>
</View>
</Card>
</Pressable>
);
};
const styles = StyleSheet.create({
card: {
padding: 16,
marginBottom: 16,
gap: 12,
},
header: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
iconFrame: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
headerInfo: {
flex: 1,
gap: 2,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
category: {
fontSize: 11,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
urgentBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
urgentText: {
fontSize: 9,
fontWeight: '900',
},
title: {
fontSize: 16,
fontWeight: '700',
},
bookmarkBtn: {
padding: 4,
},
description: {
fontSize: 13,
lineHeight: 18,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 4,
},
tags: {
flexDirection: 'row',
gap: 8,
},
tag: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
tagText: {
fontSize: 10,
fontWeight: '600',
},
readMore: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
readMoreText: {
fontSize: 13,
fontWeight: '700',
},
});
@@ -0,0 +1,91 @@
import { AwarenessArticle } from '../types';
export const awarenessArticles: AwarenessArticle[] = [
{
id: 'art_001',
title: 'Recognizing UPI QR Code Scams',
description: 'Learn how scammers use fake QR codes to steal money from your account.',
content: `UPI QR codes are meant for making payments, not receiving them. Scammers often send QR codes to victims claiming they will receive money by scanning it.
Key Safety Tips:
1. Scanning a QR code always means money will be deducted from YOUR account.
2. You NEVER need to scan a QR code or enter your UPI PIN to RECEIVE money.
3. If someone asks you to scan a QR code to "collect prize money," it's a scam.
4. Always verify the merchant name displayed in the UPI app before confirming a payment.`,
category: 'NPCI',
tags: ['UPI', 'Scam'],
icon: 'qr-code-outline',
publishDate: '2026-04-10',
isUrgent: true,
},
{
id: 'art_002',
title: 'Protecting Your OTP',
description: 'Why you should never share your One Time Password with anyone.',
content: `One Time Passwords (OTPs) are the final barrier protecting your transactions. No bank official will ever ask for your OTP over the phone or via email.
Safe Practices:
- Never share OTP, CVV, or PIN with anyone, including bank staff.
- Ensure your mobile number is updated with the bank to receive alerts.
- Do not click on links in SMS that ask for your OTP.
- Use biometric authentication where available for added security.`,
category: 'RBI',
tags: ['OTP', 'Scam'],
icon: 'shield-checkmark-outline',
publishDate: '2026-04-08',
},
{
id: 'art_003',
title: 'Fake Call/Vishing Alerts',
description: 'How to handle calls from individuals pretending to be bank employees.',
content: `Vishing (voice phishing) is a common method where scammers call you pretending to be from your bank's KYC or security department.
How to identify Vishing:
1. Sense of Urgency: They claim your account will be blocked immediately.
2. Asking for Sensitive Info: They ask for PIN, OTP, or passwords.
3. Call from unknown mobile numbers: Banks usually call from official numbers.
What to do:
- Hang up immediately if you feel suspicious.
- Call your bank's official customer care number to verify.
- Report the number on the Cyber Crime portal.`,
category: 'Fraud Alert',
tags: ['Phishing', 'Scam'],
icon: 'call-outline',
publishDate: '2026-04-12',
isUrgent: true,
},
{
id: 'art_004',
title: 'Safe Online Banking Habits',
description: 'Essential practices for secure net banking and mobile app usage.',
content: `Securing your digital footprint is vital in modern banking. Follow these RBI-recommended practices.
Checklist:
- Use strong, unique passwords for banking apps.
- Enable Two-Factor Authentication (2FA).
- Avoid using public Wi-Fi for banking transactions.
- Always log out after your session.
- Keep your banking app updated to the latest version.`,
category: 'Best Practices',
tags: ['Net Banking', 'Scam'],
icon: 'globe-outline',
publishDate: '2026-04-05',
},
{
id: 'art_005',
title: 'NPCI: UPI Safety Shield',
description: 'NPCI’s official guide to safe digital payments.',
content: `NPCI encourages users to be "UPI Chalak" (UPI Smart).
Security Features:
- PIN Entry: Required only for sending money or checking balance.
- Spam Filter: UPI apps now highlight suspicious payment requests.
- Transaction Limits: Set daily limits for UPI transactions in your bank app.
- Block & Report: Use the "Report" feature for fraudulent IDs.`,
category: 'NPCI',
tags: ['UPI'],
icon: 'flash-outline',
publishDate: '2026-04-01',
},
];
@@ -0,0 +1,81 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { awarenessArticles } from '../constants/mockData';
import { AwarenessArticle, AwarenessFilter } from '../types';
const BOOKMARKS_KEY = '@awareness_bookmarks';
export const useAwareness = () => {
const [articles] = useState<AwarenessArticle[]>(awarenessArticles);
const [bookmarks, setBookmarks] = useState<string[]>([]);
const [filter, setFilter] = useState<AwarenessFilter>({
category: 'All',
tag: 'All',
searchQuery: '',
});
const [isLoading, setIsLoading] = useState(true);
// Load bookmarks on mount
useEffect(() => {
const loadBookmarks = async () => {
try {
const saved = await AsyncStorage.getItem(BOOKMARKS_KEY);
if (saved) {
setBookmarks(JSON.parse(saved));
}
} catch (error) {
console.error('Failed to load bookmarks', error);
} finally {
setIsLoading(false);
}
};
loadBookmarks();
}, []);
// Save bookmarks when changed
const toggleBookmark = useCallback(async (id: string) => {
try {
const newBookmarks = bookmarks.includes(id)
? bookmarks.filter((b) => b !== id)
: [...bookmarks, id];
setBookmarks(newBookmarks);
await AsyncStorage.setItem(BOOKMARKS_KEY, JSON.stringify(newBookmarks));
} catch (error) {
console.error('Failed to update bookmarks', error);
}
}, [bookmarks]);
const filteredArticles = useMemo(() => {
return articles.filter((article) => {
const matchesCategory = filter.category === 'All' || article.category === filter.category;
const matchesTag = filter.tag === 'All' || article.tags.includes(filter.tag as any);
const matchesSearch =
article.title.toLowerCase().includes(filter.searchQuery.toLowerCase()) ||
article.description.toLowerCase().includes(filter.searchQuery.toLowerCase()) ||
article.content.toLowerCase().includes(filter.searchQuery.toLowerCase());
return matchesCategory && matchesTag && matchesSearch;
});
}, [articles, filter]);
const urgentArticles = useMemo(() => {
return articles.filter((a) => a.isUrgent);
}, [articles]);
const bookmarkedArticles = useMemo(() => {
return articles.filter((a) => bookmarks.includes(a.id));
}, [articles, bookmarks]);
return {
articles: filteredArticles,
urgentArticles,
bookmarkedArticles,
bookmarks,
toggleBookmark,
isLoading,
filter,
setFilter,
};
};
@@ -0,0 +1,246 @@
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 { Card, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { useAwareness } from '../hooks/useAwareness';
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessDetail'>;
export default function AwarenessDetailScreen({ route, navigation }: Props) {
const { articleId } = route.params;
const theme = useTheme();
const { articles, bookmarks, toggleBookmark } = useAwareness();
const article = articles.find((a) => a.id === articleId);
const isBookmarked = bookmarks.includes(articleId);
if (!article) {
return (
<ScreenShell title="Not Found">
<View style={styles.errorContainer}>
<Text style={{ color: theme.colors.textPrimary }}>Article not found.</Text>
</View>
</ScreenShell>
);
}
const handleShare = async () => {
try {
await Share.share({
message: `${article.title}\n\n${article.description}\n\nStay safe with BankCBS!`,
title: article.title,
});
} catch (error) {
console.error('Error sharing article', error);
}
};
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} />
</Pressable>
<Pressable
onPress={() => toggleBookmark(articleId)}
style={[styles.actionBtn, { backgroundColor: isBookmarked ? `${theme.colors.primary}15` : theme.colors.surfaceMuted }]}
>
<Ionicons
name={isBookmarked ? 'bookmark' : 'bookmark-outline'}
size={20}
color={isBookmarked ? theme.colors.primary : theme.colors.textPrimary}
/>
</Pressable>
</View>
);
return (
<ScreenShell
title="Article"
subtitle={article.category}
rightContent={rightContent}
>
<View style={styles.container}>
<View style={styles.metaRow}>
<View style={[styles.categoryBadge, { backgroundColor: `${theme.colors.primary}12` }]}>
<Text style={[styles.categoryText, { color: theme.colors.primary }]}>{article.category}</Text>
</View>
<Text style={[styles.dateText, { color: theme.colors.textMuted }]}>{article.publishDate}</Text>
</View>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{article.title}</Text>
<View style={styles.tagsRow}>
{article.tags.map(tag => (
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.tagText, { color: theme.colors.textSecondary }]}>#{tag}</Text>
</View>
))}
</View>
<Card variant="glass" style={styles.introCard}>
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
{article.description}
</Text>
</Card>
<View style={styles.contentSection}>
<Text style={[styles.contentText, { color: theme.colors.textPrimary }]}>
{article.content}
</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>
<View style={styles.ctaContent}>
<Text style={[styles.ctaTitle, { color: theme.colors.textPrimary }]}>Stay Protected</Text>
<Text style={[styles.ctaSubtitle, { color: theme.colors.textSecondary }]}>
BankCBS never asks for your OTP, PIN, or CVV. Report any suspicious activity immediately.
</Text>
</View>
</View>
<View style={styles.footerActions}>
<Pressable
onPress={() => navigation.navigate('ServiceRequestsHub')}
style={[styles.reportBtn, { borderColor: theme.colors.cardBorder }]}
>
<Ionicons name="megaphone-outline" size={18} color={theme.colors.error} />
<Text style={[styles.reportText, { color: theme.colors.error }]}>Report Fraudulent Activity</Text>
</Pressable>
</View>
</View>
<View style={{ height: 20 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
paddingBottom: 40,
},
headerActions: {
flexDirection: 'row',
gap: 8,
},
actionBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
categoryBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
},
categoryText: {
fontSize: 11,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
dateText: {
fontSize: 12,
fontWeight: '600',
},
title: {
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.5,
lineHeight: 30,
marginBottom: 12,
},
tagsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 20,
},
tag: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 10,
},
tagText: {
fontSize: 11,
fontWeight: '700',
},
introCard: {
padding: 16,
marginBottom: 24,
},
description: {
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
},
contentSection: {
marginBottom: 32,
},
contentText: {
fontSize: 16,
lineHeight: 26,
},
safetyCta: {
flexDirection: 'row',
backgroundColor: 'rgba(52, 211, 153, 0.05)',
padding: 20,
borderRadius: 20,
gap: 16,
marginBottom: 32,
},
ctaIcon: {
width: 48,
height: 48,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
ctaContent: {
flex: 1,
gap: 4,
},
ctaTitle: {
fontSize: 16,
fontWeight: '800',
},
ctaSubtitle: {
fontSize: 13,
lineHeight: 18,
},
footerActions: {
gap: 12,
},
reportBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
},
reportText: {
fontSize: 14,
fontWeight: '700',
},
errorContainer: {
padding: 20,
alignItems: 'center',
},
});
@@ -0,0 +1,215 @@
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 { Input, ScreenShell } from '../../../components';
import { ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { AwarenessCard } from '../components/AwarenessCard';
import { useAwareness } from '../hooks/useAwareness';
import { AwarenessCategory } from '../types';
type Props = NativeStackScreenProps<ServicesStackParamList, 'AwarenessList'>;
const CATEGORIES: (AwarenessCategory | 'All')[] = ['All', 'RBI', 'NPCI', 'Fraud Alert', 'Best Practices'];
export default function AwarenessListScreen({ navigation }: Props) {
const theme = useTheme();
const { articles, filter, setFilter, bookmarks, toggleBookmark, urgentArticles } = useAwareness();
const handleSearch = (query: string) => {
setFilter({ ...filter, searchQuery: query });
};
const handleCategoryPress = (category: AwarenessCategory | 'All') => {
setFilter({ ...filter, category });
};
return (
<ScreenShell title="Security aware" subtitle="Learn to protect your digital banking">
<View style={styles.searchContainer}>
<Input
placeholder="Search safety tips, fraud alerts..."
value={filter.searchQuery}
onChangeText={handleSearch}
leftAccessory={<Ionicons name="search-outline" size={20} color={theme.colors.textMuted} />}
/>
</View>
<View style={styles.filterWrapper}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterContainer}>
{CATEGORIES.map((cat) => {
const isActive = filter.category === cat;
return (
<Pressable
key={cat}
onPress={() => handleCategoryPress(cat)}
style={[
styles.filterChip,
{
backgroundColor: isActive ? theme.colors.primary : theme.colors.surfaceMuted,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
styles.filterText,
{ color: isActive ? theme.colors.white : theme.colors.textSecondary },
]}
>
{cat}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
<FlatList
data={articles}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<AwarenessCard
article={item}
isBookmarked={bookmarks.includes(item.id)}
onToggleBookmark={() => toggleBookmark(item.id)}
onPress={() => navigation.navigate('AwarenessDetail', { articleId: item.id })}
/>
)}
ListHeaderComponent={
<>
{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} />
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Critical Security Alerts
</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.urgentCarousel}>
{urgentArticles.map((article) => (
<Pressable
key={article.id}
onPress={() => navigation.navigate('AwarenessDetail', { articleId: article.id })}
style={[styles.urgentCard, { backgroundColor: `${theme.colors.error}08`, borderColor: `${theme.colors.error}20` }]}
>
<View style={[styles.urgentIcon, { backgroundColor: `${theme.colors.error}15` }]}>
<Ionicons name={article.icon as any} size={20} color={theme.colors.error} />
</View>
<Text style={[styles.urgentCardTitle, { color: theme.colors.textPrimary }]} numberOfLines={1}>
{article.title}
</Text>
<Text style={[styles.urgentCardDesc, { color: theme.colors.textSecondary }]} numberOfLines={1}>
{article.description}
</Text>
</Pressable>
))}
</ScrollView>
</View>
)}
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
{filter.category === 'All' ? 'Latest Awareness Articles' : `${filter.category} Guidelines`}
</Text>
</View>
</>
}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons 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.
</Text>
</View>
}
scrollEnabled={false} // Since it's inside ScreenShell's ScrollView
/>
</ScreenShell>
);
}
const styles = StyleSheet.create({
searchContainer: {
paddingHorizontal: 16,
marginBottom: 16,
},
filterWrapper: {
marginBottom: 20,
},
filterContainer: {
paddingHorizontal: 16,
gap: 10,
},
filterChip: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
},
filterText: {
fontSize: 13,
fontWeight: '700',
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 100,
},
urgentSection: {
marginBottom: 24,
gap: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 12,
},
sectionTitle: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.3,
},
urgentCarousel: {
gap: 12,
},
urgentCard: {
width: 260,
padding: 16,
borderRadius: 16,
borderWidth: 1,
gap: 8,
},
urgentIcon: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
urgentCardTitle: {
fontSize: 15,
fontWeight: '700',
},
urgentCardDesc: {
fontSize: 12,
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
gap: 12,
},
emptyTitle: {
fontSize: 18,
fontWeight: '800',
},
emptySubtitle: {
fontSize: 14,
textAlign: 'center',
paddingHorizontal: 40,
},
});
+21
View File
@@ -0,0 +1,21 @@
export type AwarenessCategory = 'RBI' | 'NPCI' | 'Fraud Alert' | 'Best Practices';
export type AwarenessTag = 'UPI' | 'Cards' | 'Net Banking' | 'OTP' | 'Phishing' | 'Scam';
export interface AwarenessArticle {
id: string;
title: string;
description: string;
content: string;
category: AwarenessCategory;
tags: AwarenessTag[];
icon: string;
imageUrl?: string;
publishDate: string;
isUrgent?: boolean;
}
export type AwarenessFilter = {
category: AwarenessCategory | 'All';
tag: AwarenessTag | 'All';
searchQuery: string;
};
@@ -0,0 +1,140 @@
import React, { memo, useMemo } from 'react';
import {
Dimensions,
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
View,
} from 'react-native';
import Animated, {
useAnimatedScrollHandler,
useSharedValue,
} from 'react-native-reanimated';
import type { BankCard } from '../../../types/app';
import { CardItem } from './CardItem';
interface CardCarouselProps {
cards: BankCard[];
selectedIndex: number;
onSnapToCard: (cardId: string) => void;
}
const { width: screenWidth } = Dimensions.get('window');
const CARD_WIDTH = Math.min(screenWidth - 72, 340);
const CARD_GAP = 18;
const ITEM_SIZE = CARD_WIDTH + CARD_GAP;
const SIDE_INSET = Math.max((screenWidth - CARD_WIDTH) / 2, 20);
const CardCarouselComponent = ({
cards,
selectedIndex,
onSnapToCard,
}: CardCarouselProps) => {
const scrollX = useSharedValue(selectedIndex * ITEM_SIZE);
const initialScrollIndex = useMemo(
() => (cards.length ? Math.min(selectedIndex, cards.length - 1) : 0),
[cards.length, selectedIndex],
);
const handleScroll = useAnimatedScrollHandler((event) => {
scrollX.value = event.contentOffset.x;
});
const handleMomentumScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!cards.length) {
return;
}
const nextIndex = Math.round(event.nativeEvent.contentOffset.x / ITEM_SIZE);
const card = cards[Math.min(Math.max(nextIndex, 0), cards.length - 1)];
if (card) {
onSnapToCard(card.id);
}
};
return (
<View style={styles.wrapper}>
<Animated.FlatList
data={cards}
horizontal
bounces={false}
initialScrollIndex={initialScrollIndex}
decelerationRate="fast"
showsHorizontalScrollIndicator={false}
snapToInterval={ITEM_SIZE}
snapToAlignment="start"
disableIntervalMomentum
onScroll={handleScroll}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
contentContainerStyle={styles.listContent}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ITEM_SIZE,
offset: ITEM_SIZE * index,
index,
})}
renderItem={({ item, index }) => (
<View style={styles.itemWrap}>
<CardItem
card={item}
index={index}
scrollX={scrollX}
cardWidth={CARD_WIDTH}
itemSize={ITEM_SIZE}
/>
</View>
)}
/>
<View style={styles.pagination}>
{cards.map((card, index) => (
<View
key={card.id}
style={[
styles.dot,
index === selectedIndex ? styles.dotActive : styles.dotInactive,
]}
/>
))}
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
marginHorizontal: -20,
minHeight: 246,
gap: 14,
},
listContent: {
paddingHorizontal: SIDE_INSET,
},
itemWrap: {
width: ITEM_SIZE,
},
pagination: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
dotActive: {
width: 24,
backgroundColor: '#124577',
},
dotInactive: {
backgroundColor: 'rgba(15, 23, 42, 0.14)',
},
});
export const CardCarousel = memo(CardCarouselComponent);
@@ -0,0 +1,87 @@
import React, { memo } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useTheme } from '../../../theme';
interface CardControlToggleProps {
label: string;
enabled: boolean;
onToggle: () => void;
}
const CardControlToggleComponent = ({
label,
enabled,
onToggle,
}: CardControlToggleProps) => {
const theme = useTheme();
return (
<Pressable
onPress={onToggle}
style={[
styles.row,
{
borderBottomColor: theme.colors.cardBorder,
},
]}
>
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
<View
style={[
styles.track,
{
borderRadius: theme.radius.pill,
backgroundColor: enabled ? theme.colors.accent : theme.colors.surfaceMuted,
},
]}
>
<View
style={[
styles.thumb,
enabled ? styles.thumbEnd : styles.thumbStart,
{
backgroundColor: theme.colors.white,
},
]}
/>
</View>
</Pressable>
);
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: 16,
},
label: {
fontSize: 15,
fontWeight: '600',
},
track: {
width: 52,
padding: 4,
},
thumb: {
width: 18,
height: 18,
borderRadius: 999,
},
thumbStart: {
alignSelf: 'flex-start',
},
thumbEnd: {
alignSelf: 'flex-end',
},
});
export const CardControlToggle = memo(CardControlToggleComponent);
@@ -0,0 +1,283 @@
import React, { memo, useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { CardPhysicalDelivery } from '../../../types/app';
import { formatDate } from '../../../utils/formatters';
import {
getCardDeliveryProgressIndex,
getCardDeliveryStatusLabel,
} from '../utils/cardCopy';
interface CardDeliveryStatusProps {
delivery: CardPhysicalDelivery;
compact?: boolean;
}
type DeliveryStep = {
id: string;
label: string;
};
const CardDeliveryStatusComponent = ({
delivery,
compact = false,
}: CardDeliveryStatusProps) => {
const theme = useTheme();
const statusLabel = getCardDeliveryStatusLabel(delivery);
const progressIndex = getCardDeliveryProgressIndex(delivery);
const steps = useMemo<DeliveryStep[]>(
() =>
delivery.mode === 'home'
? [
{ id: 'processing', label: 'Requested' },
{ id: 'personalizing', label: 'Prepared' },
{ id: 'shipped', label: 'In transit' },
{ id: 'delivered', label: 'Delivered' },
]
: [
{ id: 'processing', label: 'Requested' },
{ id: 'personalizing', label: 'Prepared' },
{ id: 'ready-for-pickup', label: 'At branch' },
{ id: 'delivered', label: 'Collected' },
],
[delivery.mode],
);
const accentColor =
delivery.status === 'delivered'
? theme.colors.accentStrong
: delivery.status === 'ready-for-pickup'
? theme.colors.warning
: theme.colors.primary;
const accentBackground =
delivery.status === 'delivered'
? `${theme.colors.accent}12`
: delivery.status === 'ready-for-pickup'
? `${theme.colors.warning}14`
: `${theme.colors.primary}12`;
const modeLabel = delivery.mode === 'home' ? 'Home delivery' : 'Branch pickup';
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.eyebrow, { color: theme.colors.textSecondary }]}>
Physical card delivery
</Text>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{statusLabel}</Text>
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
{delivery.status === 'delivered'
? `Completed on ${formatDate(delivery.lastUpdatedAt)}`
: `Expected by ${formatDate(delivery.estimatedDate)}`}
</Text>
</View>
<View style={[styles.badge, { backgroundColor: accentBackground }]}>
<Ionicons name="cube-outline" size={14} color={accentColor} />
<Text style={[styles.badgeText, { color: accentColor }]}>{modeLabel}</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Tracking ID
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{delivery.trackingId}
</Text>
</View>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Destination
</Text>
<Text
numberOfLines={compact ? 1 : 2}
style={[styles.metricValue, { color: theme.colors.textPrimary }]}
>
{delivery.destinationLabel}
</Text>
</View>
</View>
<View style={styles.timelineRow}>
{steps.map((step, index) => {
const isCompleted = index < progressIndex;
const isCurrent = index === progressIndex;
const activeColor = isCompleted || isCurrent ? accentColor : theme.colors.cardBorder;
const timelineDotStyle = {
backgroundColor: isCompleted || isCurrent ? activeColor : 'transparent',
borderColor: activeColor,
};
const timelineLineStyle = {
backgroundColor: index < progressIndex ? accentColor : theme.colors.cardBorder,
};
return (
<View key={step.id} style={styles.timelineItem}>
<View style={styles.timelineTopRow}>
<View style={[styles.timelineDot, timelineDotStyle]}>
{isCompleted ? (
<Ionicons name="checkmark" size={10} color={theme.colors.white} />
) : null}
</View>
{index !== steps.length - 1 ? (
<View style={[styles.timelineLine, timelineLineStyle]} />
) : null}
</View>
<Text
style={[
styles.timelineLabel,
{
color:
isCompleted || isCurrent
? theme.colors.textPrimary
: theme.colors.textMuted,
},
]}
>
{step.label}
</Text>
</View>
);
})}
</View>
{!compact ? (
<View style={[styles.footerCard, { backgroundColor: theme.colors.surfaceMuted }]}>
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, { color: theme.colors.textSecondary }]}>
Requested
</Text>
<Text style={[styles.footerValue, { color: theme.colors.textPrimary }]}>
{formatDate(delivery.requestedAt)}
</Text>
</View>
<View style={styles.footerRow}>
<Text style={[styles.footerLabel, { color: theme.colors.textSecondary }]}>
Last updated
</Text>
<Text style={[styles.footerValue, { color: theme.colors.textPrimary }]}>
{formatDate(delivery.lastUpdatedAt)}
</Text>
</View>
</View>
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 14,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
headerCopy: {
flex: 1,
gap: 4,
},
eyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
title: {
fontSize: 17,
fontWeight: '800',
letterSpacing: -0.2,
},
subtitle: {
fontSize: 13,
lineHeight: 18,
},
badge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
},
badgeText: {
fontSize: 12,
fontWeight: '700',
},
metricsRow: {
flexDirection: 'row',
gap: 12,
},
metricTile: {
flex: 1,
borderRadius: 18,
padding: 14,
gap: 6,
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
metricValue: {
fontSize: 14,
fontWeight: '800',
},
timelineRow: {
flexDirection: 'row',
alignItems: 'flex-start',
},
timelineItem: {
flex: 1,
gap: 8,
},
timelineTopRow: {
flexDirection: 'row',
alignItems: 'center',
},
timelineDot: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
timelineLine: {
flex: 1,
height: 2,
marginHorizontal: 6,
},
timelineLabel: {
fontSize: 11,
fontWeight: '700',
lineHeight: 15,
},
footerCard: {
borderRadius: 18,
padding: 14,
gap: 10,
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
footerLabel: {
fontSize: 12,
fontWeight: '600',
},
footerValue: {
fontSize: 12,
fontWeight: '800',
},
});
export const CardDeliveryStatus = memo(CardDeliveryStatusComponent);
+301
View File
@@ -0,0 +1,301 @@
import React, { memo } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import LinearGradient from 'react-native-linear-gradient';
import Animated, {
Extrapolation,
interpolate,
SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import type { BankCard } from '../../../types/app';
import { getCardStatusLabel } from '../utils/cardCopy';
interface CardItemProps {
card: BankCard;
index: number;
scrollX: SharedValue<number>;
cardWidth: number;
itemSize: number;
height?: number;
}
const CardItemComponent = ({
card,
index,
scrollX,
cardWidth,
itemSize,
height = 220,
}: CardItemProps) => {
const flipProgress = useSharedValue(0);
const statusLabel = getCardStatusLabel(card);
const statusColor =
card.status === 'blocked'
? '#FFB3BC'
: card.controls.frozen
? '#FFE08C'
: '#C8FACC';
const toggleFlip = () => {
flipProgress.value = withTiming(flipProgress.value === 0 ? 1 : 0, { duration: 420 });
};
const carouselStyle = useAnimatedStyle(() => {
const inputRange = [(index - 1) * itemSize, index * itemSize, (index + 1) * itemSize];
return {
transform: [
{
scale: interpolate(scrollX.value, inputRange, [0.92, 1, 0.92], Extrapolation.CLAMP),
},
{
translateY: interpolate(scrollX.value, inputRange, [12, 0, 12], Extrapolation.CLAMP),
},
],
opacity: interpolate(scrollX.value, inputRange, [0.72, 1, 0.72], Extrapolation.CLAMP),
};
});
const frontStyle = useAnimatedStyle(() => ({
transform: [
{ perspective: 1200 },
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [0, 180])}deg` },
],
}));
const backStyle = useAnimatedStyle(() => ({
transform: [
{ perspective: 1200 },
{ rotateY: `${interpolate(flipProgress.value, [0, 1], [180, 360])}deg` },
],
}));
return (
<Animated.View style={[styles.frame, carouselStyle, { width: cardWidth, height }]}>
<Pressable style={styles.flex} onPress={toggleFlip}>
<Animated.View style={[styles.face, frontStyle]}>
<LinearGradient colors={card.themeColors} style={styles.gradient}>
<View style={styles.headerRow}>
<View>
<Text style={styles.brandName}>CBS Bank</Text>
<Text style={styles.cardMeta}>{card.type.toUpperCase()} CARD</Text>
</View>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={styles.statusBadgeText}>{statusLabel}</Text>
</View>
</View>
<View style={styles.centerBlock}>
<Text style={styles.cardName}>{card.name}</Text>
<Text style={styles.cardNumber}>{card.maskedNumber}</Text>
<Text style={styles.holderName}>{card.cardholderName}</Text>
</View>
<View style={styles.footerRow}>
<View>
<Text style={styles.footerLabel}>VALID THRU</Text>
<Text style={styles.footerValue}>{card.expiry}</Text>
</View>
<View>
<Text style={styles.footerLabel}>NETWORK</Text>
<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)" />
<Text style={styles.flipText}>Flip</Text>
</View>
</View>
</LinearGradient>
</Animated.View>
<Animated.View style={[styles.face, styles.backFace, backStyle]}>
<LinearGradient
colors={[card.themeColors[2], card.themeColors[1], card.themeColors[0]]}
style={styles.gradient}
>
<View style={styles.magneticStrip} />
<View style={styles.backSection}>
<Text style={styles.backLabel}>PRIMARY LINKED ACCOUNT</Text>
<Text style={styles.backValue}>{card.primaryLinkedAccountId}</Text>
</View>
<View style={styles.backGrid}>
<View style={styles.backCell}>
<Text style={styles.backLabel}>DAILY LIMIT</Text>
<Text style={styles.backValue}>Rs {card.limits.daily.toLocaleString('en-IN')}</Text>
</View>
<View style={styles.backCell}>
<Text style={styles.backLabel}>LINKED ACCOUNTS</Text>
<Text style={styles.backValue}>{card.linkedAccountIds.length}</Text>
</View>
</View>
<View style={styles.backFooter}>
<Text style={styles.backSupport}>24x7 card care · 1800 100 2244</Text>
<Text style={styles.backSupport}>Tap card again to return</Text>
</View>
</LinearGradient>
</Animated.View>
</Pressable>
</Animated.View>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
frame: {
overflow: 'visible',
},
face: {
...StyleSheet.absoluteFillObject,
backfaceVisibility: 'hidden',
},
backFace: {
zIndex: 2,
},
gradient: {
flex: 1,
borderRadius: 28,
padding: 22,
justifyContent: 'space-between',
overflow: 'hidden',
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
brandName: {
color: 'white',
fontSize: 18,
fontWeight: '900',
letterSpacing: -0.3,
},
cardMeta: {
color: 'rgba(255,255,255,0.72)',
fontSize: 10,
fontWeight: '800',
letterSpacing: 1.1,
marginTop: 2,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: 'rgba(255,255,255,0.14)',
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
statusBadgeText: {
color: 'white',
fontSize: 11,
fontWeight: '700',
},
centerBlock: {
gap: 10,
},
cardName: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
},
cardNumber: {
color: 'white',
fontSize: 28,
fontWeight: '900',
letterSpacing: 2,
},
holderName: {
color: 'rgba(255,255,255,0.92)',
fontSize: 14,
fontWeight: '700',
letterSpacing: 0.6,
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
gap: 12,
},
footerLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 9,
fontWeight: '800',
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 13,
fontWeight: '800',
marginTop: 4,
},
flipHint: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
flipText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 12,
fontWeight: '700',
},
magneticStrip: {
height: 42,
marginHorizontal: -22,
backgroundColor: 'rgba(7, 19, 33, 0.8)',
},
backSection: {
gap: 6,
},
backGrid: {
flexDirection: 'row',
gap: 12,
},
backCell: {
flex: 1,
gap: 6,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.12)',
},
backLabel: {
color: 'rgba(255,255,255,0.68)',
fontSize: 10,
fontWeight: '800',
letterSpacing: 0.9,
},
backValue: {
color: 'white',
fontSize: 14,
fontWeight: '800',
},
backFooter: {
gap: 6,
},
backSupport: {
color: 'rgba(255,255,255,0.82)',
fontSize: 12,
fontWeight: '600',
},
});
export const CardItem = memo(CardItemComponent);
@@ -0,0 +1,68 @@
import React, { memo } from 'react';
import { StyleSheet, View } from 'react-native';
import { Card } from '../../../components';
import { SkeletonBlock } from '../../../components/Loader';
const CardsSkeletonComponent = () => {
return (
<View style={styles.container}>
<Card style={styles.heroCard}>
<SkeletonBlock height={22} width="34%" />
<SkeletonBlock height={36} width="68%" />
<SkeletonBlock height={18} width="48%" />
<View style={styles.bottomRow}>
<SkeletonBlock height={14} width={70} />
<SkeletonBlock height={14} width={90} />
<SkeletonBlock height={14} width={60} />
</View>
</Card>
<View style={styles.metricRow}>
{[0, 1, 2].map((item) => (
<Card key={item} style={styles.metricCard}>
<SkeletonBlock height={18} width="42%" />
<SkeletonBlock height={28} width="70%" />
<SkeletonBlock height={14} width="55%" />
</Card>
))}
</View>
<Card style={styles.sectionCard}>
<SkeletonBlock height={18} width="28%" />
<SkeletonBlock height={14} width="64%" />
<SkeletonBlock height={76} width="100%" />
<SkeletonBlock height={76} width="100%" />
</Card>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 18,
},
heroCard: {
padding: 22,
borderRadius: 28,
gap: 14,
},
bottomRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 8,
},
metricRow: {
flexDirection: 'row',
gap: 12,
},
metricCard: {
flex: 1,
gap: 12,
},
sectionCard: {
gap: 14,
},
});
export const CardsSkeleton = memo(CardsSkeletonComponent);
@@ -0,0 +1,144 @@
import React, { memo, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, {
FadeInDown,
FadeOutUp,
Layout,
} from 'react-native-reanimated';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
interface ExpandableSectionProps {
title: string;
subtitle?: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
defaultExpanded?: boolean;
badge?: string;
children: React.ReactNode;
}
const ExpandableSectionComponent = ({
title,
subtitle,
icon,
defaultExpanded = false,
badge,
children,
}: ExpandableSectionProps) => {
const theme = useTheme();
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<Animated.View layout={Layout.springify().damping(25)}>
<Card style={styles.card}>
<Pressable style={styles.header} onPress={() => setExpanded((value) => !value)}>
<View
style={[
styles.iconWrap,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name={icon as any} size={18} color={theme.colors.primary} />
</View>
<View style={styles.copy}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{badge ? (
<View
style={[
styles.badge,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.badgeText, { color: theme.colors.textSecondary }]}>
{badge}
</Text>
</View>
) : null}
</View>
{subtitle ? (
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
{subtitle}
</Text>
) : null}
</View>
<Ionicons
name={expanded ? 'chevron-up-outline' : 'chevron-down-outline'}
size={18}
color={theme.colors.textSecondary}
/>
</Pressable>
{expanded ? (
<Animated.View
entering={FadeInDown.duration(220)}
exiting={FadeOutUp.duration(180)}
layout={Layout.springify().damping(25)}
style={styles.body}
>
{children}
</Animated.View>
) : null}
</Card>
</Animated.View>
);
};
const styles = StyleSheet.create({
card: {
padding: 20,
gap: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
flex: 1,
gap: 4,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
},
title: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.3,
},
subtitle: {
fontSize: 13,
lineHeight: 19,
},
badge: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
badgeText: {
fontSize: 11,
fontWeight: '700',
},
body: {
gap: 16,
},
});
export const ExpandableSection = memo(ExpandableSectionComponent);
@@ -0,0 +1,331 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutChangeEvent,
PanResponder,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
interface LimitSliderProps {
label: string;
helperText?: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
formatValue?: (value: number) => string;
onInteractionStart?: () => void;
onInteractionEnd?: () => void;
}
const THUMB_SIZE = 24;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
const valueToProgress = (value: number, min: number, max: number) =>
max === min ? 0 : (value - min) / (max - min);
const progressToValue = (
nextProgress: number,
min: number,
max: number,
step: number,
) => {
const steppedValue =
min + Math.round((clamp(nextProgress, 0, 1) * (max - min)) / step) * step;
return clamp(steppedValue, min, max);
};
const LimitSliderComponent = ({
label,
helperText,
value,
min,
max,
step = 1000,
onChange,
formatValue = (nextValue) => `Rs ${nextValue.toLocaleString('en-IN')}`,
onInteractionStart,
onInteractionEnd,
}: LimitSliderProps) => {
const theme = useTheme();
const [displayValue, setDisplayValue] = useState(value);
const trackWidthRef = useRef(0);
const trackWidth = useSharedValue(0);
const progress = useSharedValue(valueToProgress(value, min, max));
useEffect(() => {
setDisplayValue(value);
progress.value = withTiming(valueToProgress(value, min, max), {
duration: 160,
});
}, [max, min, progress, value]);
const commitValue = useCallback(
(nextValue: number) => {
onChange(clamp(nextValue, min, max));
},
[max, min, onChange],
);
const onTrackLayout = (event: LayoutChangeEvent) => {
trackWidthRef.current = event.nativeEvent.layout.width;
trackWidth.value = event.nativeEvent.layout.width;
};
const updateFromLocationX = useCallback(
(
locationX: number,
{ animate = false, commit = false }: { animate?: boolean; commit?: boolean } = {},
) => {
const usableWidth = Math.max(trackWidthRef.current - THUMB_SIZE, 1);
const nextProgress = clamp((locationX - THUMB_SIZE / 2) / usableWidth, 0, 1);
const nextValue = progressToValue(nextProgress, min, max, step);
progress.value = animate ? withTiming(nextProgress, { duration: 120 }) : nextProgress;
setDisplayValue(nextValue);
if (commit) {
commitValue(nextValue);
}
},
[commitValue, max, min, progress, step],
);
const handleStepChange = useCallback(
(direction: -1 | 1) => {
const nextValue = clamp(value + direction * step, min, max);
setDisplayValue(nextValue);
progress.value = withTiming(valueToProgress(nextValue, min, max), {
duration: 120,
});
commitValue(nextValue);
},
[commitValue, max, min, progress, step, value],
);
const panResponder = useMemo(
() =>
PanResponder.create({
onMoveShouldSetPanResponderCapture: (_, gestureState) =>
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 4,
onMoveShouldSetPanResponder: (_, gestureState) =>
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 4,
onPanResponderTerminationRequest: () => false,
onPanResponderGrant: (event) => {
onInteractionStart?.();
updateFromLocationX(event.nativeEvent.locationX);
},
onPanResponderMove: (event) => {
updateFromLocationX(event.nativeEvent.locationX);
},
onPanResponderRelease: (event) => {
updateFromLocationX(event.nativeEvent.locationX, { commit: true });
onInteractionEnd?.();
},
onPanResponderTerminate: (event) => {
updateFromLocationX(event.nativeEvent.locationX, { commit: true });
onInteractionEnd?.();
},
}),
[onInteractionEnd, onInteractionStart, updateFromLocationX],
);
const decreaseDisabled = value <= min;
const increaseDisabled = value >= max;
const decreaseButtonStyle = {
backgroundColor: decreaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
opacity: decreaseDisabled ? 0.6 : 1,
};
const increaseButtonStyle = {
backgroundColor: increaseDisabled ? theme.colors.surfaceMuted : `${theme.colors.primary}12`,
opacity: increaseDisabled ? 0.6 : 1,
};
const fillStyle = useAnimatedStyle(() => ({
width: THUMB_SIZE / 2 + progress.value * Math.max(trackWidth.value - THUMB_SIZE, 0),
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: progress.value * Math.max(trackWidth.value - THUMB_SIZE, 0),
},
],
}));
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.copy}>
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
{helperText ? (
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
{helperText}
</Text>
) : null}
</View>
<Text style={[styles.value, { color: theme.colors.primary }]}>
{formatValue(displayValue)}
</Text>
</View>
<View style={styles.sliderRow}>
<Pressable
onPress={() => handleStepChange(-1)}
onPressIn={onInteractionStart}
onPressOut={onInteractionEnd}
disabled={decreaseDisabled}
style={[styles.stepButton, decreaseButtonStyle]}
>
<Ionicons name="remove-outline" size={18} color={theme.colors.primary} />
</Pressable>
<View style={styles.trackColumn}>
<Pressable
onPress={(event) =>
updateFromLocationX(event.nativeEvent.locationX, { animate: true, commit: true })
}
onPressIn={onInteractionStart}
onPressOut={onInteractionEnd}
>
<View style={styles.trackFrame} onLayout={onTrackLayout} {...panResponder.panHandlers}>
<View
style={[
styles.trackBackground,
{ backgroundColor: theme.colors.surfaceMuted },
]}
/>
<Animated.View
style={[
styles.trackFill,
fillStyle,
{ backgroundColor: theme.colors.primary },
]}
/>
<Animated.View
style={[
styles.thumb,
thumbStyle,
{
borderColor: `${theme.colors.primary}30`,
backgroundColor: theme.colors.white,
},
]}
/>
</View>
</Pressable>
<View style={styles.scale}>
<Text style={[styles.scaleText, { color: theme.colors.textMuted }]}>
{formatValue(min)}
</Text>
<Text style={[styles.scaleText, { color: theme.colors.textMuted }]}>
{formatValue(max)}
</Text>
</View>
</View>
<Pressable
onPress={() => handleStepChange(1)}
onPressIn={onInteractionStart}
onPressOut={onInteractionEnd}
disabled={increaseDisabled}
style={[styles.stepButton, increaseButtonStyle]}
>
<Ionicons name="add-outline" size={18} color={theme.colors.primary} />
</Pressable>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 12,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
copy: {
flex: 1,
gap: 4,
},
label: {
fontSize: 15,
fontWeight: '700',
},
helperText: {
fontSize: 12,
lineHeight: 18,
},
value: {
fontSize: 13,
fontWeight: '800',
},
sliderRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
trackColumn: {
flex: 1,
gap: 10,
},
stepButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
trackFrame: {
height: THUMB_SIZE,
justifyContent: 'center',
},
trackBackground: {
height: 10,
borderRadius: 999,
},
trackFill: {
position: 'absolute',
left: 0,
top: 7,
height: 10,
borderRadius: 999,
},
thumb: {
position: 'absolute',
left: 0,
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: THUMB_SIZE / 2,
borderWidth: 4,
},
scale: {
flexDirection: 'row',
justifyContent: 'space-between',
},
scaleText: {
fontSize: 11,
fontWeight: '600',
},
});
export const LimitSlider = memo(LimitSliderComponent);
@@ -0,0 +1,127 @@
import React, { memo, useEffect } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useTheme } from '../../../theme';
interface ToggleSwitchProps {
label: string;
description?: string;
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
}
const ToggleSwitchComponent = ({
label,
description,
value,
onValueChange,
disabled = false,
}: ToggleSwitchProps) => {
const theme = useTheme();
const progress = useSharedValue(value ? 1 : 0);
useEffect(() => {
progress.value = withTiming(value ? 1 : 0, { duration: 180 });
}, [progress, value]);
const trackStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.surfaceMuted, theme.colors.primary],
),
}));
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: withTiming(progress.value * 22, { duration: 180 }) }],
}));
return (
<Pressable
disabled={disabled}
onPress={() => onValueChange(!value)}
style={[
styles.row,
disabled && styles.rowDisabled,
{
borderBottomColor: theme.colors.cardBorder,
},
]}
>
<View style={styles.copy}>
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{label}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
{description}
</Text>
) : null}
</View>
<Animated.View
style={[
styles.track,
trackStyle,
{ borderRadius: theme.radius.pill },
]}
>
<Animated.View
style={[
styles.thumb,
thumbStyle,
{ backgroundColor: theme.colors.white },
]}
/>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 16,
},
rowDisabled: {
opacity: 0.55,
},
copy: {
flex: 1,
gap: 4,
},
label: {
fontSize: 15,
fontWeight: '700',
},
description: {
fontSize: 12,
lineHeight: 18,
},
track: {
width: 52,
height: 30,
justifyContent: 'center',
paddingHorizontal: 4,
},
thumb: {
width: 18,
height: 18,
borderRadius: 9,
},
});
export const ToggleSwitch = memo(ToggleSwitchComponent);
@@ -0,0 +1,155 @@
import React, { memo } from 'react';
import {
StyleSheet,
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../../theme';
import type { CardTransaction } from '../../../types/app';
import { formatCurrency, formatDate, formatTime } from '../../../utils/formatters';
interface TransactionItemProps {
item: CardTransaction;
showDivider?: boolean;
}
const TransactionItemComponent = ({
item,
showDivider = true,
}: TransactionItemProps) => {
const theme = useTheme();
const isCredit = item.direction === 'credit';
const statusTone =
item.status === 'completed'
? theme.colors.success
: item.status === 'pending'
? theme.colors.warning
: theme.colors.danger;
return (
<View
style={[
styles.row,
showDivider && styles.rowDivider,
]}
>
<View
style={[
styles.iconFrame,
{
backgroundColor: isCredit ? `${theme.colors.success}12` : `${theme.colors.primary}12`,
},
]}
>
<Ionicons
name={isCredit ? 'arrow-down-outline' : 'arrow-up-outline'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.primary}
/>
</View>
<View style={styles.copy}>
<View style={styles.rowTop}>
<Text style={[styles.merchant, { color: theme.colors.textPrimary }]}>{item.merchant}</Text>
<Text
style={[
styles.amount,
{ color: isCredit ? theme.colors.success : theme.colors.textPrimary },
]}
>
{isCredit ? '+' : '-'}
{formatCurrency(item.amount)}
</Text>
</View>
<View style={styles.rowBottom}>
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>{item.category}</Text>
<View
style={[
styles.metaDot,
{ backgroundColor: theme.colors.textMuted },
]}
/>
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
{formatDate(item.occurredAt)} · {formatTime(item.occurredAt)}
</Text>
</View>
<View style={[styles.statusPill, { backgroundColor: `${statusTone}12` }]}>
<Text style={[styles.statusText, { color: statusTone }]}>
{item.status.toUpperCase()}
</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: 12,
paddingVertical: 16,
},
rowDivider: {
borderBottomWidth: 1,
borderBottomColor: 'rgba(15, 23, 42, 0.08)',
},
iconFrame: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
copy: {
flex: 1,
gap: 8,
},
rowTop: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
merchant: {
flex: 1,
fontSize: 15,
fontWeight: '800',
},
amount: {
fontSize: 15,
fontWeight: '900',
textAlign: 'right',
},
rowBottom: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
},
meta: {
fontSize: 12,
fontWeight: '500',
},
metaDot: {
width: 4,
height: 4,
borderRadius: 2,
},
statusPill: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
},
statusText: {
fontSize: 10,
fontWeight: '800',
letterSpacing: 0.7,
},
});
export const TransactionItem = memo(TransactionItemComponent);
@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { selectSelectedCard } from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
export const useCardControls = () => {
const dispatch = useAppDispatch();
const selectedCard = useAppSelector(selectSelectedCard);
const updateControls = useCallback(
(changes: Partial<NonNullable<typeof selectedCard>['controls']>) => {
if (!selectedCard) {
return;
}
dispatch(cardsActions.updateCardControls({ cardId: selectedCard.id, changes }));
},
[dispatch, selectedCard],
);
return {
selectedCard,
updateControls,
};
};
@@ -0,0 +1,61 @@
import { useMemo, useState } from 'react';
import type { CardTransaction, TransactionDirection } from '../../../types/app';
export type TransactionDateFilter = '7d' | '30d' | '90d' | 'all';
export type TransactionAmountFilter = 'all' | 'under5k' | '5kto10k' | 'above10k';
export type TransactionTypeFilter = 'all' | TransactionDirection;
const DAY_MS = 1000 * 60 * 60 * 24;
export const useCardTransactionFilters = (transactions: CardTransaction[]) => {
const [dateFilter, setDateFilter] = useState<TransactionDateFilter>('30d');
const [amountFilter, setAmountFilter] = useState<TransactionAmountFilter>('all');
const [typeFilter, setTypeFilter] = useState<TransactionTypeFilter>('all');
const filteredTransactions = useMemo(() => {
const orderedTransactions = [...transactions].sort(
(left, right) =>
new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
);
const referenceTime =
orderedTransactions.length > 0 && orderedTransactions[0]
? new Date(orderedTransactions[0].occurredAt).getTime()
: Date.now();
return orderedTransactions.filter((transaction) => {
const transactionTime = new Date(transaction.occurredAt).getTime();
const transactionAmount = transaction.amount;
const passesDate =
dateFilter === 'all'
? true
: referenceTime - transactionTime <=
(dateFilter === '7d' ? 7 : dateFilter === '30d' ? 30 : 90) * DAY_MS;
const passesAmount =
amountFilter === 'all'
? true
: amountFilter === 'under5k'
? transactionAmount < 5000
: amountFilter === '5kto10k'
? transactionAmount >= 5000 && transactionAmount <= 10000
: transactionAmount > 10000;
const passesType = typeFilter === 'all' ? true : transaction.direction === typeFilter;
return passesDate && passesAmount && passesType;
});
}, [amountFilter, dateFilter, transactions, typeFilter]);
return {
dateFilter,
amountFilter,
typeFilter,
setDateFilter,
setAmountFilter,
setTypeFilter,
filteredTransactions,
};
};
@@ -0,0 +1,627 @@
import React, { useMemo, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useAppSelector } from '../../../app/hooks';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
type Props = NativeStackScreenProps<CardsStackParamList, 'ApplyCreditCard'>;
const CREDIT_PRODUCTS = [
{
id: 'infinite_rewards',
title: 'Infinite Rewards',
subtitle: 'Premium travel, dining, and lifestyle perks',
annualFee: 1499,
joiningBenefit: '5,000 welcome points',
estimatedLimit: 350000,
benefits: ['Lounge access', 'Dining cashback', 'Reward multipliers'],
},
{
id: 'cashback_plus',
title: 'Cashback Plus',
subtitle: 'Simple everyday savings on online and POS spends',
annualFee: 499,
joiningBenefit: 'Amazon voucher worth Rs 1,000',
estimatedLimit: 180000,
benefits: ['Online cashback', 'EMI offers', 'Fuel surcharge waiver'],
},
{
id: 'travel_signature',
title: 'Travel Signature',
subtitle: 'Built for frequent flyers and hotel-heavy lifestyles',
annualFee: 1999,
joiningBenefit: 'Domestic flight voucher',
estimatedLimit: 500000,
benefits: ['Air miles boost', 'Travel insurance', 'Hotel privileges'],
},
] as const;
const EMPLOYMENT_TYPES = [
{ id: 'salaried', label: 'Salaried' },
{ id: 'self-employed', label: 'Self-employed' },
] as const;
type CreditProductId = (typeof CREDIT_PRODUCTS)[number]['id'];
export default function ApplyCreditCardScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
const accounts = useAppSelector((state) => state.account.accounts);
const profile = useAppSelector((state) => state.user.profile);
const [selectedProductId, setSelectedProductId] = useState<CreditProductId>(
CREDIT_PRODUCTS[0].id,
);
const [selectedRepaymentAccountId, setSelectedRepaymentAccountId] =
useState(accounts[0]?.id ?? '');
const [employmentType, setEmploymentType] =
useState<(typeof EMPLOYMENT_TYPES)[number]['id']>('salaried');
const [annualIncome, setAnnualIncome] = useState('');
const [employerName, setEmployerName] = useState('');
const [city, setCity] = useState('');
const [consentAccepted, setConsentAccepted] = useState(false);
const selectedProduct = useMemo(
() => CREDIT_PRODUCTS.find((product) => product.id === selectedProductId) ?? CREDIT_PRODUCTS[0],
[selectedProductId],
);
const selectedRepaymentAccount = useMemo(
() =>
accounts.find((account) => account.id === selectedRepaymentAccountId) ?? accounts[0],
[selectedRepaymentAccountId, accounts],
);
const handleApply = () => {
const parsedIncome = Number(annualIncome.replace(/[^0-9]/g, ''));
if (!parsedIncome || parsedIncome < 300000) {
toast(
'Income too low',
'error',
'Enter an annual income of at least Rs 3,00,000 to continue.',
);
return;
}
if (!employerName.trim()) {
toast('Employer required', 'error', 'Enter your employer or business name.');
return;
}
if (!city.trim()) {
toast('City required', 'error', 'Enter your current city to continue.');
return;
}
if (!selectedRepaymentAccount) {
toast('Account required', 'error', 'Select a repayment account for your credit card.');
return;
}
if (!consentAccepted) {
toast(
'Consent required',
'error',
'Accept the bureau and KYC consent to submit your application.',
);
return;
}
toast(
'Application submitted',
'success',
`Your ${selectedProduct.title} credit card application is under review.`,
);
navigation.goBack();
};
return (
<ScreenShell
title="Apply credit card"
subtitle="Check your eligibility and submit a new credit card request"
>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<Card style={styles.heroCard}>
<View style={styles.heroRow}>
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.warning}14` },
]}
>
<Ionicons name="sparkles-outline" size={22} color={theme.colors.warning} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
Pre-qualified journey
</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
Use your CBS relationship to fast-track the card application experience.
</Text>
</View>
</View>
<View style={styles.heroStats}>
<View
style={[
styles.heroStat,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Customer ID
</Text>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
{profile?.customerId || ''}
</Text>
</View>
<View
style={[
styles.heroStat,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Est. limit
</Text>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
{formatCompactCurrency(selectedProduct.estimatedLimit)}
</Text>
</View>
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Choose your credit card
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Pick a credit card that matches how you spend and travel.
</Text>
</View>
<View style={styles.productList}>
{CREDIT_PRODUCTS.map((product) => {
const selected = product.id === selectedProductId;
return (
<Pressable
key={product.id}
onPress={() => setSelectedProductId(product.id)}
style={[
styles.productCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
},
]}
>
<View style={styles.productHeader}>
<View style={styles.productCopy}>
<Text style={[styles.productTitle, { color: theme.colors.textPrimary }]}>
{product.title}
</Text>
<Text style={[styles.productSubtitle, { color: theme.colors.textSecondary }]}>
{product.subtitle}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={theme.colors.primary} />
) : null}
</View>
<View style={styles.productMetaRow}>
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
Annual fee {formatCurrency(product.annualFee)}
</Text>
<Text style={[styles.productMeta, { color: theme.colors.accentStrong }]}>
{product.joiningBenefit}
</Text>
</View>
<View style={styles.benefitList}>
{product.benefits.map((benefit) => (
<View key={benefit} style={styles.benefitItem}>
<Ionicons name="checkmark-outline" size={16} color={theme.colors.accentStrong} />
<Text style={[styles.benefitText, { color: theme.colors.textSecondary }]}>
{benefit}
</Text>
</View>
))}
</View>
</Pressable>
);
})}
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Eligibility details
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
These details are used to estimate the right limit and product fit.
</Text>
</View>
<View style={styles.segmentRow}>
{EMPLOYMENT_TYPES.map((option) => {
const selected = option.id === employmentType;
return (
<Pressable
key={option.id}
onPress={() => setEmploymentType(option.id)}
style={[
styles.segmentButton,
{
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
},
]}
>
<Text
style={[
styles.segmentText,
{ color: selected ? theme.colors.primary : theme.colors.textSecondary },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<Input
label="Annual income"
keyboardType="number-pad"
value={annualIncome}
onChangeText={setAnnualIncome}
placeholder="e.g. 1200000"
/>
<Input
label={employmentType === 'salaried' ? 'Employer name' : 'Business name'}
value={employerName}
onChangeText={setEmployerName}
placeholder="Enter company or business name"
/>
<Input
label="Current city"
value={city}
onChangeText={setCity}
placeholder="Enter current city"
/>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Repayment account
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Choose the CBS account you want to link for repayments and autopay.
</Text>
</View>
<View style={styles.productList}>
{accounts.map((account) => {
const selected = account.id === selectedRepaymentAccountId;
return (
<Pressable
key={account.id}
onPress={() => setSelectedRepaymentAccountId(account.id)}
style={[
styles.accountCard,
{
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
},
]}
>
<View style={styles.productHeader}>
<View style={styles.productCopy}>
<Text style={[styles.productTitle, { color: theme.colors.textPrimary }]}>
{account.type} account
</Text>
<Text style={[styles.productSubtitle, { color: theme.colors.textSecondary }]}>
{account.maskedNumber}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={theme.colors.accentStrong} />
) : null}
</View>
<Text style={[styles.productMeta, { color: theme.colors.textPrimary }]}>
Available balance {formatCurrency(account.availableBalance)}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Final consent
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
We will use your submitted details for bureau checks, KYC, and underwriting review.
</Text>
</View>
<Pressable
onPress={() => setConsentAccepted((current) => !current)}
style={[
styles.consentRow,
{ backgroundColor: theme.colors.surfaceMuted, borderColor: theme.colors.cardBorder },
]}
>
<View
style={[
styles.consentCheck,
{
backgroundColor: consentAccepted ? theme.colors.primary : theme.colors.surface,
borderColor: consentAccepted ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
{consentAccepted ? (
<Ionicons name="checkmark" size={14} color={theme.colors.white} />
) : null}
</View>
<Text style={[styles.consentText, { color: theme.colors.textPrimary }]}>
I authorize CBS Bank to review my bureau, KYC, and income details for this credit card
application.
</Text>
</Pressable>
</Card>
<Card style={styles.summaryCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Application summary
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
A quick review of the selected product before submission.
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Product
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedProduct.title}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Estimated limit
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(selectedProduct.estimatedLimit)}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Annual fee
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(selectedProduct.annualFee)}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Repayment account
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedRepaymentAccount?.maskedNumber ?? 'Not selected'}
</Text>
</View>
</Card>
<Button
title="Submit credit card application"
onPress={handleApply}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
<View style={{ height: 60 }} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 16,
paddingBottom: 24,
},
heroCard: {
gap: 18,
},
heroRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
},
heroIcon: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
letterSpacing: -0.3,
},
heroSubtitle: {
fontSize: 13,
lineHeight: 19,
},
heroStats: {
flexDirection: 'row',
gap: 12,
},
heroStat: {
flex: 1,
borderRadius: 18,
padding: 14,
gap: 6,
},
heroStatLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroStatValue: {
fontSize: 15,
fontWeight: '800',
},
formCard: {
gap: 14,
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.3,
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 19,
},
productList: {
gap: 12,
},
productCard: {
borderRadius: 18,
borderWidth: 1,
padding: 16,
gap: 10,
},
accountCard: {
borderRadius: 18,
borderWidth: 1,
padding: 16,
gap: 8,
},
productHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
},
productCopy: {
flex: 1,
gap: 4,
},
productTitle: {
fontSize: 15,
fontWeight: '800',
},
productSubtitle: {
fontSize: 13,
lineHeight: 18,
},
productMetaRow: {
gap: 4,
},
productMeta: {
fontSize: 13,
fontWeight: '700',
},
benefitList: {
gap: 8,
},
benefitItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
benefitText: {
fontSize: 13,
lineHeight: 18,
},
segmentRow: {
flexDirection: 'row',
gap: 12,
},
segmentButton: {
flex: 1,
borderWidth: 1,
borderRadius: 16,
paddingVertical: 14,
paddingHorizontal: 12,
alignItems: 'center',
},
segmentText: {
fontSize: 14,
fontWeight: '700',
},
consentRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
borderWidth: 1,
borderRadius: 18,
padding: 16,
},
consentCheck: {
width: 24,
height: 24,
borderRadius: 8,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
},
consentText: {
flex: 1,
fontSize: 13,
lineHeight: 19,
},
summaryCard: {
gap: 14,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
},
summaryLabel: {
fontSize: 13,
fontWeight: '600',
},
summaryValue: {
flex: 1,
textAlign: 'right',
fontSize: 14,
fontWeight: '800',
},
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,835 @@
import React, { useMemo } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, EmptyState, ScreenShell } from '../../../components';
import { selectAllAccounts } from '../../home/store/accountSelectors';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { CardCarousel } from '../components/CardCarousel';
import { CardDeliveryStatus } from '../components/CardDeliveryStatus';
import { CardsSkeleton } from '../components/CardsSkeleton';
import { TransactionItem } from '../components/TransactionItem';
import {
selectCards,
selectCardsStatus,
selectSelectedCard,
selectSelectedCardIndex,
} from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
type Props = NativeStackScreenProps<CardsStackParamList, 'CardsOverview'>;
type CardServiceHubProps = {
onRequestDebitCard: () => void;
onApplyCreditCard: () => void;
};
const CardServiceHub = ({
onRequestDebitCard,
onApplyCreditCard,
}: CardServiceHubProps) => {
const theme = useTheme();
return (
<Card style={styles.serviceHubCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Card services
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Need a new card? Request a physical debit card or start a fresh credit card application.
</Text>
</View>
<View style={styles.serviceList}>
<View
style={[
styles.servicePanel,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<View style={styles.servicePanelHeader}>
<View
style={[
styles.serviceIconWrap,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="card-outline" size={18} color={theme.colors.primary} />
</View>
<View style={styles.serviceCopy}>
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
Debit card
</Text>
<Text style={[styles.serviceTitle, { color: theme.colors.textPrimary }]}>
Request physical card
</Text>
</View>
</View>
<Text style={[styles.serviceDescription, { color: theme.colors.textSecondary }]}>
Order a new physical debit card linked to your savings or salary account.
</Text>
<View style={styles.serviceTagRow}>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.accent}12` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
RuPay
</Text>
</View>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>Visa</Text>
</View>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.warning}16` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
Mastercard
</Text>
</View>
</View>
<Button
title="Request debit card"
variant="secondary"
onPress={onRequestDebitCard}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.textPrimary} />}
/>
</View>
<View
style={[
styles.servicePanel,
{ backgroundColor: `${theme.colors.primary}08` },
]}
>
<View style={styles.servicePanelHeader}>
<View
style={[
styles.serviceIconWrap,
{ backgroundColor: `${theme.colors.warning}16` },
]}
>
<Ionicons name="sparkles-outline" size={18} color={theme.colors.warning} />
</View>
<View style={styles.serviceCopy}>
<Text style={[styles.serviceEyebrow, { color: theme.colors.textSecondary }]}>
Credit card
</Text>
<Text style={[styles.serviceTitle, { color: theme.colors.textPrimary }]}>
Apply for a new card
</Text>
</View>
</View>
<Text style={[styles.serviceDescription, { color: theme.colors.textSecondary }]}>
Compare premium variants, check eligibility, and submit your credit card application.
</Text>
<View style={styles.serviceTagRow}>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.primary }]}>
Rewards
</Text>
</View>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.accent}12` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.accentStrong }]}>
Cashback
</Text>
</View>
<View
style={[
styles.serviceTag,
{ backgroundColor: `${theme.colors.warning}16` },
]}
>
<Text style={[styles.serviceTagText, { color: theme.colors.warning }]}>
Travel
</Text>
</View>
</View>
<Button
title="Apply credit card"
onPress={onApplyCreditCard}
leftAccessory={<Ionicons name="sparkles-outline" size={18} color={theme.colors.white} />}
/>
</View>
</View>
</Card>
);
};
export default function CardsOverviewScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const cards = useAppSelector(selectCards);
const cardsStatus = useAppSelector(selectCardsStatus);
const selectedCard = useAppSelector(selectSelectedCard);
const selectedIndex = useAppSelector(selectSelectedCardIndex);
const accounts = useAppSelector((state) => state.account.accounts);
const selectedTransactions = useMemo(
() =>
selectedCard
? [...selectedCard.transactions].sort(
(left, right) =>
new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
)
: [],
[selectedCard],
);
const previewTransactions = selectedTransactions.slice(0, 3);
const budgetUsage =
selectedCard && selectedCard.spendInsight.budget > 0
? selectedCard.spendInsight.totalSpent / selectedCard.spendInsight.budget
: 0;
const topCategories = selectedCard?.spendInsight.categories.slice(0, 4) ?? [];
const maxCategorySpend = Math.max(...topCategories.map((item) => item.amount), 1);
const primaryLinkedAccount = useMemo(
() =>
selectedCard
? accounts.find((account) => account.id === selectedCard.primaryLinkedAccountId) ?? null
: null,
[selectedCard, accounts],
);
const handleRequestDebitCard = () => navigation.navigate('RequestPhysicalCard');
const handleApplyCreditCard = () => navigation.navigate('ApplyCreditCard');
const handleToggleFreeze = () => {
if (!selectedCard) {
return;
}
dispatch(
cardsActions.updateCardControls({
cardId: selectedCard.id,
changes: { frozen: !selectedCard.controls.frozen },
}),
);
toast(
selectedCard.controls.frozen ? 'Card resumed' : 'Card frozen',
'success',
selectedCard.controls.frozen
? 'Transactions are available again.'
: 'Transactions are temporarily paused for this card.',
);
};
const handleToggleBlock = () => {
if (!selectedCard) {
return;
}
const nextStatus = selectedCard.status === 'blocked' ? 'active' : 'blocked';
dispatch(cardsActions.setCardStatus({ cardId: selectedCard.id, status: nextStatus }));
toast(
nextStatus === 'blocked' ? 'Card blocked' : 'Card unblocked',
nextStatus === 'blocked' ? 'error' : 'success',
nextStatus === 'blocked'
? 'This card is now blocked for all new transactions.'
: 'This card is active again.',
);
};
if (cardsStatus === 'loading') {
return (
<ScreenShell title="Cards" subtitle="Premium controls, insights, and secure card tools">
<CardsSkeleton />
</ScreenShell>
);
}
if (!cards.length || !selectedCard) {
return (
<ScreenShell title="Cards" subtitle="Premium controls, insights, and secure card tools">
<View style={styles.emptyStateWrap}>
<EmptyState
title="No cards yet"
description="Request a physical debit card or start a new credit card application from one place."
actionLabel="Request debit card"
onAction={handleRequestDebitCard}
/>
<CardServiceHub
onRequestDebitCard={handleRequestDebitCard}
onApplyCreditCard={handleApplyCreditCard}
/>
</View>
</ScreenShell>
);
}
return (
<ScreenShell
title="Cards"
subtitle="Premium controls, insights, and secure card tools"
scrollable={false}
>
<View style={styles.flex}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.heroCopy}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
Selected wallet card
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{selectedCard.name}
</Text>
</View>
<View
style={[
styles.heroBadge,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="sparkles-outline" size={14} color={theme.colors.primary} />
<Text style={[styles.heroBadgeText, { color: theme.colors.primary }]}>
{selectedCard.isDefault ? 'Default card' : 'Secondary card'}
</Text>
</View>
</View>
<CardCarousel
cards={cards}
selectedIndex={selectedIndex}
onSnapToCard={(cardId) => dispatch(cardsActions.setSelectedCard(cardId))}
/>
{selectedCard.physicalCardDelivery ? (
<Card style={styles.deliveryCard}>
<CardDeliveryStatus delivery={selectedCard.physicalCardDelivery} compact />
</Card>
) : null}
<View style={styles.metricGrid}>
<Card style={styles.metricCard}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
{selectedCard.type === 'Credit' ? 'Available limit' : 'Linked account'}
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{selectedCard.type === 'Credit'
? formatCompactCurrency(selectedCard.availableLimit)
: primaryLinkedAccount?.maskedNumber ?? 'Not linked'}
</Text>
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
{selectedCard.type === 'Credit'
? `Network ${selectedCard.network}`
: primaryLinkedAccount
? `${primaryLinkedAccount.type} account`
: `Network ${selectedCard.network}`}
</Text>
</Card>
<Card style={styles.metricCard}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Spend today
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{formatCompactCurrency(selectedCard.spendToday)}
</Text>
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
{selectedCard.controls.frozen ? 'Temporarily frozen' : 'Live spend tracking'}
</Text>
</Card>
<Card style={[styles.metricCard, styles.metricCardWide]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Rewards balance
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{selectedCard.rewards.points.toLocaleString('en-IN')} pts
</Text>
<Text style={[styles.metricSubtext, { color: theme.colors.textMuted }]}>
Redeem value {formatCurrency(selectedCard.rewards.redeemValue)}
</Text>
</Card>
</View>
<Card style={styles.actionsCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Quick controls
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Manage your selected card instantly
</Text>
</View>
<View style={styles.actionsGrid}>
<Button
title="View details"
fullWidth={false}
style={styles.actionButton}
onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}
leftAccessory={
<Ionicons name="card-outline" size={18} color={theme.colors.white} />
}
/>
<Button
title={selectedCard.controls.frozen ? 'Unfreeze' : 'Freeze'}
variant="secondary"
fullWidth={false}
style={styles.actionButton}
onPress={handleToggleFreeze}
leftAccessory={
<Ionicons
name={selectedCard.controls.frozen ? 'play-circle-outline' : 'snow-outline'}
size={18}
color={theme.colors.textPrimary}
/>
}
/>
<Button
title={selectedCard.status === 'blocked' ? 'Unblock' : 'Block card'}
variant={selectedCard.status === 'blocked' ? 'secondary' : 'danger'}
fullWidth={false}
style={styles.actionButton}
onPress={handleToggleBlock}
leftAccessory={
<Ionicons
name={selectedCard.status === 'blocked' ? 'lock-open-outline' : 'lock-closed-outline'}
size={18}
color={
selectedCard.status === 'blocked' ? theme.colors.textPrimary : theme.colors.white
}
/>
}
/>
</View>
</Card>
<CardServiceHub
onRequestDebitCard={handleRequestDebitCard}
onApplyCreditCard={handleApplyCreditCard}
/>
<Card style={styles.insightCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Spending pulse
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
{selectedCard.spendInsight.monthLabel} budget usage
</Text>
</View>
<View style={styles.progressWrap}>
<View
style={[
styles.progressTrack,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<View
style={[
styles.progressFill,
{
width: `${Math.min(budgetUsage * 100, 100)}%`,
backgroundColor:
budgetUsage > 0.85 ? theme.colors.warning : theme.colors.accent,
},
]}
/>
</View>
<Text style={[styles.progressText, { color: theme.colors.textSecondary }]}>
{formatCurrency(selectedCard.spendInsight.totalSpent)} of{' '}
{formatCurrency(selectedCard.spendInsight.budget)}
</Text>
</View>
<View style={styles.categoryChart}>
{topCategories.map((category) => (
<View key={category.id} style={styles.categoryColumn}>
<View
style={[
styles.categoryBar,
{
height: 28 + (category.amount / maxCategorySpend) * 56,
backgroundColor: category.color,
},
]}
/>
<Text style={[styles.categoryLabel, { color: theme.colors.textSecondary }]}>
{category.label}
</Text>
<Text style={[styles.categoryAmount, { color: theme.colors.textPrimary }]}>
{formatCompactCurrency(category.amount)}
</Text>
</View>
))}
</View>
</Card>
<Card style={styles.transactionsCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Recent card transactions
</Text>
<Pressable onPress={() => navigation.navigate('CardDetails', { cardId: selectedCard.id })}>
<Text style={[styles.linkText, { color: theme.colors.primary }]}>See all</Text>
</Pressable>
</View>
{previewTransactions.length ? (
previewTransactions.map((transaction, index) => (
<TransactionItem
key={transaction.id}
item={transaction}
showDivider={index !== previewTransactions.length - 1}
/>
))
) : (
<EmptyState
title="No card transactions"
description="Your recent card activity will appear here once you start spending."
/>
)}
</Card>
<Card style={styles.rewardsCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Rewards and offers
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Two live perks on this card
</Text>
</View>
<View style={styles.offerList}>
{selectedCard.offers.slice(0, 2).map((offer) => (
<View
key={offer.id}
style={[
styles.offerCard,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.offerMerchant, { color: theme.colors.primary }]}>
{offer.merchant}
</Text>
<Text style={[styles.offerTitle, { color: theme.colors.textPrimary }]}>
{offer.title}
</Text>
<Text style={[styles.offerSubtitle, { color: theme.colors.textSecondary }]}>
{offer.subtitle}
</Text>
<Text style={[styles.offerCashback, { color: theme.colors.accentStrong }]}>
{offer.cashback}
</Text>
</View>
))}
</View>
</Card>
<View style={styles.footerSpace} />
</ScrollView>
<Pressable
onPress={handleRequestDebitCard}
style={[
styles.fab,
{ backgroundColor: theme.colors.primary },
]}
>
<Ionicons name="add-outline" size={24} color={theme.colors.white} />
<Text style={styles.fabLabel}>Request Card</Text>
</Pressable>
</View>
<View style={styles.bottomInset} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 120,
},
emptyStateWrap: {
gap: 20,
},
heroCopy: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
heroEyebrow: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
},
heroTitle: {
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.7,
marginTop: 4,
},
heroBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 10,
},
heroBadgeText: {
fontSize: 12,
fontWeight: '700',
},
metricGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
deliveryCard: {
gap: 14,
},
metricCard: {
width: '48%',
gap: 8,
},
metricCardWide: {
width: '100%',
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.9,
},
metricValue: {
fontSize: 22,
fontWeight: '900',
letterSpacing: -0.5,
},
metricSubtext: {
fontSize: 12,
lineHeight: 18,
},
actionsCard: {
gap: 16,
},
serviceHubCard: {
gap: 16,
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.3,
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
actionButton: {
width: '48%',
},
serviceList: {
gap: 12,
},
servicePanel: {
borderRadius: 20,
padding: 16,
gap: 12,
},
servicePanelHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
serviceIconWrap: {
width: 42,
height: 42,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
serviceCopy: {
flex: 1,
gap: 2,
},
serviceEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
serviceTitle: {
fontSize: 16,
fontWeight: '800',
},
serviceDescription: {
fontSize: 13,
lineHeight: 18,
},
serviceTagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
serviceTag: {
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
},
serviceTagText: {
fontSize: 11,
fontWeight: '800',
letterSpacing: 0.4,
},
insightCard: {
gap: 18,
},
progressWrap: {
gap: 10,
},
progressTrack: {
height: 12,
borderRadius: 999,
overflow: 'hidden',
},
progressFill: {
height: 12,
borderRadius: 999,
},
progressText: {
fontSize: 12,
fontWeight: '600',
},
categoryChart: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
gap: 12,
},
categoryColumn: {
flex: 1,
alignItems: 'center',
gap: 8,
},
categoryBar: {
width: '100%',
borderRadius: 16,
},
categoryLabel: {
fontSize: 11,
fontWeight: '700',
},
categoryAmount: {
fontSize: 11,
fontWeight: '800',
},
transactionsCard: {
gap: 4,
},
linkText: {
fontSize: 13,
fontWeight: '700',
},
rewardsCard: {
gap: 16,
},
offerList: {
gap: 12,
},
offerCard: {
borderRadius: 18,
padding: 16,
gap: 6,
},
offerMerchant: {
fontSize: 11,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.9,
},
offerTitle: {
fontSize: 15,
fontWeight: '800',
},
offerSubtitle: {
fontSize: 13,
lineHeight: 18,
},
offerCashback: {
fontSize: 13,
fontWeight: '800',
},
footerSpace: {
height: 24,
},
fab: {
position: 'absolute',
right: 4,
bottom: 22,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 999,
paddingHorizontal: 18,
paddingVertical: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.18,
shadowRadius: 18,
elevation: 10,
},
fabLabel: {
color: 'white',
fontSize: 14,
fontWeight: '800',
},
bottomInset: {
height: 60,
},
});
@@ -0,0 +1,598 @@
import React, { useMemo, useState } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { CardsStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import type { CardNetwork } from '../../../types/app';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { selectCards } from '../store/cardsSelectors';
import { cardsActions } from '../store/cardsSlice';
import { createMockPhysicalCard } from '../utils/cardCopy';
type Props = NativeStackScreenProps<CardsStackParamList, 'RequestPhysicalCard'>;
const NETWORK_OPTIONS: Array<{
id: CardNetwork;
title: string;
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}> = [
{
id: 'RuPay',
title: 'RuPay',
subtitle: 'Great for domestic everyday payments',
icon: 'shield-checkmark-outline',
},
{
id: 'VISA',
title: 'Visa',
subtitle: 'Wider acceptance for online and travel use',
icon: 'globe-outline',
},
{
id: 'Mastercard',
title: 'Mastercard',
subtitle: 'Balanced rewards and global acceptance',
icon: 'flash-outline',
},
] as const;
const DELIVERY_OPTIONS = [
{
id: 'home',
title: 'Home delivery',
subtitle: 'Courier to your registered or alternate address',
icon: 'home-outline',
},
{
id: 'branch',
title: 'Branch pickup',
subtitle: 'Collect it from your home branch once ready',
icon: 'business-outline',
},
] as const;
export default function RequestPhysicalCardScreen({ navigation }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const cards = useAppSelector(selectCards);
const accounts = useAppSelector((state) => state.account.accounts);
const profile = useAppSelector((state) => state.user.profile);
const [selectedAccountId, setSelectedAccountId] = useState(accounts[0]?.id ?? '');
const [selectedNetwork, setSelectedNetwork] = useState<CardNetwork>('RuPay');
const [deliveryMode, setDeliveryMode] =
useState<(typeof DELIVERY_OPTIONS)[number]['id']>('home');
const [nameOnCard, setNameOnCard] = useState(`${profile?.firstName || ''} ${profile?.lastName || ''}`);
const [deliveryAddress, setDeliveryAddress] = useState('');
const [landmark, setLandmark] = useState('');
const selectedAccount = useMemo(
() => accounts.find((account) => account.id === selectedAccountId) ?? accounts[0],
[selectedAccountId, accounts],
);
const issuanceFee = selectedAccount?.type === 'Salary' ? 0 : 199;
const deliveryEta = deliveryMode === 'home' ? '3-5 business days' : 'Ready in 2 business days';
const estimatedDeliveryDate = useMemo(
() =>
new Date(
Date.now() + (deliveryMode === 'home' ? 4 : 2) * 24 * 60 * 60 * 1000,
).toISOString(),
[deliveryMode],
);
const handleSubmit = () => {
if (!selectedAccount) {
toast('Unable to place request', 'error', 'Select a linked account first.');
return;
}
if (!nameOnCard.trim()) {
toast('Name required', 'error', 'Enter the cardholder name to print on the card.');
return;
}
if (deliveryMode === 'home' && !deliveryAddress.trim()) {
toast('Address required', 'error', 'Add a delivery address for doorstep dispatch.');
return;
}
const newCard = createMockPhysicalCard({
index: cards.length + 1,
accountId: selectedAccount.id,
cardholderName: nameOnCard.trim(),
type: 'Debit',
network: selectedNetwork,
name: `${selectedNetwork} Debit`,
deliveryMode,
destinationLabel:
deliveryMode === 'home'
? [deliveryAddress.trim(), landmark.trim()].filter(Boolean).join(', ')
: selectedAccount.branch,
});
dispatch(cardsActions.addCard(newCard));
toast(
'Physical card requested',
'success',
deliveryMode === 'home'
? 'Your debit card request was submitted and doorstep delivery has been scheduled.'
: 'Your debit card request was submitted for branch pickup.',
);
navigation.replace('CardDetails', { cardId: newCard.id });
};
return (
<ScreenShell
title="Request physical card"
subtitle="Order a new debit card for your linked bank account"
>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
<Card style={styles.heroCard}>
<View style={styles.heroTopRow}>
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="card-outline" size={22} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
Debit card request
</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
Choose the account, network, and delivery mode for your new physical card.
</Text>
</View>
</View>
<View style={styles.heroStats}>
<View
style={[
styles.heroStat,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Issuance fee
</Text>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
{issuanceFee === 0 ? 'Free' : formatCurrency(issuanceFee)}
</Text>
</View>
<View
style={[
styles.heroStat,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroStatLabel, { color: theme.colors.textSecondary }]}>
Delivery ETA
</Text>
<Text style={[styles.heroStatValue, { color: theme.colors.textPrimary }]}>
{deliveryEta}
</Text>
</View>
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Linked account
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
This account will be used for debit-card settlements and charges.
</Text>
</View>
<View style={styles.choiceList}>
{accounts.map((account) => {
const selected = account.id === selectedAccountId;
return (
<Pressable
key={account.id}
onPress={() => setSelectedAccountId(account.id)}
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
},
]}
>
<View style={styles.choiceHeader}>
<View>
<Text style={[styles.choiceTitle, { color: theme.colors.textPrimary }]}>
{account.type} account
</Text>
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
{account.maskedNumber} · {account.branch}
</Text>
</View>
{selected ? (
<Ionicons
name="checkmark-circle"
size={20}
color={theme.colors.primary}
/>
) : null}
</View>
<Text style={[styles.choiceValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(account.availableBalance)}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Card network
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Pick the network that fits how you spend.
</Text>
</View>
<View style={styles.choiceList}>
{NETWORK_OPTIONS.map((option) => {
const selected = option.id === selectedNetwork;
return (
<Pressable
key={option.id}
onPress={() => setSelectedNetwork(option.id)}
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.accent}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.accent}40` : theme.colors.cardBorder,
},
]}
>
<View style={styles.choiceHeader}>
<View style={styles.networkTitleRow}>
<Ionicons 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
name="checkmark-circle"
size={20}
color={theme.colors.accentStrong}
/>
) : null}
</View>
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
{option.subtitle}
</Text>
</Pressable>
);
})}
</View>
</Card>
<Card style={styles.formCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Delivery details
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Decide how you want to receive your new physical debit card.
</Text>
</View>
<View style={styles.choiceList}>
{DELIVERY_OPTIONS.map((option) => {
const selected = option.id === deliveryMode;
return (
<Pressable
key={option.id}
onPress={() => setDeliveryMode(option.id)}
style={[
styles.choiceCard,
{
backgroundColor: selected ? `${theme.colors.primary}10` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
},
]}
>
<View style={styles.choiceHeader}>
<View style={styles.networkTitleRow}>
<Ionicons 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
name="checkmark-circle"
size={20}
color={theme.colors.primary}
/>
) : null}
</View>
<Text style={[styles.choiceSubtitle, { color: theme.colors.textSecondary }]}>
{option.subtitle}
</Text>
</Pressable>
);
})}
</View>
<Input
label="Name on card"
value={nameOnCard}
onChangeText={setNameOnCard}
placeholder="Enter cardholder name"
/>
{deliveryMode === 'home' ? (
<>
<Input
label="Delivery address"
value={deliveryAddress}
onChangeText={setDeliveryAddress}
placeholder="Flat / street / area"
/>
<Input
label="Landmark (optional)"
value={landmark}
onChangeText={setLandmark}
placeholder="Nearby landmark"
/>
</>
) : (
<View
style={[
styles.pickupCard,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Ionicons name="business-outline" size={18} color={theme.colors.primary} />
<View style={styles.pickupCopy}>
<Text style={[styles.pickupTitle, { color: theme.colors.textPrimary }]}>
Pickup branch
</Text>
<Text style={[styles.pickupSubtitle, { color: theme.colors.textSecondary }]}>
{selectedAccount?.branch ?? 'Home branch'} will hold your card after dispatch.
</Text>
</View>
</View>
)}
</Card>
<Card style={styles.summaryCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Request summary
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Final review before we place the request.
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Card type
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
Physical debit card
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Linked account
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedAccount?.maskedNumber ?? 'Not selected'}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Network
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{selectedNetwork}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Fulfilment
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{deliveryEta}
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Delivery status
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
Request received
</Text>
</View>
<View style={styles.summaryRow}>
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
Estimated by
</Text>
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
{formatDate(estimatedDeliveryDate)}
</Text>
</View>
</Card>
<Button
title="Request physical debit card"
onPress={handleSubmit}
leftAccessory={<Ionicons name="paper-plane-outline" size={18} color={theme.colors.white} />}
/>
<View style={styles.bottomInset} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 16,
paddingBottom: 24,
},
heroCard: {
gap: 18,
},
heroTopRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
},
heroIcon: {
width: 52,
height: 52,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroTitle: {
fontSize: 20,
fontWeight: '800',
letterSpacing: -0.3,
},
heroSubtitle: {
fontSize: 13,
lineHeight: 19,
},
heroStats: {
flexDirection: 'row',
gap: 12,
},
heroStat: {
flex: 1,
borderRadius: 18,
padding: 14,
gap: 6,
},
heroStatLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroStatValue: {
fontSize: 15,
fontWeight: '800',
},
formCard: {
gap: 14,
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
letterSpacing: -0.3,
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 19,
},
choiceList: {
gap: 12,
},
choiceCard: {
borderRadius: 18,
borderWidth: 1,
padding: 16,
gap: 8,
},
choiceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
},
choiceTitle: {
fontSize: 15,
fontWeight: '800',
},
choiceSubtitle: {
fontSize: 13,
lineHeight: 18,
},
choiceValue: {
fontSize: 14,
fontWeight: '700',
},
networkTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
pickupCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
borderRadius: 18,
padding: 16,
},
pickupCopy: {
flex: 1,
gap: 4,
},
pickupTitle: {
fontSize: 14,
fontWeight: '800',
},
pickupSubtitle: {
fontSize: 13,
lineHeight: 18,
},
summaryCard: {
gap: 14,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
},
summaryLabel: {
fontSize: 13,
fontWeight: '600',
},
summaryValue: {
flex: 1,
textAlign: 'right',
fontSize: 14,
fontWeight: '800',
},
bottomInset: {
height: 60,
},
});
+187
View File
@@ -0,0 +1,187 @@
import type {
BankCard,
CardControlState,
CardLimitConfig,
CardNotificationSettings,
CardStatus,
CardStatement,
VirtualCard,
} from '../../../types/app';
import { demoCards } from '../../../constants/mockData';
import { endpoints } from '../../../services/api/endpoints';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { resolveWithMock } from '../../../services/api/serviceHelpers';
interface UpdateCardControlsRequest {
cardId: string;
controls: Partial<CardControlState>;
}
interface UpdateCardStatusRequest {
cardId: string;
status: CardStatus;
}
interface UpdateCardLimitsRequest {
cardId: string;
limits: Partial<CardLimitConfig>;
}
interface UpdateCardNotificationsRequest {
cardId: string;
notifications: Partial<CardNotificationSettings>;
}
interface GenerateVirtualCardRequest {
cardId: string;
virtualCard: VirtualCard;
}
const findDemoCard = (cardId: string) => demoCards.find((card) => card.id === cardId) ?? demoCards[0]!;
export const cardsApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getCards: builder.query<BankCard[], void>({
queryFn: () =>
resolveWithMock({
mockData: demoCards,
request: async () => {
const response = await apiClient.get<BankCard[]>(endpoints.cards.overview);
return response.data;
},
}),
providesTags: ['Cards'],
}),
getCardDetail: builder.query<BankCard, string>({
queryFn: (cardId) =>
resolveWithMock({
mockData: findDemoCard(cardId),
request: async () => {
const response = await apiClient.get<BankCard>(endpoints.cards.detail);
return response.data;
},
}),
providesTags: ['Cards'],
}),
updateCardControls: builder.mutation<BankCard, UpdateCardControlsRequest>({
queryFn: ({ cardId, controls }) => {
const fallbackCard = findDemoCard(cardId);
return resolveWithMock({
mockData: () => ({
...fallbackCard,
controls: {
...fallbackCard.controls,
...controls,
},
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.controls);
return response.data;
},
});
},
invalidatesTags: ['Cards'],
}),
updateCardStatus: builder.mutation<BankCard, UpdateCardStatusRequest>({
queryFn: ({ cardId, status }) =>
resolveWithMock({
mockData: () => ({
...findDemoCard(cardId),
status,
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.status);
return response.data;
},
}),
invalidatesTags: ['Cards'],
}),
updateCardLimits: builder.mutation<BankCard, UpdateCardLimitsRequest>({
queryFn: ({ cardId, limits }) =>
resolveWithMock({
mockData: () => ({
...findDemoCard(cardId),
limits: {
...findDemoCard(cardId).limits,
...limits,
},
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.limits);
return response.data;
},
}),
invalidatesTags: ['Cards'],
}),
updateCardNotifications: builder.mutation<BankCard, UpdateCardNotificationsRequest>({
queryFn: ({ cardId, notifications }) =>
resolveWithMock({
mockData: () => ({
...findDemoCard(cardId),
notifications: {
...findDemoCard(cardId).notifications,
...notifications,
},
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.notifications);
return response.data;
},
}),
invalidatesTags: ['Cards'],
}),
generateVirtualCard: builder.mutation<BankCard, GenerateVirtualCardRequest>({
queryFn: ({ cardId, virtualCard }) =>
resolveWithMock({
mockData: () => ({
...findDemoCard(cardId),
virtualCard,
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.virtualCard);
return response.data;
},
}),
invalidatesTags: ['Cards'],
}),
deleteVirtualCard: builder.mutation<BankCard, string>({
queryFn: (cardId) =>
resolveWithMock({
mockData: () => ({
...findDemoCard(cardId),
virtualCard: null,
}),
request: async () => {
const response = await apiClient.post<BankCard>(endpoints.cards.virtualCard);
return response.data;
},
}),
invalidatesTags: ['Cards'],
}),
getCardStatements: builder.query<CardStatement[], string>({
queryFn: (cardId) =>
resolveWithMock({
mockData: findDemoCard(cardId).statements,
request: async () => {
const response = await apiClient.get<CardStatement[]>(endpoints.cards.statements);
return response.data;
},
}),
providesTags: ['Cards'],
}),
}),
});
export const {
useGetCardsQuery,
useGetCardDetailQuery,
useUpdateCardControlsMutation,
useUpdateCardStatusMutation,
useUpdateCardLimitsMutation,
useUpdateCardNotificationsMutation,
useGenerateVirtualCardMutation,
useDeleteVirtualCardMutation,
useGetCardStatementsQuery,
} = cardsApi;
+30
View File
@@ -0,0 +1,30 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../../app/store';
export const selectCardsState = (state: RootState) => state.cards;
export const selectCards = (state: RootState) => state.cards.cards;
export const selectCardsStatus = (state: RootState) => state.cards.status;
export const selectSelectedCard = createSelector([selectCardsState], (cardsState) => {
if (!cardsState.cards.length) {
return null;
}
return (
cardsState.cards.find((card) => card.id === cardsState.selectedCardId) ?? cardsState.cards[0]
);
});
export const selectSelectedCardIndex = createSelector([selectCardsState], (cardsState) => {
if (!cardsState.cards.length) {
return 0;
}
const cardIndex = cardsState.cards.findIndex((card) => card.id === cardsState.selectedCardId);
return cardIndex >= 0 ? cardIndex : 0;
});
export const selectCardById = (state: RootState, cardId: string) =>
state.cards.cards.find((card) => card.id === cardId) ?? null;
+189
View File
@@ -0,0 +1,189 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type {
AsyncStatus,
BankCard,
CardControlState,
CardLimitConfig,
CardNotificationSettings,
CardStatus,
VirtualCard,
} from '../../../types/app';
import { seedCards } from './cardsThunk';
interface CardsState {
status: AsyncStatus;
cards: BankCard[];
selectedCardId: string | null;
}
const initialState: CardsState = {
status: 'idle',
cards: [],
selectedCardId: null,
};
const findCard = (state: CardsState, cardId: string) =>
state.cards.find((card) => card.id === cardId);
const cardsSlice = createSlice({
name: 'cards',
initialState,
reducers: {
setSelectedCard(state, action: PayloadAction<string>) {
state.selectedCardId = action.payload;
},
updateCardControls(
state,
action: PayloadAction<{ cardId: string; changes: Partial<CardControlState> }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.controls = {
...card.controls,
...action.payload.changes,
};
},
setCardStatus(
state,
action: PayloadAction<{ cardId: string; status: CardStatus }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.status = action.payload.status;
},
updateCardLimits(
state,
action: PayloadAction<{ cardId: string; changes: Partial<CardLimitConfig> }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.limits = {
...card.limits,
...action.payload.changes,
};
},
toggleCardNotification(
state,
action: PayloadAction<{ cardId: string; key: keyof CardNotificationSettings }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.notifications[action.payload.key] = !card.notifications[action.payload.key];
},
renameCard(
state,
action: PayloadAction<{ cardId: string; name: string }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.name = action.payload.name;
},
setDefaultCard(state, action: PayloadAction<{ cardId: string }>) {
state.cards.forEach((card) => {
card.isDefault = card.id === action.payload.cardId;
});
state.selectedCardId = action.payload.cardId;
},
setPrimaryLinkedAccount(
state,
action: PayloadAction<{ cardId: string; accountId: string }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card || !card.linkedAccountIds.includes(action.payload.accountId)) {
return;
}
card.primaryLinkedAccountId = action.payload.accountId;
},
setVirtualCard(
state,
action: PayloadAction<{ cardId: string; virtualCard: VirtualCard }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.virtualCard = action.payload.virtualCard;
},
updateVirtualCardLimit(
state,
action: PayloadAction<{ cardId: string; spendingLimit: number }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card?.virtualCard) {
return;
}
card.virtualCard.spendingLimit = action.payload.spendingLimit;
},
deleteVirtualCard(state, action: PayloadAction<{ cardId: string }>) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
card.virtualCard = null;
},
redeemRewards(
state,
action: PayloadAction<{ cardId: string; points: number }>,
) {
const card = findCard(state, action.payload.cardId);
if (!card) {
return;
}
const nextPoints = Math.max(card.rewards.points - action.payload.points, 0);
card.rewards.points = nextPoints;
card.rewards.redeemValue = Math.round(nextPoints * 0.25);
},
addCard(state, action: PayloadAction<BankCard>) {
state.cards = [action.payload, ...state.cards];
state.selectedCardId = action.payload.id;
},
},
extraReducers: (builder) => {
builder
.addCase(seedCards.pending, (state) => {
state.status = 'loading';
})
.addCase(seedCards.fulfilled, (state, action) => {
state.status = 'succeeded';
state.cards = action.payload;
state.selectedCardId = action.payload[0]?.id ?? null;
})
.addCase(seedCards.rejected, (state) => {
state.status = 'failed';
});
},
});
export const cardsActions = cardsSlice.actions;
export const cardsReducer = cardsSlice.reducer;
+10
View File
@@ -0,0 +1,10 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { demoCards } from '../../../constants/mockData';
import type { BankCard } from '../../../types/app';
import { sleep } from '../../../utils/async';
export const seedCards = createAsyncThunk<BankCard[]>('cards/seedCards', async () => {
await sleep(120);
return demoCards;
});
+11
View File
@@ -0,0 +1,11 @@
export type {
BankCard,
CardControlState,
CardLimitConfig,
CardNotificationSettings,
CardOffer,
CardSpendInsight,
CardStatement,
CardTransaction,
VirtualCard,
} from '../../../types/app';
+223
View File
@@ -0,0 +1,223 @@
import type {
BankCard,
CardControlState,
CardDeliveryMode,
CardDeliveryStage,
CardKind,
CardNetwork,
CardPhysicalDelivery,
VirtualCard,
} from '../../../types/app';
const buildMaskedNumber = (fullNumber: string) => `**** ${fullNumber.slice(-4)}`;
export const summariseCardControls = (controls: CardControlState) =>
controls.frozen ? 'Card frozen' : 'Card active';
export const getCardStatusLabel = (card: BankCard) => {
if (card.status === 'blocked') {
return 'Blocked';
}
if (card.controls.frozen) {
return 'Frozen';
}
return 'Active';
};
export const createPhysicalCardDeliveryPayload = ({
mode,
destinationLabel,
status = 'processing',
requestedAt = new Date().toISOString(),
}: {
mode: CardDeliveryMode;
destinationLabel: string;
status?: CardDeliveryStage;
requestedAt?: string;
}): CardPhysicalDelivery => {
const requestedDate = new Date(requestedAt);
const estimatedDate = new Date(
requestedDate.getTime() + (mode === 'home' ? 4 : 2) * 24 * 60 * 60 * 1000,
).toISOString();
return {
mode,
status,
trackingId: `CBS${Date.now().toString().slice(-8)}`,
requestedAt,
lastUpdatedAt: requestedAt,
estimatedDate,
destinationLabel,
};
};
export const getCardDeliveryStatusLabel = (delivery: CardPhysicalDelivery) => {
switch (delivery.status) {
case 'processing':
return 'Request received';
case 'personalizing':
return 'Card being prepared';
case 'shipped':
return delivery.mode === 'home' ? 'Shipped' : 'Sent to branch';
case 'ready-for-pickup':
return 'Ready for pickup';
case 'delivered':
return delivery.mode === 'home' ? 'Delivered' : 'Collected';
default:
return 'In progress';
}
};
export const getCardDeliveryProgressIndex = (delivery: CardPhysicalDelivery) => {
switch (delivery.status) {
case 'processing':
return 0;
case 'personalizing':
return 1;
case 'shipped':
case 'ready-for-pickup':
return 2;
case 'delivered':
return 3;
default:
return 0;
}
};
export const createVirtualCardPayload = (
cardId: string,
spendingLimit: number,
): VirtualCard => {
const seed = Date.now().toString();
const fullNumber = `4111 ${seed.slice(-4)} ${seed.slice(-8, -4)} ${seed.slice(-12, -8) || '1188'}`;
return {
id: `virtual_${cardId}_${seed}`,
maskedNumber: buildMaskedNumber(fullNumber.replace(/\s/g, '')),
fullNumber,
expiry: '12/28',
cvv: seed.slice(-3),
spendingLimit,
active: true,
createdAt: new Date().toISOString(),
};
};
export const createMockPhysicalCard = ({
index,
accountId,
cardholderName,
type = index % 2 === 0 ? 'Credit' : 'Debit',
network = index % 2 === 0 ? 'RuPay' : 'VISA',
name,
deliveryMode,
destinationLabel,
deliveryStatus = 'processing',
physicalCardDelivery,
}: {
index: number;
accountId: string;
cardholderName: string;
type?: CardKind;
network?: CardNetwork;
name?: string;
deliveryMode?: CardDeliveryMode;
destinationLabel?: string;
deliveryStatus?: CardDeliveryStage;
physicalCardDelivery?: CardPhysicalDelivery | null;
}): BankCard => {
const serial = `${4721 + index}${8654 + index}${1021 + index}${7300 + index}`.slice(0, 16);
const fullNumber = `${serial.slice(0, 4)} ${serial.slice(4, 8)} ${serial.slice(8, 12)} ${serial.slice(12, 16)}`;
const resolvedType = type;
const resolvedNetwork = network;
const resolvedName =
name ?? `${resolvedType === 'Credit' ? 'Platinum Credit' : 'Signature Debit'} ${index + 1}`;
const resolvedPhysicalCardDelivery =
physicalCardDelivery ??
(deliveryMode && destinationLabel
? createPhysicalCardDeliveryPayload({
mode: deliveryMode,
destinationLabel,
status: deliveryStatus,
})
: null);
return {
id: `card_added_${Date.now()}_${index}`,
name: resolvedName,
cardholderName,
type: resolvedType,
maskedNumber: buildMaskedNumber(serial),
expiry: '11/31',
network: resolvedNetwork,
status: 'active',
availableLimit: resolvedType === 'Credit' ? 180000 : 125000,
spendToday: 0,
linkedAccountIds: [accountId],
primaryLinkedAccountId: accountId,
isDefault: false,
themeColors:
resolvedType === 'Credit'
? ['#0F172A', '#1E3A8A', '#38BDF8']
: resolvedNetwork === 'RuPay'
? ['#0B3B2E', '#0F766E', '#35D6A3']
: ['#311847', '#7C3AED', '#EC4899'],
controls: {
online: true,
atm: true,
contactless: true,
international: false,
frozen: false,
},
limits: {
daily: 100000,
atmWithdrawal: 25000,
pos: 50000,
},
notifications: {
transactionAlerts: true,
suspiciousActivity: true,
internationalUsage: false,
},
secureDetails: {
fullNumber,
cvv: '517',
billingCycle:
resolvedType === 'Credit' ? '15th of every month' : 'Real-time debit settlements',
outstandingBalance: 0,
minimumDue: 0,
dueDate: new Date().toISOString(),
},
rewards: {
points: 2500,
tier: 'Silver',
redeemValue: 625,
},
offers: [
{
id: `offer_added_${index}`,
title: 'Welcome cashback',
subtitle: 'Use your new card within 30 days to unlock a joining reward.',
cashback: 'Up to Rs 500',
merchant: 'CBS Bank',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
},
],
statements: [],
spendInsight: {
monthLabel: 'This month',
budget: 50000,
totalSpent: 0,
categories: [
{ id: `food_${index}`, label: 'Food', amount: 0, color: '#35D6A3' },
{ id: `travel_${index}`, label: 'Travel', amount: 0, color: '#59C6FF' },
{ id: `shopping_${index}`, label: 'Shopping', amount: 0, color: '#FF7C8A' },
],
},
transactions: [],
virtualCard: null,
physicalCardDelivery: resolvedPhysicalCardDelivery,
};
};
@@ -0,0 +1,208 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { Card } from '../../../components';
import { useTheme } from '../../../theme';
import type { DepositAccount } from '../../../types/app';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
import { DepositStatusPill } from './DepositStatusPill';
interface DepositAccountCardProps {
deposit: DepositAccount;
onPress: () => void;
}
const DepositAccountCardComponent = ({
deposit,
onPress,
}: DepositAccountCardProps) => {
const theme = useTheme();
const nearMaturity = isDepositNearMaturity(deposit);
const daysUntilMaturity = getDaysUntilMaturity(deposit.maturityDate);
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.pressable,
{
opacity: pressed ? 0.94 : 1,
transform: [{ scale: pressed ? 0.99 : 1 }],
},
]}
>
<Card style={styles.card}>
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={[styles.eyebrow, { color: theme.colors.textSecondary }]}>
{deposit.type}
</Text>
<Text style={[styles.number, { color: theme.colors.textPrimary }]}>
{deposit.maskedNumber}
</Text>
</View>
<DepositStatusPill status={deposit.status} nearMaturity={nearMaturity} />
</View>
<View style={styles.amountRow}>
<View>
<Text style={[styles.amountLabel, { color: theme.colors.textSecondary }]}>
{deposit.kind === 'fd' ? 'Deposit amount' : 'Monthly installment'}
</Text>
<Text style={[styles.amountValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(
deposit.kind === 'fd'
? deposit.principalAmount
: deposit.monthlyInstallment ?? deposit.principalAmount,
)}
</Text>
</View>
<View
style={[
styles.ratePill,
{ backgroundColor: `${theme.colors.accent}12` },
]}
>
<Ionicons name="trending-up-outline" size={14} color={theme.colors.accentStrong} />
<Text style={[styles.rateText, { color: theme.colors.accentStrong }]}>
{deposit.interestRate}% p.a.
</Text>
</View>
</View>
<View style={styles.metricsGrid}>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Start date
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{formatDate(deposit.startDate)}
</Text>
</View>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Maturity date
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{formatDate(deposit.maturityDate)}
</Text>
</View>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Maturity amount
</Text>
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.maturityAmount)}
</Text>
</View>
<View style={[styles.metricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
Tracking
</Text>
<Text
style={[
styles.metricValue,
{
color:
deposit.status === 'closed'
? theme.colors.textSecondary
: nearMaturity
? theme.colors.warning
: theme.colors.textPrimary,
},
]}
>
{deposit.status === 'closed'
? 'Closed'
: daysUntilMaturity <= 0
? 'Ready'
: `${daysUntilMaturity} days left`}
</Text>
</View>
</View>
</Card>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
width: '100%',
},
card: {
gap: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
headerCopy: {
flex: 1,
gap: 4,
},
eyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
number: {
fontSize: 16,
fontWeight: '800',
},
amountRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
},
amountLabel: {
fontSize: 12,
fontWeight: '600',
},
amountValue: {
marginTop: 4,
fontSize: 22,
fontWeight: '900',
letterSpacing: -0.5,
},
ratePill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
},
rateText: {
fontSize: 11,
fontWeight: '800',
},
metricsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
metricTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
metricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
metricValue: {
fontSize: 14,
fontWeight: '800',
lineHeight: 19,
},
});
export const DepositAccountCard = memo(DepositAccountCardComponent);
@@ -0,0 +1,57 @@
import React, { memo } from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../../../theme';
interface DepositOptionChipProps {
label: string;
selected: boolean;
onPress: () => void;
}
const DepositOptionChipComponent = ({
label,
selected,
onPress,
}: DepositOptionChipProps) => {
const theme = useTheme();
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.chip,
{
backgroundColor: selected ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
borderColor: selected ? `${theme.colors.primary}40` : theme.colors.cardBorder,
opacity: pressed ? 0.9 : 1,
transform: [{ scale: pressed ? 0.98 : 1 }],
},
]}
>
<Text
style={[
styles.label,
{ color: selected ? theme.colors.primary : theme.colors.textSecondary },
]}
>
{label}
</Text>
</Pressable>
);
};
const styles = StyleSheet.create({
chip: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 10,
},
label: {
fontSize: 12,
fontWeight: '700',
},
});
export const DepositOptionChip = memo(DepositOptionChipComponent);
@@ -0,0 +1,46 @@
import React, { memo } from 'react';
import {
StyleSheet,
Text,
View,
} from 'react-native';
import { useTheme } from '../../../theme';
interface DepositRatePillProps {
rate: number;
}
const DepositRatePillComponent = ({ rate }: DepositRatePillProps) => {
const theme = useTheme();
return (
<View
style={[
styles.pill,
{
backgroundColor: 'rgba(53, 214, 163, 0.12)',
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Text style={[styles.text, { color: theme.colors.accent }]}>Up to {rate}% p.a.</Text>
</View>
);
};
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 6,
},
text: {
fontSize: 12,
fontWeight: '700',
},
});
export const DepositRatePill = memo(DepositRatePillComponent);
@@ -0,0 +1,63 @@
import React, { memo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../../theme';
import type { DepositStatus } from '../../../types/app';
interface DepositStatusPillProps {
status: DepositStatus;
nearMaturity?: boolean;
}
const DepositStatusPillComponent = ({
status,
nearMaturity = false,
}: DepositStatusPillProps) => {
const theme = useTheme();
const tone = nearMaturity
? {
backgroundColor: `${theme.colors.warning}16`,
color: theme.colors.warning,
label: 'MATURING',
}
: status === 'closed'
? {
backgroundColor: `${theme.colors.textMuted}18`,
color: theme.colors.textSecondary,
label: 'CLOSED',
}
: status === 'matured'
? {
backgroundColor: `${theme.colors.accent}16`,
color: theme.colors.accentStrong,
label: 'MATURED',
}
: {
backgroundColor: `${theme.colors.primary}12`,
color: theme.colors.primary,
label: 'ACTIVE',
};
return (
<View style={[styles.pill, { backgroundColor: tone.backgroundColor }]}>
<Text style={[styles.label, { color: tone.color }]}>{tone.label}</Text>
</View>
);
};
const styles = StyleSheet.create({
pill: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
},
label: {
fontSize: 10,
fontWeight: '800',
letterSpacing: 0.7,
},
});
export const DepositStatusPill = memo(DepositStatusPillComponent);
@@ -0,0 +1,44 @@
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { selectDepositDraft } from '../store/depositSelectors';
import { depositActions } from '../store/depositSlice';
export const useDepositPlanner = () => {
const dispatch = useAppDispatch();
const draft = useAppSelector(selectDepositDraft);
return {
draft,
setProduct: useCallback(
(productId: string) => {
dispatch(depositActions.setDepositProduct(productId));
},
[dispatch],
),
setAmount: useCallback(
(amount: string) => {
dispatch(depositActions.setDepositAmount(amount));
},
[dispatch],
),
setMonthlyInstallment: useCallback(
(monthlyInstallment: string) => {
dispatch(depositActions.setMonthlyInstallment(monthlyInstallment));
},
[dispatch],
),
setTenure: useCallback(
(tenure: number) => {
dispatch(depositActions.setDepositTenure(tenure));
},
[dispatch],
),
setInterestPayoutType: useCallback(
(interestPayoutType: 'cumulative' | 'non-cumulative') => {
dispatch(depositActions.setInterestPayoutType(interestPayoutType));
},
[dispatch],
),
};
};
@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { usePullToRefresh } from '../../../hooks/usePullToRefresh';
import {
useGetFixedDepositsQuery,
useGetRecurringDepositsQuery,
} from '../services/depositApi';
export const useDepositPortfolio = () => {
const fixedDepositsQuery = useGetFixedDepositsQuery();
const recurringDepositsQuery = useGetRecurringDepositsQuery();
const deposits = useMemo(
() =>
[...(fixedDepositsQuery.data ?? []), ...(recurringDepositsQuery.data ?? [])].sort(
(left, right) =>
new Date(right.startDate).getTime() - new Date(left.startDate).getTime(),
),
[fixedDepositsQuery.data, recurringDepositsQuery.data],
);
const { refreshing, onRefresh } = usePullToRefresh(async () => {
await Promise.all([
fixedDepositsQuery.refetch(),
recurringDepositsQuery.refetch(),
]);
});
return {
deposits,
refreshing,
onRefresh,
isLoading: fixedDepositsQuery.isLoading || recurringDepositsQuery.isLoading,
isFetching: fixedDepositsQuery.isFetching || recurringDepositsQuery.isFetching,
isError: fixedDepositsQuery.isError || recurringDepositsQuery.isError,
error: fixedDepositsQuery.error ?? recurringDepositsQuery.error ?? null,
};
};
@@ -0,0 +1,294 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
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 { useAppSelector } from '../../../app/hooks';
import { Card, Input, ScreenShell } from '../../../components';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import {
selectFixedDepositProduct,
selectRecurringDepositProduct,
} from '../store/depositSelectors';
import {
calculateFixedDepositMaturity,
calculateRecurringDepositMaturity,
} from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositCalculator'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type CalculatorMode = 'fd' | 'rd';
type TenureUnit = 'months' | 'years';
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d.]/g, ''));
export default function DepositCalculatorScreen({ route }: Props) {
const theme = useTheme();
const fixedProduct = useAppSelector(selectFixedDepositProduct);
const recurringProduct = useAppSelector(selectRecurringDepositProduct);
const [mode, setMode] = useState<CalculatorMode>(route.params?.initialKind ?? 'fd');
const [amount, setAmount] = useState('100000');
const [monthlyInstallment, setMonthlyInstallment] = useState('5000');
const [rate, setRate] = useState(String(fixedProduct?.rate ?? 7.1));
const [tenureValue, setTenureValue] = useState('12');
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
useEffect(() => {
if (route.params?.initialKind) {
setMode(route.params.initialKind);
}
}, [route.params?.initialKind]);
useEffect(() => {
const nextRate =
mode === 'fd' ? fixedProduct?.rate ?? 7.1 : recurringProduct?.rate ?? 6.8;
setRate(String(nextRate));
}, [fixedProduct?.rate, mode, recurringProduct?.rate]);
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
const monthlyInstallmentNumber = useMemo(
() => toPositiveNumber(monthlyInstallment),
[monthlyInstallment],
);
const rateNumber = useMemo(() => Number(rate), [rate]);
const maturityAmount = useMemo(
() =>
mode === 'fd'
? calculateFixedDepositMaturity(amountNumber, rateNumber, tenureMonths, 'cumulative')
: calculateRecurringDepositMaturity(
monthlyInstallmentNumber,
rateNumber,
tenureMonths,
),
[amountNumber, mode, monthlyInstallmentNumber, rateNumber, tenureMonths],
);
const totalInvestment =
mode === 'fd' ? amountNumber : monthlyInstallmentNumber * tenureMonths;
return (
<ScreenShell
title="Deposit calculator"
subtitle="Compare projected returns for fixed and recurring deposits"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
>
<Card style={styles.formCard}>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Calculator type
</Text>
<View style={styles.optionRow}>
<DepositOptionChip
label="Fixed deposit"
selected={mode === 'fd'}
onPress={() => setMode('fd')}
/>
<DepositOptionChip
label="Recurring deposit"
selected={mode === 'rd'}
onPress={() => setMode('rd')}
/>
</View>
</View>
{mode === 'fd' ? (
<Input
label="Principal amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
placeholder="100000"
/>
) : (
<Input
label="Monthly deposit"
value={monthlyInstallment}
onChangeText={setMonthlyInstallment}
keyboardType="number-pad"
placeholder="5000"
/>
)}
<Input
label="Interest rate (%)"
value={rate}
onChangeText={setRate}
keyboardType="decimal-pad"
placeholder="7.1"
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Tenure
</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder="12"
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
</Card>
<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>
<View style={styles.resultCopy}>
<Text style={[styles.resultTitle, { color: theme.colors.textPrimary }]}>
Estimated maturity
</Text>
<Text style={[styles.resultSubtitle, { color: theme.colors.textSecondary }]}>
Based on the rate and tenure entered above
</Text>
</View>
</View>
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
<View style={styles.resultGrid}>
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
Total contribution
</Text>
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(totalInvestment || 0)}
</Text>
</View>
<View style={[styles.resultTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
Estimated gains
</Text>
<Text style={[styles.resultTileValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
</Text>
</View>
</View>
</Card>
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
keyboardAvoider: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
resultCard: {
gap: 18,
},
resultHeader: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
resultIcon: {
width: 44,
height: 44,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
resultCopy: {
flex: 1,
gap: 4,
},
resultTitle: {
fontSize: 18,
fontWeight: '800',
},
resultSubtitle: {
fontSize: 13,
lineHeight: 18,
},
resultValue: {
fontSize: 30,
fontWeight: '900',
letterSpacing: -0.8,
},
resultGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
resultTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
resultLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
resultTileValue: {
fontSize: 15,
fontWeight: '800',
lineHeight: 20,
},
});
@@ -0,0 +1,565 @@
import React, { useMemo, useState } from 'react';
import {
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 { AppModal, Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency, formatDate } from '../../../utils/formatters';
import { TransactionItem } from '../../cards/components/TransactionItem';
import { ExpandableSection } from '../../cards/components/ExpandableSection';
import { DepositStatusPill } from '../components/DepositStatusPill';
import {
useCloseDepositMutation,
useGetDepositDetailQuery,
} from '../services/depositApi';
import {
calculatePrematureClosureAmount,
getDaysUntilMaturity,
getDepositProgress,
isDepositNearMaturity,
} from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositDetails'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
export default function DepositDetailsScreen({ navigation, route }: Props) {
const theme = useTheme();
const toast = useToast();
const [confirmVisible, setConfirmVisible] = useState(false);
const { data: deposit, isLoading, isError, refetch } = useGetDepositDetailQuery({
depositId: route.params.depositId,
kind: route.params.kind,
});
const [closeDeposit, closeState] = useCloseDepositMutation();
const nearMaturity = deposit ? isDepositNearMaturity(deposit) : false;
const closureQuote = deposit ? calculatePrematureClosureAmount(deposit) : null;
const progress = deposit
? getDepositProgress(deposit.startDate, deposit.maturityDate)
: 0;
const daysUntilMaturity = deposit
? Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)
: 0;
const history = useMemo(
() =>
(deposit?.transactions ?? []).map((transaction) => ({
id: transaction.id,
merchant: transaction.title,
amount: transaction.amount,
direction: transaction.direction,
status: transaction.status,
occurredAt: transaction.occurredAt,
category: transaction.category,
})),
[deposit?.transactions],
);
const handleCloseDeposit = async () => {
if (!deposit) {
return;
}
try {
await closeDeposit({
depositId: deposit.id,
kind: deposit.kind,
}).unwrap();
setConfirmVisible(false);
toast(
deposit.status === 'matured' ? 'Maturity proceeds credited' : 'Deposit closed',
'success',
deposit.status === 'matured'
? 'The maturity amount has been credited to your linked account.'
: 'Closure request completed successfully.',
);
} catch {
toast(
'Unable to close deposit',
'error',
'Please try again in a moment.',
);
}
};
if (isLoading) {
return (
<ScreenShell title="Deposit details" subtitle="Loading selected deposit">
<Card>
<Loader />
</Card>
</ScreenShell>
);
}
if (isError || !deposit) {
return (
<ScreenShell title="Deposit details" subtitle="We could not load the selected deposit">
<EmptyState
title="Deposit unavailable"
description="Pull back and try opening the deposit again."
actionLabel="Retry"
onAction={refetch}
/>
</ScreenShell>
);
}
const canClose = deposit.status !== 'closed' && deposit.canWithdrawPrematurely;
const closeActionLabel =
deposit.status === 'matured' ? 'Credit maturity proceeds' : 'Premature close';
const investedAmount =
deposit.kind === 'fd'
? deposit.principalAmount
: deposit.totalInvestment;
return (
<ScreenShell
title="Deposit details"
subtitle={`${deposit.maskedNumber} · ${deposit.type}`}
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<Card style={styles.heroCard}>
<View style={styles.heroTopRow}>
<View style={styles.heroCopy}>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{deposit.type}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{deposit.maskedNumber}
</Text>
</View>
<DepositStatusPill status={deposit.status} nearMaturity={nearMaturity} />
</View>
<View style={styles.heroMetrics}>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Invested amount
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(investedAmount)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Maturity value
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.maturityAmount)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Interest earned
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.interestEarned)}
</Text>
</View>
<View style={[styles.heroMetricTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Tracking
</Text>
<Text
style={[
styles.heroMetricValue,
{
color:
deposit.status === 'closed'
? theme.colors.textSecondary
: nearMaturity
? theme.colors.warning
: theme.colors.textPrimary,
},
]}
>
{deposit.status === 'closed'
? 'Closed'
: deposit.status === 'matured'
? 'Ready'
: `${daysUntilMaturity} days left`}
</Text>
</View>
</View>
<View style={styles.progressWrap}>
<View style={[styles.progressTrack, { backgroundColor: theme.colors.surfaceMuted }]}>
<View
style={[
styles.progressFill,
{
width: `${progress * 100}%`,
backgroundColor:
deposit.status === 'closed'
? theme.colors.textMuted
: nearMaturity
? theme.colors.warning
: theme.colors.accent,
},
]}
/>
</View>
<Text style={[styles.progressCaption, { color: theme.colors.textSecondary }]}>
Started on {formatDate(deposit.startDate)} · Matures on {formatDate(deposit.maturityDate)}
</Text>
</View>
</Card>
<ExpandableSection
title="Deposit snapshot"
subtitle="Principal, interest, payout, nominee, and settlement account"
icon="document-text-outline"
defaultExpanded
>
<View style={styles.detailGrid}>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Interest rate
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.interestRate}% p.a.
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Tenure
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.tenureMonths} months
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Payout frequency
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.payoutLabel}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Settlement account
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.settlementAccountMasked}
</Text>
</View>
{deposit.nominee ? (
<View style={[styles.detailTile, styles.detailTileWide, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Nominee
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{deposit.nominee.name} · {deposit.nominee.relationship}
</Text>
</View>
) : null}
</View>
</ExpandableSection>
<ExpandableSection
title="Returns and maturity"
subtitle="Projected value, accrued gains, and current closure estimate"
icon="analytics-outline"
defaultExpanded
>
<View style={styles.detailGrid}>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Total investment
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(deposit.totalInvestment)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Estimated proceeds
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Penalty
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
</Text>
</View>
<View style={[styles.detailTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.detailLabel, { color: theme.colors.textSecondary }]}>
Maturity date
</Text>
<Text style={[styles.detailValue, { color: theme.colors.textPrimary }]}>
{formatDate(deposit.maturityDate)}
</Text>
</View>
</View>
</ExpandableSection>
<ExpandableSection
title="Transaction history"
subtitle="Creation, installments, interest payouts, and closure credits"
icon="swap-horizontal-outline"
badge={`${history.length} entries`}
>
{history.length ? (
history.map((item, index) => (
<TransactionItem
key={item.id}
item={item}
showDivider={index !== history.length - 1}
/>
))
) : (
<EmptyState
title="No transactions yet"
description="Transaction history will appear here after the deposit is created."
/>
)}
</ExpandableSection>
<Card style={styles.actionsCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Actions
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Estimate new returns or close the deposit if you need funds earlier
</Text>
<Button
title="Open calculator"
variant="secondary"
onPress={() =>
navigation.navigate('DepositCalculator', { initialKind: deposit.kind })
}
leftAccessory={<Ionicons name="calculator-outline" size={18} color={theme.colors.textPrimary} />}
/>
{canClose ? (
<Button
title={closeActionLabel}
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
loading={closeState.isLoading}
onPress={() => setConfirmVisible(true)}
leftAccessory={
<Ionicons
name={deposit.status === 'matured' ? 'cash-outline' : 'close-circle-outline'}
size={18}
color={theme.colors.white}
/>
}
/>
) : null}
</Card>
</ScrollView>
<AppModal
visible={confirmVisible}
title={closeActionLabel}
description={
deposit.status === 'matured'
? 'The maturity amount will be credited to your linked settlement account.'
: 'This will close the deposit before maturity and credit the net proceeds after penalty.'
}
onClose={() => setConfirmVisible(false)}
>
<View style={styles.modalList}>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Net proceeds
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.netAmount ?? deposit.maturityAmount)}
</Text>
</View>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Penalty
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(closureQuote?.penaltyAmount ?? 0)}
</Text>
</View>
<View style={styles.modalRow}>
<Text style={[styles.modalLabel, { color: theme.colors.textSecondary }]}>
Credit account
</Text>
<Text style={[styles.modalValue, { color: theme.colors.textPrimary }]}>
{deposit.settlementAccountMasked}
</Text>
</View>
</View>
<View style={styles.modalActions}>
<Button
title="Cancel"
variant="secondary"
onPress={() => setConfirmVisible(false)}
style={styles.modalButton}
fullWidth={false}
/>
<Button
title="Confirm"
variant={deposit.status === 'matured' ? 'primary' : 'danger'}
onPress={handleCloseDeposit}
loading={closeState.isLoading}
style={styles.modalButton}
fullWidth={false}
/>
</View>
</AppModal>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 18,
paddingBottom: 40,
},
heroCard: {
gap: 18,
},
heroTopRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroCopy: {
flex: 1,
gap: 4,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.6,
},
heroMetrics: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
heroMetricTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
progressWrap: {
gap: 10,
},
progressTrack: {
height: 12,
borderRadius: 999,
overflow: 'hidden',
},
progressFill: {
height: 12,
borderRadius: 999,
},
progressCaption: {
fontSize: 12,
fontWeight: '500',
},
detailGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
detailTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
detailTileWide: {
width: '100%',
},
detailLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
detailValue: {
fontSize: 14,
fontWeight: '800',
lineHeight: 20,
},
actionsCard: {
gap: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
modalList: {
gap: 10,
marginTop: 4,
},
modalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
modalLabel: {
fontSize: 13,
fontWeight: '500',
},
modalValue: {
fontSize: 13,
fontWeight: '800',
textAlign: 'right',
flex: 1,
},
modalActions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
modalButton: {
flex: 1,
},
});
@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useState } from 'react';
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 { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { DepositAccountCard } from '../components/DepositAccountCard';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useDepositPortfolio } from '../hooks/useDepositPortfolio';
import { getDaysUntilMaturity, isDepositNearMaturity } from '../utils/depositCalculators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'DepositsOverview'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type FilterKey = 'all' | 'fd' | 'rd' | 'maturing';
const FILTERS: Array<{ id: FilterKey; label: string }> = [
{ id: 'all', label: 'All deposits' },
{ id: 'fd', label: 'Fixed deposit' },
{ id: 'rd', label: 'Recurring deposit' },
{ id: 'maturing', label: 'Maturing soon' },
];
export default function DepositsOverviewScreen({ navigation, route }: Props) {
const theme = useTheme();
const { deposits, isLoading, isError, onRefresh, refreshing } = useDepositPortfolio();
const [filter, setFilter] = useState<FilterKey>(route.params?.initialKind ?? 'all');
useEffect(() => {
if (route.params?.initialKind) {
setFilter(route.params.initialKind);
}
}, [route.params?.initialKind]);
const summary = useMemo(() => {
const activeDeposits = deposits.filter((deposit) => deposit.status === 'active');
const nearMaturityDeposits = deposits.filter((deposit) => isDepositNearMaturity(deposit));
return {
totalDeposits: deposits.length,
activeDepositsCount: activeDeposits.length,
totalActiveValue: activeDeposits.reduce(
(sum, deposit) => sum + deposit.totalInvestment,
0,
),
projectedMaturityValue: deposits.reduce(
(sum, deposit) => sum + deposit.maturityAmount,
0,
),
nearMaturityDeposits,
};
}, [deposits]);
const filteredDeposits = useMemo(() => {
switch (filter) {
case 'fd':
return deposits.filter((deposit) => deposit.kind === 'fd');
case 'rd':
return deposits.filter((deposit) => deposit.kind === 'rd');
case 'maturing':
return deposits.filter((deposit) => isDepositNearMaturity(deposit));
case 'all':
default:
return deposits;
}
}, [deposits, filter]);
const selectedFilterLabel =
FILTERS.find((item) => item.id === filter)?.label ?? 'All deposits';
const handleOpenFixedDeposit = () => navigation.navigate('OpenFixedDeposit');
const handleOpenRecurringDeposit = () => navigation.navigate('OpenRecurringDeposit');
const handleOpenCalculator = () => navigation.navigate('DepositCalculator');
return (
<ScreenShell
title="Deposits"
subtitle="Fixed and recurring deposits, maturity tracking, and calculators"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={theme.colors.primary}
colors={[theme.colors.primary]}
/>
}
>
<View style={styles.summaryGrid}>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View
style={[
styles.heroIcon,
{ backgroundColor: `${theme.colors.primary}12` },
]}
>
<Ionicons name="wallet-outline" size={20} color={theme.colors.primary} />
</View>
<View style={styles.heroCopy}>
<Text style={[styles.heroEyebrow, { color: theme.colors.primary }]}>
Portfolio snapshot
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
Keep every deposit journey in one clear view
</Text>
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
Review portfolio value, upcoming maturities, and quick actions without
jumping across sections.
</Text>
</View>
</View>
<View
style={[
styles.heroHighlight,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<View style={styles.heroHighlightCopy}>
<Text
style={[
styles.heroHighlightLabel,
{ color: theme.colors.textSecondary },
]}
>
Active portfolio
</Text>
<Text
style={[
styles.heroHighlightValue,
{ color: theme.colors.textPrimary },
]}
>
{formatCompactCurrency(summary.totalActiveValue)}
</Text>
<Text
style={[
styles.heroHighlightCaption,
{ color: theme.colors.textMuted },
]}
>
{summary.activeDepositsCount} active plans across fixed and recurring
deposits
</Text>
</View>
<View
style={[
styles.heroBadge,
{ backgroundColor: `${theme.colors.warning}14` },
]}
>
<Text style={[styles.heroBadgeValue, { color: theme.colors.warning }]}>
{summary.nearMaturityDeposits.length}
</Text>
<Text
style={[
styles.heroBadgeLabel,
{ color: theme.colors.textSecondary },
]}
>
maturing soon
</Text>
</View>
</View>
<View style={styles.heroMetrics}>
<View
style={[
styles.heroMetricCard,
{ backgroundColor: `${theme.colors.primary}08` },
]}
>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Projected maturity
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{formatCompactCurrency(summary.projectedMaturityValue)}
</Text>
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
Estimated portfolio value when all plans mature
</Text>
</View>
<View
style={[
styles.heroMetricCard,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.heroMetricLabel, { color: theme.colors.textSecondary }]}>
Total deposits
</Text>
<Text style={[styles.heroMetricValue, { color: theme.colors.textPrimary }]}>
{summary.totalDeposits}
</Text>
<Text style={[styles.heroMetricCaption, { color: theme.colors.textMuted }]}>
{summary.totalDeposits === 1 ? '1 deposit' : `${summary.totalDeposits} deposits`} currently tracked
</Text>
</View>
</View>
</Card>
</View>
<Card style={styles.actionCard}>
<View style={styles.sectionHeader}>
<View>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Quick actions
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
Start a new plan or estimate returns from one place
</Text>
</View>
</View>
<View style={styles.actionGrid}>
<View style={styles.actionRow}>
<View style={styles.actionColumn}>
<Button
title="Open FD"
style={styles.actionButton}
onPress={handleOpenFixedDeposit}
leftAccessory={
<Ionicons
name="trending-up-outline"
size={18}
color={theme.colors.white}
/>
}
/>
</View>
<View style={styles.actionColumn}>
<Button
title="Open RD"
variant="secondary"
style={styles.actionButton}
onPress={handleOpenRecurringDeposit}
leftAccessory={
<Ionicons
name="repeat-outline"
size={18}
color={theme.colors.textPrimary}
/>
}
/>
</View>
</View>
<Button
title="Calculator"
variant="secondary"
style={styles.actionButton}
onPress={handleOpenCalculator}
leftAccessory={
<Ionicons
name="calculator-outline"
size={18}
color={theme.colors.textPrimary}
/>
}
/>
</View>
</Card>
{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>
<View style={styles.watchCopy}>
<Text style={[styles.watchTitle, { color: theme.colors.textPrimary }]}>
Nearing maturity
</Text>
<Text style={[styles.watchSubtitle, { color: theme.colors.textSecondary }]}>
Review and plan your next action before these deposits mature
</Text>
</View>
</View>
{summary.nearMaturityDeposits.map((deposit) => (
<View
key={`${deposit.id}_watch`}
style={[styles.watchRow, { backgroundColor: theme.colors.surfaceMuted }]}
>
<View style={styles.watchRowCopy}>
<Text style={[styles.watchDeposit, { color: theme.colors.textPrimary }]}>
{deposit.maskedNumber}
</Text>
<Text style={[styles.watchMeta, { color: theme.colors.textSecondary }]}>
{deposit.type} · {formatCurrency(deposit.maturityAmount)}
</Text>
</View>
<Text
style={[
styles.watchDays,
{
color: theme.colors.warning,
backgroundColor: `${theme.colors.warning}12`,
},
]}
>
{Math.max(getDaysUntilMaturity(deposit.maturityDate), 0)} days
</Text>
</View>
))}
</Card>
) : null}
<View style={styles.portfolioSection}>
<View style={styles.portfolioHeader}>
<View style={styles.portfolioCopy}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Your deposits
</Text>
<Text style={[styles.sectionSubtitle, { color: theme.colors.textSecondary }]}>
{filteredDeposits.length} shown in {selectedFilterLabel.toLowerCase()}
</Text>
</View>
<View
style={[
styles.portfolioCountBadge,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text
style={[styles.portfolioCountValue, { color: theme.colors.textPrimary }]}
>
{summary.totalDeposits}
</Text>
<Text
style={[styles.portfolioCountLabel, { color: theme.colors.textSecondary }]}
>
total
</Text>
</View>
</View>
<View style={styles.filterWrap}>
{FILTERS.map((item) => (
<DepositOptionChip
key={item.id}
label={item.label}
selected={filter === item.id}
onPress={() => setFilter(item.id)}
/>
))}
</View>
</View>
{isLoading && !deposits.length ? (
<Card>
<Loader />
</Card>
) : null}
{isError && !deposits.length ? (
<EmptyState
title="Unable to load deposits"
description="Please pull to refresh or try again in a moment."
actionLabel="Refresh"
onAction={onRefresh}
/>
) : null}
{!isLoading && !isError && !filteredDeposits.length ? (
<EmptyState
title="No deposits found"
description="Open a new fixed or recurring deposit to start building your savings ladder."
actionLabel="Open fixed deposit"
onAction={handleOpenFixedDeposit}
/>
) : null}
{filteredDeposits.map((deposit) => (
<DepositAccountCard
key={deposit.id}
deposit={deposit}
onPress={() =>
navigation.navigate('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
})
}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
</ScreenShell>
);
}
const styles = StyleSheet.create({
scrollContent: {
gap: 18,
paddingBottom: 24,
},
summaryGrid: {
gap: 12,
},
heroCard: {
gap: 16,
},
heroHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
heroIcon: {
width: 46,
height: 46,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
heroCopy: {
flex: 1,
gap: 4,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
fontSize: 20,
fontWeight: '900',
lineHeight: 26,
},
heroSubtitle: {
fontSize: 12,
lineHeight: 18,
},
heroHighlight: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
borderRadius: 18,
padding: 16,
},
heroHighlightCopy: {
flex: 1,
gap: 4,
},
heroHighlightLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroHighlightValue: {
fontSize: 28,
fontWeight: '900',
letterSpacing: -0.8,
},
heroHighlightCaption: {
fontSize: 12,
lineHeight: 18,
},
heroBadge: {
minWidth: 88,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: 14,
paddingVertical: 12,
},
heroBadgeValue: {
fontSize: 22,
fontWeight: '900',
letterSpacing: -0.5,
},
heroBadgeLabel: {
fontSize: 11,
fontWeight: '700',
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
heroMetrics: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
heroMetricCard: {
flex: 1,
minWidth: 140,
borderRadius: 18,
padding: 14,
gap: 6,
},
heroMetricLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroMetricValue: {
fontSize: 20,
fontWeight: '900',
letterSpacing: -0.4,
},
heroMetricCaption: {
fontSize: 12,
lineHeight: 18,
},
actionCard: {
gap: 16,
},
sectionHeader: {
gap: 4,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
sectionSubtitle: {
fontSize: 13,
lineHeight: 18,
},
actionGrid: {
gap: 12,
},
actionRow: {
flexDirection: 'row',
gap: 12,
},
actionColumn: {
flex: 1,
},
actionButton: {
width: '100%',
},
watchCard: {
gap: 14,
},
watchHeader: {
flexDirection: 'row',
gap: 12,
},
watchIcon: {
width: 40,
height: 40,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
watchCopy: {
flex: 1,
gap: 4,
},
watchTitle: {
fontSize: 16,
fontWeight: '800',
},
watchSubtitle: {
fontSize: 13,
lineHeight: 18,
},
watchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
borderRadius: 18,
padding: 14,
},
watchRowCopy: {
flex: 1,
gap: 4,
},
watchDeposit: {
fontSize: 14,
fontWeight: '800',
},
watchMeta: {
fontSize: 12,
fontWeight: '500',
},
watchDays: {
fontSize: 13,
fontWeight: '800',
borderRadius: 999,
overflow: 'hidden',
paddingHorizontal: 10,
paddingVertical: 8,
},
portfolioSection: {
gap: 14,
},
portfolioHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
portfolioCopy: {
flex: 1,
gap: 4,
},
portfolioCountBadge: {
minWidth: 72,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
gap: 2,
paddingHorizontal: 12,
paddingVertical: 10,
},
portfolioCountValue: {
fontSize: 20,
fontWeight: '900',
letterSpacing: -0.4,
},
portfolioCountLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
filterWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
bottomSpacer: {
height: 60,
},
});
@@ -0,0 +1,380 @@
import React, { useMemo, useState } from 'react';
import {
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 { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import type { DepositInterestPayoutType } from '../../../types/app';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useCreateFixedDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
import { selectFixedDepositProduct } from '../store/depositSelectors';
import { calculateFixedDepositMaturity } from '../utils/depositCalculators';
import { validateFixedDepositInput } from '../utils/depositValidators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'OpenFixedDeposit'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type TenureUnit = 'months' | 'years';
const PAYOUT_OPTIONS: Array<{
id: DepositInterestPayoutType;
label: string;
}> = [
{ id: 'cumulative', label: 'Cumulative' },
{ id: 'non-cumulative', label: 'Non-cumulative' },
];
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
export default function OpenFixedDepositScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
useGetDepositProductsQuery();
const product = useAppSelector(selectFixedDepositProduct);
const [createFixedDeposit, createState] = useCreateFixedDepositMutation();
const [amount, setAmount] = useState('');
const [tenureValue, setTenureValue] = useState(
String(product?.defaultTenureMonths ?? 12),
);
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
const [payoutType, setPayoutType] =
useState<DepositInterestPayoutType>('cumulative');
const [errors, setErrors] = useState<{
amount?: string;
tenureMonths?: string;
interestPayoutType?: string;
}>({});
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const amountNumber = useMemo(() => toPositiveNumber(amount), [amount]);
const maturityAmount = useMemo(() => {
if (!product) {
return 0;
}
return calculateFixedDepositMaturity(
amountNumber,
product.rate,
tenureMonths,
payoutType,
);
}, [amountNumber, payoutType, product, tenureMonths]);
const handleSubmit = async () => {
if (!product) {
return;
}
const validation = validateFixedDepositInput(
{
amount: amountNumber,
tenureMonths,
interestPayoutType: payoutType,
},
product,
);
setErrors(validation);
if (Object.keys(validation).length) {
toast('Check deposit inputs', 'error', 'Please correct the highlighted fields.');
return;
}
try {
const deposit = await createFixedDeposit({
amount: amountNumber,
tenureMonths,
interestPayoutType: payoutType,
}).unwrap();
toast('Fixed deposit created', 'success', 'Your new FD is now active.');
navigation.replace('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
});
} catch {
toast('Unable to open fixed deposit', 'error', 'Please try again shortly.');
}
};
if (!product) {
return (
<ScreenShell title="Open fixed deposit" subtitle="Loading fixed deposit product">
<Card>
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
Loading product details...
</Text>
</Card>
</ScreenShell>
);
}
return (
<ScreenShell
title="Open fixed deposit"
subtitle="Create a new FD using your existing deposit product settings"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{product.name}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{product.rate}% p.a.
</Text>
</View>
<View style={[styles.heroPill, { backgroundColor: `${theme.colors.accent}12` }]}>
<Ionicons
name="shield-checkmark-outline"
size={16}
color={theme.colors.accentStrong}
/>
<Text style={[styles.heroPillText, { color: theme.colors.accentStrong }]}>
Min {formatCurrency(product.minimumAmount ?? 0)}
</Text>
</View>
</View>
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
Maximum amount {formatCurrency(product.maximumAmount ?? 0)} · Tenure between{' '}
{product.minimumTenureMonths} and {product.maximumTenureMonths} months
</Text>
</Card>
<Card style={styles.formCard}>
<Input
label="Deposit amount"
value={amount}
onChangeText={setAmount}
keyboardType="number-pad"
placeholder="100000"
error={errors.amount}
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder={String(product.defaultTenureMonths)}
error={errors.tenureMonths}
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>
Interest payout
</Text>
<View style={styles.optionRow}>
{PAYOUT_OPTIONS.map((option) => (
<DepositOptionChip
key={option.id}
label={option.label}
selected={payoutType === option.id}
onPress={() => setPayoutType(option.id)}
/>
))}
</View>
{errors.interestPayoutType ? (
<Text style={[styles.errorText, { color: theme.colors.error }]}>
{errors.interestPayoutType}
</Text>
) : null}
</View>
</Card>
<Card style={styles.previewCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Maturity preview
</Text>
<View style={styles.previewGrid}>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Principal
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(amountNumber || 0)}
</Text>
</View>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated maturity
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
</View>
<View
style={[
styles.previewTile,
styles.previewTileWide,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated interest earned
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - amountNumber, 0))}
</Text>
</View>
</View>
</Card>
<Button
title="Create fixed deposit"
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
<View style={{ height: 50 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
heroCard: {
gap: 10,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.5,
},
heroPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
alignSelf: 'flex-start',
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
},
heroCaption: {
fontSize: 13,
lineHeight: 18,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
errorText: {
fontSize: 12,
fontWeight: '500',
},
previewCard: {
gap: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
previewGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
previewTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
previewTileWide: {
width: '100%',
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
},
});
@@ -0,0 +1,339 @@
import React, { useMemo, useState } from 'react';
import {
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 { useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { useToast } from '../../../hooks/useToast';
import type { MainTabParamList, ServicesStackParamList } from '../../../navigation/types';
import { useTheme } from '../../../theme';
import { formatCurrency } from '../../../utils/formatters';
import { DepositOptionChip } from '../components/DepositOptionChip';
import { useCreateRecurringDepositMutation, useGetDepositProductsQuery } from '../services/depositApi';
import { selectRecurringDepositProduct } from '../store/depositSelectors';
import { calculateRecurringDepositMaturity } from '../utils/depositCalculators';
import { validateRecurringDepositInput } from '../utils/depositValidators';
type Props = CompositeScreenProps<
NativeStackScreenProps<ServicesStackParamList, 'OpenRecurringDeposit'>,
BottomTabScreenProps<MainTabParamList, 'Services'>
>;
type TenureUnit = 'months' | 'years';
const TENURE_UNITS: TenureUnit[] = ['months', 'years'];
const toPositiveNumber = (value: string) => Number(value.replace(/[^\d]/g, ''));
export default function OpenRecurringDepositScreen({ navigation }: Props) {
const theme = useTheme();
const toast = useToast();
useGetDepositProductsQuery();
const product = useAppSelector(selectRecurringDepositProduct);
const [createRecurringDeposit, createState] = useCreateRecurringDepositMutation();
const [monthlyInstallment, setMonthlyInstallment] = useState('');
const [tenureValue, setTenureValue] = useState(
String(product?.defaultTenureMonths ?? 24),
);
const [tenureUnit, setTenureUnit] = useState<TenureUnit>('months');
const [errors, setErrors] = useState<{
monthlyInstallment?: string;
tenureMonths?: string;
}>({});
const installmentNumber = useMemo(
() => toPositiveNumber(monthlyInstallment),
[monthlyInstallment],
);
const tenureMonths = useMemo(() => {
const numericValue = toPositiveNumber(tenureValue);
return tenureUnit === 'years' ? numericValue * 12 : numericValue;
}, [tenureUnit, tenureValue]);
const totalInvestment = installmentNumber * tenureMonths;
const maturityAmount = useMemo(() => {
if (!product) {
return 0;
}
return calculateRecurringDepositMaturity(
installmentNumber,
product.rate,
tenureMonths,
);
}, [installmentNumber, product, tenureMonths]);
const handleSubmit = async () => {
if (!product) {
return;
}
const validation = validateRecurringDepositInput(
{
monthlyInstallment: installmentNumber,
tenureMonths,
},
product,
);
setErrors(validation);
if (Object.keys(validation).length) {
toast('Check recurring deposit inputs', 'error', 'Please correct the highlighted fields.');
return;
}
try {
const deposit = await createRecurringDeposit({
monthlyInstallment: installmentNumber,
tenureMonths,
}).unwrap();
toast('Recurring deposit created', 'success', 'Your new RD is now active.');
navigation.replace('DepositDetails', {
depositId: deposit.id,
kind: deposit.kind,
});
} catch {
toast('Unable to open recurring deposit', 'error', 'Please try again shortly.');
}
};
if (!product) {
return (
<ScreenShell title="Open recurring deposit" subtitle="Loading recurring deposit product">
<Card>
<Text style={[styles.placeholderText, { color: theme.colors.textSecondary }]}>
Loading product details...
</Text>
</Card>
</ScreenShell>
);
}
return (
<ScreenShell
title="Open recurring deposit"
subtitle="Set a monthly installment and grow savings automatically"
scrollable={false}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Card style={styles.heroCard}>
<View style={styles.heroHeader}>
<View>
<Text style={[styles.heroEyebrow, { color: theme.colors.textSecondary }]}>
{product.name}
</Text>
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
{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} />
<Text style={[styles.heroPillText, { color: theme.colors.primary }]}>
Min {formatCurrency(product.minimumInstallment ?? 0)}
</Text>
</View>
</View>
<Text style={[styles.heroCaption, { color: theme.colors.textSecondary }]}>
Maximum installment {formatCurrency(product.maximumInstallment ?? 0)} · Tenure
between {product.minimumTenureMonths} and {product.maximumTenureMonths} months
</Text>
</Card>
<Card style={styles.formCard}>
<Input
label="Monthly installment"
value={monthlyInstallment}
onChangeText={setMonthlyInstallment}
keyboardType="number-pad"
placeholder="5000"
error={errors.monthlyInstallment}
/>
<View style={styles.fieldGroup}>
<Text style={[styles.fieldLabel, { color: theme.colors.textSecondary }]}>Tenure</Text>
<Input
value={tenureValue}
onChangeText={setTenureValue}
keyboardType="number-pad"
placeholder={String(product.defaultTenureMonths)}
error={errors.tenureMonths}
/>
<View style={styles.optionRow}>
{TENURE_UNITS.map((unit) => (
<DepositOptionChip
key={unit}
label={unit === 'months' ? 'Months' : 'Years'}
selected={tenureUnit === unit}
onPress={() => setTenureUnit(unit)}
/>
))}
</View>
</View>
</Card>
<Card style={styles.previewCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Growth preview
</Text>
<View style={styles.previewGrid}>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Total investment
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(totalInvestment || 0)}
</Text>
</View>
<View style={[styles.previewTile, { backgroundColor: theme.colors.surfaceMuted }]}>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated maturity
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.round(maturityAmount))}
</Text>
</View>
<View
style={[
styles.previewTile,
styles.previewTileWide,
{ backgroundColor: theme.colors.surfaceMuted },
]}
>
<Text style={[styles.previewLabel, { color: theme.colors.textSecondary }]}>
Estimated gains
</Text>
<Text style={[styles.previewValue, { color: theme.colors.textPrimary }]}>
{formatCurrency(Math.max(Math.round(maturityAmount) - totalInvestment, 0))}
</Text>
</View>
</View>
</Card>
<Button
title="Create recurring deposit"
onPress={handleSubmit}
loading={createState.isLoading}
leftAccessory={
<Ionicons name="add-circle-outline" size={18} color={theme.colors.white} />
}
/>
</ScrollView>
<View style={{ height: 50 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
gap: 18,
paddingBottom: 24,
},
heroCard: {
gap: 10,
},
heroHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
heroEyebrow: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
heroTitle: {
marginTop: 4,
fontSize: 24,
fontWeight: '900',
letterSpacing: -0.5,
},
heroPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 8,
alignSelf: 'flex-start',
},
heroPillText: {
fontSize: 11,
fontWeight: '800',
},
heroCaption: {
fontSize: 13,
lineHeight: 18,
},
formCard: {
gap: 18,
},
fieldGroup: {
gap: 10,
},
fieldLabel: {
fontSize: 12,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
previewCard: {
gap: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '800',
},
previewGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
previewTile: {
width: '48%',
borderRadius: 18,
padding: 14,
gap: 6,
},
previewTileWide: {
width: '100%',
},
previewLabel: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
},
previewValue: {
fontSize: 16,
fontWeight: '900',
lineHeight: 22,
},
placeholderText: {
fontSize: 14,
fontWeight: '500',
},
});
+371
View File
@@ -0,0 +1,371 @@
import { demoDepositAccounts, demoDepositProducts } from '../../../constants/mockData';
import { appConfig } from '../../../config/appConfig';
import { apiClient } from '../../../services/api/apiClient';
import { baseApi } from '../../../services/api/baseApi';
import { endpoints } from '../../../services/api/endpoints';
import { toApiError } from '../../../services/api/serviceHelpers';
import type {
DepositAccount,
DepositInterestPayoutType,
DepositKind,
DepositProduct,
} from '../../../types/app';
import { sleep } from '../../../utils/async';
import {
calculateFixedDepositMaturity,
calculatePrematureClosureAmount,
calculateRecurringDepositMaturity,
getDepositCanonicalStatus,
} from '../utils/depositCalculators';
interface DepositDetailArgs {
depositId: string;
kind: DepositKind;
}
interface CreateFixedDepositRequest {
amount: number;
tenureMonths: number;
interestPayoutType: DepositInterestPayoutType;
}
interface CreateRecurringDepositRequest {
monthlyInstallment: number;
tenureMonths: number;
}
interface CloseDepositRequest {
depositId: string;
kind: DepositKind;
}
const cloneDeposit = (deposit: DepositAccount): DepositAccount => ({
...deposit,
nominee: deposit.nominee ? { ...deposit.nominee } : undefined,
transactions: deposit.transactions.map((transaction) => ({ ...transaction })),
});
let mockDeposits: DepositAccount[] = demoDepositAccounts.map(cloneDeposit);
const getProductByKind = (kind: DepositKind) =>
demoDepositProducts.find((product) => product.kind === kind) ?? null;
const listMockDeposits = (kind: DepositKind) =>
mockDeposits
.filter((deposit) => deposit.kind === kind)
.map((deposit) => ({
...cloneDeposit(deposit),
status: getDepositCanonicalStatus(deposit),
}))
.sort(
(left, right) =>
new Date(right.startDate).getTime() - new Date(left.startDate).getTime(),
);
const findMockDeposit = ({ depositId, kind }: DepositDetailArgs) => {
const deposit =
mockDeposits.find((item) => item.id === depositId && item.kind === kind) ?? null;
return deposit
? {
...cloneDeposit(deposit),
status: getDepositCanonicalStatus(deposit),
}
: null;
};
const createMaskedDepositNumber = (kind: DepositKind) =>
`${kind.toUpperCase()} XXXX ${Math.floor(1000 + Math.random() * 9000)}`;
const createFixedDepositMock = ({
amount,
tenureMonths,
interestPayoutType,
}: CreateFixedDepositRequest) => {
const product = getProductByKind('fd');
if (!product) {
throw new Error('Fixed deposit product unavailable.');
}
const maturityAmount = Math.round(
calculateFixedDepositMaturity(amount, product.rate, tenureMonths, interestPayoutType),
);
const nextDeposit: DepositAccount = {
id: `fd_${Date.now()}`,
productId: product.id,
kind: 'fd',
type: 'Fixed Deposit',
maskedNumber: createMaskedDepositNumber('fd'),
principalAmount: amount,
totalInvestment: amount,
interestRate: product.rate,
tenureMonths,
startDate: new Date().toISOString(),
maturityDate: new Date(
new Date().setMonth(new Date().getMonth() + tenureMonths),
).toISOString(),
maturityAmount,
interestEarned: Math.max(maturityAmount - amount, 0),
payoutLabel:
interestPayoutType === 'cumulative' ? 'At maturity' : 'Quarterly payout',
interestPayoutType,
status: 'active',
settlementAccountMasked: 'XXXX 9312',
nominee: {
name: 'Megha Sharma',
relationship: 'Spouse',
},
canWithdrawPrematurely: true,
prematurePenaltyRate: 1,
transactions: [
{
id: `dep_txn_${Date.now()}_create`,
title: 'FD booking',
category: 'Deposit creation',
amount,
direction: 'debit',
status: 'completed',
occurredAt: new Date().toISOString(),
},
],
};
mockDeposits = [nextDeposit, ...mockDeposits];
return cloneDeposit(nextDeposit);
};
const createRecurringDepositMock = ({
monthlyInstallment,
tenureMonths,
}: CreateRecurringDepositRequest) => {
const product = getProductByKind('rd');
if (!product) {
throw new Error('Recurring deposit product unavailable.');
}
const totalInvestment = monthlyInstallment * tenureMonths;
const maturityAmount = Math.round(
calculateRecurringDepositMaturity(monthlyInstallment, product.rate, tenureMonths),
);
const nextDeposit: DepositAccount = {
id: `rd_${Date.now()}`,
productId: product.id,
kind: 'rd',
type: 'Recurring Deposit',
maskedNumber: createMaskedDepositNumber('rd'),
principalAmount: monthlyInstallment,
monthlyInstallment,
totalInvestment,
interestRate: product.rate,
tenureMonths,
startDate: new Date().toISOString(),
maturityDate: new Date(
new Date().setMonth(new Date().getMonth() + tenureMonths),
).toISOString(),
maturityAmount,
interestEarned: Math.max(maturityAmount - totalInvestment, 0),
payoutLabel: 'Monthly deposit',
status: 'active',
settlementAccountMasked: 'XXXX 9312',
nominee: {
name: 'Megha Sharma',
relationship: 'Spouse',
},
canWithdrawPrematurely: true,
prematurePenaltyRate: 0.75,
transactions: [
{
id: `dep_txn_${Date.now()}_create`,
title: 'RD started',
category: 'Deposit creation',
amount: monthlyInstallment,
direction: 'debit',
status: 'completed',
occurredAt: new Date().toISOString(),
},
],
};
mockDeposits = [nextDeposit, ...mockDeposits];
return cloneDeposit(nextDeposit);
};
const closeMockDeposit = ({ depositId, kind }: CloseDepositRequest) => {
const depositIndex = mockDeposits.findIndex(
(deposit) => deposit.id === depositId && deposit.kind === kind,
);
if (depositIndex < 0) {
throw new Error('Deposit not found.');
}
const existingDeposit = mockDeposits[depositIndex]!;
const closure = calculatePrematureClosureAmount(existingDeposit);
const canonicalStatus = getDepositCanonicalStatus(existingDeposit);
const closedDeposit: DepositAccount = {
...existingDeposit,
status: 'closed',
maturityAmount: Math.round(closure.netAmount),
interestEarned:
kind === 'fd'
? Math.max(Math.round(closure.netAmount) - existingDeposit.principalAmount, 0)
: Math.max(Math.round(closure.netAmount) - existingDeposit.totalInvestment, 0),
transactions: [
{
id: `dep_txn_${Date.now()}_close`,
title:
canonicalStatus === 'matured'
? 'Maturity proceeds credited'
: 'Premature closure credited',
category: 'Deposit closure',
amount: Math.round(closure.netAmount),
direction: 'credit',
status: 'completed',
occurredAt: new Date().toISOString(),
},
...existingDeposit.transactions,
],
};
mockDeposits = mockDeposits.map((deposit, index) =>
index === depositIndex ? closedDeposit : deposit,
);
return cloneDeposit(closedDeposit);
};
const handleMock = async <T>(factory: () => T) => {
await sleep(280);
return { data: factory() };
};
const handleRequest = async <T>(request: () => Promise<T>) => {
try {
return { data: await request() };
} catch (error) {
return { error: toApiError(error) };
}
};
export const depositApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getDepositProducts: builder.query<DepositProduct[], void>({
queryFn: async () =>
appConfig.features.useMockServer
? handleMock(() => demoDepositProducts.map((product) => ({ ...product })))
: handleRequest(async () => {
const response = await apiClient.get<DepositProduct[]>(endpoints.deposits.products);
return response.data;
}),
providesTags: ['Deposits'],
}),
getFixedDeposits: builder.query<DepositAccount[], void>({
queryFn: async () =>
appConfig.features.useMockServer
? handleMock(() => listMockDeposits('fd'))
: handleRequest(async () => {
const response = await apiClient.get<DepositAccount[]>(
endpoints.deposits.fixed.list,
);
return response.data;
}),
providesTags: ['Deposits'],
}),
getRecurringDeposits: builder.query<DepositAccount[], void>({
queryFn: async () =>
appConfig.features.useMockServer
? handleMock(() => listMockDeposits('rd'))
: handleRequest(async () => {
const response = await apiClient.get<DepositAccount[]>(
endpoints.deposits.recurring.list,
);
return response.data;
}),
providesTags: ['Deposits'],
}),
getDepositDetail: builder.query<DepositAccount, DepositDetailArgs>({
queryFn: async (args) => {
if (appConfig.features.useMockServer) {
await sleep(240);
const deposit = findMockDeposit(args);
return deposit
? { data: deposit }
: {
error: {
status: 404,
message: 'Deposit not found',
},
};
}
return handleRequest(async () => {
const response = await apiClient.get<DepositAccount>(
args.kind === 'fd'
? endpoints.deposits.fixed.detail(args.depositId)
: endpoints.deposits.recurring.detail(args.depositId),
);
return response.data;
});
},
providesTags: ['Deposits'],
}),
createFixedDeposit: builder.mutation<DepositAccount, CreateFixedDepositRequest>({
queryFn: async (payload) =>
appConfig.features.useMockServer
? handleMock(() => createFixedDepositMock(payload))
: handleRequest(async () => {
const response = await apiClient.post<DepositAccount>(
endpoints.deposits.fixed.create,
payload,
);
return response.data;
}),
invalidatesTags: ['Deposits'],
}),
createRecurringDeposit: builder.mutation<DepositAccount, CreateRecurringDepositRequest>({
queryFn: async (payload) =>
appConfig.features.useMockServer
? handleMock(() => createRecurringDepositMock(payload))
: handleRequest(async () => {
const response = await apiClient.post<DepositAccount>(
endpoints.deposits.recurring.create,
payload,
);
return response.data;
}),
invalidatesTags: ['Deposits'],
}),
closeDeposit: builder.mutation<DepositAccount, CloseDepositRequest>({
queryFn: async (payload) =>
appConfig.features.useMockServer
? handleMock(() => closeMockDeposit(payload))
: handleRequest(async () => {
const response = await apiClient.post<DepositAccount>(
payload.kind === 'fd'
? endpoints.deposits.fixed.close
: endpoints.deposits.recurring.close,
payload,
);
return response.data;
}),
invalidatesTags: ['Deposits'],
}),
}),
});
export const {
useCloseDepositMutation,
useCreateFixedDepositMutation,
useCreateRecurringDepositMutation,
useGetDepositDetailQuery,
useGetDepositProductsQuery,
useGetFixedDepositsQuery,
useGetRecurringDepositsQuery,
} = depositApi;
@@ -0,0 +1,22 @@
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from '../../../app/store';
export const selectDepositState = (state: RootState) => state.deposit;
export const selectDepositDraft = (state: RootState) => state.deposit.draft;
export const selectDepositProducts = (state: RootState) => state.deposit.products;
export const selectSelectedDepositId = (state: RootState) => state.deposit.selectedDepositId;
export const selectSelectedDepositProduct = createSelector(
[selectDepositState],
(depositState) =>
depositState.products.find((product) => product.id === depositState.draft.productId) ?? null,
);
export const selectFixedDepositProduct = createSelector([selectDepositProducts], (products) =>
products.find((product) => product.kind === 'fd') ?? null,
);
export const selectRecurringDepositProduct = createSelector([selectDepositProducts], (products) =>
products.find((product) => product.kind === 'rd') ?? null,
);
+79
View File
@@ -0,0 +1,79 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AsyncStatus, DepositDraft, DepositProduct } from '../../../types/app';
import { seedDepositProducts } from './depositThunk';
interface DepositState {
status: AsyncStatus;
products: DepositProduct[];
draft: DepositDraft;
receiptId: string | null;
selectedDepositId: string | null;
}
const initialState: DepositState = {
status: 'idle',
products: [],
draft: {
amount: '',
monthlyInstallment: '',
tenureMonths: 12,
interestPayoutType: 'cumulative',
},
receiptId: null,
selectedDepositId: null,
};
const depositSlice = createSlice({
name: 'deposit',
initialState,
reducers: {
setDepositProduct(state, action: PayloadAction<string>) {
state.draft.productId = action.payload;
},
setDepositAmount(state, action: PayloadAction<string>) {
state.draft.amount = action.payload;
},
setMonthlyInstallment(state, action: PayloadAction<string>) {
state.draft.monthlyInstallment = action.payload;
},
setDepositTenure(state, action: PayloadAction<number>) {
state.draft.tenureMonths = action.payload;
},
setInterestPayoutType(state, action: PayloadAction<'cumulative' | 'non-cumulative'>) {
state.draft.interestPayoutType = action.payload;
},
setDepositReceipt(state, action: PayloadAction<string>) {
state.receiptId = action.payload;
},
setSelectedDeposit(state, action: PayloadAction<string | null>) {
state.selectedDepositId = action.payload;
},
resetDepositFlow(state) {
state.draft = {
amount: '',
monthlyInstallment: '',
tenureMonths: 12,
interestPayoutType: 'cumulative',
};
state.receiptId = null;
state.selectedDepositId = null;
},
},
extraReducers: (builder) => {
builder
.addCase(seedDepositProducts.pending, (state) => {
state.status = 'loading';
})
.addCase(seedDepositProducts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.products = action.payload;
})
.addCase(seedDepositProducts.rejected, (state) => {
state.status = 'failed';
});
},
});
export const depositActions = depositSlice.actions;
export const depositReducer = depositSlice.reducer;
+13
View File
@@ -0,0 +1,13 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { demoDepositProducts } from '../../../constants/mockData';
import type { DepositProduct } from '../../../types/app';
import { sleep } from '../../../utils/async';
export const seedDepositProducts = createAsyncThunk<DepositProduct[]>(
'deposit/seedProducts',
async () => {
await sleep(160);
return demoDepositProducts;
},
);
+10
View File
@@ -0,0 +1,10 @@
export type {
DepositAccount,
DepositDraft,
DepositInterestPayoutType,
DepositKind,
DepositProduct,
DepositStatus,
DepositTransaction,
DepositType,
} from '../../../types/app';
@@ -0,0 +1,88 @@
import { demoDepositProducts } from '../../../constants/mockData';
import {
calculateFixedDepositMaturity,
calculateRecurringDepositMaturity,
getDepositCanonicalStatus,
isDepositNearMaturity,
} from './depositCalculators';
import {
validateFixedDepositInput,
validateRecurringDepositInput,
} from './depositValidators';
describe('deposit calculators', () => {
it('gives higher maturity for cumulative FD than non-cumulative FD', () => {
const cumulative = calculateFixedDepositMaturity(100000, 7.1, 12, 'cumulative');
const nonCumulative = calculateFixedDepositMaturity(
100000,
7.1,
12,
'non-cumulative',
);
expect(cumulative).toBeGreaterThan(nonCumulative);
expect(nonCumulative).toBeGreaterThan(100000);
});
it('gives maturity above total contributions for RD', () => {
const maturity = calculateRecurringDepositMaturity(5000, 6.8, 24);
expect(maturity).toBeGreaterThan(5000 * 24);
});
});
describe('deposit validators', () => {
const fixedProduct = demoDepositProducts.find((product) => product.kind === 'fd')!;
const recurringProduct = demoDepositProducts.find((product) => product.kind === 'rd')!;
it('rejects fixed deposits below minimum amount', () => {
const errors = validateFixedDepositInput(
{
amount: (fixedProduct.minimumAmount ?? 0) - 1,
tenureMonths: fixedProduct.defaultTenureMonths,
interestPayoutType: 'cumulative',
},
fixedProduct,
);
expect(errors.amount).toBeDefined();
});
it('rejects recurring deposits below minimum installment', () => {
const errors = validateRecurringDepositInput(
{
monthlyInstallment: (recurringProduct.minimumInstallment ?? 0) - 1,
tenureMonths: recurringProduct.defaultTenureMonths,
},
recurringProduct,
);
expect(errors.monthlyInstallment).toBeDefined();
});
});
describe('deposit lifecycle helpers', () => {
it('marks deposits with past maturity dates as matured', () => {
const status = getDepositCanonicalStatus(
{
status: 'active',
maturityDate: '2026-04-05T00:00:00.000Z',
},
new Date('2026-04-09T00:00:00.000Z'),
);
expect(status).toBe('matured');
});
it('identifies deposits maturing within 7 days', () => {
const nearMaturity = isDepositNearMaturity(
{
status: 'active',
maturityDate: '2026-04-14T00:00:00.000Z',
},
new Date('2026-04-09T00:00:00.000Z'),
);
expect(nearMaturity).toBe(true);
});
});
@@ -0,0 +1,147 @@
import type {
DepositAccount,
DepositInterestPayoutType,
DepositStatus,
} from '../../../types/app';
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export const calculateFixedDepositMaturity = (
principal: number,
annualRate: number,
tenureMonths: number,
payoutType: DepositInterestPayoutType = 'cumulative',
) => {
if (principal <= 0 || annualRate <= 0 || tenureMonths <= 0) {
return 0;
}
const years = tenureMonths / 12;
if (payoutType === 'non-cumulative') {
return principal + principal * (annualRate / 100) * years;
}
const quarterlyRate = annualRate / 100 / 4;
const periods = years * 4;
return principal * Math.pow(1 + quarterlyRate, periods);
};
export const calculateRecurringDepositMaturity = (
monthlyInstallment: number,
annualRate: number,
tenureMonths: number,
) => {
if (monthlyInstallment <= 0 || annualRate <= 0 || tenureMonths <= 0) {
return 0;
}
const monthlyRate = annualRate / 100 / 12;
let maturityAmount = 0;
for (let month = 0; month < tenureMonths; month += 1) {
maturityAmount += monthlyInstallment * Math.pow(1 + monthlyRate, tenureMonths - month);
}
return maturityAmount;
};
export const getDepositCanonicalStatus = (
deposit: Pick<DepositAccount, 'status' | 'maturityDate'>,
now = new Date(),
): DepositStatus => {
if (deposit.status === 'closed') {
return 'closed';
}
return new Date(deposit.maturityDate).getTime() <= now.getTime() ? 'matured' : 'active';
};
export const getDaysUntilMaturity = (maturityDate: string, now = new Date()) => {
const diff = new Date(maturityDate).getTime() - now.getTime();
return Math.ceil(diff / MS_PER_DAY);
};
export const isDepositNearMaturity = (
deposit: Pick<DepositAccount, 'status' | 'maturityDate'>,
now = new Date(),
) => {
const daysUntilMaturity = getDaysUntilMaturity(deposit.maturityDate, now);
return getDepositCanonicalStatus(deposit, now) === 'active' && daysUntilMaturity <= 7;
};
export const getDepositProgress = (
startDate: string,
maturityDate: string,
now = new Date(),
) => {
const start = new Date(startDate).getTime();
const maturity = new Date(maturityDate).getTime();
const current = now.getTime();
if (maturity <= start) {
return 1;
}
return clamp((current - start) / (maturity - start), 0, 1);
};
export const calculatePrematureClosureAmount = (
deposit: Pick<
DepositAccount,
'kind' | 'principalAmount' | 'monthlyInstallment' | 'interestRate' | 'startDate' | 'maturityDate' | 'maturityAmount' | 'prematurePenaltyRate' | 'status'
>,
now = new Date(),
) => {
const canonicalStatus = getDepositCanonicalStatus(deposit, now);
if (canonicalStatus === 'closed') {
return {
penaltyAmount: 0,
netAmount: deposit.maturityAmount,
};
}
if (canonicalStatus === 'matured') {
return {
penaltyAmount: 0,
netAmount: deposit.maturityAmount,
};
}
const elapsedMonths = Math.max(
1,
Math.round((now.getTime() - new Date(deposit.startDate).getTime()) / (MS_PER_DAY * 30)),
);
const currentValue =
deposit.kind === 'fd'
? calculateFixedDepositMaturity(
deposit.principalAmount,
deposit.interestRate,
elapsedMonths,
'non-cumulative',
)
: calculateRecurringDepositMaturity(
deposit.monthlyInstallment ?? 0,
deposit.interestRate,
elapsedMonths,
);
const investedBase =
deposit.kind === 'fd'
? deposit.principalAmount
: (deposit.monthlyInstallment ?? 0) * elapsedMonths;
const penaltyAmount = currentValue * ((deposit.prematurePenaltyRate ?? 0) / 100);
return {
penaltyAmount,
netAmount: Math.max(currentValue - penaltyAmount, investedBase * 0.9),
};
};

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