feat(app): profile, notification and design update

This commit is contained in:
uttam05111990 2026-08-03 19:48:33 +05:30
parent 461feba0ba
commit 18077e260c
40 changed files with 1058 additions and 235 deletions

View File

@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
@ -11,6 +15,15 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}" android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true"> android:supportsRtl="true">
<!-- Default icon/colour for FCM-displayed notifications (background / quit state) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_color"
tools:replace="android:resource" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,3 +1,5 @@
<resources> <resources>
<color name="bootsplash_background">#ffffff</color> <color name="bootsplash_background">#ffffff</color>
<!-- Brand primary colour used for notification accent -->
<color name="notification_color">#07BAD2</color>
</resources> </resources>

View File

@ -10,6 +10,7 @@ import { NotificationService } from '@services';
import BootSplash from 'react-native-bootsplash'; import BootSplash from 'react-native-bootsplash';
import messaging from '@react-native-firebase/messaging'; import messaging from '@react-native-firebase/messaging';
import { getStaffNotifications } from './features/notification/thunk'; import { getStaffNotifications } from './features/notification/thunk';
import { navigateToLeadDetails } from '@utils';
const ThemedStatusBar = () => { const ThemedStatusBar = () => {
const { theme: colors, isDark } = useTheme(); const { theme: colors, isDark } = useTheme();
@ -84,9 +85,12 @@ const App = () => {
const initApp = async () => { const initApp = async () => {
try { try {
const cleanupFn = await NotificationService.initialize({ const cleanupFn = await NotificationService.initialize({
onNotificationOpen: data => { onNotificationOpen: data => {
// TODO: Use data (e.g. { screen, id }) to deep-navigate once
console.log('[App] Notification opened with data:', data); console.log('[App] Notification opened with data:', data);
// Deep-link: if the notification carries a lead_id, open LeadDetails
if (data?.type === 'lead' && data?.lead_id) {
navigateToLeadDetails(data.lead_id);
}
}, },
onTokenRefreshed: token => { onTokenRefreshed: token => {
// TODO: Send the refreshed token to your backend so the server // TODO: Send the refreshed token to your backend so the server

View File

@ -5,8 +5,6 @@ export * from './listApi';
export * from './customersApi'; export * from './customersApi';
export * from './fcmTokenApi'; export * from './fcmTokenApi';
export * from './notificationApi'; export * from './notificationApi';
export * from './profileApi';
export * from './dashboardLeadCountApi'; export * from './dashboardLeadCountApi';
export * from './dashboardProjectActivityApi'; export * from './dashboardProjectActivityApi';

30
app/api/profileApi.ts Normal file
View File

@ -0,0 +1,30 @@
import { UpdateProfilePayload, UpdateProfileResponse, UserData } from '@interfaces';
import { api } from '@utils';
export const updateProfileApi = async (
payload: UpdateProfilePayload,
): Promise<UpdateProfileResponse> => {
const formData = new FormData();
formData.append('id', String(payload.id));
formData.append('email', payload.email);
if (payload.firstname !== undefined) formData.append('firstname', payload.firstname);
if (payload.lastname !== undefined) formData.append('lastname', payload.lastname);
if (payload.phone !== undefined) formData.append('phone', payload.phone);
if (payload.email_signature !== undefined) formData.append('email_signature', payload.email_signature);
if (payload.password) formData.append('password', payload.password);
if (payload.passwordr) formData.append('passwordr', payload.passwordr);
if (payload.profile_image) {
formData.append('profile_image', payload.profile_image as any);
}
return await api.post<UpdateProfileResponse>('/api/staffupdate', formData);
};
export const getStaffDetailsApi = async (
id: string | number,
): Promise<UserData> => {
return await api.get<UserData>(`/api/staffs/${id}`);
};

View File

@ -19,21 +19,20 @@ export const getStyles = (colors: ThemeColors) =>
topRow: { topRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 12,
}, },
avatarCircle: { avatarCircle: {
width: 44, width: 50,
height: 44, height: 50,
borderRadius: 22, borderRadius: 25,
backgroundColor: colors.icon, backgroundColor: colors.icon,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 10, marginRight: 12,
flexShrink: 0, flexShrink: 0,
}, },
avatarInitial: { avatarInitial: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 16, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
info: { info: {
@ -51,11 +50,6 @@ export const getStyles = (colors: ThemeColors) =>
color: colors.textSecondary, color: colors.textSecondary,
marginBottom: 2, marginBottom: 2,
}, },
contact: {
fontSize: 11,
color: colors.textMuted,
lineHeight: 16,
},
dateText: { dateText: {
fontSize: 11, fontSize: 11,
color: colors.textMuted, color: colors.textMuted,
@ -84,39 +78,4 @@ export const getStyles = (colors: ThemeColors) =>
inactiveText: { inactiveText: {
color: '#DC2626', color: '#DC2626',
}, },
actionRow: {
flexDirection: 'row',
gap: 10,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 10,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
height: 36,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
gap: 5,
borderWidth: 1,
},
primaryBtn: {
backgroundColor: colors.icon,
borderColor: colors.icon,
},
primaryBtnText: {
color: '#FFFFFF',
fontWeight: '600',
fontSize: 13,
},
secondaryBtn: {
backgroundColor: colors.background,
borderColor: colors.border,
},
secondaryBtnText: {
color: colors.textSecondary,
fontWeight: '600',
fontSize: 13,
},
}); });

View File

@ -37,12 +37,6 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
{fullname && company ? ( {fullname && company ? (
<Text style={styles.company} numberOfLines={1}>{fullname}</Text> <Text style={styles.company} numberOfLines={1}>{fullname}</Text>
) : null} ) : null}
<Text style={styles.contact} numberOfLines={1}>
<Icon name="call-outline" size={10} color={colors.textMuted} />
{' '}{phonenumber || 'N/A'}{' '}
<Icon name="mail-outline" size={10} color={colors.textMuted} />
{' '}{email || 'N/A'}
</Text>
{datecreated ? ( {datecreated ? (
<Text style={styles.dateText} numberOfLines={1}> <Text style={styles.dateText} numberOfLines={1}>
Added {formatDate(datecreated)} Added {formatDate(datecreated)}
@ -64,36 +58,6 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
</Text> </Text>
</View> </View>
</View> </View>
{/* Bottom Row: Action Buttons */}
<View style={styles.actionRow}>
{phonenumber ? (
<TouchableOpacity
style={[styles.actionBtn, styles.primaryBtn]}
onPress={onPhonePress}>
<Icon name="call" size={14} color="#FFFFFF" />
<Text style={styles.primaryBtnText}>Call</Text>
</TouchableOpacity>
) : null}
{email ? (
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onEmailPress}>
<Icon name="mail" size={14} color={colors.textSecondary} />
<Text style={styles.secondaryBtnText}>Email</Text>
</TouchableOpacity>
) : null}
{website ? (
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onWebsitePress}>
<Icon name="globe" size={14} color={colors.textSecondary} />
<Text style={styles.secondaryBtnText}>Website</Text>
</TouchableOpacity>
) : null}
</View>
</View> </View>
); );
}; };

View File

@ -19,20 +19,19 @@ export const getStyles = (colors: ThemeColors) =>
topRow: { topRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 12,
}, },
avatar: { avatar: {
width: 44, width: 50,
height: 44, height: 50,
borderRadius: 22, borderRadius: 25,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: 10, marginRight: 12,
flexShrink: 0, flexShrink: 0,
}, },
avatarText: { avatarText: {
color: '#FFFFFF', color: '#FFFFFF',
fontSize: 16, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
info: { info: {
@ -50,11 +49,6 @@ export const getStyles = (colors: ThemeColors) =>
color: colors.textSecondary, color: colors.textSecondary,
marginBottom: 2, marginBottom: 2,
}, },
contact: {
fontSize: 11,
color: colors.textMuted,
lineHeight: 16,
},
statusBadge: { statusBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -133,29 +127,4 @@ export const getStyles = (colors: ThemeColors) =>
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}, },
actionRow: {
flexDirection: 'row',
gap: 10,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 10,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
height: 36,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
gap: 5,
borderWidth: 1,
},
secondaryBtn: {
backgroundColor: colors.background,
borderColor: colors.border,
},
btnText: {
fontSize: 13,
fontWeight: '600',
},
}); });

View File

@ -57,12 +57,6 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
{company ? ( {company ? (
<Text style={styles.company} numberOfLines={1}>{company}</Text> <Text style={styles.company} numberOfLines={1}>{company}</Text>
) : null} ) : null}
<Text style={styles.contact} numberOfLines={1}>
<Icon name="call-outline" size={10} color={colors.textMuted} />
{' '}{phonenumber || 'N/A'}{' '}
<Icon name="mail-outline" size={10} color={colors.textMuted} />
{' '}{email || 'N/A'}
</Text>
</View> </View>
<View style={{ position: 'relative' }}> <View style={{ position: 'relative' }}>
@ -112,22 +106,6 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
)} )}
</View> </View>
</View> </View>
{/* Bottom Row: Call + Email Buttons */}
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: `${accent}12`, borderColor: `${accent}30` }]}
onPress={onPhonePress}>
<Icon name="call" size={14} color={accent} />
<Text style={[styles.btnText, { color: accent }]}>Call</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onEmailPress}>
<Icon name="mail" size={14} color={colors.textSecondary} />
<Text style={[styles.btnText, { color: colors.textSecondary }]}>Email</Text>
</TouchableOpacity>
</View>
</View> </View>
); );
}; };

View File

@ -92,12 +92,6 @@ export const CustomerDetailsScreen = () => {
fullname={customer.fullname} fullname={customer.fullname}
active={customer.active} active={customer.active}
datecreated={customer.datecreated} datecreated={customer.datecreated}
email={customer.email}
phonenumber={customer.phonenumber}
website={customer.website}
onEmailPress={() => handleEmailPress(customer.email)}
onPhonePress={() => handlePhonePress(customer.phonenumber)}
onWebsitePress={() => handleWebsitePress(customer.website)}
/> />
{/* Contact Details */} {/* Contact Details */}
@ -107,7 +101,7 @@ export const CustomerDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handleEmailPress(customer.email)} onPress={() => handleEmailPress(customer.email)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={16} color={colors.icon} /> <Icon name="mail-outline" size={16} color={colors.icon} />
@ -116,6 +110,9 @@ export const CustomerDetailsScreen = () => {
<Text style={styles.infoLabel}>Email</Text> <Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{customer.email}</Text> <Text style={styles.infoValue}>{customer.email}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>
@ -125,7 +122,7 @@ export const CustomerDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handlePhonePress(customer.phonenumber)} onPress={() => handlePhonePress(customer.phonenumber)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="call-outline" size={16} color={colors.icon} /> <Icon name="call-outline" size={16} color={colors.icon} />
@ -134,6 +131,9 @@ export const CustomerDetailsScreen = () => {
<Text style={styles.infoLabel}>Phone</Text> <Text style={styles.infoLabel}>Phone</Text>
<Text style={styles.infoValue}>{customer.phonenumber}</Text> <Text style={styles.infoValue}>{customer.phonenumber}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>
@ -143,7 +143,7 @@ export const CustomerDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handleWebsitePress(customer.website)} onPress={() => handleWebsitePress(customer.website)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={16} color={colors.icon} /> <Icon name="globe-outline" size={16} color={colors.icon} />
@ -152,6 +152,9 @@ export const CustomerDetailsScreen = () => {
<Text style={styles.infoLabel}>Website</Text> <Text style={styles.infoLabel}>Website</Text>
<Text style={styles.infoValue}>{customer.website}</Text> <Text style={styles.infoValue}>{customer.website}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>

View File

@ -175,6 +175,14 @@ export const getStyles = (colors: ThemeColors) =>
backgroundColor: colors.border, backgroundColor: colors.border,
marginVertical: 4, marginVertical: 4,
}, },
actionIconButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${colors.icon}10`,
justifyContent: 'center',
alignItems: 'center',
},
// Error & empty states // Error & empty states
centered: { centered: {

View File

@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Alert,
Image,
ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Icon from 'react-native-vector-icons/Ionicons';
import { useNavigation } from '@react-navigation/native';
import { useTheme } from '@theme';
import { FormInput, Loader } from '@components';
import { useAppDispatch, useAppSelector, RootState, updateUserData } from '@store';
import { getStyles } from './editProfile.styles';
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
import ImagePicker from 'react-native-image-crop-picker';
export const EditProfileScreen = () => {
const navigation = useNavigation();
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const dispatch = useAppDispatch();
const userData = useAppSelector((state: RootState) => state.auth.user_data);
const { loading, loadingDetails, successMessage, error, staffDetails } = useAppSelector(
(state: RootState) => state.editProfile,
);
// Form state
const [firstname, setFirstname] = useState(userData?.firstname || '');
const [lastname, setLastname] = useState(userData?.lastname || '');
const [email, setEmail] = useState(userData?.email || '');
const [phone, setPhone] = useState(userData?.phonenumber || '');
const [emailSignature, setEmailSignature] = useState(userData?.email_signature || '');
const [password, setPassword] = useState('');
const [passwordr, setPasswordr] = useState('');
const [profileImageUri, setProfileImageUri] = useState('');
const [profileImageMime, setProfileImageMime] = useState('image/jpeg');
const [profileImageName, setProfileImageName] = useState('profile_image.jpg');
const [isImageChanged, setIsImageChanged] = useState(false);
const resolveServerUrl = async (path?: string | null) => {
if (!path) return '';
const cleanPath = path.replace(/"/g, '').trim();
if (!cleanPath) return '';
if (
cleanPath.startsWith('http') ||
cleanPath.startsWith('file://') ||
cleanPath.startsWith('content://')
) {
return cleanPath;
}
const storedBaseUrl = await AsyncStorage.getItem('base_url');
const baseUrl = storedBaseUrl || 'https://demo-convexcrm.convexsol.co';
const cleanBase = baseUrl.replace(/\/+$/, '');
const cleanRelPath = cleanPath.replace(/^\/+/, '');
return `${cleanBase}/${cleanRelPath}`;
};
useEffect(() => {
resolveServerUrl(userData?.profile_image).then((url) => {
setProfileImageUri(url);
});
}, [userData?.profile_image]);
// UI toggle state
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Fetch staff details on mount
// useEffect(() => {
// if (userData?.staffid) {
// dispatch(getStaffDetails(userData.staffid));
// }
// }, [dispatch, userData?.staffid]);
// Update local form state when details are loaded from the server
// useEffect(() => {
// if (staffDetails) {
// setFirstname(staffDetails.firstname || '');
// setLastname(staffDetails.lastname || '');
// setEmail(staffDetails.email || '');
// setPhone(staffDetails.phonenumber || '');
// setEmailSignature(staffDetails.email_signature || '');
// resolveServerUrl(userData?.profile_image).then((url) => {
// setProfileImageUri(url);
// });
// }
// }, [staffDetails, userData?.profile_image]);
// Reset edit profile state on unmount
useEffect(() => {
return () => {
dispatch(resetEditProfileState());
};
}, [dispatch]);
const handleCameraLaunch = () => {
ImagePicker.openCamera({
width: 400,
height: 400,
cropping: true,
mediaType: 'photo',
})
.then((image) => {
setProfileImageUri(image.path);
setProfileImageMime(image.mime || 'image/jpeg');
setProfileImageName(image.path.split('/').pop() || 'profile_image.jpg');
setIsImageChanged(true);
})
.catch((err) => {
if (err.message && !err.message.includes('cancel')) {
Alert.alert('Error', err.message || 'Failed to open camera');
}
});
};
const handleGalleryLaunch = () => {
ImagePicker.openPicker({
width: 400,
height: 400,
cropping: true,
mediaType: 'photo',
})
.then((image) => {
setProfileImageUri(image.path);
setProfileImageMime(image.mime || 'image/jpeg');
setProfileImageName(image.path.split('/').pop() || 'profile_image.jpg');
setIsImageChanged(true);
})
.catch((err) => {
if (err.message && !err.message.includes('cancel')) {
Alert.alert('Error', err.message || 'Failed to open gallery');
}
});
};
const handleChooseImage = () => {
Alert.alert(
'Change Profile Picture',
'Choose an option to update your photo.',
[
{
text: 'Take Photo',
onPress: handleCameraLaunch,
},
{
text: 'Choose from Gallery',
onPress: handleGalleryLaunch,
},
// {
// text: 'Remove Photo',
// style: 'destructive',
// onPress: () => setProfileImageUri(''),
// },
{ text: 'Cancel', style: 'cancel' },
],
);
};
const handleSave = () => {
if (!email.trim()) {
Alert.alert('Validation Error', 'Email address is required.');
return;
}
if (password && password !== passwordr) {
Alert.alert('Validation Error', 'New passwords do not match.');
return;
}
const staffId = userData?.staffid || '';
// Build the profile_image payload if a local file URI is selected and changed in this session
let profileImagePayload = undefined;
if (isImageChanged && profileImageUri) {
const isLocal =
!profileImageUri.startsWith('http://') &&
!profileImageUri.startsWith('https://');
const formattedUri =
isLocal &&
!profileImageUri.startsWith('file://') &&
!profileImageUri.startsWith('content://')
? `file://${profileImageUri}`
: profileImageUri;
profileImagePayload = {
uri: formattedUri,
name: profileImageName,
type: profileImageMime,
};
}
dispatch(
updateProfile({
id: staffId,
email,
firstname: firstname || undefined,
lastname: lastname || undefined,
phone: phone || undefined,
email_signature: emailSignature || undefined,
password: password || undefined,
passwordr: passwordr || undefined,
profile_image: profileImagePayload,
}),
)
.unwrap()
.then((response) => {
// Success
const updatedImage = response.user_data?.profile_image || userData?.profile_image;
dispatch(
updateUserData({
firstname,
lastname,
email,
phonenumber: phone,
email_signature: emailSignature,
profile_image: updatedImage,
}),
);
Alert.alert('Success', 'Profile updated successfully.', [
{
text: 'OK',
onPress: () => {
dispatch(resetEditProfileState());
navigation.goBack();
},
},
]);
})
.catch((err) => {
Alert.alert('Error', err || 'Failed to update profile');
});
};
const staffId = userData?.staffid || '';
const initials = `${firstname.substring(0, 1)}${lastname.substring(0, 1)}`.toUpperCase() || 'U';
const hasProfileImage = !!profileImageUri;
if (loadingDetails && !staffDetails) {
return <Loader message="Fetching profile details..." />;
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled">
{/* ── Avatar Edit Section ── */}
<View style={styles.avatarSection}>
<TouchableOpacity
style={styles.avatarWrapper}
activeOpacity={0.8}
onPress={handleChooseImage}>
{hasProfileImage ? (
<Image source={{ uri: profileImageUri }} style={styles.avatarImage} />
) : (
<View style={styles.avatarCircle}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
)}
<View style={styles.changePhotoBadge}>
<Icon name="camera" size={14} color="#FFFFFF" />
</View>
</TouchableOpacity>
</View>
{/* ── Personal Info Card ── */}
<View style={styles.card}>
<Text style={styles.sectionTitle}>Basic Information</Text>
{/* Staff ID (Read-only) */}
<FormInput
label="Staff ID"
value={staffId}
onChangeText={() => {}}
editable={false}
selectTextOnFocus={false}
leftIcon={<Icon name="card-outline" size={16} color={colors.textMuted} />}
inputStyle={{ color: colors.textMuted }}
/>
{/* First & Last Name */}
<View style={styles.row}>
<View style={styles.col}>
<FormInput
label="First Name"
value={firstname}
onChangeText={setFirstname}
placeholder="First Name"
leftIcon={<Icon name="person-outline" size={16} color={colors.icon} />}
/>
</View>
<View style={styles.col}>
<FormInput
label="Last Name"
value={lastname}
onChangeText={setLastname}
placeholder="Last Name"
leftIcon={<Icon name="person-outline" size={16} color={colors.icon} />}
/>
</View>
</View>
{/* Email (Required) */}
<FormInput
label="Email Address"
value={email}
onChangeText={setEmail}
required
placeholder="john.doe@company.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
leftIcon={<Icon name="mail-outline" size={16} color={colors.icon} />}
/>
{/* Phone */}
<FormInput
label="Phone Number"
value={phone}
onChangeText={setPhone}
placeholder="Phone Number"
keyboardType="phone-pad"
leftIcon={<Icon name="call-outline" size={16} color={colors.icon} />}
/>
</View>
{/* ── Email Signature Card ── */}
{/* <View style={styles.card}>
<Text style={styles.sectionTitle}>Email Signature</Text>
<FormInput
label="HTML Signature Block"
value={emailSignature}
onChangeText={setEmailSignature}
placeholder="<p>Best regards,<br><b>John Doe</b></p>"
multiline
inputStyle={styles.multilineInput}
/>
</View> */}
{/* ── Security Card ── */}
<View style={styles.card}>
<Text style={styles.sectionTitle}>Change Password</Text>
<Text style={styles.infoText}>
Leave password fields empty if you don't wish to change your current password.
</Text>
<FormInput
label="New Password"
value={password}
onChangeText={setPassword}
placeholder="••••••••"
secureTextEntry={!showPassword}
autoCapitalize="none"
autoCorrect={false}
leftIcon={<Icon name="lock-closed-outline" size={16} color={colors.icon} />}
rightIcon={
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
activeOpacity={0.7}>
<Icon
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={16}
color={colors.textMuted}
/>
</TouchableOpacity>
}
/>
<FormInput
label="Repeat New Password"
value={passwordr}
onChangeText={setPasswordr}
placeholder="••••••••"
secureTextEntry={!showConfirmPassword}
autoCapitalize="none"
autoCorrect={false}
leftIcon={<Icon name="lock-closed-outline" size={16} color={colors.icon} />}
rightIcon={
<TouchableOpacity
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
activeOpacity={0.7}>
<Icon
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
size={16}
color={colors.textMuted}
/>
</TouchableOpacity>
}
/>
</View>
{/* ── Save Button ── */}
<TouchableOpacity
style={[styles.saveButton, loading && { opacity: 0.7 }]}
activeOpacity={0.8}
onPress={handleSave}
disabled={loading}>
{loading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Icon name="checkmark-circle-outline" size={20} color="#FFFFFF" />
<Text style={styles.saveButtonText}>Save Changes</Text>
</>
)}
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
};

View File

@ -0,0 +1,123 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
keyboardView: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
avatarSection: {
alignItems: 'center',
marginTop: 8,
marginBottom: 24,
},
avatarWrapper: {
position: 'relative',
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.12,
shadowRadius: 10,
elevation: 4,
},
avatarCircle: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
},
avatarImage: {
width: 90,
height: 90,
borderRadius: 45,
},
avatarInitial: {
color: '#FFFFFF',
fontSize: 32,
fontWeight: '700',
},
changePhotoBadge: {
position: 'absolute',
bottom: 0,
right: 0,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.icon,
borderWidth: 2,
borderColor: colors.card,
justifyContent: 'center',
alignItems: 'center',
},
card: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 18,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
marginBottom: 20,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.text,
marginBottom: 16,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
infoText: {
fontSize: 11,
color: colors.textMuted,
marginTop: -8,
marginBottom: 12,
lineHeight: 14,
},
row: {
flexDirection: 'row',
gap: 12,
},
col: {
flex: 1,
},
inputStyle: {
color: colors.text,
},
multilineInput: {
height: 90,
textAlignVertical: 'top',
},
saveButton: {
backgroundColor: colors.icon,
height: 48,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
gap: 8,
shadowColor: colors.icon,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
marginTop: 8,
},
saveButtonText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '700',
},
});

View File

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

View File

@ -0,0 +1,61 @@
import { createReducer } from '@reduxjs/toolkit';
import { updateProfile, getStaffDetails, resetEditProfileState } from './thunk';
import { UserData } from '@interfaces';
export interface EditProfileState {
loading: boolean;
loadingDetails: boolean;
error: string | null;
successMessage: string | null;
staffDetails: UserData | null;
}
const initialState: EditProfileState = {
loading: false,
loadingDetails: false,
error: null,
successMessage: null,
staffDetails: null,
};
export const editProfileReducer = createReducer(initialState, builder => {
builder
.addCase(resetEditProfileState, () => initialState)
// updateProfile cases
.addCase(updateProfile.pending, acc => {
acc.loading = true;
acc.error = null;
acc.successMessage = null;
})
.addCase(updateProfile.fulfilled, acc => {
acc.loading = false;
acc.successMessage = 'Profile updated successfully.';
acc.error = null;
})
.addCase(updateProfile.rejected, (acc, action) => {
acc.loading = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to update profile';
})
// getStaffDetails cases
.addCase(getStaffDetails.pending, acc => {
acc.loadingDetails = true;
acc.error = null;
})
.addCase(getStaffDetails.fulfilled, (acc, action) => {
acc.loadingDetails = false;
acc.staffDetails = action.payload;
acc.error = null;
})
.addCase(getStaffDetails.rejected, (acc, action) => {
acc.loadingDetails = false;
acc.error =
(action.payload as string) ??
action.error.message ??
'Failed to load staff details';
});
});
export default editProfileReducer;

View File

@ -0,0 +1,32 @@
import { createAction, createAsyncThunk } from '@reduxjs/toolkit';
import { updateProfileApi, getStaffDetailsApi } from '@api';
import { UpdateProfilePayload, UpdateProfileResponse, UserData } from '@interfaces';
export const resetEditProfileState = createAction('editProfile/resetState');
export const updateProfile = createAsyncThunk<UpdateProfileResponse, UpdateProfilePayload>(
'editProfile/updateProfile',
async (payload, { rejectWithValue }) => {
console.log('payload', payload)
try {
return await updateProfileApi(payload);
} catch (error: any) {
const serverMessage =
error?.response?.data?.message ||
error.message ||
'Failed to update profile';
return rejectWithValue(serverMessage);
}
},
);
export const getStaffDetails = createAsyncThunk<UserData, string | number>(
'editProfile/getStaffDetails',
async (id, { rejectWithValue }) => {
try {
return await getStaffDetailsApi(id);
} catch (error: any) {
return rejectWithValue(error.message || 'Failed to fetch staff details');
}
},
);

View File

@ -17,5 +17,6 @@ export * from './tickets';
export * from './customerDetails'; export * from './customerDetails';
export * from './addCustomer'; export * from './addCustomer';
export * from './notification'; export * from './notification';
export * from './editProfile';

View File

@ -87,13 +87,9 @@ export const LeadDetailsScreen = () => {
<LeadDetailHeader <LeadDetailHeader
name={lead.name} name={lead.name}
company={lead.company} company={lead.company}
email={lead.email}
phonenumber={lead.phonenumber}
statusId={lead.status} statusId={lead.status}
statusName={lead.status_name || lead.status} statusName={lead.status_name || lead.status}
statusColor={lead.color} statusColor={lead.color}
onEmailPress={() => handleEmailPress(lead.email)}
onPhonePress={() => handlePhonePress(lead.phonenumber)}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
/> />
@ -124,7 +120,7 @@ export const LeadDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handleEmailPress(lead.email)} onPress={() => handleEmailPress(lead.email)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="mail-outline" size={15} color={colors.icon} /> <Icon name="mail-outline" size={15} color={colors.icon} />
@ -133,6 +129,9 @@ export const LeadDetailsScreen = () => {
<Text style={styles.infoLabel}>Email</Text> <Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{lead.email}</Text> <Text style={styles.infoValue}>{lead.email}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>
@ -142,7 +141,7 @@ export const LeadDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handlePhonePress(lead.phonenumber)} onPress={() => handlePhonePress(lead.phonenumber)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="call-outline" size={15} color={colors.icon} /> <Icon name="call-outline" size={15} color={colors.icon} />
@ -151,6 +150,9 @@ export const LeadDetailsScreen = () => {
<Text style={styles.infoLabel}>Phone</Text> <Text style={styles.infoLabel}>Phone</Text>
<Text style={styles.infoValue}>{lead.phonenumber}</Text> <Text style={styles.infoValue}>{lead.phonenumber}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>
@ -160,7 +162,7 @@ export const LeadDetailsScreen = () => {
<> <>
<TouchableOpacity <TouchableOpacity
style={styles.infoRow} style={styles.infoRow}
// onPress={() => handleWebsitePress(lead.website)} onPress={() => handleWebsitePress(lead.website)}
activeOpacity={0.7}> activeOpacity={0.7}>
<View style={styles.infoIconWrap}> <View style={styles.infoIconWrap}>
<Icon name="globe-outline" size={15} color={colors.icon} /> <Icon name="globe-outline" size={15} color={colors.icon} />
@ -169,6 +171,9 @@ export const LeadDetailsScreen = () => {
<Text style={styles.infoLabel}>Website</Text> <Text style={styles.infoLabel}>Website</Text>
<Text style={styles.infoValue}>{lead.website}</Text> <Text style={styles.infoValue}>{lead.website}</Text>
</View> </View>
<View style={styles.actionIconButton}>
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
</View>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
</> </>

View File

@ -93,6 +93,14 @@ export const getStyles = (colors: ThemeColors) =>
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
backgroundColor: colors.border, backgroundColor: colors.border,
}, },
actionIconButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${colors.icon}10`,
justifyContent: 'center',
alignItems: 'center',
},
cardsContainer: { cardsContainer: {
marginTop: 4, marginTop: 4,
}, },

View File

@ -13,6 +13,8 @@ import { useAppDispatch, useAppSelector, RootState } from '@store';
import { getStaffNotifications, markNotificationRead, clearNotifications } from './thunk'; import { getStaffNotifications, markNotificationRead, clearNotifications } from './thunk';
import { NotificationItem as NotificationItemType } from '@interfaces'; import { NotificationItem as NotificationItemType } from '@interfaces';
import { Loader, NotificationItem } from '@components'; import { Loader, NotificationItem } from '@components';
import { navigateToLeadDetails } from '@utils';
export const NotificationScreen = () => { export const NotificationScreen = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -63,7 +65,14 @@ export const NotificationScreen = () => {
}), }),
); );
} }
// Note: You can add navigation logic here if `item.link` is provided
if (item.link) {
const match = item.link.match(/#leadid=(\d+)/);
if (match) {
const leadId = match[1];
navigateToLeadDetails(leadId);
}
}
}; };
const renderItem = ({ item }: { item: NotificationItemType }) => { const renderItem = ({ item }: { item: NotificationItemType }) => {

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Text, Text,
View, View,
@ -8,6 +8,7 @@ import {
Image, Image,
Alert, Alert,
} 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 './profile.styles'; import { getStyles } from './profile.styles';
@ -60,8 +61,37 @@ export const ProfileScreen = () => {
const isAdmin = userData?.admin === '1'; const isAdmin = userData?.admin === '1';
const roleText = isAdmin ? 'Administrator' : 'Staff Member'; const roleText = isAdmin ? 'Administrator' : 'Staff Member';
const isActive = userData?.active === '1'; const isActive = userData?.active === '1';
const hasProfileImage = const [profileImageUrl, setProfileImageUrl] = useState('');
userData?.profile_image && userData.profile_image.startsWith('http');
useEffect(() => {
const resolveImageUrl = async () => {
if (!userData?.profile_image) {
setProfileImageUrl('');
return;
}
const rawImage = userData.profile_image.replace(/"/g, '').trim();
if (!rawImage) {
setProfileImageUrl('');
return;
}
if (
rawImage.startsWith('http') ||
rawImage.startsWith('file://') ||
rawImage.startsWith('content://')
) {
setProfileImageUrl(rawImage);
return;
}
const storedBaseUrl = await AsyncStorage.getItem('base_url');
const baseUrl = storedBaseUrl || 'https://demo-convexcrm.convexsol.co';
const cleanBase = baseUrl.replace(/\/+$/, '');
const cleanPath = rawImage.replace(/^\/+/, '');
setProfileImageUrl(`${cleanBase}/${cleanPath}`);
};
resolveImageUrl();
}, [userData?.profile_image]);
const hasProfileImage = !!profileImageUrl;
// Derive initials // Derive initials
const initials = fullName const initials = fullName
@ -84,7 +114,7 @@ export const ProfileScreen = () => {
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
{hasProfileImage ? ( {hasProfileImage ? (
<Image <Image
source={{ uri: userData!.profile_image }} source={{ uri: profileImageUrl }}
style={styles.avatarImage} style={styles.avatarImage}
/> />
) : ( ) : (

View File

@ -6,6 +6,7 @@ export * from './list';
export * from './customers'; export * from './customers';
export * from './fcmToken'; export * from './fcmToken';
export * from './notification'; export * from './notification';
export * from './profile';
export * from './dashboardLeadCount'; export * from './dashboardLeadCount';
export * from './dashboardProjectActivity'; export * from './dashboardProjectActivity';

34
app/interfaces/profile.ts Normal file
View File

@ -0,0 +1,34 @@
export interface UpdateProfilePayload {
id: string;
email: string;
firstname?: string;
lastname?: string;
phone?: string;
email_signature?: string;
password?: string;
passwordr?: string;
profile_image?: {
uri: string;
name: string;
type: string;
};
}
// Partial user_data returned by the API on success
export interface UpdatedStaffData {
staffid?: string;
email?: string;
firstname?: string;
lastname?: string;
phone?: string;
profile_image?: string;
email_signature?: string;
}
export interface UpdateProfileResponse {
status: boolean;
is_twofactor?: boolean;
user_data?: UpdatedStaffData;
token?: string;
message?: string; // present when status = false
}

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Text, Text,
View, View,
@ -7,6 +7,7 @@ import {
Image, Image,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DrawerContentComponentProps } from '@react-navigation/drawer'; import { DrawerContentComponentProps } from '@react-navigation/drawer';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { menuItems } from '@mock-data'; import { menuItems } from '@mock-data';
@ -82,8 +83,38 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
const isAdmin = userData?.admin === '1'; const isAdmin = userData?.admin === '1';
const roleText = isAdmin ? 'Administrator' : 'Staff Member'; const roleText = isAdmin ? 'Administrator' : 'Staff Member';
const isActive = userData?.active === '1'; const isActive = userData?.active === '1';
const hasProfileImage =
userData?.profile_image && userData.profile_image.startsWith('http'); const [profileImageUrl, setProfileImageUrl] = useState('');
useEffect(() => {
const resolveImageUrl = async () => {
if (!userData?.profile_image) {
setProfileImageUrl('');
return;
}
const rawImage = userData.profile_image.replace(/"/g, '').trim();
if (!rawImage) {
setProfileImageUrl('');
return;
}
if (
rawImage.startsWith('http') ||
rawImage.startsWith('file://') ||
rawImage.startsWith('content://')
) {
setProfileImageUrl(rawImage);
return;
}
const storedBaseUrl = await AsyncStorage.getItem('base_url');
const baseUrl = storedBaseUrl || 'https://demo-convexcrm.convexsol.co';
const cleanBase = baseUrl.replace(/\/+$/, '');
const cleanPath = rawImage.replace(/^\/+/, '');
setProfileImageUrl(`${cleanBase}/${cleanPath}`);
};
resolveImageUrl();
}, [userData?.profile_image]);
const hasProfileImage = !!profileImageUrl;
const initials = const initials =
fullName fullName
@ -108,7 +139,7 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
{hasProfileImage ? ( {hasProfileImage ? (
<Image <Image
source={{ uri: userData!.profile_image }} source={{ uri: profileImageUrl }}
style={styles.avatarImage} style={styles.avatarImage}
/> />
) : ( ) : (

View File

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { createDrawerNavigator } from '@react-navigation/drawer'; import { createDrawerNavigator } from '@react-navigation/drawer';
import { TabStack } from './tabStack'; import { NavigatorScreenParams } from '@react-navigation/native';
import { TabStack, TabStackParamList } from './tabStack';
import { CustomDrawerContent } from './customDrawerContent'; import { CustomDrawerContent } from './customDrawerContent';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
export type DrawerStackParamList = { export type DrawerStackParamList = {
home: undefined; home: NavigatorScreenParams<TabStackParamList> | undefined;
}; };
const Drawer = createDrawerNavigator<DrawerStackParamList>(); const Drawer = createDrawerNavigator<DrawerStackParamList>();

View File

@ -1,16 +1,25 @@
import React from 'react'; import React from 'react';
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer, NavigatorScreenParams } 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, DrawerStackParamList } from './drawerStack';
import { useAppSelector } from '../store/store'; import { useAppSelector } from '../store/store';
import { RootState } from '../store/rootReducer'; import { RootState } from '../store/rootReducer';
import { navigationRef, flushPendingNavigation } from '@utils';
export type RootStackParamList = { export type RootStackParamList = {
AuthStack: undefined; AuthStack: undefined;
DrawerStack: undefined; DrawerStack: NavigatorScreenParams<DrawerStackParamList> | undefined;
}; };
// Augment the global ReactNavigation type so navigationRef.current.navigate()
// is fully typed everywhere in the app (React Navigation v6 pattern).
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
const RootStack = createNativeStackNavigator<RootStackParamList>(); const RootStack = createNativeStackNavigator<RootStackParamList>();
export const RootNavigator = () => { export const RootNavigator = () => {
@ -18,7 +27,7 @@ export const RootNavigator = () => {
const initialRouteName = token ? 'DrawerStack' : 'AuthStack'; const initialRouteName = token ? 'DrawerStack' : 'AuthStack';
return ( return (
<NavigationContainer> <NavigationContainer ref={navigationRef} onReady={flushPendingNavigation}>
<RootStack.Navigator <RootStack.Navigator
screenOptions={{ headerShown: false }} screenOptions={{ headerShown: false }}
initialRouteName={initialRouteName} initialRouteName={initialRouteName}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { TouchableOpacity, View, Text } from 'react-native'; import { TouchableOpacity, View, Text } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigatorScreenParams } from '@react-navigation/native';
import { DrawerNavigationProp } from '@react-navigation/drawer'; import { DrawerNavigationProp } from '@react-navigation/drawer';
import Icon from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons';
import { route, RouteParams } from '@utils'; import { route, RouteParams } from '@utils';
@ -10,21 +11,39 @@ import {
AddLeadScreen, AddLeadScreen,
ProfileScreen, ProfileScreen,
NotificationScreen, NotificationScreen,
EditProfileScreen,
} from '@features'; } from '@features';
import { LeadsStack } from './leadsStack'; import { LeadsStack, LeadsStackParamList } from './leadsStack';
import { CustomersStack } from './customersStack'; import { CustomersStack, CustomersStackParamList } from './customersStack';
import { useTheme } from '@theme'; import { useTheme } from '@theme';
import { useAppSelector, RootState } from '@store'; import { useAppSelector, RootState } from '@store';
import { getStyles } from './tabStack.styles'; import { getStyles } from './tabStack.styles';
export type TabStackParamList = Pick< export type DashboardStackParamList = {
RouteParams, dashboardList: undefined;
'dashboard' | 'leads' | 'addLead' | 'customers' | 'profile' notifications: undefined;
>; };
export type AddLeadStackParamList = {
addLeadForm: undefined;
};
export type ProfileStackParamList = {
profileForm: undefined;
editProfile: undefined;
};
export type TabStackParamList = {
dashboard: NavigatorScreenParams<DashboardStackParamList> | undefined;
leads: NavigatorScreenParams<LeadsStackParamList> | undefined;
addLead: NavigatorScreenParams<AddLeadStackParamList> | undefined;
customers: NavigatorScreenParams<CustomersStackParamList> | undefined;
profile: NavigatorScreenParams<ProfileStackParamList> | undefined;
};
const Tab = createBottomTabNavigator<TabStackParamList>(); const Tab = createBottomTabNavigator<TabStackParamList>();
const DashboardStackNav = createNativeStackNavigator(); const DashboardStackNav = createNativeStackNavigator<DashboardStackParamList>();
const DashboardStack = () => { const DashboardStack = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
@ -103,7 +122,7 @@ const DashboardStack = () => {
); );
}; };
const AddLeadStackNav = createNativeStackNavigator(); const AddLeadStackNav = createNativeStackNavigator<AddLeadStackParamList>();
const AddLeadStack = () => { const AddLeadStack = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
@ -149,7 +168,7 @@ const AddLeadStack = () => {
); );
}; };
const ProfileStackNav = createNativeStackNavigator(); const ProfileStackNav = createNativeStackNavigator<ProfileStackParamList>();
const ProfileStack = () => { const ProfileStack = () => {
const { theme: colors } = useTheme(); const { theme: colors } = useTheme();
const styles = getStyles(colors); const styles = getStyles(colors);
@ -189,8 +208,37 @@ const ProfileStack = () => {
<Icon name="menu-outline" size={20} color={colors.text} /> <Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
), ),
headerRight: () => (
<TouchableOpacity
onPress={() => navigation.navigate(route.editProfile)}
style={{
marginRight: 8,
backgroundColor: colors.icon,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: colors.icon,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 3,
}}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="create-outline" size={16} color="#FFFFFF" />
</TouchableOpacity>
),
})} })}
/> />
<ProfileStackNav.Screen
name={route.editProfile}
component={EditProfileScreen}
options={{
headerTitle: 'Edit Profile',
}}
/>
</ProfileStackNav.Navigator> </ProfileStackNav.Navigator>
); );
}; };
@ -240,6 +288,14 @@ export const TabStack = () => {
options={{ options={{
tabBarLabel: 'Dashboard', tabBarLabel: 'Dashboard',
}} }}
listeners={({ navigation }) => ({
tabPress: (e) => {
e.preventDefault();
navigation.navigate(route.dashboard, {
screen: 'dashboardList',
});
},
})}
/> />
<Tab.Screen <Tab.Screen
name={route.leads} name={route.leads}
@ -247,6 +303,14 @@ export const TabStack = () => {
options={{ options={{
tabBarLabel: 'Leads', tabBarLabel: 'Leads',
}} }}
listeners={({ navigation }) => ({
tabPress: (e) => {
e.preventDefault();
navigation.navigate(route.leads, {
screen: route.leadsList,
});
},
})}
/> />
<Tab.Screen <Tab.Screen
name={route.addLead} name={route.addLead}
@ -254,6 +318,14 @@ export const TabStack = () => {
options={{ options={{
tabBarLabel: 'Add Lead', tabBarLabel: 'Add Lead',
}} }}
listeners={({ navigation }) => ({
tabPress: (e) => {
e.preventDefault();
navigation.navigate(route.addLead, {
screen: 'addLeadForm',
});
},
})}
/> />
<Tab.Screen <Tab.Screen
name={route.customers} name={route.customers}
@ -261,6 +333,14 @@ export const TabStack = () => {
options={{ options={{
tabBarLabel: 'Customers', tabBarLabel: 'Customers',
}} }}
listeners={({ navigation }) => ({
tabPress: (e) => {
e.preventDefault();
navigation.navigate(route.customers, {
screen: route.customersList,
});
},
})}
/> />
<Tab.Screen <Tab.Screen
name={route.profile} name={route.profile}
@ -268,6 +348,14 @@ export const TabStack = () => {
options={{ options={{
tabBarLabel: 'Profile', tabBarLabel: 'Profile',
}} }}
listeners={({ navigation }) => ({
tabPress: (e) => {
e.preventDefault();
navigation.navigate(route.profile, {
screen: 'profileForm',
});
},
})}
/> />
</Tab.Navigator> </Tab.Navigator>
); );

View File

@ -7,6 +7,7 @@ import notifee, {
EventType, EventType,
} from '@notifee/react-native'; } from '@notifee/react-native';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { navigateToLeadDetails } from '@utils';
const CHANNEL_ID = 'convex_crm_default'; const CHANNEL_ID = 'convex_crm_default';
const CHANNEL_NAME = 'Convex CRM Notifications'; const CHANNEL_NAME = 'Convex CRM Notifications';
@ -92,6 +93,8 @@ export async function displayNotification(
android: { android: {
channelId: CHANNEL_ID, channelId: CHANNEL_ID,
pressAction: { id: 'default' }, pressAction: { id: 'default' },
smallIcon: 'ic_notification',
color: '#07BAD2', // Brand teal sampled from app logo
}, },
}); });
} catch (error) { } catch (error) {
@ -99,31 +102,17 @@ export async function displayNotification(
} }
} }
export function onForegroundMessage( export function onForegroundMessage(onOpen?: NotificationOpenHandler): () => void {
onOpen?: NotificationOpenHandler, // Show a Notifee notification for every incoming FCM message
): () => void { const unsubscribeFCM = messaging().onMessage(async remoteMessage => {
// FCM foreground listener const title = remoteMessage.notification?.title ?? 'Convex CRM';
const unsubscribeFCM = messaging().onMessage( const body = remoteMessage.notification?.body ?? 'You have a new notification';
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { await displayNotification(title, body, (remoteMessage.data ?? {}) as NotificationData);
console.log('[NotificationService] Foreground FCM message:', remoteMessage); });
const title = // Handle tap on a Notifee notification while app is in foreground
remoteMessage.notification?.title ?? 'Convex CRM';
const body =
remoteMessage.notification?.body ?? 'You have a new notification';
const data = (remoteMessage.data ?? {}) as NotificationData;
await displayNotification(title, body, data);
},
);
// Notifee foreground event listener (handles taps on Notifee notifications)
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => { const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS && detail.notification?.data) { if (type === EventType.PRESS && detail.notification?.data) {
console.log(
'[NotificationService] Notifee notification pressed (foreground):',
detail.notification.data,
);
onOpen?.(detail.notification.data as NotificationData); onOpen?.(detail.notification.data as NotificationData);
} }
}); });
@ -135,35 +124,22 @@ export function onForegroundMessage(
} }
export function registerBackgroundHandler(): void { export function registerBackgroundHandler(): void {
messaging().setBackgroundMessageHandler( // Display data-only FCM messages that arrive while app is in background
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => { messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log( if (remoteMessage.data && !remoteMessage.notification) {
'[NotificationService] Background FCM message:', const title = (remoteMessage.data.title as string) ?? 'Convex CRM';
remoteMessage, const body = (remoteMessage.data.body as string) ?? 'You have a new notification';
); await displayNotification(title, body, remoteMessage.data as NotificationData);
// FCM data-only messages in the background need manual display }
if (remoteMessage.data && !remoteMessage.notification) { });
const title =
(remoteMessage.data.title as string) ?? 'Convex CRM';
const body =
(remoteMessage.data.body as string) ?? 'You have a new notification';
await displayNotification(title, body, remoteMessage.data as NotificationData);
}
},
);
// Notifee background event (handles taps on notifications from background) // Handle tap on a notification while app is in background or killed
notifee.onBackgroundEvent(async ({ type, detail }) => { notifee.onBackgroundEvent(async ({ type, detail }) => {
if (type === EventType.PRESS) { if (type === EventType.PRESS) {
console.log( const data = detail.notification?.data as Record<string, string> | undefined;
'[NotificationService] Notifee notification pressed (background):', if (data?.type === 'lead' && data?.lead_id) {
detail.notification?.data, navigateToLeadDetails(data.lead_id);
); }
}
if (type === EventType.DISMISSED) {
console.log(
'[NotificationService] Notification dismissed (background)',
);
} }
}); });
} }

View File

@ -1,7 +1,10 @@
import { createReducer } from '@reduxjs/toolkit'; import { createAction, createReducer } from '@reduxjs/toolkit';
import { login } from './thunk'; import { login } from './thunk';
import { UserData } from '@interfaces'; import { UserData } from '@interfaces';
// Patch user_data in auth state after a successful profile update
export const updateUserData = createAction<Partial<UserData>>('auth/updateUserData');
export interface LoginState { export interface LoginState {
loginSuccess: boolean; loginSuccess: boolean;
loginLoading: boolean; loginLoading: boolean;
@ -46,7 +49,13 @@ export const authReducer = createReducer(initialState, builder => {
acc.loginError = (payload as string) ?? error.message ?? 'Login failed'; acc.loginError = (payload as string) ?? error.message ?? 'Login failed';
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed'; acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
acc.loginSuccess = false; acc.loginSuccess = false;
})
.addCase(updateUserData, (acc, action) => {
if (acc.user_data) {
acc.user_data = { ...acc.user_data, ...action.payload };
}
}); });
}); });
export default authReducer; export default authReducer;

View File

@ -14,6 +14,7 @@ import customerDetailsReducer from '../features/customerDetails/reducers';
import addCustomerReducer from '../features/addCustomer/reducers'; import addCustomerReducer from '../features/addCustomer/reducers';
import notificationReducer from '../features/notification/reducers'; import notificationReducer from '../features/notification/reducers';
import dashboardReducer from '../features/dashboard/reducers'; import dashboardReducer from '../features/dashboard/reducers';
import editProfileReducer from '../features/editProfile/reducers';
const appReducer = combineReducers({ const appReducer = combineReducers({
auth: authReducer, auth: authReducer,
@ -31,6 +32,7 @@ const appReducer = combineReducers({
addCustomer: addCustomerReducer, addCustomer: addCustomerReducer,
notifications: notificationReducer, notifications: notificationReducer,
dashboard: dashboardReducer, dashboard: dashboardReducer,
editProfile: editProfileReducer,
}); });
const rootReducer = (state: any, action: any) => { const rootReducer = (state: any, action: any) => {

View File

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

View File

@ -0,0 +1,44 @@
import { createRef } from 'react';
import { NavigationContainerRef, CommonActions } from '@react-navigation/native';
// Shared ref — attach to <NavigationContainer ref={navigationRef}>
export const navigationRef =
createRef<NavigationContainerRef<ReactNavigation.RootParamList>>();
// Queued leadId when app launches from a killed state (navigator not ready yet)
let pendingLeadId: string | null = null;
// Navigate to LeadDetails. If navigator isn't ready yet, queue it for onReady.
export function navigateToLeadDetails(leadId: string): void {
if (!navigationRef.current?.isReady()) {
pendingLeadId = leadId;
return;
}
navigationRef.current.dispatch(
CommonActions.navigate({
name: 'DrawerStack',
params: {
screen: 'home',
params: {
screen: 'leads',
params: {
// Seed the LeadsStack with leadsList first so the back button
// is always present when arriving from a notification.
initial: false,
screen: 'leadDetails',
params: { lead: { id: leadId } },
},
},
},
}),
);
}
// Call from NavigationContainer's onReady — executes any queued navigation
export function flushPendingNavigation(): void {
if (pendingLeadId) {
navigateToLeadDetails(pendingLeadId);
pendingLeadId = null;
}
}

View File

@ -28,6 +28,7 @@ export const route = {
addCustomer: 'addCustomer', addCustomer: 'addCustomer',
profile: 'profile', profile: 'profile',
notifications: 'notifications', notifications: 'notifications',
editProfile: 'editProfile',
} as const; } as const;
// ─── Route Param Types ──────────────────────────────────────────────────────── // ─── Route Param Types ────────────────────────────────────────────────────────
@ -56,6 +57,7 @@ export type RouteParams = {
addCustomer: undefined; addCustomer: undefined;
profile: undefined; profile: undefined;
notifications: undefined; notifications: undefined;
editProfile: undefined;
}; };

View File

@ -29,6 +29,7 @@
"react-native-device-info": "^15.0.2", "react-native-device-info": "^15.0.2",
"react-native-gesture-handler": "^2.32.0", "react-native-gesture-handler": "^2.32.0",
"react-native-gifted-charts": "^1.4.77", "react-native-gifted-charts": "^1.4.77",
"react-native-image-crop-picker": "^0.51.1",
"react-native-linear-gradient": "^2.8.3", "react-native-linear-gradient": "^2.8.3",
"react-native-reanimated": "^4.5.0", "react-native-reanimated": "^4.5.0",
"react-native-safe-area-context": "^5.8.0", "react-native-safe-area-context": "^5.8.0",

View File

@ -6754,6 +6754,11 @@ react-native-gifted-charts@^1.4.77:
dependencies: dependencies:
gifted-charts-core "0.1.81" gifted-charts-core "0.1.81"
react-native-image-crop-picker@^0.51.1:
version "0.51.1"
resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.51.1.tgz#1e42865454030c5194e693a9e7691bf5afe460f5"
integrity sha512-GIFRyXJgv1dPceKd/hraK9q9V38v45rSg2ONR6RiSePcOJemkHpc/PMU86pq6lWPilDYHSxbZmea2pNMk85ayw==
react-native-is-edge-to-edge@^1.3.1: react-native-is-edge-to-edge@^1.3.1:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139" resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139"