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
+6
View File
@@ -0,0 +1,6 @@
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
import type { AppDispatch, RootState } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
+44
View File
@@ -0,0 +1,44 @@
import { combineReducers } from '@reduxjs/toolkit';
import { baseApi } from '../services/api/baseApi';
import { authReducer } from '../modules/auth/store/authSlice';
import { accountReducer } from '../modules/home/store/accountSlice';
import { transferReducer } from '../modules/transfer/store/transferSlice';
import { paymentsReducer } from '../modules/payments/store/paymentsSlice';
import { depositReducer } from '../modules/deposit/store/depositSlice';
import { serviceReducer } from '../modules/services/store/serviceSlice';
import { loanReducer } from '../modules/loan/store/loanSlice';
import { insuranceReducer } from '../modules/insurance/store/insuranceSlice';
import { cardsReducer } from '../modules/cards/store/cardsSlice';
import { userReducer } from '../modules/profile/store/userSlice';
import { uiReducer } from '../modules/ui/store/uiSlice';
import { agentReducer } from '../modules/agent/store/agentSlice';
import { schemesReducer } from '../modules/schemes/store/schemesSlice';
import { schemesApi } from '../modules/schemes/services/schemesApi';
import { beneficiariesReducer } from '../modules/payments/store/beneficiariesSlice';
const appReducer = combineReducers({
auth: authReducer,
user: userReducer,
account: accountReducer,
transfer: transferReducer,
payments: paymentsReducer,
deposit: depositReducer,
service: serviceReducer,
loan: loanReducer,
insurance: insuranceReducer,
cards: cardsReducer,
ui: uiReducer,
schemes: schemesReducer,
beneficiaries: beneficiariesReducer,
agent: agentReducer,
[baseApi.reducerPath]: baseApi.reducer,
[schemesApi.reducerPath]: schemesApi.reducer,
});
export const rootReducer = (state: any, action: any) => {
if (action.type === 'auth/logoutSession/fulfilled') {
state = undefined;
}
return appReducer(state, action);
};
+16
View File
@@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '../services/api/baseApi';
import { schemesApi } from '../modules/schemes/services/schemesApi';
import { rootReducer } from './rootReducer';
export const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(baseApi.middleware, schemesApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
+97
View File
@@ -0,0 +1,97 @@
import React, { memo } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import Ionicons from '@react-native-vector-icons/ionicons';
import { useTheme } from '../../theme';
interface AppHeaderProps {
title: string;
subtitle?: string;
rightContent?: React.ReactNode;
showBackButton?: boolean;
}
const AppHeaderComponent = ({
title,
subtitle,
rightContent,
showBackButton,
}: AppHeaderProps) => {
const theme = useTheme();
const navigation = useNavigation();
const shouldShowBackButton = showBackButton ?? navigation.canGoBack();
return (
<View style={styles.wrapper}>
<View style={styles.leading}>
{shouldShowBackButton ? (
<Pressable
onPress={() => navigation.goBack()}
style={[
styles.backButton,
{
backgroundColor: theme.colors.cardGlass,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.pill,
},
]}
>
<Ionicons name="chevron-back" size={20} color={theme.colors.textPrimary} />
</Pressable>
) : null}
<View style={styles.copy}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{/* {subtitle ? (
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{subtitle}</Text>
) : null} */}
</View>
</View>
{rightContent}
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
leading: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
flex: 1,
},
copy: {
gap: 4,
flex: 1,
},
title: {
fontSize: 20,
fontWeight: '700',
textTransform: "uppercase",
letterSpacing: 0.5,
lineHeight: 20,
},
subtitle: {
fontSize: 13,
lineHeight: 18,
},
backButton: {
width: 42,
height: 42,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
export const AppHeader = memo(AppHeaderComponent);
+230
View File
@@ -0,0 +1,230 @@
import React, { memo, useState } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { AccountSummary } from '../../types/app';
import { useTheme } from '../../theme';
import { formatCurrency } from '../../utils/formatters';
interface BalanceCardProps {
account: AccountSummary;
secondaryLabel?: string;
}
const BalanceCardComponent = ({ account, secondaryLabel = 'SAVINGS ACCOUNT' }: BalanceCardProps) => {
const theme = useTheme();
const [isBalanceVisible, setIsBalanceVisible] = useState(true);
const formattedBalance = formatCurrency(account.availableBalance).replace('₹', '');
return (
<LinearGradient
colors={['#124577', '#124577', '#124577']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.card, styles.gradientCard, { borderRadius: theme.radius.xl }]}
>
{/* Decorative Brand Accent */}
<View style={styles.brandAccent} />
<View style={styles.topRow}>
<View style={styles.bankInfo}>
<View style={styles.bankIconFrame}>
<Ionicons name="business" size={20} color="white" />
</View>
<View>
<Text style={styles.bankName}>CBS Bank</Text>
<Text style={styles.cardType}>{secondaryLabel}</Text>
</View>
</View>
{account.isPrimary && (
<View style={styles.primaryBadge}>
<Text style={styles.primaryBadgeText}>PRIMARY</Text>
</View>
)}
</View>
<View style={styles.mainContent}>
<Text style={styles.maskedNumber}>A/c {account.maskedNumber}</Text>
<View style={styles.balanceContainer}>
<Text style={styles.balanceLabel}>AVAILABLE BALANCE</Text>
<View style={styles.amountRow}>
<Text style={styles.amountValue}>
{isBalanceVisible ? `₹${formattedBalance}` : '₹ ••••••'}
</Text>
<Pressable
onPress={() => setIsBalanceVisible((currentValue) => !currentValue)}
style={styles.visibilityButton}
hitSlop={8}
>
<Ionicons
name={isBalanceVisible ? 'eye-outline' : 'eye-off-outline'}
size={20}
color="rgba(255, 255, 255, 0.7)"
/>
</Pressable>
</View>
</View>
</View>
<View style={styles.footerRow}>
<View style={styles.footerItem}>
<Text style={styles.footerLabel}>ACCOUNT HOLDER</Text>
<Text style={styles.footerValue}>{account.accountHolder}</Text>
</View>
<View style={[styles.footerItem, styles.footerItemEnd]}>
<Text style={styles.footerLabel}>UPI STATUS</Text>
<View style={styles.upiRow}>
<View style={styles.upiDot} />
<Text style={styles.footerValue}>Active</Text>
</View>
</View>
</View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
card: {
padding: 24,
gap: 32,
position: 'relative',
minHeight: 260,
},
gradientCard: {
overflow: 'hidden',
},
brandAccent: {
position: 'absolute',
top: -50,
right: -50,
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
bankInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
bankIconFrame: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
bankName: {
color: 'white',
fontSize: 18,
fontWeight: '900',
letterSpacing: -0.5,
},
cardType: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 10,
fontWeight: '800',
letterSpacing: 1,
textTransform: 'uppercase',
},
primaryBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 10,
backgroundColor: 'white',
},
primaryBadgeText: {
color: '#124577',
fontSize: 10,
fontWeight: '900',
letterSpacing: 0.5,
},
mainContent: {
gap: 20,
},
maskedNumber: {
color: 'white',
fontSize: 18,
fontWeight: '700',
letterSpacing: 1.5,
},
balanceContainer: {
gap: 4,
},
balanceLabel: {
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
fontWeight: '800',
letterSpacing: 1,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
},
amountValue: {
color: 'white',
fontSize: 36,
fontWeight: '900',
letterSpacing: -1,
},
visibilityButton: {
marginLeft: 12,
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
footerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
marginTop: 4,
},
footerItem: {
gap: 4,
},
footerItemEnd: {
alignItems: 'flex-end',
},
footerLabel: {
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 9,
fontWeight: '800',
letterSpacing: 1,
},
footerValue: {
color: 'white',
fontSize: 14,
fontWeight: '700',
},
upiRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
upiDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#35D6A3',
},
});
export const BalanceCard = memo(BalanceCardComponent);
+112
View File
@@ -0,0 +1,112 @@
import React, { useEffect, useRef } from 'react';
import {
Animated,
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useTheme } from '../../theme';
interface BottomSheetProps {
visible: boolean;
title?: string;
onClose: () => void;
children: React.ReactNode;
}
export const BottomSheet = ({ visible, title, onClose, children }: BottomSheetProps) => {
const theme = useTheme();
const translateY = useRef(new Animated.Value(320)).current;
useEffect(() => {
Animated.timing(translateY, {
toValue: visible ? 0 : 320,
duration: 220,
useNativeDriver: true,
}).start();
}, [translateY, visible]);
const handleBackdropPress = () => {
Keyboard.dismiss();
onClose();
};
return (
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.flex}
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
>
<TouchableWithoutFeedback onPress={handleBackdropPress}>
<View style={[styles.backdrop, { backgroundColor: theme.colors.overlay }]}>
<TouchableWithoutFeedback onPress={() => {}}>
<Animated.View
style={[
styles.sheet,
{
backgroundColor: theme.colors.backgroundElevated,
borderTopLeftRadius: theme.radius.lg,
borderTopRightRadius: theme.radius.lg,
transform: [{ translateY }],
},
]}
>
<View style={[styles.handle, { backgroundColor: theme.colors.cardBorder }]} />
{title ? (
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
) : null}
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
contentContainerStyle={styles.sheetScroll}
>
{children}
</ScrollView>
</Animated.View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</Modal>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
backdrop: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
paddingHorizontal: 20,
paddingTop: 14,
paddingBottom: 28,
gap: 12,
maxHeight: '80%',
},
sheetScroll: {
flexGrow: 1,
},
handle: {
width: 42,
height: 4,
borderRadius: 999,
alignSelf: 'center',
},
title: {
fontSize: 18,
fontWeight: '700',
},
});
+158
View File
@@ -0,0 +1,158 @@
import React, { memo } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../theme';
import { haptics } from '../../services/haptics/haptics';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'cyber';
interface ButtonProps {
title: string;
onPress?: (event: GestureResponderEvent) => void;
variant?: ButtonVariant;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
style?: StyleProp<ViewStyle>;
leftAccessory?: React.ReactNode;
}
const ButtonComponent = ({
title,
onPress,
variant = 'primary',
disabled = false,
loading = false,
fullWidth = true,
style,
leftAccessory,
}: ButtonProps) => {
const theme = useTheme();
const handlePress = (e: GestureResponderEvent) => {
if (!disabled && !loading) {
haptics.selection();
onPress?.(e);
}
};
const isSolid = variant === 'primary' || variant === 'danger' || variant === 'cyber';
const containerStyle = [
styles.button,
{
borderRadius: theme.radius.md,
borderColor:
variant === 'ghost'
? 'transparent'
: variant === 'secondary'
? theme.colors.cardBorder
: variant === 'cyber'
? theme.colors.cyberCyan + '30'
: 'transparent',
backgroundColor:
variant === 'secondary' ? theme.colors.cardGlass : 'transparent',
width: fullWidth ? ('100%' as const) : undefined,
opacity: disabled || loading ? 0.6 : 1,
},
variant === 'cyber' && {
shadowColor: theme.colors.cyberCyan,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 10,
elevation: 8,
},
style,
];
const content = (
<View style={styles.content}>
{loading ? (
<ActivityIndicator color={theme.colors.white} />
) : (
<>
{leftAccessory}
<Text
style={[
styles.label,
{
color:
variant === 'ghost'
? theme.colors.textPrimary
: variant === 'secondary' ? theme.colors.textPrimary : theme.colors.white,
letterSpacing: variant === 'cyber' ? 2 : 0,
textTransform: variant === 'cyber' ? 'uppercase' : 'none',
},
]}
>
{title}
</Text>
</>
)}
</View>
);
return (
<Pressable disabled={disabled || loading} onPress={handlePress} style={containerStyle}>
{isSolid ? (
<LinearGradient
colors={
variant === 'danger'
? [theme.colors.danger, '#FF586E']
: variant === 'cyber'
? ['#2563EB', '#3B82F6']
: [theme.colors.primary, theme.colors.primary]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.gradient,
{
borderRadius: theme.radius.md,
},
]}
>
{content}
</LinearGradient>
) : (
content
)}
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
borderWidth: 1,
overflow: 'hidden',
},
gradient: {
width: '100%',
},
content: {
minHeight: 54,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
paddingHorizontal: 18,
},
label: {
fontSize: 15,
fontWeight: '700',
},
});
export const Button = memo(ButtonComponent);
+45
View File
@@ -0,0 +1,45 @@
import React, { memo, type PropsWithChildren } from 'react';
import {
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { useTheme } from '../../theme';
interface CardProps extends PropsWithChildren {
style?: StyleProp<ViewStyle>;
variant?: 'default' | 'glass';
}
const CardComponent = ({ children, style, variant = 'default' }: CardProps) => {
const theme = useTheme();
return (
<View
style={[
styles.card,
theme.shadows.soft,
{
backgroundColor: variant === 'glass' ? theme.colors.cardGlass : theme.colors.surface,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.lg,
},
style,
]}
>
{children}
</View>
);
};
const styles = StyleSheet.create({
card: {
borderWidth: 1,
padding: 18,
},
});
export const Card = memo(CardComponent);
+637
View File
@@ -0,0 +1,637 @@
import React, { useState, useMemo, useCallback, useRef } from 'react';
import {
View,
Text,
Pressable,
Modal,
StyleSheet,
Dimensions,
ScrollView,
PanResponder,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useTheme } from '../../theme';
const SCREEN_WIDTH = Dimensions.get('window').width;
const DAY_SIZE = Math.floor((SCREEN_WIDTH - 80) / 7);
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Generate year range: 1950 → current year + 10
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_RANGE_START = 1950;
const YEAR_RANGE_END = CURRENT_YEAR + 10;
const YEARS = Array.from({ length: YEAR_RANGE_END - YEAR_RANGE_START + 1 }, (_, i) => YEAR_RANGE_START + i);
interface DatePickerProps {
label?: string;
placeholder?: string;
value?: string; // DD/MM/YYYY
onDateChange: (dateStr: string) => void;
error?: string;
/** Max selectable date. Defaults to today */
maxDate?: Date;
/** Min selectable date */
minDate?: Date;
}
type PickerMode = 'days' | 'months' | 'years';
const parseDDMMYYYY = (str: string | undefined): Date | null => {
if (!str) return null;
const parts = str.split('/');
if (parts.length !== 3) return null;
const d = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10) - 1;
const y = parseInt(parts[2], 10);
if (isNaN(d) || isNaN(m) || isNaN(y)) return null;
return new Date(y, m, d);
};
const formatDDMMYYYY = (date: Date): string => {
const d = String(date.getDate()).padStart(2, '0');
const m = String(date.getMonth() + 1).padStart(2, '0');
const y = date.getFullYear();
return `${d}/${m}/${y}`;
};
const isSameDay = (a: Date, b: Date) =>
a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
export const DatePicker = ({
label,
placeholder = 'DD/MM/YYYY',
value,
onDateChange,
error,
maxDate,
minDate,
}: DatePickerProps) => {
const theme = useTheme();
const today = useMemo(() => new Date(), []);
const selectedDate = useMemo(() => parseDDMMYYYY(value), [value]);
const [visible, setVisible] = useState(false);
const [viewMonth, setViewMonth] = useState(selectedDate?.getMonth() ?? today.getMonth());
const [viewYear, setViewYear] = useState(selectedDate?.getFullYear() ?? today.getFullYear());
const [pickerMode, setPickerMode] = useState<PickerMode>('days');
const yearScrollRef = useRef<ScrollView>(null);
const openCalendar = useCallback(() => {
const ref = selectedDate ?? today;
setViewMonth(ref.getMonth());
setViewYear(ref.getFullYear());
setPickerMode('days');
setVisible(true);
}, [selectedDate, today]);
// ── Month navigation ──────────────────────────────────────
const prevMonth = useCallback(() => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear(y => y - 1);
} else {
setViewMonth(m => m - 1);
}
}, [viewMonth]);
const nextMonth = useCallback(() => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear(y => y + 1);
} else {
setViewMonth(m => m + 1);
}
}, [viewMonth]);
// ── Swipe handler (PanResponder) ──────────────────────────
const swipeThreshold = 50;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder: (_, gestureState) => {
// Only capture horizontal swipes
return Math.abs(gestureState.dx) > 15 && Math.abs(gestureState.dy) < 30;
},
onPanResponderRelease: (_, gestureState) => {
if (gestureState.dx > swipeThreshold) {
// Swipe right → previous month (functional updates avoid stale closure)
setViewMonth(m => {
if (m === 0) {
setViewYear(y => y - 1);
return 11;
}
return m - 1;
});
} else if (gestureState.dx < -swipeThreshold) {
// Swipe left → next month
setViewMonth(m => {
if (m === 11) {
setViewYear(y => y + 1);
return 0;
}
return m + 1;
});
}
},
})
).current;
// ── Day calculations ──────────────────────────────────────
const daysInMonth = useMemo(() => new Date(viewYear, viewMonth + 1, 0).getDate(), [viewYear, viewMonth]);
const firstDayOfWeek = useMemo(() => new Date(viewYear, viewMonth, 1).getDay(), [viewYear, viewMonth]);
const calendarDays = useMemo(() => {
const days: (number | null)[] = [];
for (let i = 0; i < firstDayOfWeek; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
return days;
}, [daysInMonth, firstDayOfWeek]);
const isDayDisabled = useCallback(
(day: number) => {
const d = new Date(viewYear, viewMonth, day);
if (maxDate && d > maxDate) return true;
if (minDate && d < minDate) return true;
return false;
},
[viewYear, viewMonth, maxDate, minDate],
);
const handleSelect = (day: number) => {
const d = new Date(viewYear, viewMonth, day);
onDateChange(formatDDMMYYYY(d));
setVisible(false);
};
// ── Month picker handler ──────────────────────────────────
const handleMonthSelect = (monthIndex: number) => {
setViewMonth(monthIndex);
setPickerMode('days');
};
// ── Year picker handler ───────────────────────────────────
const handleYearSelect = (year: number) => {
setViewYear(year);
setPickerMode('days');
};
// Auto-scroll to current year when year picker opens
const handleOpenYearPicker = useCallback(() => {
setPickerMode('years');
// Scroll to the current viewYear position after render
setTimeout(() => {
const yearIndex = YEARS.indexOf(viewYear);
if (yearIndex >= 0 && yearScrollRef.current) {
yearScrollRef.current.scrollTo({ y: Math.max(0, (yearIndex - 2) * 48), animated: false });
}
}, 100);
}, [viewYear]);
// ── Render: Days grid ─────────────────────────────────────
const renderDaysView = () => (
<>
{/* Header: tappable month & year */}
<View style={styles.calendarHeader}>
<Pressable onPress={prevMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="chevron-back" size={20} color={theme.colors.primary} />
</Pressable>
<View style={styles.monthYearWrap}>
<Pressable onPress={() => setPickerMode('months')} style={styles.headerTappable}>
<Text style={[styles.monthText, { color: theme.colors.primary }]}>{MONTHS[viewMonth]}</Text>
<Ionicons name="chevron-down" size={14} color={theme.colors.primary} style={{ marginLeft: 4 }} />
</Pressable>
<Pressable onPress={handleOpenYearPicker} style={styles.headerTappable}>
<Text style={[styles.yearText, { color: theme.colors.textMuted }]}>{viewYear}</Text>
<Ionicons name="chevron-down" size={12} color={theme.colors.textMuted} style={{ marginLeft: 3 }} />
</Pressable>
</View>
<Pressable onPress={nextMonth} hitSlop={12} style={[styles.navBtn, { backgroundColor: `${theme.colors.primary}10` }]}>
<Ionicons name="chevron-forward" size={20} color={theme.colors.primary} />
</Pressable>
</View>
{/* Weekday headers */}
<View style={styles.weekdayRow}>
{WEEKDAYS.map(wd => (
<View key={wd} style={[styles.dayCell, { width: DAY_SIZE, height: 32 }]}>
<Text style={[styles.weekdayText, { color: theme.colors.textMuted }]}>{wd}</Text>
</View>
))}
</View>
{/* Days grid with swipe */}
<View {...panResponder.panHandlers}>
<View style={styles.daysGrid}>
{calendarDays.map((day, idx) => {
if (day === null) {
return <View key={`empty-${idx}`} style={[styles.dayCell, { width: DAY_SIZE, height: DAY_SIZE }]} />;
}
const dateObj = new Date(viewYear, viewMonth, day);
const isToday = isSameDay(dateObj, today);
const isSelected = selectedDate ? isSameDay(dateObj, selectedDate) : false;
const disabled = isDayDisabled(day);
return (
<Pressable
key={day}
disabled={disabled}
onPress={() => handleSelect(day)}
style={[
styles.dayCell,
{ width: DAY_SIZE, height: DAY_SIZE },
isSelected && {
backgroundColor: theme.colors.primary,
borderRadius: DAY_SIZE / 2,
},
isToday && !isSelected && {
borderWidth: 1.5,
borderColor: theme.colors.primary,
borderRadius: DAY_SIZE / 2,
},
]}
>
<Text
style={[
styles.dayText,
{ color: theme.colors.textPrimary },
isSelected && { color: theme.colors.white, fontWeight: '800' },
isToday && !isSelected && { color: theme.colors.primary, fontWeight: '700' },
disabled && { color: theme.colors.textMuted, opacity: 0.4 },
]}
>
{day}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* Footer */}
<View style={styles.calendarFooter}>
<Pressable onPress={() => setVisible(false)} style={[styles.footerBtn, { borderColor: theme.colors.cardBorder }]}>
<Text style={[styles.footerBtnText, { color: theme.colors.textMuted }]}>Cancel</Text>
</Pressable>
<Pressable
onPress={() => {
setViewMonth(today.getMonth());
setViewYear(today.getFullYear());
handleSelect(today.getDate());
}}
style={[styles.footerBtn, styles.todayBtn, { backgroundColor: `${theme.colors.primary}12` }]}
>
<Text style={[styles.footerBtnText, { color: theme.colors.primary, fontWeight: '700' }]}>Today</Text>
</Pressable>
</View>
</>
);
// ── Render: Month picker grid ─────────────────────────────
const renderMonthsView = () => (
<>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Month</Text>
<View style={{ width: 22 }} />
</View>
<View style={styles.monthGrid}>
{MONTHS_SHORT.map((m, idx) => {
const isActive = idx === viewMonth;
return (
<Pressable
key={m}
onPress={() => handleMonthSelect(idx)}
style={[
styles.monthCell,
{
backgroundColor: isActive ? theme.colors.primary : `${theme.colors.primary}08`,
borderColor: isActive ? theme.colors.primary : theme.colors.cardBorder,
},
]}
>
<Text
style={[
styles.monthCellText,
{ color: isActive ? '#fff' : theme.colors.textPrimary },
]}
>
{m}
</Text>
</Pressable>
);
})}
</View>
</>
);
// ── Render: Year picker list ──────────────────────────────
const renderYearsView = () => (
<>
<View style={styles.pickerHeader}>
<Pressable onPress={() => setPickerMode('days')} hitSlop={12}>
<Ionicons name="arrow-back" size={22} color={theme.colors.textPrimary} />
</Pressable>
<Text style={[styles.pickerTitle, { color: theme.colors.textPrimary }]}>Select Year</Text>
<View style={{ width: 22 }} />
</View>
<ScrollView
ref={yearScrollRef}
style={styles.yearList}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.yearListContent}
>
{YEARS.map(year => {
const isActive = year === viewYear;
return (
<Pressable
key={year}
onPress={() => handleYearSelect(year)}
style={[
styles.yearRow,
{
backgroundColor: isActive ? `${theme.colors.primary}12` : 'transparent',
borderColor: isActive ? theme.colors.primary : 'transparent',
},
]}
>
<Text
style={[
styles.yearRowText,
{
color: isActive ? theme.colors.primary : theme.colors.textPrimary,
fontWeight: isActive ? '800' : '500',
},
]}
>
{year}
</Text>
{isActive && (
<Ionicons name="checkmark" size={20} color={theme.colors.primary} />
)}
</Pressable>
);
})}
</ScrollView>
</>
);
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{label.toUpperCase()}
</Text>
)}
{/* Tappable field */}
<Pressable
onPress={openCalendar}
style={[
styles.inputWrapper,
{
borderColor: error ? theme.colors.error : theme.colors.cardBorder,
backgroundColor: 'rgba(0, 0, 0, 0.02)',
},
]}
>
<Ionicons name="calendar-outline" size={18} color={theme.colors.primary} style={{ marginRight: 10 }} />
<Text
style={[
styles.inputText,
{ color: value ? theme.colors.textPrimary : theme.colors.textMuted },
]}
>
{value || placeholder}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.textMuted} />
</Pressable>
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
{/* Calendar Modal */}
<Modal transparent visible={visible} animationType="fade" onRequestClose={() => setVisible(false)}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setVisible(false)}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
<Pressable
style={[
styles.calendarCard,
{
backgroundColor: theme.colors.backgroundElevated,
borderColor: theme.colors.cardBorder,
},
]}
onPress={() => {}} // prevent dismiss when tapping inside
>
{pickerMode === 'days' && renderDaysView()}
{pickerMode === 'months' && renderMonthsView()}
{pickerMode === 'years' && renderYearsView()}
</Pressable>
</Animated.View>
</Pressable>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
marginLeft: 4,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
height: 52,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 16,
},
inputText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
},
errorText: {
fontSize: 12,
fontWeight: '500',
marginTop: 2,
marginLeft: 4,
},
overlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
calendarCard: {
width: SCREEN_WIDTH - 40,
borderRadius: 20,
borderWidth: 1,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.15,
shadowRadius: 20,
elevation: 10,
},
// ── Header ───────────────────────────────────
calendarHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
navBtn: {
width: 36,
height: 36,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
monthYearWrap: {
alignItems: 'center',
},
headerTappable: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 3,
},
monthText: {
fontSize: 18,
fontWeight: '800',
},
yearText: {
fontSize: 13,
fontWeight: '600',
marginTop: 2,
},
// ── Days grid ────────────────────────────────
weekdayRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 4,
},
weekdayText: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 0.5,
},
daysGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'flex-start',
},
dayCell: {
alignItems: 'center',
justifyContent: 'center',
},
dayText: {
fontSize: 14,
fontWeight: '500',
},
// ── Footer ───────────────────────────────────
calendarFooter: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
marginTop: 16,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
},
footerBtn: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
borderColor: 'transparent',
},
todayBtn: {
borderWidth: 0,
},
footerBtnText: {
fontSize: 14,
fontWeight: '600',
},
// ── Month / Year picker shared ───────────────
pickerHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
pickerTitle: {
fontSize: 17,
fontWeight: '800',
},
// ── Month picker grid ────────────────────────
monthGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
justifyContent: 'center',
},
monthCell: {
width: (SCREEN_WIDTH - 100) / 3,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
monthCellText: {
fontSize: 15,
fontWeight: '700',
},
// ── Year picker list ─────────────────────────
yearList: {
maxHeight: 300,
},
yearListContent: {
paddingBottom: 8,
},
yearRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 10,
borderWidth: 1.5,
marginBottom: 4,
},
yearRowText: {
fontSize: 16,
},
});
+69
View File
@@ -0,0 +1,69 @@
import React, { memo } from 'react';
import {
StyleSheet,
Text,
View,
} from 'react-native';
import { useTheme } from '../../theme';
import { Button } from '../Button';
interface EmptyStateProps {
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
}
const EmptyStateComponent = ({
title,
description,
actionLabel,
onAction,
}: EmptyStateProps) => {
const theme = useTheme();
return (
<View style={styles.container}>
<View
style={[
styles.icon,
{
backgroundColor: theme.colors.cardGlass,
borderColor: theme.colors.cardBorder,
},
]}
/>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
{description}
</Text>
{actionLabel && onAction ? <Button title={actionLabel} onPress={onAction} /> : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 12,
paddingVertical: 16,
},
icon: {
width: 72,
height: 72,
borderRadius: 24,
borderWidth: 1,
},
title: {
fontSize: 18,
fontWeight: '700',
},
description: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
});
export const EmptyState = memo(EmptyStateComponent);
+117
View File
@@ -0,0 +1,117 @@
import React, { useEffect } from 'react';
import {
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
withRepeat,
Easing
} from 'react-native-reanimated';
import { useTheme } from '../../theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
leftAccessory?: React.ReactNode;
rightAccessory?: React.ReactNode;
}
export const Input = ({
label,
error,
leftAccessory,
rightAccessory,
...props
}: InputProps) => {
const theme = useTheme();
const focused = useSharedValue(0);
const handleFocus = () => {
focused.value = withTiming(1, { duration: 250 });
};
const handleBlur = () => {
focused.value = withTiming(0, { duration: 250 });
};
const containerAnimatedStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focused.value,
[0, 1],
[theme.colors.cardBorder, theme.colors.primary]
),
backgroundColor: interpolateColor(
focused.value,
[0, 1],
['rgba(0, 0, 0, 0.02)', '#FFFFFF']
),
transform: [{ scale: withTiming(focused.value === 1 ? 1.01 : 1, { duration: 200 }) }],
}));
return (
<View style={styles.container}>
{label && (
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
{label.toUpperCase()}
</Text>
)}
<Animated.View style={[styles.inputWrapper, containerAnimatedStyle]}>
{leftAccessory && <View style={styles.accessory}>{leftAccessory}</View>}
<TextInput
{...props}
onFocus={handleFocus}
onBlur={handleBlur}
placeholderTextColor={theme.colors.textMuted}
style={[styles.input, { color: theme.colors.textPrimary }]}
/>
{rightAccessory && <View style={styles.accessory}>{rightAccessory}</View>}
</Animated.View>
{error && <Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
gap: 6,
},
label: {
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
marginLeft: 4,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
height: 52,
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 16,
overflow: 'hidden',
},
input: {
flex: 1,
fontSize: 15,
fontWeight: '500',
height: '100%',
},
accessory: {
marginRight: 10,
opacity: 0.7,
},
errorText: {
fontSize: 12,
fontWeight: '500',
marginTop: 2,
marginLeft: 4,
},
});
@@ -0,0 +1,76 @@
import React, { useRef } from 'react';
import {
ScrollView,
Platform,
StyleSheet,
type StyleProp,
type ViewStyle,
type NativeScrollEvent,
type NativeSyntheticEvent,
type TextInput,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
} from 'react-native-reanimated';
import { useKeyboardHandler } from '../../hooks/useKeyboardHandler';
interface KeyboardAwareScrollViewProps {
children: React.ReactNode;
/** Style for the content container inside the scroll view */
contentContainerStyle?: StyleProp<ViewStyle>;
/** Style for the scroll view wrapper */
style?: StyleProp<ViewStyle>;
/** Extra bottom padding to add when keyboard is visible (e.g. for footers) */
extraPadding?: number;
/** Whether to show scroll indicator */
showsVerticalScrollIndicator?: boolean;
}
/**
* A drop-in ScrollView replacement that automatically adds bottom padding
* when the keyboard opens, ensuring all inputs remain visible.
*
* Works reliably on BOTH iOS and Android without KeyboardAvoidingView.
* Uses animated padding for smooth transitions.
*/
export const KeyboardAwareScrollView = ({
children,
contentContainerStyle,
style,
extraPadding = 40,
showsVerticalScrollIndicator = false,
}: KeyboardAwareScrollViewProps) => {
const scrollRef = useRef<ScrollView>(null);
const { keyboardHeight, isVisible } = useKeyboardHandler();
// Calculate the dynamic padding: keyboard height + extra buffer
const bottomPad = isVisible ? keyboardHeight + extraPadding : 0;
return (
<ScrollView
ref={scrollRef}
style={[styles.flex, style]}
contentContainerStyle={[
contentContainerStyle,
{ paddingBottom: (contentContainerStyle as any)?.paddingBottom || 40 + bottomPad },
]}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
automaticallyAdjustKeyboardInsets={false}
overScrollMode="always"
bounces
>
{children}
</ScrollView>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
+148
View File
@@ -0,0 +1,148 @@
import React from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableWithoutFeedback,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
interface KeyboardSafeViewProps {
/**
* Content to render inside the keyboard-safe container.
*/
children: React.ReactNode;
/**
* If true, wraps children in a ScrollView that scrolls to the focused input.
* Use for forms and long content screens.
* @default false
*/
scrollable?: boolean;
/**
* Extra vertical offset (e.g. for headers, tab bars, safe areas).
* On iOS this is added to the KeyboardAvoidingView's keyboardVerticalOffset.
* @default 0
*/
extraOffset?: number;
/**
* Custom style for the outer container.
*/
style?: StyleProp<ViewStyle>;
/**
* Custom contentContainerStyle for the inner ScrollView (only used when scrollable=true).
*/
contentContainerStyle?: StyleProp<ViewStyle>;
/**
* Whether to dismiss the keyboard when tapping outside inputs.
* @default true
*/
dismissOnTap?: boolean;
/**
* Whether keyboard should persist taps on inner elements.
* @default 'handled'
*/
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled';
/**
* ScrollView keyboardDismissMode.
* @default 'interactive'
*/
keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
/**
* Whether to show the scroll indicator when scrollable.
* @default false
*/
showsVerticalScrollIndicator?: boolean;
/**
* Enable bounces on iOS ScrollView.
* @default true
*/
bounces?: boolean;
}
/**
* A reusable keyboard-safe wrapper that handles:
* - KeyboardAvoidingView with correct behavior for iOS/Android
* - Optional ScrollView with auto-scroll to focused input
* - Dismiss keyboard on outside tap
* - Proper offsets for headers, tab bars, and safe areas
*/
export const KeyboardSafeView = ({
children,
scrollable = false,
extraOffset = 0,
style,
contentContainerStyle,
dismissOnTap = true,
keyboardShouldPersistTaps = 'handled',
keyboardDismissMode = 'interactive',
showsVerticalScrollIndicator = false,
bounces = true,
}: KeyboardSafeViewProps) => {
// iOS: 'padding' works best, adds padding at the bottom
// Android: 'height' or undefined works best (Android handles it natively in most cases)
const behavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
// Base offset: accounts for navigation header height on iOS
// Android's windowSoftInputMode handles most of the offset natively
const baseOffset = Platform.OS === 'ios' ? 90 : 0;
const totalOffset = baseOffset + extraOffset;
const content = scrollable ? (
<ScrollView
contentContainerStyle={[styles.scrollContent, contentContainerStyle]}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
keyboardDismissMode={keyboardDismissMode}
bounces={bounces}
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
>
{children}
</ScrollView>
) : (
<View style={[styles.flex, contentContainerStyle]}>
{children}
</View>
);
const wrappedContent = dismissOnTap && !scrollable ? (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
{content}
</TouchableWithoutFeedback>
) : (
content
);
return (
<KeyboardAvoidingView
behavior={behavior}
style={[styles.flex, style]}
keyboardVerticalOffset={totalOffset}
>
{wrappedContent}
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
},
});
export default KeyboardSafeView;
+75
View File
@@ -0,0 +1,75 @@
import React, { memo, useEffect, useRef } from 'react';
import {
ActivityIndicator,
Animated,
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { useTheme } from '../../theme';
interface LoaderProps {
size?: 'small' | 'large';
style?: StyleProp<ViewStyle>;
}
const LoaderComponent = ({ size = 'large', style }: LoaderProps) => {
const theme = useTheme();
return (
<View style={[styles.loader, style]}>
<ActivityIndicator color={theme.colors.accent} size={size} />
</View>
);
};
interface SkeletonBlockProps {
height?: number;
width?: number | `${number}%`;
}
export const SkeletonBlock = ({ height = 18, width = '100%' }: SkeletonBlockProps) => {
const theme = useTheme();
const opacity = useRef(new Animated.Value(0.35)).current;
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(opacity, {
toValue: 0.75,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.35,
duration: 800,
useNativeDriver: true,
}),
]),
).start();
}, [opacity]);
return (
<Animated.View
style={{
height,
width,
borderRadius: 999,
backgroundColor: theme.colors.cardGlass,
opacity,
}}
/>
);
};
const styles = StyleSheet.create({
loader: {
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
});
export const Loader = memo(LoaderComponent);
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TouchableWithoutFeedback,
View,
} from 'react-native';
import { useTheme } from '../../theme';
interface AppModalProps {
visible: boolean;
title: string;
description?: string;
onClose: () => void;
children?: React.ReactNode;
}
export const AppModal = ({
visible,
title,
description,
onClose,
children,
}: AppModalProps) => {
const theme = useTheme();
const handleOverlayPress = () => {
Keyboard.dismiss();
onClose();
};
return (
<Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.flex}
keyboardVerticalOffset={Platform.OS === 'ios' ? 10 : 0}
>
<TouchableWithoutFeedback onPress={handleOverlayPress}>
<View style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}>
<TouchableWithoutFeedback onPress={() => {}}>
<View
style={[
styles.content,
{
backgroundColor: theme.colors.backgroundElevated,
borderRadius: theme.radius.lg,
borderColor: theme.colors.cardBorder,
},
]}
>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
{description}
</Text>
) : null}
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
contentContainerStyle={styles.childrenScroll}
>
{children}
</ScrollView>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
</Modal>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
overlay: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
content: {
borderWidth: 1,
padding: 20,
gap: 12,
maxHeight: '80%',
},
childrenScroll: {
flexGrow: 1,
gap: 12,
},
title: {
fontSize: 20,
fontWeight: '700',
},
description: {
fontSize: 14,
lineHeight: 20,
},
});
+182
View File
@@ -0,0 +1,182 @@
import React from 'react';
import {
Keyboard,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
TouchableWithoutFeedback,
View,
StatusBar,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import LinearGradient from 'react-native-linear-gradient';
import { useTheme } from '../../theme';
import { AppHeader } from '../AppHeader';
import { TAB_BAR_HEIGHT } from '../../utils/layoutConstants';
interface ScreenShellProps {
title?: string;
subtitle?: string;
rightContent?: React.ReactNode;
scrollable?: boolean;
children: React.ReactNode;
contentContainerStyle?: StyleProp<ViewStyle>;
transparent?: boolean;
/**
* Extra keyboard vertical offset (e.g. for screens with custom headers or tab bars).
* Added on top of the default offset.
* @default 0
*/
keyboardExtraOffset?: number;
/**
* Whether to enable keyboard avoidance for this screen.
* @default true
*/
keyboardAware?: boolean;
/**
* Whether this screen is rendered inside a bottom tab navigator
* with an absolutely-positioned tab bar. When true, adds bottom
* padding so content never hides behind the tab bar.
* @default true
*/
withTabBar?: boolean;
fixedFooter?: React.ReactNode;
}
export const ScreenShell = ({
title,
subtitle,
rightContent,
scrollable = true,
children,
contentContainerStyle,
transparent = false,
keyboardExtraOffset = 0,
keyboardAware = true,
withTabBar = true,
fixedFooter,
}: ScreenShellProps) => {
const theme = useTheme();
// iOS needs 'padding' behavior, Android handles it natively via windowSoftInputMode
// but we use 'height' as a fallback for Android screens that need it
const kavBehavior = Platform.OS === 'ios' ? 'padding' as const : 'height' as const;
// Base offset accounts for the navigation header + safe area
const baseOffset = Platform.OS === 'ios' ? 90 : 0;
const totalOffset = baseOffset + keyboardExtraOffset;
// Bottom spacing: if inside tab navigator, add TAB_BAR_HEIGHT + breathing room
// If there's a fixedFooter, we don't necessarily need tab bar padding on the scroll view,
// but let's leave it for safety or adjust based on layout.
const tabBarPadding = withTabBar ? TAB_BAR_HEIGHT + 16 : 20;
const scrollContent = (
<ScrollView
contentContainerStyle={[
styles.content,
{ paddingBottom: tabBarPadding },
contentContainerStyle,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
>
{children}
</ScrollView>
);
const staticContent = (
<View style={styles.flex}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.content,
styles.staticContent,
{ paddingBottom: withTabBar ? TAB_BAR_HEIGHT : 0 },
contentContainerStyle,
]}
>
{children}
</View>
</TouchableWithoutFeedback>
</View>
);
const content = scrollable ? scrollContent : staticContent;
const innerContent = keyboardAware ? (
<KeyboardAvoidingView
behavior={kavBehavior}
style={styles.flex}
keyboardVerticalOffset={totalOffset}
>
{content}
</KeyboardAvoidingView>
) : (
content
);
const container = (
<SafeAreaView edges={['top']} style={styles.flex}>
{/* ✅ Status Bar Added */}
<StatusBar
barStyle={'dark-content'}
backgroundColor="transparent"
translucent
/>
<View style={[styles.frame, transparent && styles.transparentFrame]}>
{title ? (
<AppHeader
title={title}
subtitle={subtitle}
rightContent={rightContent}
/>
) : null}
{innerContent}
{fixedFooter}
</View>
</SafeAreaView>
);
if (transparent) {
return container;
}
return (
<LinearGradient
colors={theme.colors.backgroundGradient}
style={styles.flex}
>
{container}
</LinearGradient>
);
};
const styles = StyleSheet.create({
flex: {
flex: 1,
},
frame: {
flex: 1,
paddingHorizontal: 10,
gap: 18,
},
transparentFrame: {
paddingHorizontal: 0,
},
content: {
gap: 16,
},
staticContent: {
flex: 1,
},
});
export default ScreenShell;
+115
View File
@@ -0,0 +1,115 @@
import React, { useEffect } from 'react';
import {
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppDispatch, useAppSelector } from '../../app/hooks';
import { selectToasts } from '../../modules/ui/store/uiSelectors';
import { uiActions } from '../../modules/ui/store/uiSlice';
import { useTheme } from '../../theme';
interface ToastCardProps {
id: string;
title: string;
description?: string;
type: 'success' | 'error' | 'info';
}
const ToastCard = ({ id, title, description, type }: ToastCardProps) => {
const dispatch = useAppDispatch();
const theme = useTheme();
useEffect(() => {
const timer = setTimeout(() => {
dispatch(uiActions.dismissToast(id));
}, 3200);
return () => {
clearTimeout(timer);
};
}, [dispatch, id]);
const accentColor =
type === 'success'
? theme.colors.success
: type === 'error'
? theme.colors.danger
: theme.colors.info;
return (
<Pressable
onPress={() => dispatch(uiActions.dismissToast(id))}
style={[
styles.toast,
{
backgroundColor: theme.colors.backgroundElevated,
borderColor: theme.colors.cardBorder,
borderRadius: theme.radius.md,
},
]}
>
<View style={[styles.accent, { backgroundColor: accentColor }]} />
<View style={styles.copy}>
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
{description}
</Text>
) : null}
</View>
</Pressable>
);
};
export const ToastHost = () => {
const toasts = useAppSelector(selectToasts);
if (!toasts.length) {
return null;
}
return (
<SafeAreaView pointerEvents="box-none" style={styles.host}>
{toasts.slice(-3).map((toast) => (
<ToastCard key={toast.id} {...toast} />
))}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
host: {
position: 'absolute',
top: 0,
right: 0,
left: 0,
paddingHorizontal: 20,
gap: 10,
},
toast: {
flexDirection: 'row',
borderWidth: 1,
padding: 14,
gap: 12,
},
accent: {
width: 6,
borderRadius: 999,
},
copy: {
flex: 1,
gap: 4,
},
title: {
fontSize: 14,
fontWeight: '700',
},
description: {
fontSize: 12,
lineHeight: 18,
},
});
+170
View File
@@ -0,0 +1,170 @@
import React, { memo, useMemo } from 'react';
import {
StyleSheet,
Text,
View,
} from 'react-native';
import Ionicons from '@react-native-vector-icons/ionicons';
import type { TransactionRecord } from '../../types/app';
import { useTheme } from '../../theme';
import { formatCurrency, formatDate } from '../../utils/formatters';
interface TransactionItemProps {
item: TransactionRecord;
}
type RiskLevel = 'safe' | 'medium' | 'high';
const TransactionItemComponent = ({ item }: TransactionItemProps) => {
const theme = useTheme();
const isCredit = item.direction === 'credit';
const riskLevel = useMemo<RiskLevel>(() => {
const sum = item.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
if (sum % 10 < 7) return 'safe';
if (sum % 10 < 9) return 'medium';
return 'high';
}, [item.id]);
const riskConfig = {
safe: { color: theme.colors.success, label: 'SECURE', icon: 'shield-checkmark' as const },
medium: { color: theme.colors.warning, label: 'FLAGGED', icon: 'warning' as const },
high: { color: theme.colors.danger, label: 'HIGH RISK', icon: 'alert-circle' as const },
}[riskLevel];
return (
<View
style={[
styles.wrapper,
{
borderBottomColor: 'rgba(255, 255, 255, 0.05)',
},
]}
>
<View style={styles.leading}>
<View
style={[
styles.icon,
{
backgroundColor: isCredit
? `${theme.colors.success}10`
: 'rgba(255, 255, 255, 0.05)',
borderColor: isCredit ? `${theme.colors.success}30` : 'rgba(255, 255, 255, 0.1)',
},
]}
>
<Ionicons
name={isCredit ? 'chevron-down' : 'chevron-up'}
size={18}
color={isCredit ? theme.colors.success : theme.colors.white}
/>
</View>
<View style={styles.copy}>
<View style={styles.titleRow}>
<Text style={[styles.title, { color: theme.colors.white }]}>{item.title}</Text>
</View>
<View style={styles.subtitleRow}>
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
{item.counterparty}
</Text>
<View style={styles.dot} />
<Text style={[styles.subtitle, { color: 'rgba(255, 255, 255, 0.5)' }]}>
{formatDate(item.occurredAt)}
</Text>
</View>
</View>
</View>
<View style={styles.trailing}>
<Text
style={[
styles.amount,
{ color: isCredit ? theme.colors.success : theme.colors.white },
]}
>
{isCredit ? '+' : '-'}
{formatCurrency(item.amount)}
</Text>
<View style={[styles.riskBadge, { backgroundColor: `${riskConfig.color}10`, borderColor: `${riskConfig.color}20` }]}>
<Text style={[styles.riskText, { color: riskConfig.color }]}>{riskConfig.label}</Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
wrapper: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 18,
borderBottomWidth: 1,
},
leading: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
flex: 1,
},
icon: {
width: 46,
height: 46,
borderRadius: 23,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
copy: {
gap: 4,
flex: 1,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
},
title: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.3,
},
subtitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
dot: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
subtitle: {
fontSize: 12,
fontWeight: '500',
},
trailing: {
alignItems: 'flex-end',
gap: 4,
},
amount: {
fontSize: 16,
fontWeight: '800',
letterSpacing: -0.2,
},
riskBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
borderWidth: 1,
},
riskText: {
fontSize: 8,
fontWeight: '900',
letterSpacing: 0.5,
},
});
export const TransactionItem = memo(TransactionItemComponent);
+15
View File
@@ -0,0 +1,15 @@
export * from './AppHeader';
export * from './BalanceCard';
export * from './BottomSheet';
export * from './Button';
export * from './Card';
export * from './DatePicker';
export * from './EmptyState';
export * from './Input';
export * from './KeyboardAwareScrollView';
export * from './KeyboardSafeView';
export * from './Loader';
export * from './Modal';
export * from './ScreenShell';
export * from './ToastHost';
export * from './TransactionItem';
+16
View File
@@ -0,0 +1,16 @@
import { env } from './env';
export const appConfig = {
appName: 'BankCBS',
supportEmail: 'support@bankcbs.dev',
security: {
sessionTimeoutMs: env.sessionTimeoutMs,
biometricPromptTitle: 'Verify your identity',
},
features: {
useMockServer: env.useMockServer,
enableBiometrics: true,
enablePushNotifications: true,
enableChatSupport: true,
},
};
+5
View File
@@ -0,0 +1,5 @@
export const env = {
apiBaseUrl: 'https://sandbox.bankcbs.dev/v1',
useMockServer: true,
sessionTimeoutMs: 300000,
};
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
export const QUERY_TAGS = [
'Auth',
'User',
'Accounts',
'Transactions',
'Beneficiaries',
'Transfer',
'Payments',
'Deposits',
'Services',
'Loans',
'LoanActive',
'LoanApplications',
'LoanDocuments',
'LoanHistory',
'LoanNotifications',
'Insurance',
'Cards',
'Profile',
'Branches',
'AgentDashboard',
'AgentCustomers',
'AgentCollections',
'AgentCommission',
'AgentLoans',
'AgentSms',
] as const;
export type QueryTag = (typeof QUERY_TAGS)[number];
+33
View File
@@ -0,0 +1,33 @@
export const ROUTES = {
splash: 'Splash',
login: 'Login',
otp: 'OTP',
mpinSetup: 'MPINSetup',
home: 'Home',
transferType: 'TransferType',
transferBeneficiary: 'TransferBeneficiary',
transferAmount: 'TransferAmount',
transferConfirm: 'TransferConfirm',
transferSuccess: 'TransferSuccess',
paymentsHome: 'PaymentsHome',
upiScan: 'UpiScan',
upiAmount: 'UpiAmount',
upiPin: 'UpiPin',
upiSuccess: 'UpiSuccess',
depositType: 'DepositType',
depositDetails: 'DepositDetails',
depositConfirm: 'DepositConfirm',
depositSuccess: 'DepositSuccess',
servicesHub: 'ServicesHub',
serviceRequest: 'ServiceRequest',
serviceSubmit: 'ServiceSubmit',
serviceTrack: 'ServiceTrack',
supportChat: 'SupportChat',
loanOverview: 'LoanOverview',
emiCalculator: 'EmiCalculator',
insuranceOverview: 'InsuranceOverview',
cardsOverview: 'CardsOverview',
cardControls: 'CardControls',
profile: 'Profile',
language: 'Language',
} as const;
+58
View File
@@ -0,0 +1,58 @@
import type { LanguageCode } from '../types/app';
export const translations: Record<LanguageCode, Record<string, string>> = {
en: {
welcomeBack: 'Welcome back',
secureLogin: 'Secure login',
verifyOtp: 'Verify OTP',
setupMpin: 'Set up MPIN',
availableBalance: 'Available balance',
quickActions: 'Quick actions',
recentTransactions: 'Recent transactions',
spendingAnalytics: 'Spending analytics',
transferMoney: 'Transfer money',
upiPayments: 'UPI payments',
depositPlans: 'Deposit plans',
serviceDesk: 'Service desk',
cardControls: 'Card controls',
profile: 'Profile',
trackRequests: 'Track requests',
supportChat: 'Support chat',
},
hi: {
welcomeBack: 'फिर से स्वागत है',
secureLogin: 'सुरक्षित लॉगिन',
verifyOtp: 'ओटीपी सत्यापित करें',
setupMpin: 'एमपिन सेट करें',
availableBalance: 'उपलब्ध बैलेंस',
quickActions: 'त्वरित कार्य',
recentTransactions: 'हाल की ट्रांजैक्शन',
spendingAnalytics: 'खर्च विश्लेषण',
transferMoney: 'पैसे ट्रांसफर करें',
upiPayments: 'यूपीआई भुगतान',
depositPlans: 'जमा योजनाएं',
serviceDesk: 'सेवा डेस्क',
cardControls: 'कार्ड नियंत्रण',
profile: 'प्रोफाइल',
trackRequests: 'अनुरोध ट्रैक करें',
supportChat: 'सपोर्ट चैट',
},
bn: {
welcomeBack: 'আবার স্বাগতম',
secureLogin: 'নিরাপদ লগইন',
verifyOtp: 'ওটিপি যাচাই করুন',
setupMpin: 'এমপিন সেট করুন',
availableBalance: 'উপলব্ধ ব্যালেন্স',
quickActions: 'দ্রুত কাজ',
recentTransactions: 'সাম্প্রতিক লেনদেন',
spendingAnalytics: 'খরচের বিশ্লেষণ',
transferMoney: 'টাকা পাঠান',
upiPayments: 'ইউপিআই পেমেন্ট',
depositPlans: 'ডিপোজিট প্ল্যান',
serviceDesk: 'সার্ভিস ডেস্ক',
cardControls: 'কার্ড কন্ট্রোল',
profile: 'প্রোফাইল',
trackRequests: 'অনুরোধ ট্র্যাক করুন',
supportChat: 'সাপোর্ট চ্যাট',
},
};
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '../app/hooks';
import { bootstrapAuthSession, logoutSession } from '../modules/auth/store/authThunk';
import { selectIsAuthenticated } from '../modules/auth/store/authSelectors';
import { seedDashboardState } from '../modules/home/store/accountThunk';
import { seedBeneficiaries } from '../modules/transfer/store/transferThunk';
import { seedRecentPayees } from '../modules/payments/store/paymentsThunk';
import { seedDepositProducts } from '../modules/deposit/store/depositThunk';
import {
seedServiceRequests,
seedSupportMessages,
} from '../modules/services/store/serviceThunk';
import { seedLoanProducts } from '../modules/loan/store/loanThunk';
import { seedInsurancePlans } from '../modules/insurance/store/insuranceThunk';
import { seedCards } from '../modules/cards/store/cardsThunk';
import { seedUserProfile } from '../modules/profile/store/userThunk';
import { markAppReady } from '../modules/ui/store/uiThunk';
import { registerUnauthorizedHandler } from '../services/api/interceptors';
import { registerForPushNotifications } from '../services/notifications/pushNotifications';
import { useSessionTimeout } from './useSessionTimeout';
import { useToast } from './useToast';
/** Dispatch all seed thunks to populate demo/mock data. */
const seedAllData = (dispatch: ReturnType<typeof useAppDispatch>) => {
dispatch(seedUserProfile());
dispatch(seedDashboardState());
dispatch(seedBeneficiaries());
dispatch(seedRecentPayees());
dispatch(seedDepositProducts());
dispatch(seedServiceRequests());
dispatch(seedSupportMessages());
dispatch(seedLoanProducts());
dispatch(seedInsurancePlans());
dispatch(seedCards());
};
export const useAppBootstrap = () => {
const dispatch = useAppDispatch();
const toast = useToast();
const isAuthenticated = useAppSelector(selectIsAuthenticated);
const hasBootstrapped = useRef(false);
useSessionTimeout();
// Initial bootstrap on mount
useEffect(() => {
dispatch(bootstrapAuthSession());
seedAllData(dispatch);
dispatch(markAppReady());
void registerForPushNotifications();
hasBootstrapped.current = true;
}, [dispatch]);
// Re-seed all data when the user logs back in after a logout.
// Logout wipes all Redux state via rootReducer, so profile/dashboard/etc.
// need to be re-populated when isAuthenticated flips back to true.
useEffect(() => {
if (isAuthenticated && hasBootstrapped.current) {
seedAllData(dispatch);
}
}, [isAuthenticated, dispatch]);
useEffect(() => {
const unregister = registerUnauthorizedHandler(() => {
dispatch(logoutSession());
toast('Signed out', 'info', 'Your session was refreshed for security reasons.');
});
return unregister;
}, [dispatch, toast]);
};
+60
View File
@@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Keyboard, Platform, type KeyboardEvent } from 'react-native';
interface KeyboardState {
/** Whether the keyboard is currently visible. */
isVisible: boolean;
/** The keyboard height in points. 0 when hidden. */
keyboardHeight: number;
}
/**
* Hook that tracks keyboard visibility and height.
* Provides a dismiss function for convenience.
*
* Usage:
* ```ts
* const { isVisible, keyboardHeight, dismiss } = useKeyboardHandler();
* ```
*/
export function useKeyboardHandler() {
const [state, setState] = useState<KeyboardState>({
isVisible: false,
keyboardHeight: 0,
});
const heightRef = useRef(0);
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const onShow = (e: KeyboardEvent) => {
const height = e.endCoordinates.height;
heightRef.current = height;
setState({ isVisible: true, keyboardHeight: height });
};
const onHide = () => {
heightRef.current = 0;
setState({ isVisible: false, keyboardHeight: 0 });
};
const showSub = Keyboard.addListener(showEvent, onShow);
const hideSub = Keyboard.addListener(hideEvent, onHide);
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
const dismiss = useCallback(() => {
Keyboard.dismiss();
}, []);
return {
...state,
dismiss,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useState } from 'react';
export const usePullToRefresh = (refreshAction: () => Promise<unknown> | void) => {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.resolve(refreshAction());
setRefreshing(false);
}, [refreshAction]);
return {
refreshing,
onRefresh,
};
};
+37
View File
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { useAppDispatch } from '../app/hooks';
import { logoutSession } from '../modules/auth/store/authThunk';
import { uiActions } from '../modules/ui/store/uiSlice';
import { sessionManager } from '../services/security/sessionManager';
import { useToast } from './useToast';
export const useSessionTimeout = () => {
const dispatch = useAppDispatch();
const toast = useToast();
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'active') {
if (sessionManager.hasExpired()) {
dispatch(uiActions.setSessionExpired(true));
dispatch(logoutSession());
toast('Session expired', 'error', 'Please log in again to continue securely.');
}
sessionManager.touch();
}
if (nextState === 'background' || nextState === 'inactive') {
sessionManager.touch();
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription.remove();
};
}, [dispatch, toast]);
};
+31
View File
@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { useAppDispatch } from '../app/hooks';
import { uiActions } from '../modules/ui/store/uiSlice';
import { haptics } from '../services/haptics/haptics';
export const useToast = () => {
const dispatch = useAppDispatch();
return useCallback(
(title: string, type: 'success' | 'error' | 'info', description?: string) => {
if (type === 'success') {
haptics.success();
} else if (type === 'error') {
haptics.error();
} else {
haptics.selection();
}
dispatch(
uiActions.showToast({
id: `${Date.now()}_${type}`,
title,
type,
description,
}),
);
},
[dispatch],
);
};
+20
View File
@@ -0,0 +1,20 @@
import { useCallback } from 'react';
import { translations } from '../constants/translations';
import { useAppSelector } from '../app/hooks';
import { selectUserLanguage } from '../modules/profile/store/userSelectors';
export const useTranslation = () => {
const language = useAppSelector(selectUserLanguage);
const t = useCallback(
(key: keyof (typeof translations)['en']): string =>
translations[language][key] ?? translations.en[key] ?? key,
[language],
);
return {
language,
t,
};
};
@@ -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);

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