From 1503707eed1645c31eb7aa96b97055096f9500de Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Fri, 19 Dec 2025 17:54:49 +0800 Subject: [PATCH 1/4] Notifikasi terhubung ke DB Add: - components/Notification/ - hooks/use-notification-store.tsx.back Fix: - app.config.js - app/(application)/(user)/_layout.tsx - app/(application)/(user)/home.tsx - app/(application)/(user)/test-notifications.tsx - app/(application)/_layout.tsx - app/_layout.tsx - components/_Icon/IconComponent.tsx - components/_Icon/IconPlus.tsx - components/_ShareComponent/NotificationInitializer.tsx - context/AuthContext.tsx - hooks/use-notification-store.tsx - ios/HIPMIBadungConnect/Info.plist - screens/Home/HeaderBell.tsx - service/api-device-token.ts - service/api-notifications.ts ### No Issue --- app.config.js | 2 +- app/(application)/(user)/_layout.tsx | 8 ++ app/(application)/(user)/home.tsx | 9 +- .../(user)/test-notifications.tsx | 18 ++- app/(application)/_layout.tsx | 15 ++- app/_layout.tsx | 21 ++-- .../NotificationInitializer.tsx | 73 ++++++----- components/_Icon/IconComponent.tsx | 3 +- components/_Icon/IconPlus.tsx | 11 +- context/AuthContext.tsx | 16 +-- hooks/use-notification-store.tsx | 105 ++++++++++++++-- hooks/use-notification-store.tsx.back | 113 ++++++++++++++++++ ios/HIPMIBadungConnect/Info.plist | 2 +- screens/Home/HeaderBell.tsx | 12 +- service/api-device-token.ts | 5 +- service/api-notifications.ts | 36 +++++- 16 files changed, 352 insertions(+), 97 deletions(-) rename components/{_ShareComponent => Notification}/NotificationInitializer.tsx (64%) create mode 100644 hooks/use-notification-store.tsx.back diff --git a/app.config.js b/app.config.js index 5b031bc..eaf9372 100644 --- a/app.config.js +++ b/app.config.js @@ -21,7 +21,7 @@ export default { "Aplikasi membutuhkan akses lokasi untuk menampilkan peta.", }, associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"], - buildNumber: "15", + buildNumber: "16", }, android: { diff --git a/app/(application)/(user)/_layout.tsx b/app/(application)/(user)/_layout.tsx index d567847..f7a1365 100644 --- a/app/(application)/(user)/_layout.tsx +++ b/app/(application)/(user)/_layout.tsx @@ -1,4 +1,6 @@ import { BackButton } from "@/components"; +import { IconPlus } from "@/components/_Icon"; +import { IconDot } from "@/components/_Icon/IconComponent"; import LeftButtonCustom from "@/components/Button/BackButton"; import { MainColor } from "@/constants/color-palet"; import { ICON_SIZE_SMALL } from "@/constants/constans-value"; @@ -56,6 +58,12 @@ export default function UserLayout() { options={{ title: "Notifikasi", headerLeft: () => , + headerRight: () => ( + router.push("/test-notifications")} + /> + ), }} /> diff --git a/app/(application)/(user)/home.tsx b/app/(application)/(user)/home.tsx index dfc8d7e..756769e 100644 --- a/app/(application)/(user)/home.tsx +++ b/app/(application)/(user)/home.tsx @@ -3,7 +3,6 @@ import { ButtonCustom, StackCustom, ViewWrapper } from "@/components"; import { MainColor } from "@/constants/color-palet"; import { useAuth } from "@/hooks/use-auth"; -import { useNotificationStore } from "@/hooks/use-notification-store"; import Home_BottomFeatureSection from "@/screens/Home/bottomFeatureSection"; import HeaderBell from "@/screens/Home/HeaderBell"; import Home_ImageSection from "@/screens/Home/imageSection"; @@ -14,8 +13,8 @@ import { apiUser } from "@/service/api-client/api-user"; import { apiVersion } from "@/service/api-config"; import { Ionicons } from "@expo/vector-icons"; import { Redirect, router, Stack, useFocusEffect } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; -import { RefreshControl, Text, View } from "react-native"; +import { useCallback, useState } from "react"; +import { RefreshControl } from "react-native"; export default function Application() { const { token, user, userData } = useAuth(); @@ -99,7 +98,9 @@ export default function Application() { } > - {/* router.push("./test-notifications")}>Test Notif */} + {/* router.push("./test-notifications")}> + Test Notif + */} diff --git a/app/(application)/(user)/test-notifications.tsx b/app/(application)/(user)/test-notifications.tsx index b0a2d7f..653bb57 100644 --- a/app/(application)/(user)/test-notifications.tsx +++ b/app/(application)/(user)/test-notifications.tsx @@ -6,10 +6,9 @@ import { } from "@/components"; import { useAuth } from "@/hooks/use-auth"; import { apiGetAllTokenDevice } from "@/service/api-device-token"; -import { - apiNotificationsSend, -} from "@/service/api-notifications"; +import { apiNotificationsSend } from "@/service/api-notifications"; import { useEffect, useState } from "react"; +import Toast from "react-native-toast-message"; export default function TestNotification() { const { user } = useAuth(); @@ -39,7 +38,18 @@ export default function TestNotification() { }, }); - console.log("[RES SEND NOTIF]", JSON.stringify(response.data, null, 2)); + if (response.success) { + console.log("[RES SEND NOTIF]", JSON.stringify(response, null, 2)); + Toast.show({ + type: "success", + text1: "Notifikasi berhasil dikirim", + }); + } else { + Toast.show({ + type: "error", + text1: "Gagal mengirim notifikasi", + }); + } }; return ( diff --git a/app/(application)/_layout.tsx b/app/(application)/_layout.tsx index 57e4922..6a3a404 100644 --- a/app/(application)/_layout.tsx +++ b/app/(application)/_layout.tsx @@ -1,15 +1,26 @@ import { BackButton } from "@/components"; +import NotificationInitializer from "@/components/Notification/NotificationInitializer"; +import { NotificationProvider } from "@/hooks/use-notification-store"; import { HeaderStyles } from "@/styles/header-styles"; import { Stack } from "expo-router"; export default function ApplicationLayout() { + return ( + <> + + + + + + ); +} + +function ApplicationStack() { return ( <> - - {/* Take Picture */} - - - - - - - - - + + + + + + ); } diff --git a/components/_ShareComponent/NotificationInitializer.tsx b/components/Notification/NotificationInitializer.tsx similarity index 64% rename from components/_ShareComponent/NotificationInitializer.tsx rename to components/Notification/NotificationInitializer.tsx index 0e9c222..0b9cbde 100644 --- a/components/_ShareComponent/NotificationInitializer.tsx +++ b/components/Notification/NotificationInitializer.tsx @@ -8,7 +8,14 @@ import { Platform } from "react-native"; import * as Device from "expo-device"; import * as Application from "expo-application"; import { apiDeviceRegisterToken } from "@/service/api-device-token"; -import messaging from "@react-native-firebase/messaging"; +import messaging, { + isSupported, + requestPermission, + getToken, + AuthorizationStatus, +} from "@react-native-firebase/messaging"; + +// ✅ Modular imports (sesuai v22+) export default function NotificationInitializer() { // Setup handler notifikasi @@ -24,48 +31,40 @@ export default function NotificationInitializer() { const registerDeviceToken = async () => { try { - // 1. Minta izin & ambil FCM token - if (!messaging().isSupported()) return; - const authStatus = await messaging().requestPermission(); - if (authStatus === messaging.AuthorizationStatus.AUTHORIZED) { - const token = await messaging().getToken(); - console.log("✅ FCM Token:", token); - if (!token) { - logout(); - return; - } - } else { - console.warn("Izin notifikasi ditolak"); + // ✅ Dapatkan instance messaging + const messagingInstance = messaging(); + + // ✅ Gunakan instance sebagai argumen + const supported = await isSupported(messagingInstance); + if (!supported) { + console.log("‼️ FCM tidak didukung"); return; - } - const fcmToken = await messaging().getToken(); - if (!fcmToken) { - console.warn("Gagal mendapatkan FCM token"); + }; + + const authStatus = await requestPermission(messagingInstance); + if (authStatus !== AuthorizationStatus.AUTHORIZED) { + console.warn("Izin telah ditolak"); return; } - // 2. Ambil info device + const fcmToken = await getToken(messagingInstance); + if (!fcmToken) { + logout(); + return; + } + + console.log("✅ FCM Token:", fcmToken); + const platform = Platform.OS; // "ios" | "android" const model = Device.modelName || "unknown"; - const appVersion = (Application.nativeApplicationVersion || "unknown") + "-" + (Application.nativeBuildVersion || "unknown"); - const deviceId = Device.osInternalBuildId || Device.modelName + "-" + Date.now(); + const appVersion = + (Application.nativeApplicationVersion || "unknown") + + "-" + + (Application.nativeBuildVersion || "unknown"); + const deviceId = + Device.osInternalBuildId || Device.modelName + "-" + Date.now(); - // console.log( - // "📱 Device info:", - // JSON.stringify( - // { - // fcmToken, - // platform, - // deviceId, - // model, - // appVersion, - // }, - // null, - // 2 - // ) - // ); - - // 3. Kirim ke backend + // Kirim ke backend await apiDeviceRegisterToken({ data: { fcmToken, @@ -102,7 +101,7 @@ export default function NotificationInitializer() { } console.log("📥 Menambahkan ke store:", { title, body, safeData }); - addNotification({ title, body, data: safeData }); + addNotification({ title, body, data: safeData , type: "notification", }); console.log("✅ Notifikasi ditambahkan ke state"); }; diff --git a/components/_Icon/IconComponent.tsx b/components/_Icon/IconComponent.tsx index 247f47f..0db3dd8 100644 --- a/components/_Icon/IconComponent.tsx +++ b/components/_Icon/IconComponent.tsx @@ -98,13 +98,14 @@ export const IconView = ({ ); }; -export const IconDot = ({ size, color }: { size?: number; color?: string }) => { +export const IconDot = ({ size, color, onPress }: { size?: number; color?: string , onPress?: () => void}) => { return ( <> ); diff --git a/components/_Icon/IconPlus.tsx b/components/_Icon/IconPlus.tsx index a80bdbe..0a29765 100644 --- a/components/_Icon/IconPlus.tsx +++ b/components/_Icon/IconPlus.tsx @@ -4,12 +4,21 @@ import { Octicons } from "@expo/vector-icons"; export { IconPlus }; -function IconPlus({ color, size }: { color?: string; size?: number }) { +function IconPlus({ + color, + size, + onPress, +}: { + color?: string; + size?: number; + onPress?: () => void; +}) { return ( ); } diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index fa7eee1..e90cfcf 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -140,13 +140,15 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { await AsyncStorage.setItem("userData", JSON.stringify(dataUser)); if (response.active) { - if (response.roleId === "1") { - router.replace("/(application)/(user)/home"); - return; - } else { - router.replace("/(application)/admin/dashboard"); - return; - } + // if (response.roleId === "1") { + // router.replace("/(application)/(user)/home"); + // return; + // } else { + // router.replace("/(application)/admin/dashboard"); + // return; + // } + router.replace("/(application)/(user)/home"); + return } else { router.replace("/(application)/(user)/waiting-room"); return; diff --git a/hooks/use-notification-store.tsx b/hooks/use-notification-store.tsx index 084f97d..bd165f8 100644 --- a/hooks/use-notification-store.tsx +++ b/hooks/use-notification-store.tsx @@ -1,52 +1,131 @@ // hooks/useNotificationStore.ts -import { createContext, useContext, useState, ReactNode } from 'react'; -import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { useAuth } from "./use-auth"; +import { + apiGetNotificationsById, + apiNotificationUnreadCount, +} from "@/service/api-notifications"; type AppNotification = { id: string; title: string; body: string; data?: Record; - read: boolean; + isRead: boolean; timestamp: number; + type: "notification" | "trigger"; + // untuk id dari setiap kategori app + appId?: string; + kategoriApp?: + | "JOB" + | "VOTING" + | "EVENT" + | "DONASI" + | "INVESTASI" + | "COLLABORATION" + | "FORUM" + | "ACCESS"; // Untuk trigger akses user; }; -const NotificationContext = createContext<{ +type NotificationContextType = { notifications: AppNotification[]; - addNotification: (notif: Omit) => void; + unreadCount: number; + addNotification: ( + notif: Omit + ) => void; markAsRead: (id: string) => void; -}>({ + syncUnreadCount: () => Promise; +}; + +const NotificationContext = createContext({ notifications: [], + unreadCount: 0, addNotification: () => {}, markAsRead: () => {}, + syncUnreadCount: async () => {}, }); export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const { user } = useAuth(); const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); - const addNotification = (notif: Omit) => { - setNotifications(prev => [ + console.log( + "🚀 Notifications Masuk:", + JSON.stringify(notifications, null, 2) + ); + + // Sync unread count dari backend saat provider di-mount + useEffect(() => { + fetchUnreadCount(); + }, [user?.id]); + + const fetchUnreadCount = async () => { + try { + const count = await apiNotificationUnreadCount({ + id: user?.id as any, + }); // ← harus return number + const result = count.data; + console.log("📖 Unread count:", result); + setUnreadCount(result); + } catch (error) { + console.error("Gagal fetch unread count:", error); + } + }; + + const addNotification = ( + notif: Omit + ) => { + setNotifications((prev) => [ { ...notif, id: Date.now().toString(), - read: false, + isRead: false, timestamp: Date.now(), }, ...prev, ]); + + setUnreadCount((prev) => prev + 1); }; const markAsRead = (id: string) => { - setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, read: true } : n)) + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) ); }; + const syncUnreadCount = async () => { + try { + const count = await apiNotificationUnreadCount({ + id: user?.id as any, + }); // ← harus return number + const result = count.data; + setUnreadCount(result); + } catch (error) { + console.warn("⚠️ Gagal sync unread count:", error); + } + }; + return ( - + {children} ); }; -export const useNotificationStore = () => useContext(NotificationContext); \ No newline at end of file +export const useNotificationStore = () => useContext(NotificationContext); diff --git a/hooks/use-notification-store.tsx.back b/hooks/use-notification-store.tsx.back new file mode 100644 index 0000000..9222faa --- /dev/null +++ b/hooks/use-notification-store.tsx.back @@ -0,0 +1,113 @@ +// hooks/useNotificationStore.ts +import { apiGetNotificationsById } from "@/service/api-notifications"; +import { createContext, ReactNode, useContext, useState, useEffect } from "react"; +import { useAuth } from "./use-auth"; + +type AppNotification = { + id: string; + title: string; + body: string; + data?: Record; + isRead: boolean; + timestamp: number; + type: "notification" | "trigger"; + appId?: string; + kategoriApp?: + | "JOB" + | "VOTING" + | "EVENT" + | "DONASI" + | "INVESTASI" + | "COLLABORATION" + | "FORUM" + | "ACCESS"; +}; + +type NotificationContextType = { + notifications: AppNotification[]; + unreadCount: number; + addNotification: ( + notif: Omit + ) => void; + markAsRead: (id: string) => void; + syncUnreadCount: () => Promise; +}; + +const NotificationContext = createContext({ + notifications: [], + unreadCount: 0, + addNotification: () => {}, + markAsRead: () => {}, + syncUnreadCount: async () => {}, +}); + +export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const {user} = useAuth() + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + + // 🔔 Sync unread count dari backend saat provider di-mount + useEffect(() => { + const fetchUnreadCount = async () => { + try { + const count = await apiGetNotificationsById({ + id: user?.id as any, + category: "count-as-unread" + }); // ← harus return number + const result = count.data + setUnreadCount(result); + } catch (error) { + console.erro("⚠️ Gagal fetch unread count:", error); + } + }; + + fetchUnreadCount(); + }, []); + + const addNotification = ( + notif: Omit + ) => { + setNotifications((prev) => [ + { + ...notif, + id: Date.now().toString(), + isRead: false, + timestamp: Date.now(), + }, + ...prev, + ]); + // Tambahkan ke unread count (untuk notifikasi foreground) + setUnreadCount((prev) => prev + 1); + }; + + const markAsRead = (id: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + ); + // Kurangi unread count + setUnreadCount((prev) => Math.max(0, prev - 1)); + }; + + const syncUnreadCount = async () => { + try { + const count = await apiGetNotificationsById({ + id: user?.id as any, + category: "count-as-unread" + }); // ← harus return number + const result = count.data + setUnreadCount(result); + } catch (error) { + console.warn("⚠️ Gagal sync unread count:", error); + } + }; + + return ( + + {children} + + ); +}; + +export const useNotificationStore = () => useContext(NotificationContext); \ No newline at end of file diff --git a/ios/HIPMIBadungConnect/Info.plist b/ios/HIPMIBadungConnect/Info.plist index 3f06716..906bd00 100644 --- a/ios/HIPMIBadungConnect/Info.plist +++ b/ios/HIPMIBadungConnect/Info.plist @@ -39,7 +39,7 @@ CFBundleVersion - 15 + 16 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/screens/Home/HeaderBell.tsx b/screens/Home/HeaderBell.tsx index edc8b80..b6eeb67 100644 --- a/screens/Home/HeaderBell.tsx +++ b/screens/Home/HeaderBell.tsx @@ -1,13 +1,13 @@ // components/HeaderBell.tsx -import { Ionicons } from "@expo/vector-icons"; -import { View, Text } from "react-native"; -import { router } from "expo-router"; -import { useNotificationStore } from "@/hooks/use-notification-store"; import { MainColor } from "@/constants/color-palet"; +import { useNotificationStore } from "@/hooks/use-notification-store"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useEffect } from "react"; +import { Text, View } from "react-native"; export default function HeaderBell() { - const { notifications } = useNotificationStore(); - const unreadCount = notifications.filter((n) => !n.read).length; + const { notifications , unreadCount} = useNotificationStore(); // console.log("NOTIF:", JSON.stringify(notifications, null, 2)); return ( diff --git a/service/api-device-token.ts b/service/api-device-token.ts index 95a2c63..9d059ca 100644 --- a/service/api-device-token.ts +++ b/service/api-device-token.ts @@ -18,10 +18,7 @@ export async function apiDeviceRegisterToken({ const response = await apiConfig.post(`/mobile/auth/device-tokens`, { data: data, }); - console.log( - "Device token registered:", - JSON.stringify(response.data, null, 2) - ); + return response.data; } catch (error) { console.error("Failed to register device token:", error); diff --git a/service/api-notifications.ts b/service/api-notifications.ts index bff72a3..8c43ad9 100644 --- a/service/api-notifications.ts +++ b/service/api-notifications.ts @@ -13,12 +13,42 @@ export async function apiNotificationsSend({ data: NotificationProp; }) { try { - const response = await apiConfig.post(`/mobile/notifications`, { + const response = await apiConfig.post(`/mobile/notification`, { data: data, }); - console.log("Fecth Notif", response.data); - + return response.data; + } catch (error) { + throw error; + } +} + +export async function apiGetNotificationsById({ + id, + category, +}: { + id: string; + category: "count-as-unread" | "all"; +}) { + console.log("ID", id); + console.log("Category", category); + + try { + const response = await apiConfig.get( + `/mobile/notification/${id}?category=${category}` + ); + + return response.data; + } catch (error) { + throw error; + } +} + +export async function apiNotificationUnreadCount({ id }: { id: string }) { + try { + const response = await apiConfig.get( + `/mobile/notification/${id}/unread-count` + ); return response.data; } catch (error) { throw error; -- 2.49.1 From 54611ef812816724311ce5ecc456c0e7b3c74bdf Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Tue, 23 Dec 2025 17:45:30 +0800 Subject: [PATCH 2/4] Fix notifikasi system Add: - screens/Admin/AdminNotificationBell.tsx Fix: - app.config.js - app/(application)/(user)/home.tsx - app/(application)/(user)/test-notifications.tsx - app/(application)/admin/_layout.tsx - app/_layout.tsx - components/Notification/NotificationInitializer.tsx - context/AuthContext.tsx - hooks/use-notification-store.tsx - ios/HIPMIBadungConnect/Info.plist - screens/Home/HeaderBell.tsx - service/api-device-token.ts - service/api-notifications.ts ### No Issue --- app.config.js | 2 +- app/(application)/(user)/home.tsx | 9 +++- .../(user)/test-notifications.tsx | 22 +++------- app/(application)/admin/_layout.tsx | 12 +++--- app/_layout.tsx | 2 - .../Notification/NotificationInitializer.tsx | 6 +-- context/AuthContext.tsx | 9 ++-- hooks/use-notification-store.tsx | 3 ++ ios/HIPMIBadungConnect/Info.plist | 2 +- screens/Admin/AdminNotificationBell.tsx | 41 +++++++++++++++++++ screens/Home/HeaderBell.tsx | 11 +++-- service/api-device-token.ts | 4 +- service/api-notifications.ts | 24 ++++++++--- 13 files changed, 103 insertions(+), 44 deletions(-) create mode 100644 screens/Admin/AdminNotificationBell.tsx diff --git a/app.config.js b/app.config.js index eaf9372..e8ccb96 100644 --- a/app.config.js +++ b/app.config.js @@ -21,7 +21,7 @@ export default { "Aplikasi membutuhkan akses lokasi untuk menampilkan peta.", }, associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"], - buildNumber: "16", + buildNumber: "17", }, android: { diff --git a/app/(application)/(user)/home.tsx b/app/(application)/(user)/home.tsx index 756769e..e92a5e5 100644 --- a/app/(application)/(user)/home.tsx +++ b/app/(application)/(user)/home.tsx @@ -3,6 +3,7 @@ import { ButtonCustom, StackCustom, ViewWrapper } from "@/components"; import { MainColor } from "@/constants/color-palet"; import { useAuth } from "@/hooks/use-auth"; +import { useNotificationStore } from "@/hooks/use-notification-store"; import Home_BottomFeatureSection from "@/screens/Home/bottomFeatureSection"; import HeaderBell from "@/screens/Home/HeaderBell"; import Home_ImageSection from "@/screens/Home/imageSection"; @@ -20,12 +21,16 @@ export default function Application() { const { token, user, userData } = useAuth(); const [data, setData] = useState(); const [refreshing, setRefreshing] = useState(false); + const { syncUnreadCount } = useNotificationStore(); + + useFocusEffect( useCallback(() => { onLoadData(); checkVersion(); userData(token as string); + syncUnreadCount() }, [user?.id, token]) ); @@ -98,9 +103,9 @@ export default function Application() { } > - {/* router.push("./test-notifications")}> + router.push("./test-notifications")}> Test Notif - */} + diff --git a/app/(application)/(user)/test-notifications.tsx b/app/(application)/(user)/test-notifications.tsx index 653bb57..538ae43 100644 --- a/app/(application)/(user)/test-notifications.tsx +++ b/app/(application)/(user)/test-notifications.tsx @@ -5,36 +5,26 @@ import { TextInputCustom, } from "@/components"; import { useAuth } from "@/hooks/use-auth"; -import { apiGetAllTokenDevice } from "@/service/api-device-token"; import { apiNotificationsSend } from "@/service/api-notifications"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Toast from "react-native-toast-message"; export default function TestNotification() { const { user } = useAuth(); const [data, setData] = useState(""); - useEffect(() => { - // fecthData(); - }, []); - - const fecthData = async () => { - const response = await apiGetAllTokenDevice(); - console.log( - "[RES GET ALL TOKEN DEVICE]", - JSON.stringify(response.data, null, 2) - ); - }; - const handleSubmit = async () => { console.log("[Data Dikirim]", data); const response = await apiNotificationsSend({ data: { - fcmToken: - "cVmHm-3P4E-1vjt6AA9kSF:APA91bHTkHjGTLxrFsb6Le6bZmzboZhwMGYXU4p0FP9yEeXixLDXNKS4F5vLuZV3sRgSnjjQsPpLOgstVLHJB8VJTObctKLdN-CxAp4dnP7Jbc_mH53jWvs", title: "Test dari Backend (App Router)!", body: data, userLoginId: user?.id || "", + appId: "hipmi", + status: "publish", + kategoriApp: "EVENT", + type: "announcement", + deepLink: "event/23189913801", }, }); diff --git a/app/(application)/admin/_layout.tsx b/app/(application)/admin/_layout.tsx index c1f91d1..d4ec2cb 100644 --- a/app/(application)/admin/_layout.tsx +++ b/app/(application)/admin/_layout.tsx @@ -15,6 +15,7 @@ import { ICON_SIZE_XLARGE, } from "@/constants/constans-value"; import { useAuth } from "@/hooks/use-auth"; +import AdminNotificationBell from "@/screens/Admin/AdminNotificationBell"; import { adminListMenu, superAdminListMenu, @@ -192,11 +193,12 @@ export default function AdminLayout() { label: "Notifikasi", value: "notification", icon: ( - + // + ), path: "/admin/notification", }, diff --git a/app/_layout.tsx b/app/_layout.tsx index 6912c45..51bdd8b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,4 @@ -import NotificationInitializer from "@/components/Notification/NotificationInitializer"; import { AuthProvider } from "@/context/AuthContext"; -import { NotificationProvider } from "@/hooks/use-notification-store"; import AppRoot from "@/screens/RootLayout/AppRoot"; import "react-native-gesture-handler"; import { SafeAreaProvider } from "react-native-safe-area-context"; diff --git a/components/Notification/NotificationInitializer.tsx b/components/Notification/NotificationInitializer.tsx index 0b9cbde..a26f77d 100644 --- a/components/Notification/NotificationInitializer.tsx +++ b/components/Notification/NotificationInitializer.tsx @@ -39,7 +39,7 @@ export default function NotificationInitializer() { if (!supported) { console.log("‼️ FCM tidak didukung"); return; - }; + } const authStatus = await requestPermission(messagingInstance); if (authStatus !== AuthorizationStatus.AUTHORIZED) { @@ -62,7 +62,7 @@ export default function NotificationInitializer() { "-" + (Application.nativeBuildVersion || "unknown"); const deviceId = - Device.osInternalBuildId || Device.modelName + "-" + Date.now(); + Device.osInternalBuildId || Device.modelName || "unknown"; // Kirim ke backend await apiDeviceRegisterToken({ @@ -101,7 +101,7 @@ export default function NotificationInitializer() { } console.log("📥 Menambahkan ke store:", { title, body, safeData }); - addNotification({ title, body, data: safeData , type: "notification", }); + addNotification({ title, body, data: safeData, type: "notification" }); console.log("✅ Notifikasi ditambahkan ke state"); }; diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index e90cfcf..6f868e2 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -10,6 +10,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { router } from "expo-router"; import { createContext, useEffect, useState } from "react"; import Toast from "react-native-toast-message"; +import * as Device from "expo-device"; // --- Types --- type AuthContextType = { @@ -77,7 +78,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const response = await apiLogin({ nomor: nomor }); console.log("[RESPONSE AUTH]", JSON.stringify(response)); - if (response.success) { console.log("[Keluar provider]", nomor); Toast.show({ @@ -148,7 +148,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { // return; // } router.replace("/(application)/(user)/home"); - return + return; } else { router.replace("/(application)/(user)/waiting-room"); return; @@ -283,9 +283,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setIsLoading(true); setToken(null); setUser(null); + + const deviceId = Device.osInternalBuildId || Device.modelName || "unknown"; + await AsyncStorage.removeItem("authToken"); await AsyncStorage.removeItem("userData"); - await apiDeviceTokenDeleted({userId: user?.id as any}) + await apiDeviceTokenDeleted({ userId: user?.id as any, deviceId }); Toast.show({ type: "success", diff --git a/hooks/use-notification-store.tsx b/hooks/use-notification-store.tsx index bd165f8..3319cb1 100644 --- a/hooks/use-notification-store.tsx +++ b/hooks/use-notification-store.tsx @@ -70,6 +70,7 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => { try { const count = await apiNotificationUnreadCount({ id: user?.id as any, + role: user?.masterUserRoleId as any }); // ← harus return number const result = count.data; console.log("📖 Unread count:", result); @@ -105,8 +106,10 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => { try { const count = await apiNotificationUnreadCount({ id: user?.id as any, + role: user?.masterUserRoleId as any, }); // ← harus return number const result = count.data; + console.log("📖 Unread count sync:", result); setUnreadCount(result); } catch (error) { console.warn("⚠️ Gagal sync unread count:", error); diff --git a/ios/HIPMIBadungConnect/Info.plist b/ios/HIPMIBadungConnect/Info.plist index 906bd00..ea70298 100644 --- a/ios/HIPMIBadungConnect/Info.plist +++ b/ios/HIPMIBadungConnect/Info.plist @@ -39,7 +39,7 @@ CFBundleVersion - 16 + 17 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/screens/Admin/AdminNotificationBell.tsx b/screens/Admin/AdminNotificationBell.tsx new file mode 100644 index 0000000..1666d72 --- /dev/null +++ b/screens/Admin/AdminNotificationBell.tsx @@ -0,0 +1,41 @@ +// components/HeaderBell.tsx +import { MainColor } from "@/constants/color-palet"; +import { ICON_SIZE_SMALL } from "@/constants/constans-value"; +import { useNotificationStore } from "@/hooks/use-notification-store"; +import { Ionicons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { Text, View } from "react-native"; + +export default function AdminNotificationBell() { + const { unreadCount } = useNotificationStore(); + + return ( + + + {unreadCount > 0 && ( + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} + + ); +} diff --git a/screens/Home/HeaderBell.tsx b/screens/Home/HeaderBell.tsx index b6eeb67..e756898 100644 --- a/screens/Home/HeaderBell.tsx +++ b/screens/Home/HeaderBell.tsx @@ -1,14 +1,17 @@ // components/HeaderBell.tsx import { MainColor } from "@/constants/color-palet"; +import { useAuth } from "@/hooks/use-auth"; import { useNotificationStore } from "@/hooks/use-notification-store"; import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; -import { useEffect } from "react"; import { Text, View } from "react-native"; export default function HeaderBell() { - const { notifications , unreadCount} = useNotificationStore(); - // console.log("NOTIF:", JSON.stringify(notifications, null, 2)); + const { unreadCount } = useNotificationStore(); + const { user } = useAuth(); + + const pathDetector = + user?.masterUserRoleId === "1" ? "/notifications" : "/admin/notification"; return ( @@ -17,7 +20,7 @@ export default function HeaderBell() { size={20} color={MainColor.yellow} onPress={() => { - router.push("/notifications"); + router.push(pathDetector); }} /> {unreadCount > 0 && ( diff --git a/service/api-device-token.ts b/service/api-device-token.ts index 9d059ca..7ac50d5 100644 --- a/service/api-device-token.ts +++ b/service/api-device-token.ts @@ -26,10 +26,10 @@ export async function apiDeviceRegisterToken({ } } -export async function apiDeviceTokenDeleted({ userId }: { userId: string }) { +export async function apiDeviceTokenDeleted({ userId, deviceId }: { userId: string, deviceId: string }) { try { const response = await apiConfig.delete( - `/mobile/auth/device-tokens/${userId}` + `/mobile/auth/device-tokens/${userId}?deviceId=${deviceId}` ); console.log("Device token deleted:", response.data); return response.data; diff --git a/service/api-notifications.ts b/service/api-notifications.ts index 8c43ad9..5a43f47 100644 --- a/service/api-notifications.ts +++ b/service/api-notifications.ts @@ -1,10 +1,22 @@ import { apiConfig } from "./api-config"; type NotificationProp = { - fcmToken: string; title: string; - body: Object; - userLoginId?: string; + body: string; + userLoginId: string; + appId?: string; + status?: string; + type?: "announcement" | "trigger"; + deepLink?: string; + kategoriApp?: + | "JOB" + | "VOTING" + | "EVENT" + | "DONASI" + | "INVESTASI" + | "COLLABORATION" + | "FORUM" + | "ACCESS"; // Untuk trigger akses user; }; export async function apiNotificationsSend({ @@ -44,11 +56,13 @@ export async function apiGetNotificationsById({ } } -export async function apiNotificationUnreadCount({ id }: { id: string }) { +export async function apiNotificationUnreadCount({ id, role }: { id: string, role: "user" | "admin" }) { try { const response = await apiConfig.get( - `/mobile/notification/${id}/unread-count` + `/mobile/notification/${id}/unread-count?role=${role}` ); + + console.log("Response Unread Count", response.data); return response.data; } catch (error) { throw error; -- 2.49.1 From 7743a2467c3daf49ac95f71b25b4b0c096725f2f Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Wed, 24 Dec 2025 15:29:58 +0800 Subject: [PATCH 3/4] Fitur notifikasi dan foreground Add: - types/type-notification-category.ts Fix: - app/(application)/(user)/notifications/index.tsx - app/(application)/(user)/test-notifications.tsx - app/(application)/admin/notification/index.tsx - components/Notification/NotificationInitializer.tsx - hooks/use-notification-store.tsx - service/api-notifications.ts - utils/formatChatTime.ts ### No Issue --- .../(user)/notifications/index.tsx | 138 ++++++++++-------- .../(user)/test-notifications.tsx | 6 +- .../admin/notification/index.tsx | 125 +++++++++++++++- .../Notification/NotificationInitializer.tsx | 2 +- hooks/use-notification-store.tsx | 32 ++-- service/api-notifications.ts | 23 +-- types/type-notification-category.ts | 19 +++ utils/formatChatTime.ts | 4 +- 8 files changed, 260 insertions(+), 89 deletions(-) create mode 100644 types/type-notification-category.ts diff --git a/app/(application)/(user)/notifications/index.tsx b/app/(application)/(user)/notifications/index.tsx index e136aad..8c27f5f 100644 --- a/app/(application)/(user)/notifications/index.tsx +++ b/app/(application)/(user)/notifications/index.tsx @@ -1,79 +1,57 @@ import { BaseBox, - Grid, + NewWrapper, ScrollableCustom, StackCustom, - TextCustom, - ViewWrapper, + TextCustom } from "@/components"; -import { MainColor } from "@/constants/color-palet"; -import { useState } from "react"; -import { View } from "react-native"; - -const categories = [ - { value: "all", label: "Semua" }, - { value: "event", label: "Event" }, - { value: "job", label: "Job" }, - { value: "voting", label: "Voting" }, - { value: "donasi", label: "Donasi" }, - { value: "investasi", label: "Investasi" }, - { value: "forum", label: "Forum" }, - { value: "collaboration", label: "Collaboration" }, -]; +import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent"; +import { AccentColor } from "@/constants/color-palet"; +import { useAuth } from "@/hooks/use-auth"; +import { useNotificationStore } from "@/hooks/use-notification-store"; +import { apiGetNotificationsById } from "@/service/api-notifications"; +import { listOfcategoriesAppNotification } from "@/types/type-notification-category"; +import { formatChatTime } from "@/utils/formatChatTime"; +import { router, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; +import { RefreshControl, View } from "react-native"; const selectedCategory = (value: string) => { - const category = categories.find((c) => c.value === value); + const category = listOfcategoriesAppNotification.find((c) => c.value === value); return category?.label; }; const BoxNotification = ({ - index, + data, activeCategory, }: { - index: number; + data: any; activeCategory: string | null; }) => { + const { markAsRead } = useNotificationStore(); return ( <> + backgroundColor={data.isRead ? AccentColor.darkblue : AccentColor.blue} + onPress={() => { console.log( "Notification >", selectedCategory(activeCategory as string) - ) - } + ); + router.push(data.deepLink); + markAsRead(data.id); + }} > - - # {selectedCategory(activeCategory as string)} + + {data.title} - + {data.pesan} - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint odio - unde quidem voluptate quam culpa sequi molestias ipsa corrupti id, - soluta, nostrum adipisci similique, et illo asperiores deleniti eum - labore. + + {formatChatTime(data.createdAt)} - - - - - {index + 1} Agustus 2025 - - - - - Belum lihat - - - @@ -81,17 +59,54 @@ const BoxNotification = ({ }; export default function Notifications() { - const [activeCategory, setActiveCategory] = useState("all"); + const { user } = useAuth(); + const [activeCategory, setActiveCategory] = useState("event"); + const [listData, setListData] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [loading, setLoading] = useState(false); const handlePress = (item: any) => { setActiveCategory(item.value); // tambahkan logika lain seperti filter dsb. }; + + useFocusEffect( + useCallback(() => { + fecthData(); + }, [activeCategory]) + ); + + const fecthData = async () => { + try { + setLoading(true); + const response = await apiGetNotificationsById({ + id: user?.id as any, + category: activeCategory as any, + }); + // console.log("Response Notification", JSON.stringify(response, null, 2)); + if (response.success) { + setListData(response.data); + } else { + setListData([]); + } + } catch (error) { + console.log("Error Notification", error); + } finally { + setLoading(false); + } + }; + + const onRefresh = () => { + setRefreshing(true); + fecthData(); + setRefreshing(false); + }; + return ( - ({ + data={listOfcategoriesAppNotification.map((e, i) => ({ id: i, label: e.label, value: e.value, @@ -100,12 +115,19 @@ export default function Notifications() { activeId={activeCategory as string} /> } + refreshControl={ + + } > - {Array.from({ length: 20 }).map((e, i) => ( - - - - ))} - + {loading ? ( + + ) : ( + listData.map((e, i) => ( + + + + )) + )} + ); } diff --git a/app/(application)/(user)/test-notifications.tsx b/app/(application)/(user)/test-notifications.tsx index 538ae43..28f0b12 100644 --- a/app/(application)/(user)/test-notifications.tsx +++ b/app/(application)/(user)/test-notifications.tsx @@ -17,14 +17,14 @@ export default function TestNotification() { console.log("[Data Dikirim]", data); const response = await apiNotificationsSend({ data: { - title: "Test dari Backend (App Router)!", + title: "Test Notification !!", body: data, userLoginId: user?.id || "", appId: "hipmi", status: "publish", - kategoriApp: "EVENT", + kategoriApp: "JOB", type: "announcement", - deepLink: "event/23189913801", + deepLink: "/job/cmhjz8u3h0005cfaxezyeilrr", }, }); diff --git a/app/(application)/admin/notification/index.tsx b/app/(application)/admin/notification/index.tsx index eef8cc0..5301f8c 100644 --- a/app/(application)/admin/notification/index.tsx +++ b/app/(application)/admin/notification/index.tsx @@ -1,7 +1,103 @@ -import { BackButton, TextCustom, ViewWrapper } from "@/components"; -import { Stack } from "expo-router"; +import { + BackButton, + BaseBox, + NewWrapper, + ScrollableCustom, + StackCustom, + TextCustom, +} from "@/components"; +import { AccentColor } from "@/constants/color-palet"; +import { useAuth } from "@/hooks/use-auth"; +import { useNotificationStore } from "@/hooks/use-notification-store"; +import { apiGetNotificationsById } from "@/service/api-notifications"; +import { listOfcategoriesAppNotification } from "@/types/type-notification-category"; +import { formatChatTime } from "@/utils/formatChatTime"; +import { router, Stack, useFocusEffect } from "expo-router"; +import { useCallback, useState } from "react"; +import { RefreshControl, View } from "react-native"; + +const selectedCategory = (value: string) => { + const category = listOfcategoriesAppNotification.find((c) => c.value === value); + return category?.label; +}; + +const BoxNotification = ({ + data, + activeCategory, +}: { + data: any; + activeCategory: string | null; +}) => { + const { markAsRead } = useNotificationStore(); + return ( + <> + { + console.log( + "Notification >", + selectedCategory(activeCategory as string) + ); + router.push(data.deepLink); + markAsRead(data.id); + }} + > + + + {data.title} + + + {data.pesan} + + + {formatChatTime(data.createdAt)} + + + + + ); +}; export default function AdminNotification() { + const { user } = useAuth(); + const [activeCategory, setActiveCategory] = useState("event"); + const [listData, setListData] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const handlePress = (item: any) => { + setActiveCategory(item.value); + // tambahkan logika lain seperti filter dsb. + }; + + useFocusEffect( + useCallback(() => { + fecthData(); + }, [activeCategory]) + ); + + const fecthData = async () => { + try { + const response = await apiGetNotificationsById({ + id: user?.id as any, + category: activeCategory as any, + }); + // console.log("Response Notification", JSON.stringify(response, null, 2)); + if (response.success) { + setListData(response.data); + } else { + setListData([]); + } + } catch (error) { + console.log("Error Notification", error); + } + }; + + const onRefresh = () => { + setRefreshing(true); + fecthData(); + setRefreshing(false); + }; + return ( <> - - Notification - + ({ + id: i, + label: e.label, + value: e.value, + }))} + onButtonPress={handlePress} + activeId={activeCategory as string} + /> + } + refreshControl={ + + } + > + {listData.map((e, i) => ( + + + + ))} + ); } diff --git a/components/Notification/NotificationInitializer.tsx b/components/Notification/NotificationInitializer.tsx index a26f77d..2a6e7f8 100644 --- a/components/Notification/NotificationInitializer.tsx +++ b/components/Notification/NotificationInitializer.tsx @@ -101,7 +101,7 @@ export default function NotificationInitializer() { } console.log("📥 Menambahkan ke store:", { title, body, safeData }); - addNotification({ title, body, data: safeData, type: "notification" }); + addNotification({ title, body, data: safeData, type: "announcement" }); console.log("✅ Notifikasi ditambahkan ke state"); }; diff --git a/hooks/use-notification-store.tsx b/hooks/use-notification-store.tsx index 3319cb1..0c557ad 100644 --- a/hooks/use-notification-store.tsx +++ b/hooks/use-notification-store.tsx @@ -1,4 +1,8 @@ // hooks/useNotificationStore.ts +import { + apiNotificationMarkAsRead, + apiNotificationUnreadCount, +} from "@/service/api-notifications"; import { createContext, ReactNode, @@ -7,10 +11,6 @@ import { useState, } from "react"; import { useAuth } from "./use-auth"; -import { - apiGetNotificationsById, - apiNotificationUnreadCount, -} from "@/service/api-notifications"; type AppNotification = { id: string; @@ -19,7 +19,7 @@ type AppNotification = { data?: Record; isRead: boolean; timestamp: number; - type: "notification" | "trigger"; + type: "announcement" | "trigger"; // untuk id dari setiap kategori app appId?: string; kategoriApp?: @@ -70,7 +70,7 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => { try { const count = await apiNotificationUnreadCount({ id: user?.id as any, - role: user?.masterUserRoleId as any + role: user?.masterUserRoleId as any, }); // ← harus return number const result = count.data; console.log("📖 Unread count:", result); @@ -96,10 +96,22 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => { setUnreadCount((prev) => prev + 1); }; - const markAsRead = (id: string) => { - setNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) - ); + const markAsRead = async (id: string) => { + try { + const response = await apiNotificationMarkAsRead({ id }); + console.log("🚀 Response Mark As Read:", response); + + if (response.success) { + const cloneNotifications = [...notifications]; + const index = cloneNotifications.findIndex((n) => n?.data?.id === id); + if (index !== -1) { + cloneNotifications[index].isRead = true; + setNotifications(cloneNotifications); + } + } + } catch (error) { + console.error("Gagal mark as read:", error); + } }; const syncUnreadCount = async () => { diff --git a/service/api-notifications.ts b/service/api-notifications.ts index 5a43f47..e44f3bb 100644 --- a/service/api-notifications.ts +++ b/service/api-notifications.ts @@ -1,3 +1,4 @@ +import { TypeNotificationCategoryApp } from "@/types/type-notification-category"; import { apiConfig } from "./api-config"; type NotificationProp = { @@ -8,15 +9,7 @@ type NotificationProp = { status?: string; type?: "announcement" | "trigger"; deepLink?: string; - kategoriApp?: - | "JOB" - | "VOTING" - | "EVENT" - | "DONASI" - | "INVESTASI" - | "COLLABORATION" - | "FORUM" - | "ACCESS"; // Untuk trigger akses user; + kategoriApp?: TypeNotificationCategoryApp }; export async function apiNotificationsSend({ @@ -40,7 +33,7 @@ export async function apiGetNotificationsById({ category, }: { id: string; - category: "count-as-unread" | "all"; + category: TypeNotificationCategoryApp }) { console.log("ID", id); console.log("Category", category); @@ -68,3 +61,13 @@ export async function apiNotificationUnreadCount({ id, role }: { id: string, rol throw error; } } + + +export async function apiNotificationMarkAsRead({id}: {id: string}) { + try { + const response = await apiConfig.put(`/mobile/notification/${id}`); + return response.data; + } catch (error) { + throw error; + } +} \ No newline at end of file diff --git a/types/type-notification-category.ts b/types/type-notification-category.ts new file mode 100644 index 0000000..0a3d853 --- /dev/null +++ b/types/type-notification-category.ts @@ -0,0 +1,19 @@ +export type TypeNotificationCategoryApp = + | "EVENT" + | "JOB" + | "VOTING" + | "DONASI" + | "INVESTASI" + | "COLLABORATION" + | "FORUM" + | "ACCESS"; + + export const listOfcategoriesAppNotification = [ + { value: "event", label: "Event" }, + { value: "job", label: "Job" }, + { value: "voting", label: "Voting" }, + { value: "donasi", label: "Donasi" }, + { value: "investasi", label: "Investasi" }, + { value: "forum", label: "Forum" }, + { value: "collaboration", label: "Collaboration" }, +]; \ No newline at end of file diff --git a/utils/formatChatTime.ts b/utils/formatChatTime.ts index 42186ff..2c86136 100644 --- a/utils/formatChatTime.ts +++ b/utils/formatChatTime.ts @@ -17,7 +17,7 @@ export const formatChatTime = (date: string | Date): string => { // Jika hari ini if (messageDate.isSame(now, 'day')) { - return messageDate.format('HH.mm'); // contoh: "14.30" + return messageDate.format('HH:mm'); // contoh: "14.30" } // Jika kemarin @@ -31,5 +31,5 @@ export const formatChatTime = (date: string | Date): string => { } // Lebih dari seminggu lalu → tampilkan tanggal - return messageDate.format('D MMM YYYY'); // contoh: "12 Mei 2024" + return messageDate.format('D MMM YYYY HH:mm'); // contoh: "12 Mei 2024 14:30" }; -- 2.49.1 From 3f85f330d2aad194f9a91411263ffe1d50e6c21e Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Wed, 24 Dec 2025 17:43:53 +0800 Subject: [PATCH 4/4] Background notifikasi berhasil dibuat Add: -components/Notification/BackgroundNotificationHandler.tsx ### No Issue --- app/(application)/(user)/home.tsx | 4 +- app/(application)/_layout.tsx | 2 + .../admin/notification/index.tsx | 14 +- .../BackgroundNotificationHandler.tsx | 140 ++++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 components/Notification/BackgroundNotificationHandler.tsx diff --git a/app/(application)/(user)/home.tsx b/app/(application)/(user)/home.tsx index e92a5e5..800890c 100644 --- a/app/(application)/(user)/home.tsx +++ b/app/(application)/(user)/home.tsx @@ -103,9 +103,9 @@ export default function Application() { } > - router.push("./test-notifications")}> + {/* router.push("./test-notifications")}> Test Notif - + */} diff --git a/app/(application)/_layout.tsx b/app/(application)/_layout.tsx index 6a3a404..58cecd3 100644 --- a/app/(application)/_layout.tsx +++ b/app/(application)/_layout.tsx @@ -1,4 +1,5 @@ import { BackButton } from "@/components"; +import BackgroundNotificationHandler from "@/components/Notification/BackgroundNotificationHandler"; import NotificationInitializer from "@/components/Notification/NotificationInitializer"; import { NotificationProvider } from "@/hooks/use-notification-store"; import { HeaderStyles } from "@/styles/header-styles"; @@ -9,6 +10,7 @@ export default function ApplicationLayout() { <> + diff --git a/app/(application)/admin/notification/index.tsx b/app/(application)/admin/notification/index.tsx index 5301f8c..03ce45b 100644 --- a/app/(application)/admin/notification/index.tsx +++ b/app/(application)/admin/notification/index.tsx @@ -6,7 +6,8 @@ import { StackCustom, TextCustom, } from "@/components"; -import { AccentColor } from "@/constants/color-palet"; +import { IconPlus } from "@/components/_Icon"; +import { AccentColor, MainColor } from "@/constants/color-palet"; import { useAuth } from "@/hooks/use-auth"; import { useNotificationStore } from "@/hooks/use-notification-store"; import { apiGetNotificationsById } from "@/service/api-notifications"; @@ -17,7 +18,9 @@ import { useCallback, useState } from "react"; import { RefreshControl, View } from "react-native"; const selectedCategory = (value: string) => { - const category = listOfcategoriesAppNotification.find((c) => c.value === value); + const category = listOfcategoriesAppNotification.find( + (c) => c.value === value + ); return category?.label; }; @@ -104,7 +107,12 @@ export default function AdminNotification() { options={{ title: "Admin Notifikasi", headerLeft: () => , - headerRight: () => <>, + headerRight: () => ( + router.push("/test-notifications")} + /> + ), }} /> diff --git a/components/Notification/BackgroundNotificationHandler.tsx b/components/Notification/BackgroundNotificationHandler.tsx new file mode 100644 index 0000000..0ba1e03 --- /dev/null +++ b/components/Notification/BackgroundNotificationHandler.tsx @@ -0,0 +1,140 @@ +// src/components/BackgroundNotificationHandler.tsx +import { useNotificationStore } from "@/hooks/use-notification-store"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + FirebaseMessagingTypes, + getInitialNotification, + getMessaging, + onNotificationOpenedApp, +} from "@react-native-firebase/messaging"; +import { router } from "expo-router"; +import { useEffect, useRef } from "react"; + +const HANDLED_NOTIFICATIONS_KEY = "handled_notifications"; + +export default function BackgroundNotificationHandler() { + const { addNotification, markAsRead } = useNotificationStore(); + const messaging = getMessaging(); + const unsubscribeRef = useRef<(() => void) | null>(null); // 🔑 cegah duplikasi + + useEffect(() => { + const init = async () => { + // 1. Handle (cold start) + const initialNotification = await getInitialNotification(messaging); + if (initialNotification) { + handleNotification(initialNotification); + return; + } + + // 2. Handle background + if (unsubscribeRef.current) { + unsubscribeRef.current(); + } + + const unsubscribe = onNotificationOpenedApp( + messaging, + (remoteMessage) => { + handleNotification(remoteMessage); + } + ); + + unsubscribeRef.current = unsubscribe; + }; + + init(); + + // Cleanup saat komponen unmount + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }; + }, [addNotification, messaging]); + + const isNotificationHandled = async ( + notificationId: string + ): Promise => { + const handled = await AsyncStorage.getItem(HANDLED_NOTIFICATIONS_KEY); + const ids = handled ? JSON.parse(handled) : []; + return ids.includes(notificationId); + }; + + const markNotificationAsHandled = async (notificationId: string) => { + const handled = await AsyncStorage.getItem(HANDLED_NOTIFICATIONS_KEY); + const ids = handled ? JSON.parse(handled) : []; + if (!ids.includes(notificationId)) { + ids.push(notificationId); + // Simpan maksimal 50 ID terakhir untuk hindari memori bocor + await AsyncStorage.setItem( + HANDLED_NOTIFICATIONS_KEY, + JSON.stringify(ids.slice(-50)) + ); + } + }; + + const handleNotification = async ( + remoteMessage: FirebaseMessagingTypes.RemoteMessage + ) => { + const { notification, data } = remoteMessage; + if (!notification?.title) return; + + console.log( + "🚀 Notification received:", + JSON.stringify(remoteMessage, null, 2) + ); + + const notificationId = data?.id; + if (!notificationId || typeof notificationId !== "string") { + console.warn("Notification missing notificationId, skipping navigation"); + return; + } + + // ✅ Cek apakah sudah pernah ditangani + if (await isNotificationHandled(notificationId)) { + console.log("Notification already handled, skipping:", notificationId); + return; + } + + // ✅ Tandai sebagai ditangani + await markNotificationAsHandled(notificationId); + + // ✅ Normalisasi deepLink: pastikan string + let deepLink: string | undefined; + if (data?.deepLink) { + if (typeof data.deepLink === "string") { + deepLink = data.deepLink; + } else { + // Jika object (jarang), coba string-kan + deepLink = JSON.stringify(data.deepLink); + } + } + + // Tambahkan ke UI state (agar muncul di daftar notifikasi & badge) + addNotification({ + title: notification.title, + body: notification.body || "", + type: "announcement", + data: data as Record, // aman karena di-normalisasi di useNotificationStore + }); + + markAsRead(data?.id as any); + + // Navigasi + if ( + data?.deepLink && + typeof data.deepLink === "string" && + data.deepLink.startsWith("/") + ) { + setTimeout(() => { + try { + router.push(data.deepLink as any); + } catch (error) { + console.warn("Navigation failed:", error); + } + }, 100); + } + }; + + return null; +} -- 2.49.1