Compare commits

...

3 Commits

Author SHA1 Message Date
3f85f330d2 Background notifikasi berhasil dibuat
Add:
-components/Notification/BackgroundNotificationHandler.tsx

### No Issue
2025-12-24 17:43:53 +08:00
7743a2467c Fitur notifikasi dan foreground
Add:
- types/type-notification-category.ts

Fix:
- app/(application)/(user)/notifications/index.tsx
- app/(application)/(user)/test-notifications.tsx
- app/(application)/admin/notification/index.tsx
- components/Notification/NotificationInitializer.tsx
- hooks/use-notification-store.tsx
- service/api-notifications.ts
- utils/formatChatTime.ts

### No Issue
2025-12-24 15:29:58 +08:00
54611ef812 Fix notifikasi system
Add:
- screens/Admin/AdminNotificationBell.tsx

Fix:
- app.config.js
- app/(application)/(user)/home.tsx
- app/(application)/(user)/test-notifications.tsx
- app/(application)/admin/_layout.tsx
- app/_layout.tsx
- components/Notification/NotificationInitializer.tsx
- context/AuthContext.tsx
- hooks/use-notification-store.tsx
- ios/HIPMIBadungConnect/Info.plist
- screens/Home/HeaderBell.tsx
- service/api-device-token.ts
- service/api-notifications.ts

### No Issue
2025-12-23 17:45:30 +08:00
19 changed files with 499 additions and 119 deletions

View File

@@ -21,7 +21,7 @@ export default {
"Aplikasi membutuhkan akses lokasi untuk menampilkan peta.", "Aplikasi membutuhkan akses lokasi untuk menampilkan peta.",
}, },
associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"], associatedDomains: ["applinks:cld-dkr-staging-hipmi.wibudev.com"],
buildNumber: "16", buildNumber: "17",
}, },
android: { android: {

View File

@@ -3,6 +3,7 @@
import { ButtonCustom, StackCustom, ViewWrapper } from "@/components"; import { ButtonCustom, StackCustom, ViewWrapper } from "@/components";
import { MainColor } from "@/constants/color-palet"; import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
import Home_BottomFeatureSection from "@/screens/Home/bottomFeatureSection"; import Home_BottomFeatureSection from "@/screens/Home/bottomFeatureSection";
import HeaderBell from "@/screens/Home/HeaderBell"; import HeaderBell from "@/screens/Home/HeaderBell";
import Home_ImageSection from "@/screens/Home/imageSection"; import Home_ImageSection from "@/screens/Home/imageSection";
@@ -20,12 +21,16 @@ export default function Application() {
const { token, user, userData } = useAuth(); const { token, user, userData } = useAuth();
const [data, setData] = useState<any>(); const [data, setData] = useState<any>();
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const { syncUnreadCount } = useNotificationStore();
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
onLoadData(); onLoadData();
checkVersion(); checkVersion();
userData(token as string); userData(token as string);
syncUnreadCount()
}, [user?.id, token]) }, [user?.id, token])
); );

View File

@@ -1,79 +1,57 @@
import { import {
BaseBox, BaseBox,
Grid, NewWrapper,
ScrollableCustom, ScrollableCustom,
StackCustom, StackCustom,
TextCustom, TextCustom
ViewWrapper,
} from "@/components"; } from "@/components";
import { MainColor } from "@/constants/color-palet"; import ListSkeletonComponent from "@/components/_ShareComponent/ListSkeletonComponent";
import { useState } from "react"; import { AccentColor } from "@/constants/color-palet";
import { View } from "react-native"; import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store";
const categories = [ import { apiGetNotificationsById } from "@/service/api-notifications";
{ value: "all", label: "Semua" }, import { listOfcategoriesAppNotification } from "@/types/type-notification-category";
{ value: "event", label: "Event" }, import { formatChatTime } from "@/utils/formatChatTime";
{ value: "job", label: "Job" }, import { router, useFocusEffect } from "expo-router";
{ value: "voting", label: "Voting" }, import { useCallback, useState } from "react";
{ value: "donasi", label: "Donasi" }, import { RefreshControl, View } from "react-native";
{ value: "investasi", label: "Investasi" },
{ value: "forum", label: "Forum" },
{ value: "collaboration", label: "Collaboration" },
];
const selectedCategory = (value: string) => { const selectedCategory = (value: string) => {
const category = categories.find((c) => c.value === value); const category = listOfcategoriesAppNotification.find((c) => c.value === value);
return category?.label; return category?.label;
}; };
const BoxNotification = ({ const BoxNotification = ({
index, data,
activeCategory, activeCategory,
}: { }: {
index: number; data: any;
activeCategory: string | null; activeCategory: string | null;
}) => { }) => {
const { markAsRead } = useNotificationStore();
return ( return (
<> <>
<BaseBox <BaseBox
onPress={() => backgroundColor={data.isRead ? AccentColor.darkblue : AccentColor.blue}
onPress={() => {
console.log( console.log(
"Notification >", "Notification >",
selectedCategory(activeCategory as string) selectedCategory(activeCategory as string)
) );
} router.push(data.deepLink);
markAsRead(data.id);
}}
> >
<StackCustom> <StackCustom>
<TextCustom bold> <TextCustom truncate={2} bold>
# {selectedCategory(activeCategory as string)} {data.title}
</TextCustom> </TextCustom>
<View <TextCustom truncate={2}>{data.pesan}</TextCustom>
style={{
borderBottomColor: MainColor.white_gray,
borderBottomWidth: 1,
}}
/>
<TextCustom truncate={2}> <TextCustom size="small" color="gray">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Sint odio {formatChatTime(data.createdAt)}
unde quidem voluptate quam culpa sequi molestias ipsa corrupti id,
soluta, nostrum adipisci similique, et illo asperiores deleniti eum
labore.
</TextCustom> </TextCustom>
<Grid>
<Grid.Col span={6}>
<TextCustom size="small" color="gray">
{index + 1} Agustus 2025
</TextCustom>
</Grid.Col>
<Grid.Col span={6} style={{ alignItems: "flex-end" }}>
<TextCustom size="small" color="gray">
Belum lihat
</TextCustom>
</Grid.Col>
</Grid>
</StackCustom> </StackCustom>
</BaseBox> </BaseBox>
</> </>
@@ -81,17 +59,54 @@ const BoxNotification = ({
}; };
export default function Notifications() { export default function Notifications() {
const [activeCategory, setActiveCategory] = useState<string | null>("all"); const { user } = useAuth();
const [activeCategory, setActiveCategory] = useState<string | null>("event");
const [listData, setListData] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const handlePress = (item: any) => { const handlePress = (item: any) => {
setActiveCategory(item.value); setActiveCategory(item.value);
// tambahkan logika lain seperti filter dsb. // 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 ( return (
<ViewWrapper <NewWrapper
headerComponent={ headerComponent={
<ScrollableCustom <ScrollableCustom
data={categories.map((e, i) => ({ data={listOfcategoriesAppNotification.map((e, i) => ({
id: i, id: i,
label: e.label, label: e.label,
value: e.value, value: e.value,
@@ -100,12 +115,19 @@ export default function Notifications() {
activeId={activeCategory as string} activeId={activeCategory as string}
/> />
} }
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
> >
{Array.from({ length: 20 }).map((e, i) => ( {loading ? (
<View key={i}> <ListSkeletonComponent/>
<BoxNotification index={i} activeCategory={activeCategory as any} /> ) : (
</View> listData.map((e, i) => (
))} <View key={i}>
</ViewWrapper> <BoxNotification data={e} activeCategory={activeCategory as any} />
</View>
))
)}
</NewWrapper>
); );
} }

View File

@@ -5,36 +5,26 @@ import {
TextInputCustom, TextInputCustom,
} from "@/components"; } from "@/components";
import { useAuth } from "@/hooks/use-auth"; 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 { useState } from "react";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
export default function TestNotification() { export default function TestNotification() {
const { user } = useAuth(); const { user } = useAuth();
const [data, setData] = useState(""); 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 () => { const handleSubmit = async () => {
console.log("[Data Dikirim]", data); console.log("[Data Dikirim]", data);
const response = await apiNotificationsSend({ const response = await apiNotificationsSend({
data: { data: {
fcmToken: title: "Test Notification !!",
"cVmHm-3P4E-1vjt6AA9kSF:APA91bHTkHjGTLxrFsb6Le6bZmzboZhwMGYXU4p0FP9yEeXixLDXNKS4F5vLuZV3sRgSnjjQsPpLOgstVLHJB8VJTObctKLdN-CxAp4dnP7Jbc_mH53jWvs",
title: "Test dari Backend (App Router)!",
body: data, body: data,
userLoginId: user?.id || "", userLoginId: user?.id || "",
appId: "hipmi",
status: "publish",
kategoriApp: "JOB",
type: "announcement",
deepLink: "/job/cmhjz8u3h0005cfaxezyeilrr",
}, },
}); });

View File

@@ -1,4 +1,5 @@
import { BackButton } from "@/components"; import { BackButton } from "@/components";
import BackgroundNotificationHandler from "@/components/Notification/BackgroundNotificationHandler";
import NotificationInitializer from "@/components/Notification/NotificationInitializer"; import NotificationInitializer from "@/components/Notification/NotificationInitializer";
import { NotificationProvider } from "@/hooks/use-notification-store"; import { NotificationProvider } from "@/hooks/use-notification-store";
import { HeaderStyles } from "@/styles/header-styles"; import { HeaderStyles } from "@/styles/header-styles";
@@ -9,6 +10,7 @@ export default function ApplicationLayout() {
<> <>
<NotificationProvider> <NotificationProvider>
<NotificationInitializer /> <NotificationInitializer />
<BackgroundNotificationHandler />
<ApplicationStack /> <ApplicationStack />
</NotificationProvider> </NotificationProvider>
</> </>

View File

@@ -15,6 +15,7 @@ import {
ICON_SIZE_XLARGE, ICON_SIZE_XLARGE,
} from "@/constants/constans-value"; } from "@/constants/constans-value";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import AdminNotificationBell from "@/screens/Admin/AdminNotificationBell";
import { import {
adminListMenu, adminListMenu,
superAdminListMenu, superAdminListMenu,
@@ -192,11 +193,12 @@ export default function AdminLayout() {
label: "Notifikasi", label: "Notifikasi",
value: "notification", value: "notification",
icon: ( icon: (
<Ionicons // <Ionicons
name="notifications" // name="notifications"
size={ICON_SIZE_SMALL} // size={ICON_SIZE_SMALL}
color={MainColor.white} // color={MainColor.white}
/> // />
<AdminNotificationBell/>
), ),
path: "/admin/notification", path: "/admin/notification",
}, },

View File

@@ -1,20 +1,143 @@
import { BackButton, TextCustom, ViewWrapper } from "@/components"; import {
import { Stack } from "expo-router"; 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 (
<>
<BaseBox
backgroundColor={data.isRead ? AccentColor.darkblue : AccentColor.blue}
onPress={() => {
console.log(
"Notification >",
selectedCategory(activeCategory as string)
);
router.push(data.deepLink);
markAsRead(data.id);
}}
>
<StackCustom>
<TextCustom truncate={2} bold>
{data.title}
</TextCustom>
<TextCustom truncate={2}>{data.pesan}</TextCustom>
<TextCustom size="small" color="gray">
{formatChatTime(data.createdAt)}
</TextCustom>
</StackCustom>
</BaseBox>
</>
);
};
export default function AdminNotification() { export default function AdminNotification() {
const { user } = useAuth();
const [activeCategory, setActiveCategory] = useState<string | null>("event");
const [listData, setListData] = useState<any[]>([]);
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 ( return (
<> <>
<Stack.Screen <Stack.Screen
options={{ options={{
title: "Admin Notifikasi", title: "Admin Notifikasi",
headerLeft: () => <BackButton />, headerLeft: () => <BackButton />,
headerRight: () => <></>, headerRight: () => (
<IconPlus
color={MainColor.yellow}
onPress={() => router.push("/test-notifications")}
/>
),
}} }}
/> />
<ViewWrapper> <NewWrapper
<TextCustom>Notification</TextCustom> headerComponent={
</ViewWrapper> <ScrollableCustom
data={listOfcategoriesAppNotification.map((e, i) => ({
id: i,
label: e.label,
value: e.value,
}))}
onButtonPress={handlePress}
activeId={activeCategory as string}
/>
}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
{listData.map((e, i) => (
<View key={i}>
<BoxNotification data={e} activeCategory={activeCategory as any} />
</View>
))}
</NewWrapper>
</> </>
); );
} }

View File

@@ -1,6 +1,4 @@
import NotificationInitializer from "@/components/Notification/NotificationInitializer";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { NotificationProvider } from "@/hooks/use-notification-store";
import AppRoot from "@/screens/RootLayout/AppRoot"; import AppRoot from "@/screens/RootLayout/AppRoot";
import "react-native-gesture-handler"; import "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";

View File

@@ -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<boolean> => {
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<string, string>, // 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;
}

View File

@@ -39,7 +39,7 @@ export default function NotificationInitializer() {
if (!supported) { if (!supported) {
console.log("‼️ FCM tidak didukung"); console.log("‼️ FCM tidak didukung");
return; return;
}; }
const authStatus = await requestPermission(messagingInstance); const authStatus = await requestPermission(messagingInstance);
if (authStatus !== AuthorizationStatus.AUTHORIZED) { if (authStatus !== AuthorizationStatus.AUTHORIZED) {
@@ -62,7 +62,7 @@ export default function NotificationInitializer() {
"-" + "-" +
(Application.nativeBuildVersion || "unknown"); (Application.nativeBuildVersion || "unknown");
const deviceId = const deviceId =
Device.osInternalBuildId || Device.modelName + "-" + Date.now(); Device.osInternalBuildId || Device.modelName || "unknown";
// Kirim ke backend // Kirim ke backend
await apiDeviceRegisterToken({ await apiDeviceRegisterToken({
@@ -101,7 +101,7 @@ export default function NotificationInitializer() {
} }
console.log("📥 Menambahkan ke store:", { title, body, safeData }); console.log("📥 Menambahkan ke store:", { title, body, safeData });
addNotification({ title, body, data: safeData , type: "notification", }); addNotification({ title, body, data: safeData, type: "announcement" });
console.log("✅ Notifikasi ditambahkan ke state"); console.log("✅ Notifikasi ditambahkan ke state");
}; };

View File

@@ -10,6 +10,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { router } from "expo-router"; import { router } from "expo-router";
import { createContext, useEffect, useState } from "react"; import { createContext, useEffect, useState } from "react";
import Toast from "react-native-toast-message"; import Toast from "react-native-toast-message";
import * as Device from "expo-device";
// --- Types --- // --- Types ---
type AuthContextType = { type AuthContextType = {
@@ -77,7 +78,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const response = await apiLogin({ nomor: nomor }); const response = await apiLogin({ nomor: nomor });
console.log("[RESPONSE AUTH]", JSON.stringify(response)); console.log("[RESPONSE AUTH]", JSON.stringify(response));
if (response.success) { if (response.success) {
console.log("[Keluar provider]", nomor); console.log("[Keluar provider]", nomor);
Toast.show({ Toast.show({
@@ -148,7 +148,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
// return; // return;
// } // }
router.replace("/(application)/(user)/home"); router.replace("/(application)/(user)/home");
return return;
} else { } else {
router.replace("/(application)/(user)/waiting-room"); router.replace("/(application)/(user)/waiting-room");
return; return;
@@ -283,9 +283,12 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
setIsLoading(true); setIsLoading(true);
setToken(null); setToken(null);
setUser(null); setUser(null);
const deviceId = Device.osInternalBuildId || Device.modelName || "unknown";
await AsyncStorage.removeItem("authToken"); await AsyncStorage.removeItem("authToken");
await AsyncStorage.removeItem("userData"); await AsyncStorage.removeItem("userData");
await apiDeviceTokenDeleted({userId: user?.id as any}) await apiDeviceTokenDeleted({ userId: user?.id as any, deviceId });
Toast.show({ Toast.show({
type: "success", type: "success",

View File

@@ -1,4 +1,8 @@
// hooks/useNotificationStore.ts // hooks/useNotificationStore.ts
import {
apiNotificationMarkAsRead,
apiNotificationUnreadCount,
} from "@/service/api-notifications";
import { import {
createContext, createContext,
ReactNode, ReactNode,
@@ -7,10 +11,6 @@ import {
useState, useState,
} from "react"; } from "react";
import { useAuth } from "./use-auth"; import { useAuth } from "./use-auth";
import {
apiGetNotificationsById,
apiNotificationUnreadCount,
} from "@/service/api-notifications";
type AppNotification = { type AppNotification = {
id: string; id: string;
@@ -19,7 +19,7 @@ type AppNotification = {
data?: Record<string, string>; data?: Record<string, string>;
isRead: boolean; isRead: boolean;
timestamp: number; timestamp: number;
type: "notification" | "trigger"; type: "announcement" | "trigger";
// untuk id dari setiap kategori app // untuk id dari setiap kategori app
appId?: string; appId?: string;
kategoriApp?: kategoriApp?:
@@ -70,6 +70,7 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => {
try { try {
const count = await apiNotificationUnreadCount({ const count = await apiNotificationUnreadCount({
id: user?.id as any, id: user?.id as any,
role: user?.masterUserRoleId as any,
}); // ← harus return number }); // ← harus return number
const result = count.data; const result = count.data;
console.log("📖 Unread count:", result); console.log("📖 Unread count:", result);
@@ -95,18 +96,32 @@ export const NotificationProvider = ({ children }: { children: ReactNode }) => {
setUnreadCount((prev) => prev + 1); setUnreadCount((prev) => prev + 1);
}; };
const markAsRead = (id: string) => { const markAsRead = async (id: string) => {
setNotifications((prev) => try {
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) 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 () => { const syncUnreadCount = async () => {
try { try {
const count = await apiNotificationUnreadCount({ const count = await apiNotificationUnreadCount({
id: user?.id as any, id: user?.id as any,
role: user?.masterUserRoleId as any,
}); // ← harus return number }); // ← harus return number
const result = count.data; const result = count.data;
console.log("📖 Unread count sync:", result);
setUnreadCount(result); setUnreadCount(result);
} catch (error) { } catch (error) {
console.warn("⚠️ Gagal sync unread count:", error); console.warn("⚠️ Gagal sync unread count:", error);

View File

@@ -39,7 +39,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>16</string> <string>17</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>

View File

@@ -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 (
<View style={{ position: "relative" }}>
<Ionicons
name="notifications"
size={ICON_SIZE_SMALL}
color={MainColor.white}
/>
{unreadCount > 0 && (
<View
style={{
position: "absolute",
top: -4,
right: -4,
backgroundColor: "red",
borderRadius: 8,
minWidth: 16,
height: 16,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 2,
}}
>
<Text style={{ color: "white", fontSize: 10, fontWeight: "bold" }}>
{unreadCount > 9 ? "9+" : unreadCount}
</Text>
</View>
)}
</View>
);
}

View File

@@ -1,14 +1,17 @@
// components/HeaderBell.tsx // components/HeaderBell.tsx
import { MainColor } from "@/constants/color-palet"; import { MainColor } from "@/constants/color-palet";
import { useAuth } from "@/hooks/use-auth";
import { useNotificationStore } from "@/hooks/use-notification-store"; import { useNotificationStore } from "@/hooks/use-notification-store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
import { useEffect } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
export default function HeaderBell() { export default function HeaderBell() {
const { notifications , unreadCount} = useNotificationStore(); const { unreadCount } = useNotificationStore();
// console.log("NOTIF:", JSON.stringify(notifications, null, 2)); const { user } = useAuth();
const pathDetector =
user?.masterUserRoleId === "1" ? "/notifications" : "/admin/notification";
return ( return (
<View style={{ position: "relative" }}> <View style={{ position: "relative" }}>
@@ -17,7 +20,7 @@ export default function HeaderBell() {
size={20} size={20}
color={MainColor.yellow} color={MainColor.yellow}
onPress={() => { onPress={() => {
router.push("/notifications"); router.push(pathDetector);
}} }}
/> />
{unreadCount > 0 && ( {unreadCount > 0 && (

View File

@@ -26,10 +26,10 @@ export async function apiDeviceRegisterToken({
} }
} }
export async function apiDeviceTokenDeleted({ userId }: { userId: string }) { export async function apiDeviceTokenDeleted({ userId, deviceId }: { userId: string, deviceId: string }) {
try { try {
const response = await apiConfig.delete( const response = await apiConfig.delete(
`/mobile/auth/device-tokens/${userId}` `/mobile/auth/device-tokens/${userId}?deviceId=${deviceId}`
); );
console.log("Device token deleted:", response.data); console.log("Device token deleted:", response.data);
return response.data; return response.data;

View File

@@ -1,10 +1,15 @@
import { TypeNotificationCategoryApp } from "@/types/type-notification-category";
import { apiConfig } from "./api-config"; import { apiConfig } from "./api-config";
type NotificationProp = { type NotificationProp = {
fcmToken: string;
title: string; title: string;
body: Object; body: string;
userLoginId?: string; userLoginId: string;
appId?: string;
status?: string;
type?: "announcement" | "trigger";
deepLink?: string;
kategoriApp?: TypeNotificationCategoryApp
}; };
export async function apiNotificationsSend({ export async function apiNotificationsSend({
@@ -28,7 +33,7 @@ export async function apiGetNotificationsById({
category, category,
}: { }: {
id: string; id: string;
category: "count-as-unread" | "all"; category: TypeNotificationCategoryApp
}) { }) {
console.log("ID", id); console.log("ID", id);
console.log("Category", category); console.log("Category", category);
@@ -44,11 +49,23 @@ export async function apiGetNotificationsById({
} }
} }
export async function apiNotificationUnreadCount({ id }: { id: string }) { export async function apiNotificationUnreadCount({ id, role }: { id: string, role: "user" | "admin" }) {
try { try {
const response = await apiConfig.get( const response = await apiConfig.get(
`/mobile/notification/${id}/unread-count` `/mobile/notification/${id}/unread-count?role=${role}`
); );
console.log("Response Unread Count", response.data);
return response.data;
} catch (error) {
throw error;
}
}
export async function apiNotificationMarkAsRead({id}: {id: string}) {
try {
const response = await apiConfig.put(`/mobile/notification/${id}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
throw error; throw error;

View File

@@ -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" },
];

View File

@@ -17,7 +17,7 @@ export const formatChatTime = (date: string | Date): string => {
// Jika hari ini // Jika hari ini
if (messageDate.isSame(now, 'day')) { if (messageDate.isSame(now, 'day')) {
return messageDate.format('HH.mm'); // contoh: "14.30" return messageDate.format('HH:mm'); // contoh: "14.30"
} }
// Jika kemarin // Jika kemarin
@@ -31,5 +31,5 @@ export const formatChatTime = (date: string | Date): string => {
} }
// Lebih dari seminggu lalu → tampilkan tanggal // 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"
}; };