feat(app): add lead and leadDetails

This commit is contained in:
uttam 2026-07-16 18:26:01 +05:30
parent 801218f692
commit 2a8c5f7e60
54 changed files with 184875 additions and 193 deletions

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,9 @@ import { StyleSheet, StatusBar } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { RootNavigator } from './navigation/rootNavigator'; import { RootNavigator } from './navigation/rootNavigator';
import { ThemeProvider, useTheme } from './theme'; import { ThemeProvider, useTheme } from './theme';
import { Provider } from 'react-redux';
import { store, persistor } from '@store';
import { PersistGate } from 'redux-persist/integration/react';
const ThemedStatusBar = () => { const ThemedStatusBar = () => {
const { theme: colors, isDark } = useTheme(); const { theme: colors, isDark } = useTheme();
@ -22,17 +25,17 @@ const ThemedStatusBar = () => {
const App = () => { const App = () => {
return ( return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider> <ThemeProvider>
<ThemedStatusBar /> <ThemedStatusBar />
<RootNavigator /> <RootNavigator />
</ThemeProvider> </ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>
</PersistGate>
</Provider>
); );
}; };
export default App; export default App;
const styles = StyleSheet.create({
root: { flex: 1 },
});

14
app/api/authApi.ts Normal file
View File

@ -0,0 +1,14 @@
import { LoginRequest, LoginResponse } from '@interfaces';
import { api } from '@utils';
export const loginApi = async (
payload: LoginRequest,
): Promise<LoginResponse> => {
const formData = new FormData();
formData.append('email', payload.email);
formData.append('password', payload.password);
if(payload.device_token) {
formData.append('device_token', String(payload.device_token));
}
return await api.post<LoginResponse>('/api/stafflogin', formData);
};

3
app/api/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './authApi';
export * from './leadsApi';
export * from './leadDetailsApi';

10
app/api/leadDetailsApi.ts Normal file
View File

@ -0,0 +1,10 @@
import { LeadDetailsItem } from '@interfaces';
import { api } from '@utils';
export const getLeadDetailsApi = async (
leadId: string,
staffId: string,
): Promise<LeadDetailsItem> => {
const url = `/api/leads/${leadId}/${staffId}`;
return await api.get<LeadDetailsItem>(url);
};

10
app/api/leadsApi.ts Normal file
View File

@ -0,0 +1,10 @@
import { LeadItem } from '@interfaces';
import { api } from '@utils';
export const getLeadsApi = async (
leadId: string | null,
staffId: string,
): Promise<LeadItem[]> => {
const url = `/api/leads/${leadId}/${staffId}`;
return await api.get<LeadItem[]>(url);
};

4
app/components/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './leadItemCard';
export * from './searchInput';
export * from './loader';
export * from './leadDetailHeader';

View File

@ -0,0 +1,2 @@
export * from './leadDetailHeader';
export * from './leadDetailHeader.props';

View File

@ -0,0 +1,12 @@
export interface LeadDetailHeaderProps {
name: string;
company?: string;
email?: string;
phonenumber?: string;
statusName: string;
statusColor: string;
avatarColor?: string;
onStatusPress?: () => void;
onEmailPress?: () => void;
onPhonePress?: () => void;
}

View File

@ -0,0 +1,79 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
alignItems: 'center',
paddingVertical: 24,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.card,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
avatarText: {
fontSize: 32,
fontWeight: '700',
color: '#FFFFFF',
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
flexWrap: 'wrap',
paddingHorizontal: 16,
},
name: {
fontSize: 24,
fontWeight: '700',
color: colors.text,
marginRight: 8,
},
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
statusText: {
fontSize: 13,
fontWeight: '600',
textTransform: 'capitalize',
},
company: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: 12,
textAlign: 'center',
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
paddingHorizontal: 16,
},
contactLabel: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
marginRight: 8,
minWidth: 50,
},
contactText: {
fontSize: 14,
color: colors.textSecondary,
flex: 1,
},
iconButton: {
marginLeft: 8,
padding: 4,
},
});

View File

@ -0,0 +1,103 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './leadDetailHeader.styles';
import { LeadDetailHeaderProps } from './leadDetailHeader.props';
export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
name,
company,
email,
phonenumber,
statusName,
statusColor,
avatarColor,
onStatusPress,
onEmailPress,
onPhonePress,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
// Get initials from name
const getInitials = (name: string): string => {
if (!name) return '?';
const nameParts = name.trim().split(' ');
if (nameParts.length >= 2) {
return (nameParts[0][0] + nameParts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
return (
<View style={styles.container}>
{/* Avatar */}
<View
style={[
styles.avatar,
{ backgroundColor: avatarColor || statusColor || '#1565C0' },
]}>
<Text style={styles.avatarText}>{getInitials(name)}</Text>
</View>
{/* Name and Status Row */}
<View style={styles.nameRow}>
<Text style={styles.name}>{name || 'No Name'}</Text>
<TouchableOpacity
onPress={onStatusPress}
activeOpacity={onStatusPress ? 0.7 : 1}
disabled={!onStatusPress}>
<View
style={[
styles.statusBadge,
{ backgroundColor: `${statusColor || '#6B7280'}20` },
]}>
<Text
style={[
styles.statusText,
{ color: statusColor || '#6B7280' },
]}>
{statusName || 'No Status'}
</Text>
</View>
</TouchableOpacity>
</View>
{/* Company */}
{company && <Text style={styles.company}>{company}</Text>}
{/* Email Row */}
{email && (
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Email:</Text>
<Text style={styles.contactText}>{email}</Text>
{onEmailPress && (
<TouchableOpacity
style={styles.iconButton}
onPress={onEmailPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="send" size={18} color={colors.icon} />
</TouchableOpacity>
)}
</View>
)}
{/* Phone Row */}
{phonenumber && (
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>Phone:</Text>
<Text style={styles.contactText}>{phonenumber}</Text>
{onPhonePress && (
<TouchableOpacity
style={styles.iconButton}
onPress={onPhonePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="call" size={18} color={colors.icon} />
</TouchableOpacity>
)}
</View>
)}
</View>
);
};

View File

@ -0,0 +1,2 @@
export * from './leadItemCard';
export * from './leadItemCard.props';

View File

@ -0,0 +1,6 @@
import { LeadItem } from '@interfaces';
export interface LeadItemCardProps {
item: LeadItem;
onPress?: () => void;
}

View File

@ -0,0 +1,74 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
avatarContainer: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
avatarText: {
fontSize: 20,
fontWeight: '700',
color: '#FFFFFF',
},
contentContainer: {
flex: 1,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
name: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
flex: 1,
marginRight: 8,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
flexShrink: 0,
},
statusText: {
fontSize: 11,
fontWeight: '600',
textTransform: 'capitalize',
},
contactRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
contactText: {
fontSize: 13,
color: colors.textSecondary,
flex: 1,
marginRight: 8,
},
contactIconButton: {
padding: 4,
},
});

View File

@ -0,0 +1,117 @@
import React from 'react';
import {
View,
Text,
TouchableOpacity,
Linking,
Platform,
} from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './leadItemCard.styles';
import { LeadItemCardProps } from './leadItemCard.props';
export const LeadItemCard: React.FC<LeadItemCardProps> = ({
item,
onPress,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
// Get initials from name
const getInitials = (name: string): string => {
if (!name) return '?';
const nameParts = name.trim().split(' ');
if (nameParts.length >= 2) {
return (nameParts[0][0] + nameParts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
// Handle email press
const handleEmailPress = () => {
if (item.email) {
Linking.openURL(`mailto:${item.email}`).catch(err =>
console.error('Error opening email:', err),
);
}
};
// Handle phone press
const handlePhonePress = () => {
if (item.phonenumber) {
const phoneUrl =
Platform.OS === 'ios'
? `telprompt:${item.phonenumber}`
: `tel:${item.phonenumber}`;
Linking.openURL(phoneUrl).catch(err =>
console.error('Error opening phone:', err),
);
}
};
return (
<TouchableOpacity
style={styles.card}
onPress={onPress}
activeOpacity={0.7}>
{/* Left side - Avatar/Initials */}
<View style={[styles.avatarContainer, { backgroundColor: item.color || '#1565C0' }]}>
<Text style={styles.avatarText}>{getInitials(item.name)}</Text>
</View>
{/* Right side - Content */}
<View style={styles.contentContainer}>
{/* Name and Status Row */}
<View style={styles.headerRow}>
<Text style={styles.name} numberOfLines={1}>
{item.name || 'No Name'}
</Text>
<View
style={[
styles.statusBadge,
{ backgroundColor: `${item.color || '#6B7280'}20` },
]}>
<Text
style={[
styles.statusText,
{ color: item.color || '#6B7280' },
]}>
{item.status_name || item.status || 'No Status'}
</Text>
</View>
</View>
{/* Email Row */}
{item.email ? (
<View style={styles.contactRow}>
<Text style={styles.contactText} numberOfLines={1}>
{item.email}
</Text>
<TouchableOpacity
style={styles.contactIconButton}
onPress={handleEmailPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="mail" size={20} color={colors.icon} />
</TouchableOpacity>
</View>
) : null}
{/* Phone Row */}
{item.phonenumber ? (
<View style={styles.contactRow}>
<Text style={styles.contactText} numberOfLines={1}>
{item.phonenumber}
</Text>
<TouchableOpacity
style={styles.contactIconButton}
onPress={handlePhonePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="call" size={20} color={colors.icon} />
</TouchableOpacity>
</View>
) : null}
</View>
</TouchableOpacity>
);
};

View File

@ -0,0 +1,2 @@
export * from './loader';
export * from './loader.props';

View File

@ -0,0 +1,5 @@
export interface LoaderProps {
size?: 'small' | 'large' | number;
color?: string;
message?: string;
}

View File

@ -0,0 +1,18 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
message: {
marginTop: 16,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
});

View File

@ -0,0 +1,23 @@
import React from 'react';
import { View, ActivityIndicator, Text } from 'react-native';
import { useTheme } from '@theme';
import { getStyles } from './loader.styles';
import { LoaderProps } from './loader.props';
export const Loader: React.FC<LoaderProps> = ({
size = 'large',
color,
message,
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const loaderColor = color || colors.icon;
return (
<View style={styles.container}>
<ActivityIndicator size={size} color={loaderColor} />
{message && <Text style={styles.message}>{message}</Text>}
</View>
);
};

View File

@ -0,0 +1,2 @@
export * from './searchInput';
export * from './searchInput.props';

View File

@ -0,0 +1,9 @@
import { TextInputProps, ViewStyle } from 'react-native';
export interface SearchInputProps extends Omit<TextInputProps, 'style'> {
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
onClear?: () => void;
style?: ViewStyle;
}

View File

@ -0,0 +1,29 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 12,
height: 48,
},
searchIcon: {
marginRight: 8,
},
input: {
flex: 1,
fontSize: 15,
color: colors.text,
paddingVertical: 0,
},
clearButton: {
marginLeft: 8,
padding: 2,
},
});

View File

@ -0,0 +1,55 @@
import React from 'react';
import { View, TextInput, TouchableOpacity, ViewStyle } from 'react-native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './searchInput.styles';
import { SearchInputProps } from './searchInput.props';
export const SearchInput: React.FC<SearchInputProps> = ({
value,
onChangeText,
placeholder = 'Search...',
onClear,
style,
...rest
}) => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const handleClear = () => {
onChangeText('');
if (onClear) {
onClear();
}
};
return (
<View style={[styles.container, style as ViewStyle]}>
<Icon
name="search-outline"
size={20}
color={colors.textSecondary}
style={styles.searchIcon}
/>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
{...rest}
/>
{value.length > 0 && (
<TouchableOpacity
style={styles.clearButton}
onPress={handleClear}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="close-circle" size={20} color={colors.textMuted} />
</TouchableOpacity>
)}
</View>
);
};

View File

@ -4,6 +4,7 @@ export * from './dashboard';
export * from './estimates'; export * from './estimates';
export * from './invoices'; export * from './invoices';
export * from './leads'; export * from './leads';
export * from './leadDetails';
export * from './login'; export * from './login';
export * from './profile'; export * from './profile';
export * from './projects'; export * from './projects';

View File

@ -0,0 +1,3 @@
export * from './leadDetails.screen';
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,112 @@
import React, { useEffect } from 'react';
import {
View,
Text,
ScrollView,
Linking,
Platform,
} from 'react-native';
import { RouteProp, useRoute } from '@react-navigation/native';
import { useTheme } from '@theme';
import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeadDetails } from './thunk';
import { getStyles } from './leadDetails.styles';
import { LeadsStackParamList } from '../../navigation/leadsStack';
import { Loader } from '@components';
import { LeadDetailHeader } from '../../components/leadDetailHeader/leadDetailHeader';
type LeadDetailsRouteProp = RouteProp<LeadsStackParamList, 'leadDetails'>;
export const LeadDetailsScreen = () => {
const route = useRoute<LeadDetailsRouteProp>();
const dispatch = useAppDispatch();
const { theme: colors } = useTheme();
const styles = getStyles(colors);
// Get lead ID from route params
const leadId = route.params?.lead?.id;
// Get data from Redux store
const { item: lead, loading, error } = useAppSelector(
(state: RootState) => state.leadDetails,
);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
// Fetch lead details on mount
useEffect(() => {
if (leadId && user_data?.staffid) {
dispatch(getLeadDetails({ leadId, staffid: user_data.staffid }));
}
}, [dispatch, leadId, user_data]);
// Show loader while loading
if (loading) {
return <Loader message="Loading lead details..." />;
}
// Show error if any
if (error) {
return (
<View style={styles.container}>
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 }}>
<Text style={{ color: '#EF4444', fontSize: 14, textAlign: 'center' }}>
{error}
</Text>
</View>
</View>
);
}
if (!lead) {
return (
<View style={styles.container}>
<Text style={styles.infoValue}>No lead data available</Text>
</View>
);
}
// Handle email press
const handleEmailPress = () => {
if (lead.email) {
Linking.openURL(`mailto:${lead.email}`).catch(err =>
console.error('Error opening email:', err),
);
}
};
// Handle phone press
const handlePhonePress = () => {
if (lead.phonenumber) {
const phoneUrl =
Platform.OS === 'ios'
? `telprompt:${lead.phonenumber}`
: `tel:${lead.phonenumber}`;
Linking.openURL(phoneUrl).catch(err =>
console.error('Error opening phone:', err),
);
}
};
// Handle status press (for future status change functionality)
const handleStatusPress = () => {
// TODO: Show status list modal/picker
console.log('Status pressed - show status list');
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
{/* Header with Avatar */}
<LeadDetailHeader
name={lead.name}
company={lead.company}
email={lead.email}
phonenumber={lead.phonenumber}
statusName={lead.status_name || lead.status}
statusColor={lead.color}
onStatusPress={handleStatusPress}
onEmailPress={handleEmailPress}
onPhonePress={handlePhonePress}
/>
</ScrollView>
);
};

View File

@ -0,0 +1,119 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
scrollContent: {
padding: 16,
},
header: {
alignItems: 'center',
paddingVertical: 24,
borderBottomWidth: 1,
borderBottomColor: colors.border,
marginBottom: 16,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 12,
},
avatarText: {
fontSize: 32,
fontWeight: '700',
color: '#FFFFFF',
},
name: {
fontSize: 24,
fontWeight: '700',
color: colors.text,
marginBottom: 4,
},
company: {
fontSize: 16,
color: colors.textSecondary,
marginBottom: 8,
},
statusBadge: {
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 16,
},
statusText: {
fontSize: 13,
fontWeight: '600',
textTransform: 'capitalize',
},
section: {
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
marginBottom: 12,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
infoIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.surface,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
infoContent: {
flex: 1,
},
infoLabel: {
fontSize: 12,
color: colors.textMuted,
marginBottom: 2,
},
infoValue: {
fontSize: 14,
color: colors.text,
fontWeight: '500',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.icon,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
marginBottom: 12,
},
actionButtonText: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
marginLeft: 8,
},
noteText: {
fontSize: 14,
color: colors.textSecondary,
lineHeight: 20,
},
});

View File

@ -0,0 +1,37 @@
import { createReducer } from '@reduxjs/toolkit';
import { getLeadDetails } from './thunk';
import { LeadDetailsItem } from '@interfaces';
export interface LeadDetailsState {
item: LeadDetailsItem | null;
loading: boolean;
error: string | null;
}
const initialState: LeadDetailsState = {
item: null,
loading: false,
error: null,
};
export const reducers = createReducer(initialState, builder => {
builder
.addCase(getLeadDetails.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getLeadDetails.fulfilled, (acc, action) => {
acc.loading = false;
acc.item = action.payload;
acc.error = null;
})
.addCase(getLeadDetails.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to load lead details';
});
});
export default reducers;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLeadDetailsApi } from '@api';
import { LeadDetailsItem } from '@interfaces';
export const getLeadDetails = createAsyncThunk<
LeadDetailsItem,
{ leadId: string; staffid: string }
>('leadDetails/getLeadDetails', async (payload, { rejectWithValue }) => {
try {
return await getLeadDetailsApi(payload.leadId, payload.staffid);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load lead details');
}
});

View File

@ -1,79 +1,96 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Text, Text,
View, View,
FlatList, FlatList,
TextInput,
TouchableOpacity, TouchableOpacity,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './leads.styles'; import { getStyles } from './leads.styles';
import { MOCK_LEADS } from '../../mock-data/leads'; import { useTheme } from '@theme';
import { useTheme } from '../../theme'; import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getLeads } from './thunk';
import { LeadItem } from '@interfaces';
import { LeadItemCard, SearchInput, Loader } from '@components';
import { LeadsStackParamList } from '../../navigation/leadsStack';
import { route } from '@utils';
type LeadsScreenNavigationProp = NativeStackNavigationProp<
LeadsStackParamList,
'leads'
>;
export const LeadsScreen = () => { export const LeadsScreen = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation<LeadsScreenNavigationProp>();
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const [searchTerm, setSearchTerm] = useState('');
const { items, loading, error } = useAppSelector(
(state: RootState) => state.leads,
);
const user_data = useAppSelector((state: RootState) => state.auth.user_data);
useEffect(() => {
if (user_data?.staffid) {
dispatch(getLeads({ leadId: null, staffid: user_data.staffid }));
}
}, [dispatch, user_data]);
const filteredLeads = searchTerm
? items.filter(
lead =>
lead.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.company.toLowerCase().includes(searchTerm.toLowerCase()) ||
lead.email.toLowerCase().includes(searchTerm.toLowerCase()),
)
: items;
const handleLeadPress = (lead: LeadItem) => {
// Navigate to lead details screen
navigation.navigate(route.leadDetails, { lead });
};
const renderLead = ({ item }: { item: LeadItem }) => (
<LeadItemCard item={item} onPress={() => handleLeadPress(item)} />
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.searchWrapper}> <SearchInput
<Icon value={searchTerm}
name="search-outline" onChangeText={setSearchTerm}
size={20} placeholder="Search leads, companies, emails..."
color={colors.textSecondary}
style={styles.searchIcon}
/>
<TextInput
style={styles.searchInput} style={styles.searchInput}
placeholder="Search leads, companies..."
placeholderTextColor={colors.textMuted}
/> />
</View>
<TouchableOpacity style={styles.filterButton}> <TouchableOpacity style={styles.filterButton}>
<Icon name="filter-outline" size={20} color={colors.text} /> <Icon name="filter-outline" size={20} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{loading ? (
<Loader message="Loading leads..." />
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : (
<FlatList <FlatList
data={MOCK_LEADS} data={filteredLeads}
keyExtractor={item => item.id} keyExtractor={item => item.id}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
renderItem={({ item }) => ( renderItem={renderLead}
<View style={styles.leadCard}> ListEmptyComponent={() => (
<View style={styles.cardHeader}> <View style={styles.emptyContainer}>
<View> <Text style={styles.emptyText}>No leads available.</Text>
<Text style={styles.leadName}>{item.name}</Text>
<Text style={styles.leadCompany}>{item.company}</Text>
</View>
<View
style={[
styles.statusBadge,
{ backgroundColor: `${item.color}15` },
]}
>
<Text style={[styles.statusText, { color: item.color }]}>
{item.status}
</Text>
</View>
</View>
<View style={styles.cardFooter}>
<View style={styles.infoRow}>
<Icon
name="mail-outline"
size={16}
color={colors.textSecondary}
/>
<Text style={styles.infoValue}>{item.email}</Text>
</View>
<TouchableOpacity style={styles.actionButton}>
<Icon name="chevron-forward" size={18} color={colors.icon} />
</TouchableOpacity>
</View>
</View> </View>
)} )}
/> />
)}
</View> </View>
); );
}; };

View File

@ -1,7 +1,8 @@
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme'; import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) => StyleSheet.create({ export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background, backgroundColor: colors.background,
@ -10,34 +11,18 @@ export const getStyles = (colors: ThemeColors) => StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
}, gap: 10,
searchWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
paddingHorizontal: 12,
marginRight: 10,
},
searchIcon: {
marginRight: 8,
}, },
searchInput: { searchInput: {
flex: 1, flex: 1,
height: 44,
color: colors.text,
fontSize: 14,
}, },
filterButton: { filterButton: {
width: 44, width: 48,
height: 44, height: 48,
backgroundColor: colors.surface, backgroundColor: colors.surface,
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
borderRadius: 10, borderRadius: 12,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
@ -45,65 +30,26 @@ export const getStyles = (colors: ThemeColors) => StyleSheet.create({
padding: 16, padding: 16,
paddingTop: 0, paddingTop: 0,
}, },
leadCard: { errorContainer: {
backgroundColor: colors.card, flex: 1,
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 12,
},
leadName: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
},
leadCompany: {
fontSize: 13,
color: colors.textSecondary,
marginTop: 2,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
},
statusText: {
fontSize: 11,
fontWeight: '700',
},
cardFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 12,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
},
infoValue: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 8,
},
actionButton: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: colors.border,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: 32,
},
errorText: {
color: '#EF4444',
fontSize: 14,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
emptyText: {
color: colors.textSecondary,
fontSize: 14,
textAlign: 'center',
}, },
}); });

View File

@ -0,0 +1,37 @@
import { createReducer } from '@reduxjs/toolkit';
import { getLeads } from './thunk';
import { LeadItem } from '@interfaces';
export interface LeadsState {
items: LeadItem[];
loading: boolean;
error: string | null;
}
const initialState: LeadsState = {
items: [],
loading: false,
error: null,
};
export const reducers = createReducer(initialState, builder => {
builder
.addCase(getLeads.pending, acc => {
acc.loading = true;
acc.error = null;
})
.addCase(getLeads.fulfilled, (acc, action) => {
acc.loading = false;
acc.items = action.payload;
acc.error = null;
})
.addCase(getLeads.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to load leads';
});
});
export default reducers;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getLeadsApi } from '@api';
import { LeadItem } from '@interfaces';
export const getLeads = createAsyncThunk<
LeadItem[],
{ leadId: string | null; staffid: string }
>('leads/getLeads', async (payload, { rejectWithValue }) => {
try {
return await getLeadsApi(payload.leadId, payload.staffid);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to load leads');
}
});

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Text, Text,
View, View,
@ -8,53 +8,60 @@ import {
Platform, Platform,
ScrollView, ScrollView,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { getStyles } from './login.styles'; import { getStyles } from './login.styles';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useAppDispatch, useAppSelector } from '../../store/store';
import { login } from '../../store/commonReducers/auth/thunk';
import { LoginRequest } from '@interfaces';
import { RootState } from '../../store/rootReducer';
export const LoginScreen = () => { export const LoginScreen = () => {
const dispatch = useAppDispatch();
const navigation = useNavigation<any>(); const navigation = useNavigation<any>();
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
const [tenancy, setTenancy] = useState(''); const [tenancy, setTenancy] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [localError, setLocalError] = useState('');
const [loading, setLoading] = useState(false);
const { loginLoading, loginError, loginSuccess, token, user_data } =
useAppSelector((state: RootState) => state.auth);
const handleLogin = async () => { const handleLogin = async () => {
setError(''); setLocalError('');
if (!tenancy.trim()) { if (!tenancy.trim()) {
setError('Tenancy name is required'); setLocalError('Tenancy name is required');
return; return;
} }
if (!email.trim()) { if (!email.trim()) {
setError('Email address is required'); setLocalError('Email address is required');
return; return;
} }
if (!password.trim()) { if (!password.trim()) {
setError('Password is required'); setLocalError('Password is required');
return; return;
} }
setLoading(true); const payload: LoginRequest = {
try { email,
const mockToken = 'dummy-jwt-token-for-crm'; password,
await AsyncStorage.setItem('userToken', mockToken); device_token: '',
await AsyncStorage.setItem('tenancyName', tenancy); };
setLoading(false);
await dispatch(login(payload));
};
useEffect(() => {
if (loginSuccess && token && user_data) {
navigation.reset({ navigation.reset({
index: 0, index: 0,
routes: [{ name: 'DrawerStack' }], // root-level stack name stays as-is routes: [{ name: 'DrawerStack' }],
}); });
} catch {
setLoading(false);
setError('Failed to sign in. Please try again.');
} }
}; }, [loginSuccess, token, user_data, navigation]);
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@ -76,10 +83,10 @@ export const LoginScreen = () => {
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
{error ? ( {localError || loginError ? (
<View style={styles.errorBanner}> <View style={styles.errorBanner}>
<Icon name="alert-circle" size={20} color="#EF4444" /> <Icon name="alert-circle" size={20} color="#EF4444" />
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{localError || loginError}</Text>
</View> </View>
) : null} ) : null}
@ -156,10 +163,10 @@ export const LoginScreen = () => {
style={styles.button} style={styles.button}
activeOpacity={0.8} activeOpacity={0.8}
onPress={handleLogin} onPress={handleLogin}
disabled={loading} disabled={loginLoading}
> >
<Text style={styles.buttonText}> <Text style={styles.buttonText}>
{loading ? 'Signing In...' : 'Sign In'} {loginLoading ? 'Signing In...' : 'Sign In'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

57
app/interfaces/auth.ts Normal file
View File

@ -0,0 +1,57 @@
export interface LoginRequest {
email: string;
password: string;
device_token?: string;
}
export interface Permission {
staff_id: string;
feature: string;
capability: string;
}
export interface UserData {
staffid: string;
email: string;
firstname: string;
lastname: string;
facebook: string;
linkedin: string;
phonenumber: string;
skype: string;
password: string;
datecreated: string;
profile_image: string;
last_ip: string;
last_login: string;
last_activity: string | null;
last_password_change: string | null;
new_pass_key: string | null;
new_pass_key_requested: string | null;
admin: string;
role: string;
active: string;
default_language: string;
direction: string;
media_path_slug: string;
is_not_staff: string;
hourly_rate: string;
two_factor_auth_enabled: string;
two_factor_auth_code: string | null;
two_factor_auth_code_requested: string | null;
email_signature: string;
google_auth_secret: string | null;
fcm_token: string;
device_os: string;
full_name: string;
total_unread_notifications: string;
total_unfinished_todos: string;
permissions: Permission[];
}
export interface LoginResponse {
status: boolean;
is_twofactor: boolean;
user_data: UserData;
token: string;
}

View File

@ -1 +1,4 @@
export * from './drawerItem'; export * from './drawerItem';
export * from './auth';
export * from './leads';
export * from './leadDetails';

View File

@ -0,0 +1,56 @@
export interface LeadDetailsCustomField {
label: string;
value: string;
}
export interface LeadDetailsItem {
id: string;
hash: string | null;
name: string;
title: string;
company: string;
description: string;
country: string;
zip: string;
city: string;
state: string;
address: string;
assigned: string;
dateadded: string;
from_form_id: string;
status: string;
source: string;
source_type: string;
campaign_id: string | null;
lastcontact: string;
dateassigned: string | null;
last_status_change: string | null;
addedfrom: string;
email: string;
website: string;
leadorder: string;
phonenumber: string;
date_converted: string | null;
lost: string;
junk: string;
last_lead_status: string;
is_imported_from_email_integration: string;
email_integration_uid: string | null;
is_public: string;
default_language: string;
client_id: string;
lead_value: string | null;
followup_date: string | null;
remarks: string | null;
is_followup: string;
assign_type: string;
budget: string | null;
statusorder: string;
color: string;
isdefault: string;
status_name: string;
source_name: string;
attachments: any[];
public_url: string;
customfields: LeadDetailsCustomField[];
}

48
app/interfaces/leads.ts Normal file
View File

@ -0,0 +1,48 @@
export interface LeadItem {
id: string;
hash: string | null;
name: string;
title: string;
company: string;
description: string;
country: string;
zip: string;
city: string;
state: string;
address: string;
assigned: string;
dateadded: string;
from_form_id: string;
status: string;
source: string;
source_type: string;
campaign_id: string | null;
lastcontact: string;
dateassigned: string | null;
last_status_change: string | null;
addedfrom: string;
email: string;
website: string;
leadorder: string;
phonenumber: string;
date_converted: string | null;
lost: string;
junk: string;
last_lead_status: string;
is_imported_from_email_integration: string;
email_integration_uid: string | null;
is_public: string;
default_language: string;
client_id: string;
lead_value: string | null;
followup_date: string | null;
remarks: string | null;
is_followup: string;
assign_type: string;
budget: string | null;
statusorder: string;
color: string;
isdefault: string;
status_name: string;
source_name: string;
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { LeadsScreen, LeadDetailsScreen } from '@features';
import { route, RouteParams } from '@utils';
import { useTheme } from '@theme';
export type LeadsStackParamList = Pick<RouteParams, 'leads' | 'leadDetails'>;
const Stack = createNativeStackNavigator<LeadsStackParamList>();
export const LeadsStack = () => {
const { theme: colors } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.header,
},
headerTitleStyle: {
fontSize: 16,
fontWeight: '700',
color: colors.text,
},
headerTitleAlign: 'center',
headerTintColor: colors.text,
}}>
<Stack.Screen
name={route.leads}
component={LeadsScreen}
options={{ headerTitle: 'Leads' }}
/>
<Stack.Screen
name={route.leadDetails}
component={LeadDetailsScreen}
options={{ headerTitle: 'Lead Details' }}
/>
</Stack.Navigator>
);
};

View File

@ -3,6 +3,8 @@ import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { AuthStack } from './authStack'; import { AuthStack } from './authStack';
import { DrawerStack } from './drawerStack'; import { DrawerStack } from './drawerStack';
import { useAppSelector } from '../store/store';
import { RootState } from '../store/rootReducer';
export type RootStackParamList = { export type RootStackParamList = {
AuthStack: undefined; AuthStack: undefined;
@ -12,12 +14,19 @@ export type RootStackParamList = {
const RootStack = createNativeStackNavigator<RootStackParamList>(); const RootStack = createNativeStackNavigator<RootStackParamList>();
export const RootNavigator = () => { export const RootNavigator = () => {
const { token } = useAppSelector((state: RootState) => state.auth);
console.log('RootNavigator token:', token); // Debugging line to check the token value
const initialRouteName = token ? 'DrawerStack' : 'AuthStack';
return ( return (
<NavigationContainer> <NavigationContainer>
<RootStack.Navigator screenOptions={{ headerShown: false }} initialRouteName="AuthStack"> <RootStack.Navigator
screenOptions={{ headerShown: false }}
initialRouteName={initialRouteName}
>
<RootStack.Screen name="AuthStack" component={AuthStack} /> <RootStack.Screen name="AuthStack" component={AuthStack} />
<RootStack.Screen name="DrawerStack" component={DrawerStack} /> <RootStack.Screen name="DrawerStack" component={DrawerStack} />
</RootStack.Navigator> </RootStack.Navigator>
</NavigationContainer> </NavigationContainer>
); );
} };

View File

@ -6,11 +6,11 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { route, RouteParams } from '@utils'; import { route, RouteParams } from '@utils';
import { import {
DashboardScreen, DashboardScreen,
LeadsScreen,
AddLeadScreen, AddLeadScreen,
CustomersScreen, CustomersScreen,
ProfileScreen, ProfileScreen,
} from '@features'; } from '@features';
import { LeadsStack } from './leadsStack';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { getStyles } from './tabStack.styles'; import { getStyles } from './tabStack.styles';
@ -76,7 +76,11 @@ export const TabStack = () => {
), ),
})} })}
/> />
<Tab.Screen name={route.leads} component={LeadsScreen} /> <Tab.Screen
name={route.leads}
component={LeadsStack}
options={{ headerShown: false }}
/>
<Tab.Screen name={route.addLead} component={AddLeadScreen} /> <Tab.Screen name={route.addLead} component={AddLeadScreen} />
<Tab.Screen name={route.customers} component={CustomersScreen} /> <Tab.Screen name={route.customers} component={CustomersScreen} />
<Tab.Screen name={route.profile} component={ProfileScreen} /> <Tab.Screen name={route.profile} component={ProfileScreen} />

View File

@ -0,0 +1,2 @@
export * from './thunk';
export * from './reducers';

View File

@ -0,0 +1,52 @@
import { createReducer } from '@reduxjs/toolkit';
import { login } from './thunk';
import { UserData } from '@interfaces';
export interface LoginState {
loginSuccess: boolean;
loginLoading: boolean;
status: boolean;
is_twofactor: boolean;
user_data: UserData | null;
token: string | null;
loginError: string | null;
loginMessage: string | null;
}
const initialState: LoginState = {
loginSuccess: false,
loginLoading: false,
status: false,
is_twofactor: false,
user_data: null,
token: null,
loginError: null,
loginMessage: null,
};
export const reducers = createReducer(initialState, builder => {
builder
.addCase(login.pending, acc => {
acc.loginLoading = true;
acc.loginError = null;
acc.loginMessage = null;
})
.addCase(login.fulfilled, (acc, action) => {
acc.loginLoading = false;
acc.status = action.payload.status;
acc.is_twofactor = action.payload.is_twofactor;
acc.user_data = action.payload.user_data;
acc.token = action.payload.token;
acc.loginSuccess = true;
acc.loginError = null;
acc.loginMessage = null;
})
.addCase(login.rejected, (acc, { error, payload }) => {
acc.loginLoading = false;
acc.loginError = (payload as string) ?? error.message ?? 'Login failed';
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
acc.loginSuccess = false;
});
});
export default reducers;

View File

@ -0,0 +1,14 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { LoginRequest, LoginResponse } from '@interfaces';
import { loginApi } from '@api';
export const login = createAsyncThunk<LoginResponse, LoginRequest>(
'auth/login',
async (payload, { rejectWithValue }) => {
try {
return await loginApi(payload);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to login');
}
},
);

View File

@ -0,0 +1 @@
export * from './auth';

4
app/store/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './commonReducers';
export * from './migration';
export * from './rootReducer';
export * from './store';

10
app/store/migration.ts Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
// Here we avoid typing things as the more migrations we have the more complex types we will have to create
export const migrations = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1: (state: any) => state,
};
const migrationVersions: number[] = Object.keys(migrations).map(k => Number(k));
export const persistVersion = migrationVersions[migrationVersions.length - 1];

13
app/store/rootReducer.ts Normal file
View File

@ -0,0 +1,13 @@
import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './commonReducers/auth/reducers';
import leadsReducer from '../features/leads/reducers';
import leadDetailsReducer from '../features/leadDetails/reducers';
const rootReducer = combineReducers({
auth: authReducer,
leads: leadsReducer,
leadDetails: leadDetailsReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export default rootReducer;

53
app/store/store.ts Normal file
View File

@ -0,0 +1,53 @@
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
createMigrate,
FLUSH,
PAUSE,
PERSIST,
persistReducer,
persistStore,
PURGE,
REGISTER,
REHYDRATE,
} from 'redux-persist';
import { ThunkAction } from 'redux-thunk';
import { Action, configureStore } from '@reduxjs/toolkit';
import { migrations, persistVersion } from './migration';
import rootReducer, { RootState } from './rootReducer';
import { injectStore } from '@utils';
const persistConfig = {
key: 'root',
version: persistVersion,
storage: AsyncStorage,
blacklist: [],
migrate: createMigrate(migrations, { debug: __DEV__ }),
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
devTools: __DEV__,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
injectStore(store);
export const persistor = persistStore(store);
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = (): AppDispatch => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export type AppThunk = ThunkAction<void, RootState, unknown, Action<string>>;
export default store;

View File

@ -0,0 +1,84 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
import config from 'react-native-config';
import {Store} from '@reduxjs/toolkit';
const axiosInstance: AxiosInstance = axios.create({
baseURL: config.BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
authtoken: config.AUTH_TOKEN,
},
});
axiosInstance.interceptors.request.use(
request => {
if (request.data instanceof FormData) {
delete request.headers?.['Content-Type'];
}
return request;
},
(error: AxiosError) => Promise.reject(error),
);
axiosInstance.interceptors.response.use(
response => response,
(error: AxiosError) => {
if (error.response) {
const status = error.response.status;
const data = error.response.data;
console.error('[API] response error', status, data);
} else {
console.error('[API] network error', error.message);
}
return Promise.reject(error);
},
);
let storeInstance: Store | null = null;
export const injectStore = (_store: Store | null): void => {
storeInstance = _store;
};
export const api = {
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await axiosInstance.get<T>(url, config);
return response.data;
},
async post<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.post<T>(url, body, config);
return response.data;
},
async put<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.put<T>(url, body, config);
return response.data;
},
async patch<T>(
url: string,
body?: unknown,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await axiosInstance.patch<T>(url, body, config);
return response.data;
},
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await axiosInstance.delete<T>(url, config);
return response.data;
},
};
export { axiosInstance };

70
app/utils/helper.ts Normal file
View File

@ -0,0 +1,70 @@
// import { Linking, Platform } from 'react-native';
// /**
// * Opens the default email client with the provided email address
// * @param email - The email address to send to
// */
// export const handleEmailPress = (email?: string | null) => {
// if (!email) {
// console.warn('No email provided');
// return;
// }
// Linking.openURL(`mailto:${email}`).catch(err =>
// console.error('Error opening email:', err),
// );
// };
// /**
// * Opens the phone dialer with the provided phone number
// * @param phoneNumber - The phone number to call
// */
// export const handlePhonePress = (phoneNumber?: string | null) => {
// if (!phoneNumber) {
// console.warn('No phone number provided');
// return;
// }
// const phoneUrl =
// Platform.OS === 'ios'
// ? `telprompt:${phoneNumber}`
// : `tel:${phoneNumber}`;
// Linking.openURL(phoneUrl).catch(err =>
// console.error('Error opening phone:', err),
// );
// };
// /**
// * Opens the browser with the provided website URL
// * @param website - The website URL to open
// */
// export const handleWebsitePress = (website?: string | null) => {
// if (!website) {
// console.warn('No website provided');
// return;
// }
// const url = website.startsWith('http') ? website : `https://${website}`;
// Linking.openURL(url).catch(err =>
// console.error('Error opening website:', err),
// );
// };
// /**
// * Generates initials from a full name
// * @param name - The full name to extract initials from
// * @returns The initials (e.g., "John Doe" returns "JD")
// */
// export const getInitials = (name?: string | null): string => {
// if (!name) return '?';
// const nameParts = name.trim().split(' ');
// if (nameParts.length >= 2) {
// return (nameParts[0][0] + nameParts[nameParts.length - 1][0]).toUpperCase();
// }
// return name.substring(0, 2).toUpperCase();
// };

View File

@ -1,2 +1,4 @@
export * from './route'; export * from './route';
export * from './assets'; export * from './assets';
export * from './api';
// export * from './helper';

View File

@ -16,6 +16,7 @@ export const route = {
// Sub screens // Sub screens
addLead: 'addLead', addLead: 'addLead',
leadDetails: 'leadDetails',
profile: 'profile', profile: 'profile',
} as const; } as const;
@ -34,6 +35,7 @@ export type RouteParams = {
tasks: undefined; tasks: undefined;
tickets: undefined; tickets: undefined;
addLead: undefined; addLead: undefined;
leadDetails: { lead: any };
profile: undefined; profile: undefined;
}; };

207
tsconfig.effective.json Normal file
View File

@ -0,0 +1,207 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"types": [
"jest"
],
"lib": [
"es2019",
"es2020.bigint",
"es2020.date",
"es2020.number",
"es2020.promise",
"es2020.string",
"es2020.symbol.wellknown",
"es2021.promise",
"es2021.string",
"es2021.weakref",
"es2022.array",
"es2022.object",
"es2022.string"
],
"allowJs": true,
"jsx": "react-native",
"noEmit": true,
"isolatedModules": true,
"strict": true,
"moduleResolution": "bundler",
"customConditions": [
"react-native"
],
"allowImportingTsExtensions": true,
"allowArbitraryExtensions": true,
"resolveJsonModule": true,
"resolvePackageJsonImports": false,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": false,
"baseUrl": "./",
"paths": {
"@components": [
"./app/components"
],
"@features": [
"./app/features"
],
"@features/*": [
"./app/features/*"
],
"@theme": [
"./app/theme"
],
"@theme/*": [
"./app/theme/*"
],
"@api": [
"./app/api"
],
"@api/*": [
"./app/api/*"
],
"@store": [
"./app/store"
],
"@store/*": [
"./app/store/*"
],
"@interfaces": [
"./app/interfaces"
],
"@interfaces/*": [
"./app/interfaces/*"
],
"@navigation": [
"./app/navigation"
],
"@navigation/*": [
"./app/navigation/*"
],
"@hooks/*": [
"./app/hooks/*"
],
"@utils": [
"./app/utils"
],
"@utils/*": [
"./app/utils/*"
],
"@services": [
"./app/services"
],
"@services/*": [
"./app/services/*"
],
"@mock-data": [
"./app/mock-data/index.ts"
],
"@mock-data/": [
"./app/mock-data"
],
"@mock-data/*": [
"./app/mock-data/*"
]
},
"resolvePackageJsonExports": true,
"preserveConstEnums": true,
"useDefineForClassFields": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"strictBuiltinIteratorReturn": true,
"alwaysStrict": true,
"useUnknownInCatchVariables": true
},
"files": [
"./app/api/authApi.ts",
"./app/api/index.ts",
"./app/features/index.ts",
"./app/features/addLead/addLead.styles.ts",
"./app/features/addLead/index.ts",
"./app/features/customers/customers.styles.ts",
"./app/features/customers/index.ts",
"./app/features/dashboard/dashboard.styles.ts",
"./app/features/dashboard/index.ts",
"./app/features/estimates/estimates.styles.ts",
"./app/features/estimates/index.ts",
"./app/features/invoices/index.ts",
"./app/features/invoices/invoices.styles.ts",
"./app/features/leads/index.ts",
"./app/features/leads/leads.styles.ts",
"./app/features/login/index.ts",
"./app/features/login/login.styles.ts",
"./app/features/profile/index.ts",
"./app/features/profile/profile.styles.ts",
"./app/features/projects/index.ts",
"./app/features/projects/projects.styles.ts",
"./app/features/proposals/index.ts",
"./app/features/proposals/proposals.styles.ts",
"./app/features/tasks/index.ts",
"./app/features/tasks/tasks.styles.ts",
"./app/features/tickets/index.ts",
"./app/features/tickets/tickets.styles.ts",
"./app/interfaces/auth.ts",
"./app/interfaces/drawerItem.ts",
"./app/interfaces/index.ts",
"./app/mock-data/customDrawer.ts",
"./app/mock-data/customers.ts",
"./app/mock-data/dashboard.ts",
"./app/mock-data/estimates.ts",
"./app/mock-data/index.ts",
"./app/mock-data/invoices.ts",
"./app/mock-data/leads.ts",
"./app/mock-data/projects.ts",
"./app/mock-data/proposal.ts",
"./app/mock-data/tasks.ts",
"./app/mock-data/tickets.ts",
"./app/navigation/customDrawerContent.style.ts",
"./app/navigation/tabStack.styles.ts",
"./app/store/index.ts",
"./app/store/migration.ts",
"./app/store/rootReducer.ts",
"./app/store/store.ts",
"./app/store/commonStore/auth/index.ts",
"./app/store/commonStore/auth/reducers.ts",
"./app/store/commonStore/auth/thunk.ts",
"./app/theme/colors.ts",
"./app/theme/index.ts",
"./app/types/react-native-vector-icons.d.ts",
"./app/utils/api.ts",
"./app/utils/index.ts",
"./app/utils/route.ts",
"./app/utils/assets/index.ts",
"./__tests__/App.test.tsx",
"./app/App.tsx",
"./app/features/addLead/addLead.screen.tsx",
"./app/features/customers/customers.screen.tsx",
"./app/features/dashboard/dashboard.screen.tsx",
"./app/features/estimates/estimates.screen.tsx",
"./app/features/invoices/invoices.screen.tsx",
"./app/features/leads/leads.screen.tsx",
"./app/features/login/login.screen.tsx",
"./app/features/profile/profile.screen.tsx",
"./app/features/projects/projects.screen.tsx",
"./app/features/proposals/proposals.screen.tsx",
"./app/features/tasks/tasks.screen.tsx",
"./app/features/tickets/tickets.screen.tsx",
"./app/navigation/authStack.tsx",
"./app/navigation/customDrawerContent.tsx",
"./app/navigation/drawerStack.tsx",
"./app/navigation/rootNavigator.tsx",
"./app/navigation/tabStack.tsx",
"./app/theme/ThemeContext.tsx"
],
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.d.ts"
],
"exclude": [
"**/node_modules",
"**/Pods"
]
}