From 1503707eed1645c31eb7aa96b97055096f9500de Mon Sep 17 00:00:00 2001 From: bagasbanuna Date: Fri, 19 Dec 2025 17:54:49 +0800 Subject: [PATCH] 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;