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"
};