diff --git a/app.config.js b/app.config.js index 5b031bc..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: "15", + buildNumber: "17", }, 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..800890c 100644 --- a/app/(application)/(user)/home.tsx +++ b/app/(application)/(user)/home.tsx @@ -14,19 +14,23 @@ 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(); const [data, setData] = useState(); const [refreshing, setRefreshing] = useState(false); + const { syncUnreadCount } = useNotificationStore(); + + useFocusEffect( useCallback(() => { onLoadData(); checkVersion(); userData(token as string); + syncUnreadCount() }, [user?.id, token]) ); @@ -99,7 +103,9 @@ export default function Application() { } > - {/* router.push("./test-notifications")}>Test Notif */} + {/* router.push("./test-notifications")}> + Test Notif + */} 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 b0a2d7f..28f0b12 100644 --- a/app/(application)/(user)/test-notifications.tsx +++ b/app/(application)/(user)/test-notifications.tsx @@ -5,41 +5,41 @@ 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 { apiNotificationsSend } from "@/service/api-notifications"; +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)!", + title: "Test Notification !!", body: data, userLoginId: user?.id || "", + appId: "hipmi", + status: "publish", + kategoriApp: "JOB", + type: "announcement", + deepLink: "/job/cmhjz8u3h0005cfaxezyeilrr", }, }); - 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..58cecd3 100644 --- a/app/(application)/_layout.tsx +++ b/app/(application)/_layout.tsx @@ -1,15 +1,28 @@ 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"; import { Stack } from "expo-router"; export default function ApplicationLayout() { + return ( + <> + + + + + + + ); +} + +function ApplicationStack() { return ( <> - - {/* Take Picture */} + // + ), path: "/admin/notification", }, diff --git a/app/(application)/admin/notification/index.tsx b/app/(application)/admin/notification/index.tsx index eef8cc0..03ce45b 100644 --- a/app/(application)/admin/notification/index.tsx +++ b/app/(application)/admin/notification/index.tsx @@ -1,20 +1,143 @@ -import { BackButton, TextCustom, ViewWrapper } from "@/components"; -import { Stack } from "expo-router"; +import { + BackButton, + BaseBox, + NewWrapper, + ScrollableCustom, + StackCustom, + TextCustom, +} from "@/components"; +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"; +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 ( <> , - headerRight: () => <>, + headerRight: () => ( + router.push("/test-notifications")} + /> + ), }} /> - - Notification - + ({ + id: i, + label: e.label, + value: e.value, + }))} + onButtonPress={handlePress} + activeId={activeCategory as string} + /> + } + refreshControl={ + + } + > + {listData.map((e, i) => ( + + + + ))} + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index a62a04b..51bdd8b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,8 +1,4 @@ -import NotificationInitializer from "@/components/_ShareComponent/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"; @@ -11,15 +7,12 @@ import Toast from "react-native-toast-message"; export default function RootLayout() { return ( <> - - - - - - - - - + + + + + + ); } 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; +} 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..2a6e7f8 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"); - return; - } - const fcmToken = await messaging().getToken(); - if (!fcmToken) { - console.warn("Gagal mendapatkan FCM token"); + // ✅ Dapatkan instance messaging + const messagingInstance = messaging(); + + // ✅ Gunakan instance sebagai argumen + const supported = await isSupported(messagingInstance); + if (!supported) { + console.log("‼️ FCM tidak didukung"); return; } - // 2. Ambil info device + const authStatus = await requestPermission(messagingInstance); + if (authStatus !== AuthorizationStatus.AUTHORIZED) { + console.warn("Izin telah ditolak"); + return; + } + + 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 || "unknown"; - // 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: "announcement" }); 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..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({ @@ -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; @@ -281,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 084f97d..0c557ad 100644 --- a/hooks/use-notification-store.tsx +++ b/hooks/use-notification-store.tsx @@ -1,52 +1,146 @@ // hooks/useNotificationStore.ts -import { createContext, useContext, useState, ReactNode } from 'react'; -import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging'; +import { + apiNotificationMarkAsRead, + apiNotificationUnreadCount, +} from "@/service/api-notifications"; +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; +import { useAuth } from "./use-auth"; type AppNotification = { id: string; title: string; body: string; data?: Record; - read: boolean; + isRead: boolean; timestamp: number; + type: "announcement" | "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, + role: user?.masterUserRoleId 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)) - ); + 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 () => { + 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); + } }; 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..ea70298 100644 --- a/ios/HIPMIBadungConnect/Info.plist +++ b/ios/HIPMIBadungConnect/Info.plist @@ -39,7 +39,7 @@ CFBundleVersion - 15 + 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 edc8b80..e756898 100644 --- a/screens/Home/HeaderBell.tsx +++ b/screens/Home/HeaderBell.tsx @@ -1,14 +1,17 @@ // 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 { useAuth } from "@/hooks/use-auth"; +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 HeaderBell() { - const { notifications } = useNotificationStore(); - const unreadCount = notifications.filter((n) => !n.read).length; - // 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 95a2c63..7ac50d5 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); @@ -29,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 bff72a3..e44f3bb 100644 --- a/service/api-notifications.ts +++ b/service/api-notifications.ts @@ -1,10 +1,15 @@ +import { TypeNotificationCategoryApp } from "@/types/type-notification-category"; 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?: TypeNotificationCategoryApp }; export async function apiNotificationsSend({ @@ -13,14 +18,56 @@ 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: TypeNotificationCategoryApp +}) { + 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, role }: { id: string, role: "user" | "admin" }) { + try { + const response = await apiConfig.get( + `/mobile/notification/${id}/unread-count?role=${role}` + ); + + console.log("Response Unread Count", response.data); + return response.data; + } catch (error) { + 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" };