237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
|
import { View, ActivityIndicator, FlatList, RefreshControl, Pressable } from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { api } from "@/lib/api";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { Bell, Clock } from "@/lib/icons";
|
|
|
|
type NotificationItem = {
|
|
id: string;
|
|
title?: string;
|
|
body?: string;
|
|
icon?: string;
|
|
url?: string;
|
|
sentAt?: string;
|
|
createdAt?: string;
|
|
isSent?: boolean;
|
|
};
|
|
|
|
function formatRelativeTime(dateString: string): string {
|
|
const now = new Date();
|
|
const date = new Date(dateString);
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffMins < 1) return "Just now";
|
|
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? "s" : ""} ago`;
|
|
if (diffHours < 24) return `${diffHours} hr${diffHours > 1 ? "s" : ""} ago`;
|
|
if (diffDays === 1) return "Yesterday";
|
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
}
|
|
|
|
function getDateGroup(dateString: string): string {
|
|
const now = new Date();
|
|
const date = new Date(dateString);
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const yesterday = new Date(today);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
|
|
if (itemDate.getTime() === today.getTime()) return "Today";
|
|
if (itemDate.getTime() === yesterday.getTime()) return "Yesterday";
|
|
if (now.getTime() - itemDate.getTime() < 7 * 86400000) return "This Week";
|
|
return date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
}
|
|
|
|
type SectionItem = {
|
|
type: "header" | "item";
|
|
key: string;
|
|
item?: NotificationItem;
|
|
isLast?: boolean;
|
|
};
|
|
|
|
export default function NotificationsScreen() {
|
|
const [items, setItems] = useState<NotificationItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
const fetchNotifications = useCallback(
|
|
async (pageNum: number, mode: "initial" | "refresh" | "more") => {
|
|
try {
|
|
if (mode === "initial") setLoading(true);
|
|
if (mode === "refresh") setRefreshing(true);
|
|
if (mode === "more") setLoadingMore(true);
|
|
|
|
const res = await (api as any).notifications.getAll({
|
|
query: { page: pageNum, limit: 20 },
|
|
});
|
|
|
|
const next = (res?.data ?? []) as NotificationItem[];
|
|
if (mode === "more") {
|
|
setItems((prev) => [...prev, ...next]);
|
|
} else {
|
|
setItems(next);
|
|
}
|
|
|
|
setHasMore(Boolean(res?.meta?.hasNextPage));
|
|
setPage(pageNum);
|
|
} catch (e) {
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setLoadingMore(false);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetchNotifications(1, "initial");
|
|
}, [fetchNotifications]);
|
|
|
|
const onRefresh = () => fetchNotifications(1, "refresh");
|
|
const onEndReached = () => {
|
|
if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more");
|
|
};
|
|
|
|
const grouped = useMemo(() => {
|
|
const groups: Record<string, NotificationItem[]> = {};
|
|
for (const item of items) {
|
|
const dateStr = item.sentAt || item.createdAt;
|
|
const group = dateStr ? getDateGroup(dateStr) : "Other";
|
|
if (!groups[group]) groups[group] = [];
|
|
groups[group].push(item);
|
|
}
|
|
return Object.entries(groups);
|
|
}, [items]);
|
|
|
|
const sections = useMemo(() => {
|
|
const data: SectionItem[] = [];
|
|
for (const [title, groupItems] of grouped) {
|
|
data.push({ type: "header", key: `header-${title}` });
|
|
groupItems.forEach((item, idx) => {
|
|
data.push({
|
|
type: "item",
|
|
key: item.id,
|
|
item,
|
|
isLast: idx === groupItems.length - 1,
|
|
});
|
|
});
|
|
}
|
|
return data;
|
|
}, [grouped]);
|
|
|
|
const renderSectionHeader = (title: string) => (
|
|
<View className="px-5 pt-5 pb-2">
|
|
<Text className="text-[13px] font-sans-bold text-muted-foreground uppercase tracking-wider">
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
);
|
|
|
|
const renderItem = ({ item, isLast }: { item: NotificationItem; isLast: boolean }) => {
|
|
const time = item.sentAt || item.createdAt
|
|
? formatRelativeTime(item.sentAt || item.createdAt!)
|
|
: "";
|
|
|
|
const iconName = item.icon || "bell";
|
|
|
|
return (
|
|
<View className={`${!isLast ? "border-b border-border/40" : ""}`}>
|
|
<View className="flex-row items-center px-5 py-3 bg-card">
|
|
{/* Icon */}
|
|
<View className="w-12 h-12 rounded-full bg-primary/10 items-center justify-center flex-shrink-0">
|
|
<Bell size={20} color="white" strokeWidth={2} />
|
|
</View>
|
|
|
|
{/* Content */}
|
|
<View className="flex-1 ml-3 min-w-0">
|
|
<Text
|
|
className="text-[14px] font-sans-bold text-foreground"
|
|
numberOfLines={1}
|
|
>
|
|
{item.title || "Notification"}
|
|
</Text>
|
|
{item.body ? (
|
|
<Text
|
|
className="text-muted-foreground text-[13px] font-sans-medium mt-0.5"
|
|
numberOfLines={1}
|
|
>
|
|
{item.body}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
{/* Time + Unread dot */}
|
|
<View className="items-end ml-2 flex-shrink-0">
|
|
{time ? (
|
|
<Text className="text-muted-foreground/60 text-[11px] font-sans-medium">
|
|
{time}
|
|
</Text>
|
|
) : null}
|
|
<View className="w-2 h-2 rounded-full bg-blue-500 mt-1.5" />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<StandardHeader
|
|
showBack
|
|
title="Notifications"
|
|
rightAction="notificationsSettings"
|
|
/>
|
|
|
|
{loading ? (
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator size="large" color="#E46212" />
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={sections}
|
|
keyExtractor={(i) => i.key}
|
|
renderItem={({ item }) => {
|
|
if (item.type === "header") {
|
|
return renderSectionHeader(item.key.replace("header-", ""));
|
|
}
|
|
return renderItem({ item: item.item!, isLast: item.isLast! });
|
|
}}
|
|
contentContainerStyle={{ paddingBottom: 32 }}
|
|
onEndReached={onEndReached}
|
|
onEndReachedThreshold={0.4}
|
|
refreshControl={
|
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
|
}
|
|
ListEmptyComponent={
|
|
<View className="px-5 py-12">
|
|
<EmptyState
|
|
title="No notifications"
|
|
description="You're all caught up!"
|
|
centered
|
|
/>
|
|
</View>
|
|
}
|
|
ListFooterComponent={
|
|
loadingMore ? (
|
|
<View className="py-4">
|
|
<ActivityIndicator size="small" color="#E46212" />
|
|
</View>
|
|
) : null
|
|
}
|
|
/>
|
|
)}
|
|
</ScreenWrapper>
|
|
);
|
|
}
|