From 18077e260c912c3f9352e57f03bc02baa87e19e1 Mon Sep 17 00:00:00 2001 From: uttam05111990 Date: Mon, 3 Aug 2026 19:48:33 +0530 Subject: [PATCH] feat(app): profile, notification and design update --- android/app/src/main/AndroidManifest.xml | 15 +- .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 944 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 618 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 1216 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 1811 bytes .../res/drawable-xxxhdpi/ic_notification.png | Bin 0 -> 2505 bytes android/app/src/main/res/values/colors.xml | 2 + app/App.tsx | 8 +- app/api/index.ts | 4 +- app/api/profileApi.ts | 30 ++ .../customerDetailHeader.styles.ts | 51 +-- .../customerDetailHeader.tsx | 36 -- .../leadDetailHeader.styles.ts | 41 +- .../leadDetailHeader/leadDetailHeader.tsx | 22 - .../customerDetails.screen.tsx | 21 +- .../customerDetails/customerDetails.styles.ts | 8 + .../editProfile/editProfile.screen.tsx | 421 ++++++++++++++++++ .../editProfile/editProfile.styles.ts | 123 +++++ app/features/editProfile/index.ts | 1 + app/features/editProfile/reducers.ts | 61 +++ app/features/editProfile/thunk.ts | 32 ++ app/features/index.ts | 1 + .../leadDetails/leadDetails.screen.tsx | 19 +- .../leadDetails/leadDetails.styles.ts | 8 + .../notification/notification.screen.tsx | 11 +- app/features/profile/profile.screen.tsx | 38 +- app/interfaces/index.ts | 1 + app/interfaces/profile.ts | 34 ++ app/navigation/customDrawerContent.tsx | 39 +- app/navigation/drawerStack.tsx | 5 +- app/navigation/rootNavigator.tsx | 17 +- app/navigation/tabStack.tsx | 106 ++++- app/services/notifications.ts | 72 +-- app/store/commonReducers/auth/reducers.ts | 11 +- app/store/rootReducer.ts | 2 + app/utils/index.ts | 1 + app/utils/navigationRef.ts | 44 ++ app/utils/route.ts | 2 + package.json | 1 + yarn.lock | 5 + 40 files changed, 1058 insertions(+), 235 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/ic_notification.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_notification.png create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_notification.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_notification.png create mode 100644 app/api/profileApi.ts create mode 100644 app/features/editProfile/editProfile.screen.tsx create mode 100644 app/features/editProfile/editProfile.styles.ts create mode 100644 app/features/editProfile/index.ts create mode 100644 app/features/editProfile/reducers.ts create mode 100644 app/features/editProfile/thunk.ts create mode 100644 app/interfaces/profile.ts create mode 100644 app/utils/navigationRef.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f4bacd8..1e0aafb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,9 @@ - + + + + + + + + Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D14T(hK~z{r?U!F@ zPH`B=Co&mgaUnJG??P+2m<#f^{7Kx=QZ7tZYb7Zw}b zxseN&HY{_67BQR8^ZdR$=RMB0_kEX(UA&+AcHZaveV*r>-rk*aR#c!sfw4q?e}5TT zjkcjxsC-~xK&j_bP=#{SW>lv5|G>gU=mcsPi>PGccqVuRrQH0H)r>LHCx-T6K zESQWMQEylsgV%>@Q7O7CJ1g{dqB@--cNm(1o@q}4{6AKiujTR7;fBS6ztpU8mWb`IM)PPQJ7uR;=o*M>Hs zslG@M&Bfb~x45D(6BWl`9#D@%eMM0O-c7|0bu+R*f?30fw$ugVzYZG$)EgDWU|v-2Q14dcP)|f} z6g$-4(bthg+d-{}z;6m00@T%tVldAucc?2BIn=XJ;!d3l%v{%s2)sJQgB(>9L(>I? z4)tn94puQ2iMu@Ya>X(Dc1QgcTD-{~pz9Uq1pPZS?r6t+ z#SV3?Vu$)8hhoJ^2-`wifVzW2iQ*BV*Pd^-Jc9a`Vu!jMeO5e3gW@zauT)b6p8YnK zsAWjVm!cOMaj1Wwg=(>$6)5pMqyBX7hr%BbvV< zD+O7-pCn%xtU@{64C#A|w(`;52wH=i1pg_AHCjJ9STK!6JJDU#fqrGkE}|7x(PzIa zdk?>@lUv?{UZRs|9rie_{XekKZff%~lz43*ACET)*}qGcmnteypuiZRsOT^HDs5J= S^x-Z50000Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0sl!vK~zXf?UX-A z)L|UPPsJe(qwrr24H7{TmfAHrg*5133Wm4^K@EvgQ=o%zxJY3-q@&O#ShR^c2jQSN zxCs4GjD`dkX^6|u^S+PX&+ZN!9em)!``+jKyzkxV-KEZRc9YF!8=)UEFbOU8P4EmR zU>ww`4~I?@%GY2H{`_;;FTw+uhriax!R^6owpG5Sa40pyZafyl;~AD#)?d*S51loZ zI$w7(3~Q2?v;Pc_;0EkjAHACaxCLn#f=!RZlR$;9%>_B@j!N%XO6`}U4gO6i{}){d zA6-*##G@EQJsfzrW-Xr%CkXow)&)bQlFkozzp|8j6fWx5Kw41J{Z4E7KI^r(o_*ps6SxhQ*2nrLf06H&!*8l(j07*qoM6N<$ Ef>t9MQ~&?~ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..1501b15a2e8411843a84471b2e7ce38becb326e7 GIT binary patch literal 1216 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 BZtDO5 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..e34fa999d91124ec2def6b41dcdad71dc415101b GIT binary patch literal 2505 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 #ffffff + + #07BAD2 diff --git a/app/App.tsx b/app/App.tsx index 37de323..18684d5 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -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 diff --git a/app/api/index.ts b/app/api/index.ts index b7b124a..e6621bb 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -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'; - - - diff --git a/app/api/profileApi.ts b/app/api/profileApi.ts new file mode 100644 index 0000000..3e3f113 --- /dev/null +++ b/app/api/profileApi.ts @@ -0,0 +1,30 @@ +import { UpdateProfilePayload, UpdateProfileResponse, UserData } from '@interfaces'; +import { api } from '@utils'; + +export const updateProfileApi = async ( + payload: UpdateProfilePayload, +): Promise => { + 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('/api/staffupdate', formData); +}; + +export const getStaffDetailsApi = async ( + id: string | number, +): Promise => { + return await api.get(`/api/staffs/${id}`); +}; diff --git a/app/components/customerDetailHeader/customerDetailHeader.styles.ts b/app/components/customerDetailHeader/customerDetailHeader.styles.ts index 68f56eb..13beb65 100644 --- a/app/components/customerDetailHeader/customerDetailHeader.styles.ts +++ b/app/components/customerDetailHeader/customerDetailHeader.styles.ts @@ -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, - }, }); diff --git a/app/components/customerDetailHeader/customerDetailHeader.tsx b/app/components/customerDetailHeader/customerDetailHeader.tsx index fa9c096..cd3db32 100644 --- a/app/components/customerDetailHeader/customerDetailHeader.tsx +++ b/app/components/customerDetailHeader/customerDetailHeader.tsx @@ -37,12 +37,6 @@ export const CustomerDetailHeader: React.FC = ({ {fullname && company ? ( {fullname} ) : null} - - - {' '}{phonenumber || 'N/A'}{' '} - - {' '}{email || 'N/A'} - {datecreated ? ( Added {formatDate(datecreated)} @@ -64,36 +58,6 @@ export const CustomerDetailHeader: React.FC = ({ - - {/* Bottom Row: Action Buttons */} - - {phonenumber ? ( - - - Call - - ) : null} - - {email ? ( - - - Email - - ) : null} - - {website ? ( - - - Website - - ) : null} - ); }; diff --git a/app/components/leadDetailHeader/leadDetailHeader.styles.ts b/app/components/leadDetailHeader/leadDetailHeader.styles.ts index 9190798..b8af9a6 100644 --- a/app/components/leadDetailHeader/leadDetailHeader.styles.ts +++ b/app/components/leadDetailHeader/leadDetailHeader.styles.ts @@ -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', - }, }); diff --git a/app/components/leadDetailHeader/leadDetailHeader.tsx b/app/components/leadDetailHeader/leadDetailHeader.tsx index a0f625f..752ae61 100644 --- a/app/components/leadDetailHeader/leadDetailHeader.tsx +++ b/app/components/leadDetailHeader/leadDetailHeader.tsx @@ -57,12 +57,6 @@ export const LeadDetailHeader: React.FC = ({ {company ? ( {company} ) : null} - - - {' '}{phonenumber || 'N/A'}{' '} - - {' '}{email || 'N/A'} - @@ -112,22 +106,6 @@ export const LeadDetailHeader: React.FC = ({ )} - - {/* Bottom Row: Call + Email Buttons */} - - - - Call - - - - Email - - ); }; diff --git a/app/features/customerDetails/customerDetails.screen.tsx b/app/features/customerDetails/customerDetails.screen.tsx index 4f955a4..d333d36 100644 --- a/app/features/customerDetails/customerDetails.screen.tsx +++ b/app/features/customerDetails/customerDetails.screen.tsx @@ -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 = () => { <> handleEmailPress(customer.email)} + onPress={() => handleEmailPress(customer.email)} activeOpacity={0.7}> @@ -116,6 +110,9 @@ export const CustomerDetailsScreen = () => { Email {customer.email} + + + @@ -125,7 +122,7 @@ export const CustomerDetailsScreen = () => { <> handlePhonePress(customer.phonenumber)} + onPress={() => handlePhonePress(customer.phonenumber)} activeOpacity={0.7}> @@ -134,6 +131,9 @@ export const CustomerDetailsScreen = () => { Phone {customer.phonenumber} + + + @@ -143,7 +143,7 @@ export const CustomerDetailsScreen = () => { <> handleWebsitePress(customer.website)} + onPress={() => handleWebsitePress(customer.website)} activeOpacity={0.7}> @@ -152,6 +152,9 @@ export const CustomerDetailsScreen = () => { Website {customer.website} + + + diff --git a/app/features/customerDetails/customerDetails.styles.ts b/app/features/customerDetails/customerDetails.styles.ts index b7b6761..79be724 100644 --- a/app/features/customerDetails/customerDetails.styles.ts +++ b/app/features/customerDetails/customerDetails.styles.ts @@ -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: { diff --git a/app/features/editProfile/editProfile.screen.tsx b/app/features/editProfile/editProfile.screen.tsx new file mode 100644 index 0000000..329dd2c --- /dev/null +++ b/app/features/editProfile/editProfile.screen.tsx @@ -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 ; + } + + return ( + + + + {/* ── Avatar Edit Section ── */} + + + {hasProfileImage ? ( + + ) : ( + + {initials} + + )} + + + + + + + {/* ── Personal Info Card ── */} + + Basic Information + + {/* Staff ID (Read-only) */} + {}} + editable={false} + selectTextOnFocus={false} + leftIcon={} + inputStyle={{ color: colors.textMuted }} + /> + + {/* First & Last Name */} + + + } + /> + + + } + /> + + + + {/* Email (Required) */} + } + /> + + {/* Phone */} + } + /> + + + {/* ── Email Signature Card ── */} + {/* + Email Signature + + */} + + {/* ── Security Card ── */} + + Change Password + + Leave password fields empty if you don't wish to change your current password. + + + } + rightIcon={ + setShowPassword(!showPassword)} + activeOpacity={0.7}> + + + } + /> + + } + rightIcon={ + setShowConfirmPassword(!showConfirmPassword)} + activeOpacity={0.7}> + + + } + /> + + + {/* ── Save Button ── */} + + {loading ? ( + + ) : ( + <> + + Save Changes + + )} + + + + ); +}; diff --git a/app/features/editProfile/editProfile.styles.ts b/app/features/editProfile/editProfile.styles.ts new file mode 100644 index 0000000..548d0c3 --- /dev/null +++ b/app/features/editProfile/editProfile.styles.ts @@ -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', + }, + }); diff --git a/app/features/editProfile/index.ts b/app/features/editProfile/index.ts new file mode 100644 index 0000000..50bce89 --- /dev/null +++ b/app/features/editProfile/index.ts @@ -0,0 +1 @@ +export * from './editProfile.screen'; diff --git a/app/features/editProfile/reducers.ts b/app/features/editProfile/reducers.ts new file mode 100644 index 0000000..a6a5b55 --- /dev/null +++ b/app/features/editProfile/reducers.ts @@ -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; diff --git a/app/features/editProfile/thunk.ts b/app/features/editProfile/thunk.ts new file mode 100644 index 0000000..08aaedc --- /dev/null +++ b/app/features/editProfile/thunk.ts @@ -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( + '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( + 'editProfile/getStaffDetails', + async (id, { rejectWithValue }) => { + try { + return await getStaffDetailsApi(id); + } catch (error: any) { + return rejectWithValue(error.message || 'Failed to fetch staff details'); + } + }, +); diff --git a/app/features/index.ts b/app/features/index.ts index 1958a96..3e167fc 100644 --- a/app/features/index.ts +++ b/app/features/index.ts @@ -17,5 +17,6 @@ export * from './tickets'; export * from './customerDetails'; export * from './addCustomer'; export * from './notification'; +export * from './editProfile'; diff --git a/app/features/leadDetails/leadDetails.screen.tsx b/app/features/leadDetails/leadDetails.screen.tsx index 37f467e..181ea25 100644 --- a/app/features/leadDetails/leadDetails.screen.tsx +++ b/app/features/leadDetails/leadDetails.screen.tsx @@ -87,13 +87,9 @@ export const LeadDetailsScreen = () => { handleEmailPress(lead.email)} - onPhonePress={() => handlePhonePress(lead.phonenumber)} onStatusChange={handleStatusChange} /> @@ -124,7 +120,7 @@ export const LeadDetailsScreen = () => { <> handleEmailPress(lead.email)} + onPress={() => handleEmailPress(lead.email)} activeOpacity={0.7}> @@ -133,6 +129,9 @@ export const LeadDetailsScreen = () => { Email {lead.email} + + + @@ -142,7 +141,7 @@ export const LeadDetailsScreen = () => { <> handlePhonePress(lead.phonenumber)} + onPress={() => handlePhonePress(lead.phonenumber)} activeOpacity={0.7}> @@ -151,6 +150,9 @@ export const LeadDetailsScreen = () => { Phone {lead.phonenumber} + + + @@ -160,7 +162,7 @@ export const LeadDetailsScreen = () => { <> handleWebsitePress(lead.website)} + onPress={() => handleWebsitePress(lead.website)} activeOpacity={0.7}> @@ -169,6 +171,9 @@ export const LeadDetailsScreen = () => { Website {lead.website} + + + diff --git a/app/features/leadDetails/leadDetails.styles.ts b/app/features/leadDetails/leadDetails.styles.ts index 745060f..b4fa734 100644 --- a/app/features/leadDetails/leadDetails.styles.ts +++ b/app/features/leadDetails/leadDetails.styles.ts @@ -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, }, diff --git a/app/features/notification/notification.screen.tsx b/app/features/notification/notification.screen.tsx index c5ba919..5b2c7b6 100644 --- a/app/features/notification/notification.screen.tsx +++ b/app/features/notification/notification.screen.tsx @@ -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 }) => { diff --git a/app/features/profile/profile.screen.tsx b/app/features/profile/profile.screen.tsx index bd3440a..e71e98e 100644 --- a/app/features/profile/profile.screen.tsx +++ b/app/features/profile/profile.screen.tsx @@ -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 = () => { {hasProfileImage ? ( ) : ( diff --git a/app/interfaces/index.ts b/app/interfaces/index.ts index 29e4333..ac1bcbd 100644 --- a/app/interfaces/index.ts +++ b/app/interfaces/index.ts @@ -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'; diff --git a/app/interfaces/profile.ts b/app/interfaces/profile.ts new file mode 100644 index 0000000..0c65ab0 --- /dev/null +++ b/app/interfaces/profile.ts @@ -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 +} diff --git a/app/navigation/customDrawerContent.tsx b/app/navigation/customDrawerContent.tsx index 55f1081..b82988d 100644 --- a/app/navigation/customDrawerContent.tsx +++ b/app/navigation/customDrawerContent.tsx @@ -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) => { {hasProfileImage ? ( ) : ( diff --git a/app/navigation/drawerStack.tsx b/app/navigation/drawerStack.tsx index db2430c..02fed6a 100644 --- a/app/navigation/drawerStack.tsx +++ b/app/navigation/drawerStack.tsx @@ -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 | undefined; }; const Drawer = createDrawerNavigator(); diff --git a/app/navigation/rootNavigator.tsx b/app/navigation/rootNavigator.tsx index c8e0341..0cfe018 100644 --- a/app/navigation/rootNavigator.tsx +++ b/app/navigation/rootNavigator.tsx @@ -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 | 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(); export const RootNavigator = () => { @@ -18,7 +27,7 @@ export const RootNavigator = () => { const initialRouteName = token ? 'DrawerStack' : 'AuthStack'; return ( - + ; +export type DashboardStackParamList = { + dashboardList: undefined; + notifications: undefined; +}; + +export type AddLeadStackParamList = { + addLeadForm: undefined; +}; + +export type ProfileStackParamList = { + profileForm: undefined; + editProfile: undefined; +}; + +export type TabStackParamList = { + dashboard: NavigatorScreenParams | undefined; + leads: NavigatorScreenParams | undefined; + addLead: NavigatorScreenParams | undefined; + customers: NavigatorScreenParams | undefined; + profile: NavigatorScreenParams | undefined; +}; const Tab = createBottomTabNavigator(); -const DashboardStackNav = createNativeStackNavigator(); +const DashboardStackNav = createNativeStackNavigator(); const DashboardStack = () => { const { theme: colors } = useTheme(); const styles = getStyles(colors); @@ -103,7 +122,7 @@ const DashboardStack = () => { ); }; -const AddLeadStackNav = createNativeStackNavigator(); +const AddLeadStackNav = createNativeStackNavigator(); const AddLeadStack = () => { const { theme: colors } = useTheme(); const styles = getStyles(colors); @@ -149,7 +168,7 @@ const AddLeadStack = () => { ); }; -const ProfileStackNav = createNativeStackNavigator(); +const ProfileStackNav = createNativeStackNavigator(); const ProfileStack = () => { const { theme: colors } = useTheme(); const styles = getStyles(colors); @@ -189,8 +208,37 @@ const ProfileStack = () => { ), + headerRight: () => ( + 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 }}> + + + ), })} /> + ); }; @@ -240,6 +288,14 @@ export const TabStack = () => { options={{ tabBarLabel: 'Dashboard', }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + e.preventDefault(); + navigation.navigate(route.dashboard, { + screen: 'dashboardList', + }); + }, + })} /> { options={{ tabBarLabel: 'Leads', }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + e.preventDefault(); + navigation.navigate(route.leads, { + screen: route.leadsList, + }); + }, + })} /> { options={{ tabBarLabel: 'Add Lead', }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + e.preventDefault(); + navigation.navigate(route.addLead, { + screen: 'addLeadForm', + }); + }, + })} /> { options={{ tabBarLabel: 'Customers', }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + e.preventDefault(); + navigation.navigate(route.customers, { + screen: route.customersList, + }); + }, + })} /> { options={{ tabBarLabel: 'Profile', }} + listeners={({ navigation }) => ({ + tabPress: (e) => { + e.preventDefault(); + navigation.navigate(route.profile, { + screen: 'profileForm', + }); + }, + })} /> ); diff --git a/app/services/notifications.ts b/app/services/notifications.ts index 583d4b6..b10a283 100644 --- a/app/services/notifications.ts +++ b/app/services/notifications.ts @@ -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 | undefined; + if (data?.type === 'lead' && data?.lead_id) { + navigateToLeadDetails(data.lead_id); + } } }); } diff --git a/app/store/commonReducers/auth/reducers.ts b/app/store/commonReducers/auth/reducers.ts index 2b6bc86..fe4f1cf 100644 --- a/app/store/commonReducers/auth/reducers.ts +++ b/app/store/commonReducers/auth/reducers.ts @@ -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>('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; + diff --git a/app/store/rootReducer.ts b/app/store/rootReducer.ts index 609bcc5..ea9e82f 100644 --- a/app/store/rootReducer.ts +++ b/app/store/rootReducer.ts @@ -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) => { diff --git a/app/utils/index.ts b/app/utils/index.ts index 9a0bc25..077b00a 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -2,3 +2,4 @@ export * from './route'; export * from './assets'; export * from './api'; export * from './helper'; +export * from './navigationRef'; diff --git a/app/utils/navigationRef.ts b/app/utils/navigationRef.ts new file mode 100644 index 0000000..db4844c --- /dev/null +++ b/app/utils/navigationRef.ts @@ -0,0 +1,44 @@ +import { createRef } from 'react'; +import { NavigationContainerRef, CommonActions } from '@react-navigation/native'; + +// Shared ref — attach to +export const navigationRef = + createRef>(); + +// 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; + } +} diff --git a/app/utils/route.ts b/app/utils/route.ts index aea11ad..0733637 100644 --- a/app/utils/route.ts +++ b/app/utils/route.ts @@ -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; }; diff --git a/package.json b/package.json index 1f0c2e4..02ea21f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 7b33969..9544488 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"