feat(app): profile, notification and design update
This commit is contained in:
parent
461feba0ba
commit
18077e260c
@ -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.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" />
|
||||
|
||||
<application
|
||||
@ -11,6 +15,15 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 944 B |
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-mdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
BIN
android/app/src/main/res/drawable-xxxhdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@ -1,3 +1,5 @@
|
||||
<resources>
|
||||
<color name="bootsplash_background">#ffffff</color>
|
||||
<!-- Brand primary colour used for notification accent -->
|
||||
<color name="notification_color">#07BAD2</color>
|
||||
</resources>
|
||||
|
||||
@ -10,6 +10,7 @@ import { NotificationService } from '@services';
|
||||
import BootSplash from 'react-native-bootsplash';
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
import { getStaffNotifications } from './features/notification/thunk';
|
||||
import { navigateToLeadDetails } from '@utils';
|
||||
|
||||
const ThemedStatusBar = () => {
|
||||
const { theme: colors, isDark } = useTheme();
|
||||
@ -84,9 +85,12 @@ const App = () => {
|
||||
const initApp = async () => {
|
||||
try {
|
||||
const cleanupFn = await NotificationService.initialize({
|
||||
onNotificationOpen: data => {
|
||||
// TODO: Use data (e.g. { screen, id }) to deep-navigate once
|
||||
onNotificationOpen: 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 => {
|
||||
// TODO: Send the refreshed token to your backend so the server
|
||||
|
||||
@ -5,8 +5,6 @@ export * from './listApi';
|
||||
export * from './customersApi';
|
||||
export * from './fcmTokenApi';
|
||||
export * from './notificationApi';
|
||||
export * from './profileApi';
|
||||
export * from './dashboardLeadCountApi';
|
||||
export * from './dashboardProjectActivityApi';
|
||||
|
||||
|
||||
|
||||
|
||||
30
app/api/profileApi.ts
Normal file
30
app/api/profileApi.ts
Normal 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}`);
|
||||
};
|
||||
@ -19,21 +19,20 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
avatarCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
backgroundColor: colors.icon,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 10,
|
||||
marginRight: 12,
|
||||
flexShrink: 0,
|
||||
},
|
||||
avatarInitial: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
info: {
|
||||
@ -51,11 +50,6 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
contact: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 16,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
@ -84,39 +78,4 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
inactiveText: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -37,12 +37,6 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
|
||||
{fullname && company ? (
|
||||
<Text style={styles.company} numberOfLines={1}>{fullname}</Text>
|
||||
) : 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 ? (
|
||||
<Text style={styles.dateText} numberOfLines={1}>
|
||||
Added {formatDate(datecreated)}
|
||||
@ -64,36 +58,6 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -19,20 +19,19 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 10,
|
||||
marginRight: 12,
|
||||
flexShrink: 0,
|
||||
},
|
||||
avatarText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
info: {
|
||||
@ -50,11 +49,6 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
contact: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 16,
|
||||
},
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@ -133,29 +127,4 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
height: 8,
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,12 +57,6 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
|
||||
{company ? (
|
||||
<Text style={styles.company} numberOfLines={1}>{company}</Text>
|
||||
) : 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 style={{ position: 'relative' }}>
|
||||
@ -112,22 +106,6 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -92,12 +92,6 @@ export const CustomerDetailsScreen = () => {
|
||||
fullname={customer.fullname}
|
||||
active={customer.active}
|
||||
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 */}
|
||||
@ -107,7 +101,7 @@ export const CustomerDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handleEmailPress(customer.email)}
|
||||
onPress={() => handleEmailPress(customer.email)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{customer.email}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
@ -125,7 +122,7 @@ export const CustomerDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handlePhonePress(customer.phonenumber)}
|
||||
onPress={() => handlePhonePress(customer.phonenumber)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{customer.phonenumber}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
@ -143,7 +143,7 @@ export const CustomerDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handleWebsitePress(customer.website)}
|
||||
onPress={() => handleWebsitePress(customer.website)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{customer.website}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
|
||||
@ -175,6 +175,14 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
backgroundColor: colors.border,
|
||||
marginVertical: 4,
|
||||
},
|
||||
actionIconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${colors.icon}10`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
// Error & empty states
|
||||
centered: {
|
||||
|
||||
421
app/features/editProfile/editProfile.screen.tsx
Normal file
421
app/features/editProfile/editProfile.screen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
123
app/features/editProfile/editProfile.styles.ts
Normal file
123
app/features/editProfile/editProfile.styles.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
1
app/features/editProfile/index.ts
Normal file
1
app/features/editProfile/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './editProfile.screen';
|
||||
61
app/features/editProfile/reducers.ts
Normal file
61
app/features/editProfile/reducers.ts
Normal 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;
|
||||
32
app/features/editProfile/thunk.ts
Normal file
32
app/features/editProfile/thunk.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -17,5 +17,6 @@ export * from './tickets';
|
||||
export * from './customerDetails';
|
||||
export * from './addCustomer';
|
||||
export * from './notification';
|
||||
export * from './editProfile';
|
||||
|
||||
|
||||
|
||||
@ -87,13 +87,9 @@ export const LeadDetailsScreen = () => {
|
||||
<LeadDetailHeader
|
||||
name={lead.name}
|
||||
company={lead.company}
|
||||
email={lead.email}
|
||||
phonenumber={lead.phonenumber}
|
||||
statusId={lead.status}
|
||||
statusName={lead.status_name || lead.status}
|
||||
statusColor={lead.color}
|
||||
onEmailPress={() => handleEmailPress(lead.email)}
|
||||
onPhonePress={() => handlePhonePress(lead.phonenumber)}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
@ -124,7 +120,7 @@ export const LeadDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handleEmailPress(lead.email)}
|
||||
onPress={() => handleEmailPress(lead.email)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{lead.email}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
@ -142,7 +141,7 @@ export const LeadDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handlePhonePress(lead.phonenumber)}
|
||||
onPress={() => handlePhonePress(lead.phonenumber)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{lead.phonenumber}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
@ -160,7 +162,7 @@ export const LeadDetailsScreen = () => {
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
// onPress={() => handleWebsitePress(lead.website)}
|
||||
onPress={() => handleWebsitePress(lead.website)}
|
||||
activeOpacity={0.7}>
|
||||
<View style={styles.infoIconWrap}>
|
||||
<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.infoValue}>{lead.website}</Text>
|
||||
</View>
|
||||
<View style={styles.actionIconButton}>
|
||||
<Icon name="chevron-forward" size={14} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
</>
|
||||
|
||||
@ -93,6 +93,14 @@ export const getStyles = (colors: ThemeColors) =>
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
actionIconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${colors.icon}10`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardsContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
|
||||
@ -13,6 +13,8 @@ import { useAppDispatch, useAppSelector, RootState } from '@store';
|
||||
import { getStaffNotifications, markNotificationRead, clearNotifications } from './thunk';
|
||||
import { NotificationItem as NotificationItemType } from '@interfaces';
|
||||
import { Loader, NotificationItem } from '@components';
|
||||
import { navigateToLeadDetails } from '@utils';
|
||||
|
||||
|
||||
export const NotificationScreen = () => {
|
||||
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 }) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { getStyles } from './profile.styles';
|
||||
@ -60,8 +61,37 @@ export const ProfileScreen = () => {
|
||||
const isAdmin = userData?.admin === '1';
|
||||
const roleText = isAdmin ? 'Administrator' : 'Staff Member';
|
||||
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;
|
||||
|
||||
// Derive initials
|
||||
const initials = fullName
|
||||
@ -84,7 +114,7 @@ export const ProfileScreen = () => {
|
||||
<View style={styles.avatarWrapper}>
|
||||
{hasProfileImage ? (
|
||||
<Image
|
||||
source={{ uri: userData!.profile_image }}
|
||||
source={{ uri: profileImageUrl }}
|
||||
style={styles.avatarImage}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -6,6 +6,7 @@ export * from './list';
|
||||
export * from './customers';
|
||||
export * from './fcmToken';
|
||||
export * from './notification';
|
||||
export * from './profile';
|
||||
export * from './dashboardLeadCount';
|
||||
export * from './dashboardProjectActivity';
|
||||
|
||||
|
||||
34
app/interfaces/profile.ts
Normal file
34
app/interfaces/profile.ts
Normal 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
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { DrawerContentComponentProps } from '@react-navigation/drawer';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { menuItems } from '@mock-data';
|
||||
@ -82,8 +83,38 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
|
||||
const isAdmin = userData?.admin === '1';
|
||||
const roleText = isAdmin ? 'Administrator' : 'Staff Member';
|
||||
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 =
|
||||
fullName
|
||||
@ -108,7 +139,7 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
|
||||
<View style={styles.avatarWrapper}>
|
||||
{hasProfileImage ? (
|
||||
<Image
|
||||
source={{ uri: userData!.profile_image }}
|
||||
source={{ uri: profileImageUrl }}
|
||||
style={styles.avatarImage}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
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 { useTheme } from '@theme';
|
||||
|
||||
export type DrawerStackParamList = {
|
||||
home: undefined;
|
||||
home: NavigatorScreenParams<TabStackParamList> | undefined;
|
||||
};
|
||||
|
||||
const Drawer = createDrawerNavigator<DrawerStackParamList>();
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
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 { AuthStack } from './authStack';
|
||||
import { DrawerStack } from './drawerStack';
|
||||
import { DrawerStack, DrawerStackParamList } from './drawerStack';
|
||||
import { useAppSelector } from '../store/store';
|
||||
import { RootState } from '../store/rootReducer';
|
||||
import { navigationRef, flushPendingNavigation } from '@utils';
|
||||
|
||||
export type RootStackParamList = {
|
||||
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>();
|
||||
|
||||
export const RootNavigator = () => {
|
||||
@ -18,7 +27,7 @@ export const RootNavigator = () => {
|
||||
const initialRouteName = token ? 'DrawerStack' : 'AuthStack';
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<NavigationContainer ref={navigationRef} onReady={flushPendingNavigation}>
|
||||
<RootStack.Navigator
|
||||
screenOptions={{ headerShown: false }}
|
||||
initialRouteName={initialRouteName}
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { TouchableOpacity, View, Text } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import { DrawerNavigationProp } from '@react-navigation/drawer';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { route, RouteParams } from '@utils';
|
||||
@ -10,21 +11,39 @@ import {
|
||||
AddLeadScreen,
|
||||
ProfileScreen,
|
||||
NotificationScreen,
|
||||
EditProfileScreen,
|
||||
} from '@features';
|
||||
import { LeadsStack } from './leadsStack';
|
||||
import { CustomersStack } from './customersStack';
|
||||
import { LeadsStack, LeadsStackParamList } from './leadsStack';
|
||||
import { CustomersStack, CustomersStackParamList } from './customersStack';
|
||||
import { useTheme } from '@theme';
|
||||
import { useAppSelector, RootState } from '@store';
|
||||
import { getStyles } from './tabStack.styles';
|
||||
|
||||
export type TabStackParamList = Pick<
|
||||
RouteParams,
|
||||
'dashboard' | 'leads' | 'addLead' | 'customers' | 'profile'
|
||||
>;
|
||||
export type DashboardStackParamList = {
|
||||
dashboardList: undefined;
|
||||
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 DashboardStackNav = createNativeStackNavigator();
|
||||
const DashboardStackNav = createNativeStackNavigator<DashboardStackParamList>();
|
||||
const DashboardStack = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
@ -103,7 +122,7 @@ const DashboardStack = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AddLeadStackNav = createNativeStackNavigator();
|
||||
const AddLeadStackNav = createNativeStackNavigator<AddLeadStackParamList>();
|
||||
const AddLeadStack = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
@ -149,7 +168,7 @@ const AddLeadStack = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileStackNav = createNativeStackNavigator();
|
||||
const ProfileStackNav = createNativeStackNavigator<ProfileStackParamList>();
|
||||
const ProfileStack = () => {
|
||||
const { theme: colors } = useTheme();
|
||||
const styles = getStyles(colors);
|
||||
@ -189,8 +208,37 @@ const ProfileStack = () => {
|
||||
<Icon name="menu-outline" size={20} color={colors.text} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -240,6 +288,14 @@ export const TabStack = () => {
|
||||
options={{
|
||||
tabBarLabel: 'Dashboard',
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
e.preventDefault();
|
||||
navigation.navigate(route.dashboard, {
|
||||
screen: 'dashboardList',
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.leads}
|
||||
@ -247,6 +303,14 @@ export const TabStack = () => {
|
||||
options={{
|
||||
tabBarLabel: 'Leads',
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
e.preventDefault();
|
||||
navigation.navigate(route.leads, {
|
||||
screen: route.leadsList,
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.addLead}
|
||||
@ -254,6 +318,14 @@ export const TabStack = () => {
|
||||
options={{
|
||||
tabBarLabel: 'Add Lead',
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
e.preventDefault();
|
||||
navigation.navigate(route.addLead, {
|
||||
screen: 'addLeadForm',
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.customers}
|
||||
@ -261,6 +333,14 @@ export const TabStack = () => {
|
||||
options={{
|
||||
tabBarLabel: 'Customers',
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
e.preventDefault();
|
||||
navigation.navigate(route.customers, {
|
||||
screen: route.customersList,
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={route.profile}
|
||||
@ -268,6 +348,14 @@ export const TabStack = () => {
|
||||
options={{
|
||||
tabBarLabel: 'Profile',
|
||||
}}
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
e.preventDefault();
|
||||
navigation.navigate(route.profile, {
|
||||
screen: 'profileForm',
|
||||
});
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ import notifee, {
|
||||
EventType,
|
||||
} from '@notifee/react-native';
|
||||
import { Platform } from 'react-native';
|
||||
import { navigateToLeadDetails } from '@utils';
|
||||
|
||||
const CHANNEL_ID = 'convex_crm_default';
|
||||
const CHANNEL_NAME = 'Convex CRM Notifications';
|
||||
@ -92,6 +93,8 @@ export async function displayNotification(
|
||||
android: {
|
||||
channelId: CHANNEL_ID,
|
||||
pressAction: { id: 'default' },
|
||||
smallIcon: 'ic_notification',
|
||||
color: '#07BAD2', // Brand teal – sampled from app logo
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@ -99,31 +102,17 @@ export async function displayNotification(
|
||||
}
|
||||
}
|
||||
|
||||
export function onForegroundMessage(
|
||||
onOpen?: NotificationOpenHandler,
|
||||
): () => void {
|
||||
// FCM foreground listener
|
||||
const unsubscribeFCM = messaging().onMessage(
|
||||
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
||||
console.log('[NotificationService] Foreground FCM message:', remoteMessage);
|
||||
export function onForegroundMessage(onOpen?: NotificationOpenHandler): () => void {
|
||||
// Show a Notifee notification for every incoming FCM message
|
||||
const unsubscribeFCM = messaging().onMessage(async remoteMessage => {
|
||||
const title = remoteMessage.notification?.title ?? 'Convex CRM';
|
||||
const body = remoteMessage.notification?.body ?? 'You have a new notification';
|
||||
await displayNotification(title, body, (remoteMessage.data ?? {}) as NotificationData);
|
||||
});
|
||||
|
||||
const title =
|
||||
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)
|
||||
// Handle tap on a Notifee notification while app is in foreground
|
||||
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
|
||||
if (type === EventType.PRESS && detail.notification?.data) {
|
||||
console.log(
|
||||
'[NotificationService] Notifee notification pressed (foreground):',
|
||||
detail.notification.data,
|
||||
);
|
||||
onOpen?.(detail.notification.data as NotificationData);
|
||||
}
|
||||
});
|
||||
@ -135,35 +124,22 @@ export function onForegroundMessage(
|
||||
}
|
||||
|
||||
export function registerBackgroundHandler(): void {
|
||||
messaging().setBackgroundMessageHandler(
|
||||
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
|
||||
console.log(
|
||||
'[NotificationService] Background FCM message:',
|
||||
remoteMessage,
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Display data-only FCM messages that arrive while app is in background
|
||||
messaging().setBackgroundMessageHandler(async remoteMessage => {
|
||||
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 }) => {
|
||||
if (type === EventType.PRESS) {
|
||||
console.log(
|
||||
'[NotificationService] Notifee notification pressed (background):',
|
||||
detail.notification?.data,
|
||||
);
|
||||
}
|
||||
if (type === EventType.DISMISSED) {
|
||||
console.log(
|
||||
'[NotificationService] Notification dismissed (background)',
|
||||
);
|
||||
const data = detail.notification?.data as Record<string, string> | undefined;
|
||||
if (data?.type === 'lead' && data?.lead_id) {
|
||||
navigateToLeadDetails(data.lead_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import { createAction, createReducer } from '@reduxjs/toolkit';
|
||||
import { login } from './thunk';
|
||||
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 {
|
||||
loginSuccess: boolean;
|
||||
loginLoading: boolean;
|
||||
@ -46,7 +49,13 @@ export const authReducer = createReducer(initialState, builder => {
|
||||
acc.loginError = (payload as string) ?? error.message ?? 'Login failed';
|
||||
acc.loginMessage = (payload as string) ?? error.message ?? 'Login failed';
|
||||
acc.loginSuccess = false;
|
||||
})
|
||||
.addCase(updateUserData, (acc, action) => {
|
||||
if (acc.user_data) {
|
||||
acc.user_data = { ...acc.user_data, ...action.payload };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default authReducer;
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import customerDetailsReducer from '../features/customerDetails/reducers';
|
||||
import addCustomerReducer from '../features/addCustomer/reducers';
|
||||
import notificationReducer from '../features/notification/reducers';
|
||||
import dashboardReducer from '../features/dashboard/reducers';
|
||||
import editProfileReducer from '../features/editProfile/reducers';
|
||||
|
||||
const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
@ -31,6 +32,7 @@ const appReducer = combineReducers({
|
||||
addCustomer: addCustomerReducer,
|
||||
notifications: notificationReducer,
|
||||
dashboard: dashboardReducer,
|
||||
editProfile: editProfileReducer,
|
||||
});
|
||||
|
||||
const rootReducer = (state: any, action: any) => {
|
||||
|
||||
@ -2,3 +2,4 @@ export * from './route';
|
||||
export * from './assets';
|
||||
export * from './api';
|
||||
export * from './helper';
|
||||
export * from './navigationRef';
|
||||
|
||||
44
app/utils/navigationRef.ts
Normal file
44
app/utils/navigationRef.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ export const route = {
|
||||
addCustomer: 'addCustomer',
|
||||
profile: 'profile',
|
||||
notifications: 'notifications',
|
||||
editProfile: 'editProfile',
|
||||
} as const;
|
||||
|
||||
// ─── Route Param Types ────────────────────────────────────────────────────────
|
||||
@ -56,6 +57,7 @@ export type RouteParams = {
|
||||
addCustomer: undefined;
|
||||
profile: undefined;
|
||||
notifications: undefined;
|
||||
editProfile: undefined;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"react-native-device-info": "^15.0.2",
|
||||
"react-native-gesture-handler": "^2.32.0",
|
||||
"react-native-gifted-charts": "^1.4.77",
|
||||
"react-native-image-crop-picker": "^0.51.1",
|
||||
"react-native-linear-gradient": "^2.8.3",
|
||||
"react-native-reanimated": "^4.5.0",
|
||||
"react-native-safe-area-context": "^5.8.0",
|
||||
|
||||
@ -6754,6 +6754,11 @@ react-native-gifted-charts@^1.4.77:
|
||||
dependencies:
|
||||
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:
|
||||
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user