314 lines
9.7 KiB
TypeScript
314 lines
9.7 KiB
TypeScript
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
View,
|
|
ScrollView,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
FlatList,
|
|
Dimensions,
|
|
RefreshControl,
|
|
} from "react-native";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Card } from "@/components/ui/card";
|
|
import { useSirouRouter } from "@sirou/react-native";
|
|
import { AppRoutes } from "@/lib/routes";
|
|
import { Newspaper, ChevronRight, Clock } from "@/lib/icons";
|
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
|
import { StandardHeader } from "@/components/StandardHeader";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
|
import { api, newsApi } from "@/lib/api";
|
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|
import { useAuthStore } from "@/lib/auth-store";
|
|
|
|
const { width } = Dimensions.get("window");
|
|
const LATEST_CARD_WIDTH = width * 0.8;
|
|
|
|
interface NewsItem {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
category: "ANNOUNCEMENT" | "UPDATE" | "MAINTENANCE" | "NEWS";
|
|
priority: "LOW" | "MEDIUM" | "HIGH";
|
|
publishedAt: string;
|
|
viewCount: number;
|
|
}
|
|
|
|
export default function NewsScreen() {
|
|
const nav = useSirouRouter<AppRoutes>();
|
|
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
|
|
|
|
// Safe accessor to handle initialization race conditions
|
|
const getNewsApi = () => {
|
|
if (newsApi) return newsApi;
|
|
return api.news;
|
|
};
|
|
|
|
// Latest News State
|
|
const [latestNews, setLatestNews] = useState<NewsItem[]>([]);
|
|
const [loadingLatest, setLoadingLatest] = useState(true);
|
|
|
|
// All News State
|
|
const [allNews, setAllNews] = useState<NewsItem[]>([]);
|
|
const [loadingAll, setLoadingAll] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
// Check permissions (none for viewing news)
|
|
|
|
const fetchLatest = async () => {
|
|
try {
|
|
setLoadingLatest(true);
|
|
const service = getNewsApi();
|
|
if (!service) throw new Error("News service unavailable");
|
|
const data = await service.getLatest({ query: { limit: 5 } });
|
|
setLatestNews(data || []);
|
|
} catch (err) {
|
|
console.error("[News] Latest fetch error:", err);
|
|
} finally {
|
|
setLoadingLatest(false);
|
|
}
|
|
};
|
|
|
|
const fetchAll = async (pageNum: number, isRefresh = false) => {
|
|
try {
|
|
if (!isRefresh) {
|
|
pageNum === 1 ? setLoadingAll(true) : setLoadingMore(true);
|
|
}
|
|
|
|
const service = getNewsApi();
|
|
if (!service) throw new Error("News service unavailable");
|
|
|
|
const response = await service.getAll({
|
|
query: { page: pageNum, limit: 10, isPublished: true },
|
|
});
|
|
|
|
const newData = response.data || [];
|
|
if (isRefresh) {
|
|
setAllNews(newData);
|
|
} else {
|
|
setAllNews((prev) => (pageNum === 1 ? newData : [...prev, ...newData]));
|
|
}
|
|
|
|
setHasMore(response?.meta?.hasNextPage ?? false);
|
|
setPage(pageNum);
|
|
} catch (err) {
|
|
console.error("[News] All fetch error:", err);
|
|
} finally {
|
|
setLoadingAll(false);
|
|
setLoadingMore(false);
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const onRefresh = () => {
|
|
setRefreshing(true);
|
|
fetchLatest();
|
|
fetchAll(1, true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchLatest();
|
|
fetchAll(1);
|
|
}, []);
|
|
|
|
const loadMore = () => {
|
|
if (hasMore && !loadingMore && !loadingAll) {
|
|
fetchAll(page + 1);
|
|
}
|
|
};
|
|
|
|
const getCategoryColor = (category: string) => {
|
|
switch (category) {
|
|
case "ANNOUNCEMENT":
|
|
return "bg-amber-500";
|
|
case "UPDATE":
|
|
return "bg-blue-500";
|
|
case "MAINTENANCE":
|
|
return "bg-red-500";
|
|
default:
|
|
return "bg-emerald-500";
|
|
}
|
|
};
|
|
|
|
const LatestItem = ({ item }: { item: NewsItem }) => (
|
|
<Pressable className="mr-4" key={item.id}>
|
|
<ShadowWrapper level="md">
|
|
<Card
|
|
className="overflow-hidden rounded-[20px] bg-card border-border/50"
|
|
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
|
|
>
|
|
<View className="p-5 flex-1 justify-between">
|
|
<View>
|
|
<View className="flex-row items-center gap-2 mb-2">
|
|
<View
|
|
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
|
|
>
|
|
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
|
|
{item.category}
|
|
</Text>
|
|
</View>
|
|
<Text variant="muted" className="text-[10px] font-bold">
|
|
{new Date(item.publishedAt).toLocaleDateString()}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-foreground font-black text-lg leading-tight"
|
|
numberOfLines={2}
|
|
>
|
|
{item.title}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex-row justify-between items-center">
|
|
<Text variant="muted" className="text-xs font-medium opacity-60">
|
|
Tap to read more
|
|
</Text>
|
|
<View className="bg-primary/10 p-1.5 rounded-full">
|
|
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</ShadowWrapper>
|
|
</Pressable>
|
|
);
|
|
|
|
const NewsItem = ({ item }: { item: NewsItem }) => (
|
|
<Pressable className="mb-4" key={item.id}>
|
|
<ShadowWrapper level="xs">
|
|
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
|
|
<View className="p-4">
|
|
<View className="flex-row items-center gap-2 mb-1.5">
|
|
<View
|
|
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
|
|
/>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[10px] font-black uppercase tracking-widest opacity-60"
|
|
>
|
|
{item.category}
|
|
</Text>
|
|
</View>
|
|
<Text
|
|
className="text-foreground font-bold text-sm mb-1"
|
|
numberOfLines={2}
|
|
>
|
|
{item.title}
|
|
</Text>
|
|
<Text
|
|
variant="muted"
|
|
className="text-[11px] leading-relaxed"
|
|
numberOfLines={2}
|
|
>
|
|
{item.content}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center gap-3 mt-3">
|
|
<View className="flex-row items-center gap-1">
|
|
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
|
|
<Text variant="muted" className="text-[10px] font-medium">
|
|
{new Date(item.publishedAt).toLocaleDateString()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Card>
|
|
</ShadowWrapper>
|
|
</Pressable>
|
|
);
|
|
|
|
return (
|
|
<ScreenWrapper className="bg-background">
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{ paddingBottom: 120 }}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
tintColor="#ea580c"
|
|
/>
|
|
}
|
|
>
|
|
<StandardHeader />
|
|
{/* Latest News Section */}
|
|
<View className="px-5 mt-4">
|
|
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
|
Latest News
|
|
</Text>
|
|
|
|
{loadingLatest ? (
|
|
<ActivityIndicator color="#ea580c" className="py-10" />
|
|
) : latestNews.length > 0 ? (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
decelerationRate="fast"
|
|
snapToInterval={LATEST_CARD_WIDTH + 16}
|
|
className="overflow-visible"
|
|
>
|
|
{latestNews.map((item) => (
|
|
<LatestItem key={item.id} item={item} />
|
|
))}
|
|
</ScrollView>
|
|
) : (
|
|
<View className="py-4">
|
|
<EmptyState
|
|
title="No latest updates"
|
|
description="Announcements and important updates will appear here once published."
|
|
hint="Pull to refresh to check again."
|
|
previewLines={2}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* All News Section */}
|
|
<View className="px-5 mt-8">
|
|
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
|
All News
|
|
</Text>
|
|
|
|
{loadingAll ? (
|
|
<ActivityIndicator color="#ea580c" className="py-20" />
|
|
) : allNews.length > 0 ? (
|
|
<>
|
|
{allNews.map((item) => (
|
|
<NewsItem key={item.id} item={item} />
|
|
))}
|
|
{hasMore && (
|
|
<Pressable
|
|
onPress={loadMore}
|
|
disabled={loadingMore}
|
|
className="py-4 items-center"
|
|
>
|
|
{loadingMore ? (
|
|
<ActivityIndicator color="#ea580c" size="small" />
|
|
) : (
|
|
<Text className="text-primary font-bold text-xs uppercase tracking-widest">
|
|
Load More
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
)}
|
|
</>
|
|
) : (
|
|
<View className="py-6">
|
|
<EmptyState
|
|
title="No news yet"
|
|
description="Company news, maintenance updates, and announcements will show up here."
|
|
hint="Pull to refresh to fetch the latest posts."
|
|
previewLines={4}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
</ScreenWrapper>
|
|
);
|
|
}
|