diff --git a/ProtectedRouter.tsx b/ProtectedRouter.tsx index ac0ba48a..3c802ff1 100644 --- a/ProtectedRouter.tsx +++ b/ProtectedRouter.tsx @@ -35,6 +35,10 @@ import FeedbackDetailContainer from './src/screens/caseDetails/feedback/Feedback import EmiSchedule from './src/screens/emiSchedule'; import { NetworkStatusService } from "./src/services/network-monitoring.service"; import AddNewNumber from './src/screens/addNewNumber'; +import useFCM from './src/hooks/useFCM'; +import Notifications from './src/screens/notifications'; +import { getNotifications, notificationAction } from './src/action/notificationActions'; +import useIsOnline from './src/hooks/useIsOnline'; const ANIMATION_DURATION = 300; @@ -54,6 +58,9 @@ const ProtectedRouter = () => { (state: RootState) => state.user, ); const { newVisitedCases, caseDetails } = useAppSelector(state => state.allCases); + const { data = [], notificationsWithActions } = useAppSelector(state => state.notifications); + const {isLoggedIn, deviceId, sessionDetails} = user; + const isOnline = useIsOnline(); useEffect(() => { if(newVisitedCases?.length) { @@ -73,6 +80,20 @@ const ProtectedRouter = () => { } }, [newVisitedCases]) + useEffect(() => { + if (data?.length) { + return; + } + dispatch(getNotifications()); + }, []); + + useEffect(() => { + if(isLoggedIn && isOnline && notificationsWithActions.length) { + dispatch(notificationAction(notificationsWithActions)); + } + }, [isLoggedIn, isOnline, notificationsWithActions]) + + const avTemplate = useSelector( (state: RootState) => state.case.templateData[CaseAllocationType.ADDRESS_VERIFICATION_CASE], ); @@ -81,14 +102,15 @@ const ProtectedRouter = () => { (state: RootState) => state.case.templateData[CaseAllocationType.COLLECTION_CASE], ); - const {isLoggedIn, deviceId, sessionDetails} = user; - interactionsHandler() const dispatch = useAppDispatch(); NetworkStatusService.listenForOnline(dispatch); // Firestore listener hook - useFirestoreUpdates(); + useFirestoreUpdates(); + + // Firebase cloud messaging + useFCM(); if (!deviceId) { getUniqueId().then(id => dispatch(setDeviceId(id))); @@ -275,6 +297,16 @@ const ProtectedRouter = () => { }} listeners={getScreenFocusListenerObj} /> + null, + animation: 'slide_from_right', + animationDuration: ANIMATION_DURATION, + }} + listeners={getScreenFocusListenerObj} + /> ) : ( <> diff --git a/RN-UI-LIB b/RN-UI-LIB index 70084651..106cc696 160000 --- a/RN-UI-LIB +++ b/RN-UI-LIB @@ -1 +1 @@ -Subproject commit 70084651bb3bd0030402f584551a7f4a6432342b +Subproject commit 106cc696c1f1c713c8318c7e2f1963cc45635df9 diff --git a/package.json b/package.json index 4d0ee5d5..b8a970a6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@react-native-firebase/crashlytics": "16.5.0", "@react-native-firebase/database": "16.4.6", "@react-native-firebase/firestore": "16.5.0", + "@react-native-firebase/messaging": "17.4.0", "@react-navigation/bottom-tabs": "6.5.5", "@react-navigation/native": "6.1.4", "@react-navigation/native-stack": "6.9.4", diff --git a/src/action/authActions.ts b/src/action/authActions.ts index 7bf954bc..cd6a466c 100644 --- a/src/action/authActions.ts +++ b/src/action/authActions.ts @@ -19,6 +19,7 @@ import { AppDispatch } from '../store/store'; import { setGlobalUserData } from '../constants/Global'; import { resetCasesData } from '../reducer/allCasesSlice'; import {toast} from "../../RN-UI-LIB/src/components/toast"; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { clearAllAsyncStorage } from '../components/utlis/commonFunctions'; import { logError } from '../components/utlis/errorUtils'; @@ -63,14 +64,15 @@ export const generateOTP = export const verifyOTP = ({ otp, otpToken }: VerifyOTPPayload) => - (dispatch: AppDispatch) => { + async(dispatch: AppDispatch) => { const url = getApiUrl(ApiKeys.VERIFY_OTP); - dispatch(setFormLoading(true)); + const fcmToken = await AsyncStorage.getItem('fcmtoken'); + axiosInstance .post( url, - { otp, otpToken }, + { otp, otpToken, fcmToken }, { headers: { donotHandleError: true } }, ) .then((response: AxiosResponse) => { diff --git a/src/action/notificationActions.ts b/src/action/notificationActions.ts new file mode 100644 index 00000000..0a5276e1 --- /dev/null +++ b/src/action/notificationActions.ts @@ -0,0 +1,90 @@ +import axiosInstance, { + ApiKeys, + getApiUrl, +} from '../components/utlis/apiHelper'; +import { + INotificationAction, + addActionToNotifications, + appendMoreNotifications, + setNotifications, + setNotificationsLoading, +} from '../reducer/notificationsSlice'; +import { INotification } from '../screens/notifications/NotificationItem'; +import { AppDispatch } from '../store/store'; + +interface INotificationPayload { + pageNo: number; + pageSize: number; +} + +export const hasNotCompletedAction = (notification: INotification) => { + if (!notification.actions) { + return true; + } + return !Object.keys(notification.actions)?.length; +}; + +export const getNotifications = + (payload: INotificationPayload = { pageNo: 1, pageSize: 10 }) => + (dispatch: AppDispatch) => { + dispatch(setNotificationsLoading(true)); + let currentDate = new Date(); + const yesterday = new Date(currentDate); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + const startTime = yesterday.getTime(); + const url = getApiUrl( + ApiKeys.NOTIFICATIONS, + {}, + { ...payload, startTime }, + ); + return axiosInstance + .get(url) + .then(response => { + const notifications = + response?.data?.notificationResponse?.notifications; + const { totalElements, totalPages, totalUnreadElements } = + response?.data; + if (notifications) { + const { pageNo } = payload; + if (pageNo === 1) { + dispatch( + setNotifications({ + notifications, + totalElements, + totalPages, + pageNo: payload.pageNo, + totalUnreadElements, + }), + ); + return; + } + dispatch( + appendMoreNotifications({ + notifications, + totalElements, + totalPages, + pageNo: payload.pageNo, + totalUnreadElements, + }), + ); + } + }) + .finally(() => dispatch(setNotificationsLoading(false))); + }; + +export const notificationAction = + (payload: INotificationAction[]) => (dispatch: AppDispatch) => { + const url = getApiUrl(ApiKeys.NOTIFICATION_ACTION); + return axiosInstance.post(url, payload).then(() => { + dispatch(addActionToNotifications(payload)); + }); + }; + +export const notificationDelivered = + (payload: { ids: string[] }) => (dispatch: AppDispatch) => { + const url = getApiUrl(ApiKeys.NOTIFICATION_DELIVERED); + return axiosInstance.post(url, payload).then(res => { + console.log('res:', res); + }); + }; diff --git a/src/assets/icons/NoNotificationIcon.tsx b/src/assets/icons/NoNotificationIcon.tsx new file mode 100644 index 00000000..b64b2e7c --- /dev/null +++ b/src/assets/icons/NoNotificationIcon.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import Svg, { Path, Rect } from "react-native-svg"; + +const NoNotificationIcon = () => ( + + + + + + + + +); +export default NoNotificationIcon; diff --git a/src/common/Constants.ts b/src/common/Constants.ts index 772719ec..7254d7b3 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -48,6 +48,9 @@ export const ClickstreamAPIToMonitor = { [API_URLS[ApiKeys.CASE_UNIFIED_DETAILS]]: 'AV_CASE_UNIFIED_DETAILS_API', [API_URLS[ApiKeys.EMI_SCHEDULES]]: 'AV_EMI_SCHEDULES_API', [API_URLS[ApiKeys.PAST_FEEDBACK]]: 'AV_PAST_FEEDBACK_API', + [API_URLS[ApiKeys.NOTIFICATIONS]]: 'AV_NOTIFICATIONS_FETCH_API', + [API_URLS[ApiKeys.NOTIFICATION_ACTION]]: 'AV_NOTIFICATIONS_ACTION_API', + [API_URLS[ApiKeys.NOTIFICATION_DELIVERED]]: 'AV_NOTIFICATIONS_DELIVERED_API', }; export const CLICKSTREAM_EVENT_NAMES = { @@ -176,7 +179,17 @@ export const CLICKSTREAM_EVENT_NAMES = { FA_VIEW_PHOTO_CLICKED: {name: 'FA_VIEW_PHOTO_CLICKED', description: 'FA_VIEW_PHOTO_CLICKED'}, FA_UNIFIED_ENTITY_REQUESTED: {name: 'FA_UNIFIED_ENTITY_REQUESTED', description: 'FA_UNIFIED_ENTITY_REQUESTED'}, FA_UNIFIED_ENTITY_REQUEST_SUCCESS: {name: 'FA_UNIFIED_ENTITY_REQUEST_SUCCESS', description: 'FA_UNIFIED_ENTITY_REQUEST_SUCCESS'}, - FA_UNIFIED_ENTITY_REQUEST_FAILED: {name: 'FA_UNIFIED_ENTITY_REQUEST_FAILED', description: 'FA_UNIFIED_ENTITY_REQUEST_FAILED'} + FA_UNIFIED_ENTITY_REQUEST_FAILED: {name: 'FA_UNIFIED_ENTITY_REQUEST_FAILED', description: 'FA_UNIFIED_ENTITY_REQUEST_FAILED'}, + + // Notifications + FA_NOTIFICATION_ICON_CLICK: {name: 'FA_NOTIFICATION_ICON_CLICK', description: 'FA_NOTIFICATION_ICON_CLICK'}, + FA_NOTIFICATION_ITEM_CLICK: {name: 'FA_NOTIFICATION_ITEM_CLICK', description: 'FA_NOTIFICATION_ITEM_CLICK'}, + AV_NOTIFICATIONS_FETCH_API_SUCCESS : {name: 'FA_NOTIFICATIONS_FETCH_API_SUCCESS', description: 'FA_NOTIFICATIONS_FETCH_API_SUCCESS'}, + AV_NOTIFICATIONS_FETCH_API_FAILED : {name: 'FA_NOTIFICATIONS_FETCH_API_FAILED', description: 'FA_NOTIFICATIONS_FETCH_API_FAILED'}, + AV_NOTIFICATION_ACTION_API_SUCCESS : {name: 'FA_NOTIFICATION_ACTION_API_SUCCESS', description: 'FA_NOTIFICATION_ACTION_API_SUCCESS'}, + AV_NOTIFICATION_ACTION_API_FAILED : {name: 'FA_NOTIFICATION_ACTION_API_FAILED', description: 'FA_NOTIFICATION_ACTION_API_FAILED'}, + AV_NOTIFICATION_DELIVERED_API_SUCCESS : {name: 'FA_NOTIFICATION_DELIVERED_API_SUCCESS', description: 'FA_NOTIFICATION_DELIVERED_API_SUCCESS'}, + AV_NOTIFICATION_DELIVERED_API_FAILED : {name: 'FA_NOTIFICATION_DELIVERED_API_FAILED', description: 'FA_NOTIFICATION_DELIVERED_API_FAILED'}, } as const; export enum MimeType { @@ -192,8 +205,8 @@ export const getPrefixBase64Image = (contentType: MimeType) => { export const PrefixJpegBase64Image = getPrefixBase64Image(MimeType["image/jpeg"]); -export const HEADER_HEIGHT_MAX = 112 + 50; -export const HEADER_HEIGHT_MIN = 70 + 50; +export const HEADER_HEIGHT_MAX = 134 + 50; +export const HEADER_HEIGHT_MIN = 80 + 50; export const HEADER_SCROLL_DISTANCE = (HEADER_HEIGHT_MAX - HEADER_HEIGHT_MIN) * 2; export const LocalStorageKeys = { diff --git a/src/components/notificationMenu/index.tsx b/src/components/notificationMenu/index.tsx new file mode 100644 index 00000000..9248631b --- /dev/null +++ b/src/components/notificationMenu/index.tsx @@ -0,0 +1,59 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import React from 'react'; +import { pushToScreen } from '../utlis/navigationUtlis'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import BellIcon from '../../../RN-UI-LIB/src/Icons/BellIcon'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { useAppSelector } from '../../hooks'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { addClickstreamEvent } from '../../services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '../../common/Constants'; + +const NotificationMenu = () => { + const { totalUnreadElements } = useAppSelector( + state => state.notifications, + ); + + const handleNotificationPress = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_NOTIFICATION_ICON_CLICK) + pushToScreen('Notifications') + }; + + return ( + + + + {totalUnreadElements ? ( + 9 ? 24 : 16 }, + ]}> + {totalUnreadElements > 9 ? '9+' : totalUnreadElements} + + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + notificationBadge: { + backgroundColor: COLORS.TEXT.RED, + position: 'absolute', + height: 16, + marginLeft: 18, + marginTop: -4, + borderRadius: 8, + zIndex: 100 + }, + notificationNumber: { + color: COLORS.TEXT.WHITE, + lineHeight: 16, + paddingHorizontal: 5, + }, +}); + +export default NotificationMenu; diff --git a/src/components/utlis/apiHelper.ts b/src/components/utlis/apiHelper.ts index 0a626b0f..a5599729 100644 --- a/src/components/utlis/apiHelper.ts +++ b/src/components/utlis/apiHelper.ts @@ -28,6 +28,9 @@ export enum ApiKeys { CASE_UNIFIED_DETAILS, EMI_SCHEDULES, PAST_FEEDBACK, + NOTIFICATIONS, + NOTIFICATION_ACTION, + NOTIFICATION_DELIVERED, SEND_LOCATION, } @@ -48,6 +51,9 @@ API_URLS[ApiKeys.GET_SIGNED_URL] = '/cases/get-signed-urls'; API_URLS[ApiKeys.CASE_UNIFIED_DETAILS] = '/collection-cases/unified-details/{loanAccountNumber}'; API_URLS[ApiKeys.EMI_SCHEDULES] = '/collection-cases/emi-schedules'; API_URLS[ApiKeys.PAST_FEEDBACK] = '/feedback'; +API_URLS[ApiKeys.NOTIFICATIONS] = '/notification/fetch'; +API_URLS[ApiKeys.NOTIFICATION_ACTION] = '/notification/action'; +API_URLS[ApiKeys.NOTIFICATION_DELIVERED] = '/notification/delivered'; API_URLS[ApiKeys.SEND_LOCATION] = '/geolocations/agents'; export const API_STATUS_CODE = { diff --git a/src/hooks/useFCM.ts b/src/hooks/useFCM.ts new file mode 100644 index 00000000..1033772d --- /dev/null +++ b/src/hooks/useFCM.ts @@ -0,0 +1,63 @@ +import messaging, { + FirebaseMessagingTypes, +} from '@react-native-firebase/messaging'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useEffect } from 'react'; +import auth from '@react-native-firebase/auth'; +import { useAppDispatch } from '.'; +import { prependNewNotifications } from '../reducer/notificationsSlice'; +import { notificationDelivered } from '../action/notificationActions'; +import { logError } from '../components/utlis/errorUtils'; + +// This can be used to get Notification permission. +const requestUserPermission = async () => { + const authStatus = await messaging().requestPermission(); + const enabled = + authStatus === messaging.AuthorizationStatus.AUTHORIZED || + authStatus === messaging.AuthorizationStatus.PROVISIONAL; + if (enabled) { + getFCMToken(); + } +}; +const getFCMToken = async () => { + let fcmtoken = await AsyncStorage.getItem('fcmtoken'); + if (!fcmtoken) { + try { + let fcmtoken = await messaging().getToken(); + if (fcmtoken) { + await AsyncStorage.setItem('fcmtoken', fcmtoken); + } + } catch (error: any) { + logError(error, 'unable to generate FCM token') + } + } +}; + +const notificationListener = ( + handleNotificationMessage: ( + remoteMessage: FirebaseMessagingTypes.RemoteMessage, + ) => Promise, +) => { + messaging().setBackgroundMessageHandler(handleNotificationMessage); + messaging().onMessage(handleNotificationMessage); +}; + +const useFCM = () => { + const dispatch = useAppDispatch(); + const handleNotificationMessage = async ( + remoteMessage: FirebaseMessagingTypes.RemoteMessage, + ) => { + const { data } = remoteMessage; + if (data?.payload) { + const notification = JSON.parse(data.payload); + dispatch(notificationDelivered({ids: [notification.id]})) + dispatch(prependNewNotifications({ notification })); + } + }; + useEffect(() => { + getFCMToken(); + notificationListener(handleNotificationMessage); + }, []); +}; + +export default useFCM; diff --git a/src/reducer/notificationsSlice.ts b/src/reducer/notificationsSlice.ts new file mode 100644 index 00000000..6a3c82a8 --- /dev/null +++ b/src/reducer/notificationsSlice.ts @@ -0,0 +1,124 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { INotification } from '../screens/notifications/NotificationItem'; +import { WidgetStatus } from '../screens/notifications/constants'; + +export interface INotificationAction { + id: string; + action: { + widgetStatus: WidgetStatus; + timestamp: number; + actionBy: string; + }; +} +interface INotificationsState { + data: INotification[]; + pageNo: number; + pageSize: number; + totalElements: number; + totalPages: number; + newNotificationCount: number; + totalUnreadElements: number; + notificationsWithActions: INotificationAction[]; + isLoading: boolean; +} + +const initialState: INotificationsState = { + data: [], + pageNo: 1, + pageSize: 10, + totalElements: 0, + totalPages: 0, + newNotificationCount: 0, + totalUnreadElements: 0, + notificationsWithActions: [], + isLoading: false, +}; + +const NotificationsSlice = createSlice({ + name: 'notificationsSlice', + initialState, + reducers: { + setNotifications: (state, action) => { + const { + notifications, + totalElements, + totalPages, + pageNo, + totalUnreadElements, + } = action.payload; + state.data = notifications; + state.totalElements = totalElements; + state.totalPages = totalPages; + state.newNotificationCount = 0; + state.totalUnreadElements = totalUnreadElements; + state.pageNo = pageNo; + state.isLoading = false; + }, + prependNewNotifications: (state, action) => { + const { notification } = action.payload; + const { id } = notification; + const isNotificationAlreadyPresent = + state.data.findIndex(notification => notification.id === id) !== + -1; + state.isLoading = false; + if (isNotificationAlreadyPresent) { + return; + } + state.data = [notification, ...state.data]; + state.newNotificationCount++; + state.totalUnreadElements++; + }, + appendMoreNotifications: (state, action) => { + const { + notifications, + totalElements, + pageNo, + totalPages, + totalUnreadElements, + } = action.payload; + state.data = [...state.data, ...notifications]; + state.totalElements = totalElements; + state.totalPages = totalPages; + state.pageNo = pageNo; + state.totalUnreadElements = totalUnreadElements; + state.isLoading = false; + }, + addNotificationToQueue: (state, action) => { + state.notificationsWithActions = [ + ...state.notificationsWithActions, + action.payload, + ]; + }, + addActionToNotifications: (state, action) => { + const actionPayload = action.payload; + actionPayload.forEach((action: INotificationAction) => { + const { id } = action; + const index = state.data.findIndex( + notification => notification.id === id, + ); + if (index !== -1) { + state.data[index].actions = [ + { + widgetStatus: WidgetStatus.CLICKED, + }, + ]; + } + }); + state.notificationsWithActions = []; + }, + setNotificationsLoading: (state, action) => { + state.isLoading = action.payload; + }, + }, +}); + +export const { + setNotifications, + prependNewNotifications, + appendMoreNotifications, + setNotificationsLoading, + addNotificationToQueue, + addActionToNotifications, +} = NotificationsSlice.actions; + +export default NotificationsSlice.reducer; diff --git a/src/screens/allCases/CaseListHeader.tsx b/src/screens/allCases/CaseListHeader.tsx index 2f2f4529..5bf62020 100644 --- a/src/screens/allCases/CaseListHeader.tsx +++ b/src/screens/allCases/CaseListHeader.tsx @@ -4,6 +4,7 @@ import Heading from '../../../RN-UI-LIB/src/components/Heading'; import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; import Filters from './Filters'; +import NotificationMenu from '../../components/notificationMenu'; interface ICaseListHeader { filterCount: number; @@ -32,12 +33,17 @@ const CaseListHeader: React.FC = ({ styles.filterContainer, { height: showFilters ? headerHeight : 'auto' }, ]}> - - + + {headerLabel} + {showFilters && ( = ({searchQuery, filterCount, handleSearchChange, toggleFilterModal , isVisitPlan}) => { return ( - + } @@ -100,9 +100,6 @@ const styles = StyleSheet.create({ chips: { paddingHorizontal: 18, backgroundColor: COLORS.BACKGROUND.GREY_LIGHT, - }, - searchContainer: { - paddingBottom: 10 } }); diff --git a/src/screens/caseDetails/CaseDetailHeader.tsx b/src/screens/caseDetails/CaseDetailHeader.tsx index c9a77e96..3ccdd0c6 100644 --- a/src/screens/caseDetails/CaseDetailHeader.tsx +++ b/src/screens/caseDetails/CaseDetailHeader.tsx @@ -2,13 +2,18 @@ import React from 'react'; import NavigationHeader from '../../../RN-UI-LIB/src/components/NavigationHeader'; import {goBack} from '../../components/utlis/navigationUtlis'; import {CaseDetail} from './interface'; +import { View } from 'react-native'; +import NotificationMenu from '../../components/notificationMenu'; const CaseDetailHeader: React.FC<{ caseDetail: CaseDetail }> = props => { return ( - + + } + /> + ); }; diff --git a/src/screens/notifications/MarkAllRead.tsx b/src/screens/notifications/MarkAllRead.tsx new file mode 100644 index 00000000..86601d29 --- /dev/null +++ b/src/screens/notifications/MarkAllRead.tsx @@ -0,0 +1,27 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import DoubleCheckIcon from '../../../RN-UI-LIB/src/Icons/DoubleCheckIcon'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; + +const MarkAllRead = () => { + const handleMarkAllRead = () => {}; + return ( + + + + + Mark all read + + + + ); +}; + +const styles = StyleSheet.create({ + label: { + color: COLORS.TEXT.WHITE, + }, +}); + +export default MarkAllRead; diff --git a/src/screens/notifications/NotificationItem.tsx b/src/screens/notifications/NotificationItem.tsx new file mode 100644 index 00000000..38d91013 --- /dev/null +++ b/src/screens/notifications/NotificationItem.tsx @@ -0,0 +1,182 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import React from 'react'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import { + NotificationIconsMap, + NotificationTypes, + WidgetStatus, +} from './constants'; +import Heading from '../../../RN-UI-LIB/src/components/Heading'; +import { getTimeDifference } from '../../../RN-UI-LIB/src/utlis/common'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { CaseDetail } from '../caseDetails/interface'; +import NotificationTemplate from './NotificationTemplate'; +import { CaseAllocationType } from '../allCases/interface'; +import { pushToScreen } from '../../components/utlis/navigationUtlis'; +import { + hasNotCompletedAction, + notificationAction, +} from '../../action/notificationActions'; +import useIsOnline from '../../hooks/useIsOnline'; +import { addNotificationToQueue } from '../../reducer/notificationsSlice'; +import { addClickstreamEvent } from '../../services/clickstreamEventService'; +import { CLICKSTREAM_EVENT_NAMES } from '../../common/Constants'; + +export interface INotification { + id: string; + clickable: boolean; + collectionCaseId: string; + displayed: boolean; + params: { + amount: number; + customerName: string; + paymentMode: string; + paymentTimestamp: string; + revisitDate: string; + revisitTime: string; + variation: string; + promisedDate: string; + promisedAmount: number; + }; + template: { + id: number; + templateName: keyof typeof NotificationTypes; + }; + actions: Record[]; + createdAt: number; + updatedAt: number; + scheduledAt: number; + eventTimestamp: number; + headerLabel: string; +} + +interface INotificationProps { + data: INotification; + label?: string; +} + +const NotificationItem: React.FC = ({ data }) => { + const { + id, + clickable, + template, + scheduledAt, + collectionCaseId, + params, + headerLabel, + } = data; + if (headerLabel) { + return ( + + {headerLabel} + + ); + } + const templateName = template?.templateName || ''; + const caseDetailsMap: Record = useAppSelector( + state => state.allCases.caseDetails, + ); + const { notificationsWithActions } = useAppSelector( + state => state.notifications, + ); + const { phoneNumber } = useAppSelector(state => state.user.user!!); + const dispatch = useAppDispatch(); + + const isOnline = useIsOnline(); + + const customerName = params?.customerName || ''; + + const handleNotificationAction = () => { + const payload = { + id: id, + action: { + widgetStatus: WidgetStatus.CLICKED, + timestamp: Date.now(), + actionBy: phoneNumber, + }, + }; + if (isOnline) { + dispatch( + notificationAction([...notificationsWithActions, payload]), + ); + } else { + dispatch(addNotificationToQueue(payload)); + } + }; + + const handleNotificationPress = () => { + addClickstreamEvent(CLICKSTREAM_EVENT_NAMES.FA_NOTIFICATION_ITEM_CLICK, {collectionCaseId, notificationId: id, templateName}) + const isActionNotCompleted = hasNotCompletedAction(data); + + if (isActionNotCompleted) { + handleNotificationAction(); + } + if (!collectionCaseId || !clickable) { + return; + } + const caseDetails = caseDetailsMap?.[collectionCaseId]; + if (!caseDetails) { + return; + } + const { caseType } = caseDetails; + if (caseType === CaseAllocationType.COLLECTION_CASE) { + pushToScreen('collectionCaseDetail', { + caseId: collectionCaseId, + }); + } else { + pushToScreen('caseDetail', { caseId: collectionCaseId }); + } + }; + + const notificationIcon = NotificationIconsMap[templateName]; + const notificationRead = !hasNotCompletedAction(data); + + return ( + + {notificationIcon} + + + {customerName} + + + {getTimeDifference(scheduledAt)} + + {!notificationRead ? : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderBottomWidth: 1, + borderColor: COLORS.BORDER.GREY, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: COLORS.BACKGROUND.DARK_BLUE, + marginLeft: 'auto', + }, + darkBg: { + backgroundColor: COLORS.BACKGROUND.BLUE_LIGHT_2, + }, +}); + +export default NotificationItem; diff --git a/src/screens/notifications/NotificationTemplate.tsx b/src/screens/notifications/NotificationTemplate.tsx new file mode 100644 index 00000000..9c2770d9 --- /dev/null +++ b/src/screens/notifications/NotificationTemplate.tsx @@ -0,0 +1,121 @@ +import { StyleSheet } from 'react-native'; +import React from 'react'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import { INotification } from './NotificationItem'; +import { NotificationTypes } from './constants'; +import { formatAmount } from '../../../RN-UI-LIB/src/utlis/amount'; + +interface INotificationTemplateProps { + data: INotification; +} + +const NotificationTemplate: React.FC = ({ + data, +}) => { + const { template, params } = data; + const templateName = template?.templateName || ''; + const { + amount, + paymentTimestamp, + customerName, + revisitDate, + revisitTime, + variation, + promisedDate, + promisedAmount, + } = params || {}; + + switch (templateName) { + case NotificationTypes.NEW_ADDRESS_ADDED_NOTIFICATION_TEMPLATE: + return ( + + New Address added for {customerName} + + ); + case NotificationTypes.PAYMENT_FAILED_TEMPLATE: + return ( + + Faced an error while making a payment of{' '} + {formatAmount(amount)} on{' '} + {paymentTimestamp} + + ); + case NotificationTypes.PAYMENT_MADE_TEMPLATE: + return ( + + Made a payment of {formatAmount(amount)}{' '} + on + {paymentTimestamp} + + ); + case NotificationTypes.PROMISED_TO_PAY_SCHEDULED_NOTIFICATION_TEMPLATE: + return ( + + Promise to pay{' '} + {formatAmount(promisedAmount)} on{' '} + {promisedDate} + + ); + case NotificationTypes.LONGHORN_ALERTS_NEW_PHONE_NUMBER_ADDED: + case NotificationTypes.NEW_TELEPHONE_ADDED_NOTIFICATION_TEMPLATE: + return ( + + New Number added for {customerName} + + ); + case NotificationTypes.REVISIT_SCHEDULED_NOTIFICATION_TEMPLATE: + return ( + + Revisit case of {customerName} on{' '} + {revisitDate} at {revisitTime} + + ); + case NotificationTypes.LONGHORN_ALERTS_NEW_ENQUIRY: + return ( + + New loan Enquiry{' '} + was made by {customerName} + + ); + case NotificationTypes.LONGHORN_ALERTS_NEW_TRADE_ACCOUNT: + return ( + + New loan is taken by {customerName} + + ); + case NotificationTypes.LONGHORN_ALERTS_CHANGE_IN_UTILIZATION: + return ( + + {variation} in POS{' '} + across loans for {customerName} + + ); + case NotificationTypes.LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY_ACCOUNTS: + return ( + + {variation} in DPD{' '} + across loan accounts for {customerName} + + ); + case NotificationTypes.LONGHORN_ALERTS_CHANGE_IN_SCORE: + return ( + + {variation} in CIBIL score{' '} + for {customerName} + + ); + case NotificationTypes.LONGHORN_ALERTS_MULTIPLE_TRIGGER_ALERTS: + return ( + + Multiple CIBIL alerts{' '} + received for {customerName} + + ); + default: + return New notification; + } +}; + +const styles = StyleSheet.create({}); + +export default NotificationTemplate; diff --git a/src/screens/notifications/constants.tsx b/src/screens/notifications/constants.tsx new file mode 100644 index 00000000..b7726e45 --- /dev/null +++ b/src/screens/notifications/constants.tsx @@ -0,0 +1,44 @@ +import NewAddressIcon from '../../../RN-UI-LIB/src/Icons/NewAddressIcon'; +import NewTelephoneIcon from '../../../RN-UI-LIB/src/Icons/NewTelephoneIcon'; +import NotificationIcon from '../../../RN-UI-LIB/src/Icons/NotificationIcon'; +import PaymentFailedIcon from '../../../RN-UI-LIB/src/Icons/PaymentFailedIcon'; +import PaymentSuccessIcon from '../../../RN-UI-LIB/src/Icons/PaymentSuccessIcon'; +import PromiseToPayIcon from '../../../RN-UI-LIB/src/Icons/PromiseToPayIcon'; + +export const NotificationIconsMap = { + NEW_ADDRESS_ADDED_NOTIFICATION_TEMPLATE: , + PAYMENT_FAILED_TEMPLATE: , + PAYMENT_MADE_TEMPLATE: , + PROMISED_TO_PAY_SCHEDULED_NOTIFICATION_TEMPLATE: , + NEW_TELEPHONE_ADDED_NOTIFICATION_TEMPLATE: , + LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY: , + LONGHORN_ALERTS_MULTIPLE_TRIGGER_ALERTS: , + LONGHORN_ALERTS_CHANGE_IN_SCORE: , + LONGHORN_ALERTS_NEW_PHONE_NUMBER_ADDED: , + LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY_ACCOUNTS: , + LONGHORN_ALERTS_CHANGE_IN_UTILIZATION: , + LONGHORN_ALERTS_NEW_TRADE_ACCOUNT: , + LONGHORN_ALERTS_NEW_ENQUIRY: , + REVISIT_SCHEDULED_NOTIFICATION_TEMPLATE: , +}; + +export enum NotificationTypes { + PAYMENT_MADE_TEMPLATE = 'PAYMENT_MADE_TEMPLATE', + PROMISED_TO_PAY_SCHEDULED_NOTIFICATION_TEMPLATE = 'PROMISED_TO_PAY_SCHEDULED_NOTIFICATION_TEMPLATE', + PAYMENT_FAILED_TEMPLATE = 'PAYMENT_FAILED_TEMPLATE', + NEW_ADDRESS_ADDED_NOTIFICATION_TEMPLATE = 'NEW_ADDRESS_ADDED_NOTIFICATION_TEMPLATE', + NEW_TELEPHONE_ADDED_NOTIFICATION_TEMPLATE = 'NEW_TELEPHONE_ADDED_NOTIFICATION_TEMPLATE', + LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY = 'LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY', + LONGHORN_ALERTS_MULTIPLE_TRIGGER_ALERTS = 'LONGHORN_ALERTS_MULTIPLE_TRIGGER_ALERTS', + LONGHORN_ALERTS_CHANGE_IN_SCORE = 'LONGHORN_ALERTS_CHANGE_IN_SCORE', + LONGHORN_ALERTS_NEW_PHONE_NUMBER_ADDED = 'LONGHORN_ALERTS_NEW_PHONE_NUMBER_ADDED', + LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY_ACCOUNTS = 'LONGHORN_ALERTS_CHANGE_IN_DELIQUENCY_ACCOUNTS', + LONGHORN_ALERTS_CHANGE_IN_UTILIZATION = 'LONGHORN_ALERTS_CHANGE_IN_UTILIZATION', + LONGHORN_ALERTS_NEW_TRADE_ACCOUNT = 'LONGHORN_ALERTS_NEW_TRADE_ACCOUNT', + LONGHORN_ALERTS_NEW_ENQUIRY = 'LONGHORN_ALERTS_NEW_ENQUIRY', + REVISIT_SCHEDULED_NOTIFICATION_TEMPLATE = 'REVISIT_SCHEDULED_NOTIFICATION_TEMPLATE', +} + +export enum WidgetStatus { + CLICKED = 'CLICKED', +} diff --git a/src/screens/notifications/index.tsx b/src/screens/notifications/index.tsx new file mode 100644 index 00000000..74137d73 --- /dev/null +++ b/src/screens/notifications/index.tsx @@ -0,0 +1,182 @@ +import { + ActivityIndicator, + ListRenderItemInfo, + RefreshControl, + StyleSheet, + View, + VirtualizedList, +} from 'react-native'; +import React, { useEffect, useMemo } from 'react'; +import NavigationHeader, { + Icon, +} from '../../../RN-UI-LIB/src/components/NavigationHeader'; +import { goBack } from '../../components/utlis/navigationUtlis'; +import NotificationItem, { INotification } from './NotificationItem'; +import { useAppDispatch, useAppSelector } from '../../hooks'; +import { GenericStyles } from '../../../RN-UI-LIB/src/styles'; +import { getNotifications } from '../../action/notificationActions'; +import { COLORS } from '../../../RN-UI-LIB/src/styles/colors'; +import useRefresh from '../../hooks/useRefresh'; +import Text from '../../../RN-UI-LIB/src/components/Text'; +import NoNotificationIcon from '../../assets/icons/NoNotificationIcon'; + +export const getItem = (item: Array, index: number) => item[index]; + +const Notifications = () => { + const { + data = [], + pageNo, + totalElements, + pageSize, + newNotificationCount, + isLoading, + } = useAppSelector(state => state.notifications); + const dispatch = useAppDispatch(); + + const handlePullToRefresh = () => { + dispatch(getNotifications()); + }; + + const totalNotifications = totalElements + newNotificationCount; + + const { refreshing, onRefresh } = useRefresh(handlePullToRefresh); + + const handleBack = () => { + goBack(); + }; + + const handleLoadMoreNotifications = () => { + if (isLoading || data.length >= totalNotifications) { + return; + } + dispatch(getNotifications({ pageNo: pageNo + 1, pageSize })); + }; + + useEffect(() => { + if (data?.length) { + return; + } + dispatch(getNotifications()); + }, []); + + const notificationsList: INotification[] = useMemo(() => { + if (!data?.length) { + return []; + } + let notifications: INotification[] = []; + const currentDate = new Date(); + currentDate.setHours(0, 0, 0, 0); + const todayStartTime = currentDate.getTime(); + const yesterday = new Date(currentDate); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + const yesterdayStartTime = yesterday.getTime(); + const todaysNotification: INotification[] = []; + const yesterdaysNotification: INotification[] = []; + const earlierNotifications: INotification[] = []; + + data.forEach(notification => { + const { scheduledAt } = notification; + if (scheduledAt > todayStartTime) { + todaysNotification.push(notification); + } else if ( + scheduledAt < todayStartTime && + scheduledAt > yesterdayStartTime + ) { + yesterdaysNotification.push(notification); + } else { + earlierNotifications.push(notification); + } + }); + notifications = [...todaysNotification]; + if (yesterdaysNotification.length) { + notifications = [ + ...notifications, + { headerLabel: 'Yesterday', id: -1 }, + ...yesterdaysNotification, + ]; + } + if (earlierNotifications.length) { + notifications = [ + ...notifications, + { headerLabel: 'Earlier', id: -2 }, + ...earlierNotifications, + ]; + } + return notifications; + }, [data]); + + const handleMarkAllRead = () => {}; + + const renderListItem = (row: ListRenderItemInfo) => ( + + ); + + const memoizedListItem = useMemo(() => renderListItem, [notificationsList]); + + return ( + + } + /> + item.id} + onEndReached={handleLoadMoreNotifications} + onEndReachedThreshold={0} + getItemCount={item => item.length} + refreshControl={ + + } + ListFooterComponent={ + data?.length && data.length < totalNotifications ? ( + + {isLoading ? ( + + ) : null} + + ) : null + } + ListEmptyComponent={ + + + + You don’t have any pending notifications + + + } + /> + + ); +}; + +const styles = StyleSheet.create({ + loadingContainer: { + height: 60, + }, + centerAbsolute: { + height: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + }, +}); + +export default Notifications; diff --git a/src/store/store.ts b/src/store/store.ts index 4f75d5b9..a13a29d7 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -24,6 +24,7 @@ import addressSlice from '../reducer/addressSlice'; import emiScheduleSlice from '../reducer/emiScheduleSlice'; import repaymentsSlice from '../reducer/repaymentsSlice'; import feedbackHistorySlice from '../reducer/feedbackHistorySlice'; +import notificationsSlice from '../reducer/notificationsSlice'; import MetadataSlice from "../reducer/metadataSlice"; const rootReducer = combineReducers({ @@ -39,6 +40,7 @@ const rootReducer = combineReducers({ repayments: repaymentsSlice, feedbackHistory: feedbackHistorySlice, address: addressSlice, + notifications: notificationsSlice, metadata : MetadataSlice, }); diff --git a/yarn.lock b/yarn.lock index 62b0ed8c..90101b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1572,6 +1572,11 @@ resolved "https://registry.yarnpkg.com/@react-native-firebase/firestore/-/firestore-16.5.0.tgz#84775327eed3f73fa7e3c206f2a6b5ef492fe698" integrity sha512-LBoe2pCKCFVFFKofOEu4YERT3CBIGgASK+COjt60p10yIXfxHVv89tW6d1W39iganPajSYYTLqIj4X//CYO+jA== +"@react-native-firebase/messaging@17.4.0": + version "17.4.0" + resolved "https://registry.yarnpkg.com/@react-native-firebase/messaging/-/messaging-17.4.0.tgz#9e1df987183d0ca367d0922a14b14b7a53a140cf" + integrity sha512-RSiBBfyJ3K9G6TQfZc09XaGpxB9xlP5m9DYkqjbNIqnnTiahF90770lTAS65L1Ha78vCwVO2swIlk32XbcMcMQ== + "@react-native/assets@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@react-native/assets/-/assets-1.0.0.tgz#c6f9bf63d274bafc8e970628de24986b30a55c8e"