Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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: 'সাপোর্ট চ্যাট',
|
||||
},
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export type { AuthTokens, UserProfile } from '../../../types/app';
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user