Fix undefined ke detail berita terbaru

This commit is contained in:
2025-12-05 17:42:04 +08:00
parent ec3ad12531
commit dcb8017594
6 changed files with 191 additions and 173 deletions

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -1,36 +0,0 @@
// app/api/check-update/route.ts
import prisma from "@/lib/prisma";
export async function GET() {
try {
// Ambil berita terbaru
const latestBerita = await prisma.berita.findFirst({
orderBy: { createdAt: "desc" },
select: { id: true, createdAt: true },
});
// Ambil pengumuman terbaru
const latestPengumuman = await prisma.pengumuman.findFirst({
orderBy: { createdAt: "desc" },
select: { id: true, createdAt: true },
});
return Response.json({
success: true,
data: {
berita: latestBerita
? { id: latestBerita.id, createdAt: latestBerita.createdAt.toISOString() }
: null,
pengumuman: latestPengumuman
? { id: latestPengumuman.id, createdAt: latestPengumuman.createdAt.toISOString() }
: null,
},
});
} catch (error) {
console.error("Error in /api/check-update:", error);
return Response.json(
{ success: false, message: "Gagal cek update" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
// app/api/news/latest/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET() {
try {
const berita = await prisma.berita.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { kategoriBerita: true },
});
const pengumuman = await prisma.pengumuman.findMany({
take: 3,
orderBy: { createdAt: "desc" },
include: { CategoryPengumuman: true },
});
const news = [
...berita.map((b) => ({
id: b.id,
type: "berita" as const,
title: b.judul,
content: b.content,
timestamp: b.createdAt,
kategoriBerita: b.kategoriBerita || undefined,
})),
...pengumuman.map((p) => ({
id: p.id,
type: "pengumuman" as const,
title: p.judul,
content: p.content,
timestamp: p.createdAt,
kategoriPengumuman: p.CategoryPengumuman || undefined,
})),
];
return NextResponse.json({ success: true, news }); // ✅ ganti 'data' jadi 'news'
} catch (error) {
console.error("API Error:", error);
return NextResponse.json({ success: false, error: "Gagal memuat data" }, { status: 500 });
}
}

View File

@@ -1,74 +1,94 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import {
import { Box, Paper, Text, Group, CloseButton, Badge, ActionIcon, Stack, Transition } from "@mantine/core"; ActionIcon,
Badge,
Box,
CloseButton,
Group,
Paper,
Stack,
Text,
Transition,
} from "@mantine/core";
import { IconBell, IconChevronRight } from "@tabler/icons-react"; import { IconBell, IconChevronRight } from "@tabler/icons-react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
interface NewsItem { // === Tipe yang bisa diimpor di tempat lain ===
id: string | number; export interface KategoriBerita {
id: string;
name: string;
}
export interface KategoriPengumuman {
id: string;
name: string;
}
export interface NewsItem {
id: string;
type: "berita" | "pengumuman"; type: "berita" | "pengumuman";
title: string; title: string;
content: string; content: string;
timestamp?: string | Date; timestamp?: string | Date;
kategoriBerita?: KategoriBerita;
kategoriPengumuman?: KategoriPengumuman;
} }
interface ModernNewsNotificationProps { export interface ModernNewsNotificationProps {
news: NewsItem[]; news: NewsItem[];
hasNewContent?: boolean; // ✅ TAMBAHAN hasNewContent?: boolean;
newItemCount?: number; // ← tambahkan ini newItemCount?: number;
onSeen?: () => void; // ✅ TAMBAHAN onSeen?: () => void;
autoShowDelay?: number; autoShowDelay?: number;
} }
// === Helper ===
function stripHtml(html: string): string { function stripHtml(html: string): string {
return html return html
.replace(/<[^>]+>/g, '') .replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, ' ') .replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, '&') .replace(/&amp;/gi, "&")
.replace(/\s+/g, ' ') .replace(/\s+/g, " ")
.trim(); .trim();
} }
// === Komponen Utama ===
export default function ModernNewsNotification({ export default function ModernNewsNotification({
news = [], news = [],
hasNewContent = false, hasNewContent = false,
newItemCount = 0, // 👈 tambahkan ini newItemCount = 0,
onSeen, onSeen,
autoShowDelay = 2000, autoShowDelay = 2000,
}: ModernNewsNotificationProps) { }: ModernNewsNotificationProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const [toastVisible, setToastVisible] = useState(false); const [toastVisible, setToastVisible] = useState(false);
const [widgetOpen, setWidgetOpen] = useState(false); const [widgetOpen, setWidgetOpen] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(hasNewContent); const [hasNewNotifications, setHasNewNotifications] = useState(hasNewContent);
const [hasShownToast, setHasShownToast] = useState(false); const [hasShownToast, setHasShownToast] = useState(false);
const [iconVisible, setIconVisible] = useState(true); const [iconVisible, setIconVisible] = useState(true);
const pathname = usePathname();
// Sinkronisasi dari luar // Sinkronisasi prop eksternal
useEffect(() => { useEffect(() => {
if (hasNewContent) { setHasNewNotifications(hasNewContent);
setHasNewNotifications(true);
// Jangan otomatis tampilkan toast di sini — biarkan saat page load saja
}
}, [hasNewContent]); }, [hasNewContent]);
// Auto show toast hanya saat page pertama kali load // Tampilkan toast pertama kali
useEffect(() => { useEffect(() => {
if (news.length > 0 && !toastVisible && !hasShownToast) { if (news.length > 0 && !toastVisible && !hasShownToast) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setToastVisible(true); setToastVisible(true);
setHasShownToast(true); setHasShownToast(true);
// Jika ada new content, anggap sudah "dilihat" setelah toast muncul if (hasNewNotifications) onSeen?.();
if (hasNewNotifications) {
onSeen?.();
}
}, autoShowDelay); }, autoShowDelay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [news.length, autoShowDelay, toastVisible, hasShownToast, hasNewNotifications, onSeen]); }, [news.length, autoShowDelay, toastVisible, hasShownToast, hasNewNotifications, onSeen]);
// Auto hide toast // Sembunyikan toast otomatis
useEffect(() => { useEffect(() => {
if (toastVisible) { if (toastVisible) {
const timer = setTimeout(() => setToastVisible(false), 8000); const timer = setTimeout(() => setToastVisible(false), 8000);
@@ -76,7 +96,7 @@ export default function ModernNewsNotification({
} }
}, [toastVisible]); }, [toastVisible]);
// Scroll handler // Kontrol visibilitas ikon saat scroll
useEffect(() => { useEffect(() => {
let lastScrollY = window.scrollY; let lastScrollY = window.scrollY;
const HIDE_THRESHOLD = 100; const HIDE_THRESHOLD = 100;
@@ -84,11 +104,11 @@ export default function ModernNewsNotification({
const handleScroll = () => { const handleScroll = () => {
const currentScrollY = window.scrollY; const currentScrollY = window.scrollY;
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up'; const scrollDirection = currentScrollY > lastScrollY ? "down" : "up";
if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) { if (scrollDirection === "down" && currentScrollY > HIDE_THRESHOLD) {
setIconVisible(false); setIconVisible(false);
} else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) { } else if (scrollDirection === "up" && currentScrollY < SHOW_THRESHOLD) {
setIconVisible(true); setIconVisible(true);
} }
@@ -99,19 +119,25 @@ export default function ModernNewsNotification({
lastScrollY = currentScrollY; lastScrollY = currentScrollY;
}; };
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [toastVisible]); }, [toastVisible]);
const currentNews = news[0]; const currentNews = news[0];
// 🔗 Arahkan ke detail dengan kategori aman
const handleNotificationClick = (item: NewsItem) => { const handleNotificationClick = (item: NewsItem) => {
setWidgetOpen(false); setWidgetOpen(false);
onSeen?.(); // ✅ tandai sebagai dilihat onSeen?.();
if (item.type === "berita") { if (item.type === "berita") {
router.push("/darmasaba/desa/berita/semua"); const kategori = item.kategoriBerita?.name || "umum";
const safeKategori = encodeURIComponent(kategori);
router.push(`/darmasaba/desa/berita/${safeKategori}/${item.id}`);
} else if (item.type === "pengumuman") { } else if (item.type === "pengumuman") {
router.push("/darmasaba/desa/pengumuman"); const kategori = item.kategoriPengumuman?.name || "umum";
const safeKategori = encodeURIComponent(kategori);
router.push(`/darmasaba/desa/pengumuman/${safeKategori}/${item.id}`);
} }
}; };
@@ -119,35 +145,40 @@ export default function ModernNewsNotification({
setToastVisible(false); setToastVisible(false);
setWidgetOpen(true); setWidgetOpen(true);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.(); // ✅ onSeen?.();
}; };
const handleDismissToast = (e: React.MouseEvent) => { const handleDismissToast = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setToastVisible(false); setToastVisible(false);
onSeen?.(); // ✅ onSeen?.();
}; };
// Only show on landing page // Hanya tampilkan di landing page
if (pathname !== '/darmasaba') { if (pathname !== "/darmasaba") return null;
return null;
}
return ( return (
<> <>
{/* Floating Bell Icon */} {/* Floating Bell Icon */}
<Transition mounted={iconVisible} transition="slide-down" duration={200}> <Transition mounted={iconVisible} transition="slide-down" duration={200}>
{(transitionStyles) => ( {(transitionStyles) => (
<Box style={{ ...transitionStyles, position: "fixed", bottom: "24px", right: "24px" }}> <Box
style={{
...transitionStyles,
position: "fixed",
bottom: "24px",
right: "24px",
}}
>
<ActionIcon <ActionIcon
size="xl" size="xl"
radius="xl" radius="xl"
variant="filled" variant="filled"
color="#1e5a7e" color="#1e5a7e"
onClick={() => { onClick={() => {
setWidgetOpen(!widgetOpen); setWidgetOpen((open) => !open);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.(); // ✅ onSeen?.();
}} }}
style={{ style={{
width: "60px", width: "60px",
@@ -168,7 +199,6 @@ export default function ModernNewsNotification({
right: "6px", right: "6px",
minWidth: "22px", minWidth: "22px",
height: "22px", height: "22px",
padding: "0 6px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -208,12 +238,14 @@ export default function ModernNewsNotification({
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<IconBell size={20} /> <IconBell size={20} />
<Text c="white" fw={600} size="md">Berita & Pengumuman</Text> <Text c="white" fw={600} size="md">
Berita & Pengumuman
</Text>
</Group> </Group>
<CloseButton <CloseButton
onClick={() => { onClick={() => {
setWidgetOpen(false); setWidgetOpen(false);
onSeen?.(); // ✅ onSeen?.();
}} }}
variant="transparent" variant="transparent"
c="white" c="white"
@@ -224,13 +256,15 @@ export default function ModernNewsNotification({
<Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}> <Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}>
{news.length === 0 ? ( {news.length === 0 ? (
<Box p="xl" style={{ textAlign: "center" }}> <Box p="xl" style={{ textAlign: "center" }}>
<Text c="dimmed" size="sm">Tidak ada berita terbaru</Text> <Text c="dimmed" size="sm">
Tidak ada berita terbaru
</Text>
</Box> </Box>
) : ( ) : (
<Stack gap="xs"> <Stack gap="xs">
{news.map((item, index) => ( {news.map((item) => (
<Paper <Paper
key={item.id || index} key={item.id}
p="md" p="md"
radius="md" radius="md"
style={{ style={{
@@ -276,7 +310,11 @@ export default function ModernNewsNotification({
</Transition> </Transition>
{/* Toast Notification */} {/* Toast Notification */}
<Transition mounted={toastVisible && !!currentNews} transition="slide-left" duration={300}> <Transition
mounted={toastVisible && !!currentNews}
transition="slide-left"
duration={300}
>
{(styles) => ( {(styles) => (
<Paper <Paper
style={{ style={{
@@ -314,7 +352,9 @@ export default function ModernNewsNotification({
variant="light" variant="light"
leftSection={currentNews?.type === "berita" ? "📰" : "📢"} leftSection={currentNews?.type === "berita" ? "📰" : "📢"}
> >
{currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"} {currentNews?.type === "berita"
? "Berita Terbaru"
: "Pengumuman"}
</Badge> </Badge>
<CloseButton onClick={handleDismissToast} size="sm" /> <CloseButton onClick={handleDismissToast} size="sm" />
</Group> </Group>
@@ -329,7 +369,7 @@ export default function ModernNewsNotification({
<Group justify="space-between" mt="md"> <Group justify="space-between" mt="md">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{news.length > 1 ? `${news.length} berita tersedia` : '1 berita'} {news.length > 1 ? `${news.length} berita tersedia` : "1 berita"}
</Text> </Text>
<Text <Text
size="xs" size="xs"

View File

@@ -60,7 +60,7 @@ function Penghargaan() {
<Box <Box
onClick={() => setShowVideo(true)} onClick={() => setShowVideo(true)}
style={{ style={{
backgroundImage: "url('/assets/images/award-poster.jpg')", backgroundImage: "url('/mangupuraaward.jpeg')",
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
cursor: 'pointer', cursor: 'pointer',

View File

@@ -15,15 +15,17 @@ import Apbdes from "./_com/main-page/apbdes";
import Prestasi from "./_com/main-page/prestasi"; import Prestasi from "./_com/main-page/prestasi";
import ScrollToTopButton from "./_com/scrollToTopButton"; import ScrollToTopButton from "./_com/scrollToTopButton";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita"; import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman"; import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
import NewsReaderLanding from "./_com/NewsReaderalanding"; import NewsReaderLanding from "./_com/NewsReaderalanding";
import ModernNewsNotification from "./_com/ModernNewsNotification"; import ModernNewsNotification from "./_com/ModernNewsNotification";
import type { NewsItem } from "./_com/ModernNewsNotification"; // pastikan tipe ini diekspor
export default function Page() { export default function Page() {
// Tetap gunakan Valtio untuk card utama (NewsReaderLanding)
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst); const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst); const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
const featured = snap1; const featured = snap1;
@@ -31,13 +33,15 @@ export default function Page() {
const loadingFeatured = featured.loading; const loadingFeatured = featured.loading;
const loadingPengumuman = pengumuman.loading; const loadingPengumuman = pengumuman.loading;
// State untuk notifikasi
const [notificationNews, setNotificationNews] = useState<NewsItem[]>([]);
const [hasNewContent, setHasNewContent] = useState(false); const [hasNewContent, setHasNewContent] = useState(false);
const [newItemCount, setNewItemCount] = useState(0); const [newItemCount, setNewItemCount] = useState(0);
const lastBeritaId = useRef<string | null>(null); const lastBeritaId = useRef<string | null>(null);
const lastPengumumanId = useRef<string | null>(null); const lastPengumumanId = useRef<string | null>(null);
// 🔁 Inisialisasi dari localStorage saat mount // Inisialisasi dari localStorage
useEffect(() => { useEffect(() => {
const savedBerita = localStorage.getItem("lastSeenBeritaId"); const savedBerita = localStorage.getItem("lastSeenBeritaId");
const savedPengumuman = localStorage.getItem("lastSeenPengumumanId"); const savedPengumuman = localStorage.getItem("lastSeenPengumumanId");
@@ -45,13 +49,7 @@ export default function Page() {
if (savedPengumuman) lastPengumumanId.current = savedPengumuman; if (savedPengumuman) lastPengumumanId.current = savedPengumuman;
}, []); }, []);
// Simpan ID saat data dimuat (termasuk dari API) // Load data utama (untuk card)
useEffect(() => {
if (featured.data?.id) lastBeritaId.current = featured.data.id;
if (pengumuman.data?.id) lastPengumumanId.current = pengumuman.data.id;
}, [featured.data?.id, pengumuman.data?.id]);
// Load data awal
useEffect(() => { useEffect(() => {
if (!featured.data && !loadingFeatured) { if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load(); stateDashboardBerita.berita.findFirst.load();
@@ -64,91 +62,64 @@ export default function Page() {
} }
}, []); }, []);
// 🔁 Polling untuk cek update setiap 30 detik // 🔁 Fetch berita & pengumuman lengkap untuk notifikasi
useEffect(() => { const fetchNotificationData = async () => {
const checkForUpdates = async () => { try {
try { const res = await fetch("/api/news/latest");
const res = await fetch("/api/check-update"); const result = await res.json();
const result = await res.json(); if (result.success && Array.isArray(result.news)) {
const news = result.news as NewsItem[];
if (!result.success) return; // Ambil ID terbaru
const latestBerita = news.find((n) => n.type === "berita");
const latestPengumuman = news.find((n) => n.type === "pengumuman");
const { berita, pengumuman } = result.data; const isNewBerita = latestBerita && lastBeritaId.current !== null && latestBerita.id !== lastBeritaId.current;
const isNewPengumuman = latestPengumuman && lastPengumumanId.current !== null && latestPengumuman.id !== lastPengumumanId.current;
// Deteksi hanya jika sudah pernah ada data sebelumnya // Simpan ID terbaru ke ref
const isNewBerita = berita && lastBeritaId.current !== null && berita.id !== lastBeritaId.current; if (latestBerita) lastBeritaId.current = (latestBerita.id);
const isNewPengumuman = pengumuman && lastPengumumanId.current !== null && pengumuman.id !== lastPengumumanId.current; if (latestPengumuman) lastPengumumanId.current = (latestPengumuman.id);
if (isNewBerita || isNewPengumuman) { // Jika ini bukan inisialisasi pertama, tampilkan notifikasi
// Hitung berapa yang benar-benar baru if (lastBeritaId.current !== null || lastPengumumanId.current !== null) {
const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0); if (isNewBerita || isNewPengumuman) {
setNewItemCount(count); const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0);
setHasNewContent(true); setNewItemCount(count);
setHasNewContent(true);
// Reload hanya yang berubah }
if (isNewBerita) stateDashboardBerita.berita.findFirst.load();
if (isNewPengumuman) stateDesaPengumuman.pengumuman.findFirst.load();
} else { } else {
// Jika ini adalah pertama kali (masih null), simpan ID tanpa notifikasi // Simpan ke localStorage saat pertama kali
if (lastBeritaId.current === null && berita) { if (latestBerita) localStorage.setItem("lastSeenBeritaId", (latestBerita.id));
lastBeritaId.current = berita.id; if (latestPengumuman) localStorage.setItem("lastSeenPengumumanId", (latestPengumuman.id));
localStorage.setItem("lastSeenBeritaId", berita.id);
}
if (lastPengumumanId.current === null && pengumuman) {
lastPengumumanId.current = pengumuman.id;
localStorage.setItem("lastSeenPengumumanId", pengumuman.id);
}
} }
} catch (err) {
console.error("Gagal cek update berita/pengumuman:", err);
}
};
const interval = setInterval(checkForUpdates, 30_000); setNotificationNews(news);
}
} catch (err) {
console.error("Gagal fetch data notifikasi:", err);
}
};
// Load data notifikasi pertama kali
useEffect(() => {
fetchNotificationData();
}, []);
// Polling setiap 30 detik
useEffect(() => {
const interval = setInterval(fetchNotificationData, 30_000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const newsData = useMemo(() => {
const items = [];
if (featured.data) {
items.push({
id: String(featured.data.id || "berita-1"),
type: "berita" as const,
title: String(featured.data.judul || "Berita Terbaru"),
content: String(featured.data.content || ""),
timestamp: featured.data.createdAt
? (typeof featured.data.createdAt === 'string'
? featured.data.createdAt
: new Date(featured.data.createdAt).toISOString())
: new Date().toISOString(),
});
}
if (pengumuman.data) {
items.push({
id: String(pengumuman.data.id || "pengumuman-1"),
type: "pengumuman" as const,
title: String(pengumuman.data.judul || "Pengumuman Penting"),
content: String(pengumuman.data.content || ""),
timestamp: pengumuman.data.createdAt
? (typeof pengumuman.data.createdAt === 'string'
? pengumuman.data.createdAt
: new Date(pengumuman.data.createdAt).toISOString())
: new Date().toISOString(),
});
}
return items;
}, [featured.data, pengumuman.data]);
const handleSeen = () => { const handleSeen = () => {
setHasNewContent(false); setHasNewContent(false);
setNewItemCount(0); setNewItemCount(0);
// Simpan ke localStorage saat dilihat const latestBerita = notificationNews.find(n => n.type === "berita");
if (featured.data?.id) localStorage.setItem("lastSeenBeritaId", featured.data.id); const latestPengumuman = notificationNews.find(n => n.type === "pengumuman");
if (pengumuman.data?.id) localStorage.setItem("lastSeenPengumumanId", pengumuman.data.id); if (latestBerita) localStorage.setItem("lastSeenBeritaId", String(latestBerita.id));
}; if (latestPengumuman) localStorage.setItem("lastSeenPengumumanId", String(latestPengumuman.id));
};
return ( return (
<Box id="page-root"> <Box id="page-root">
@@ -168,7 +139,7 @@ export default function Page() {
<NewsReaderLanding /> <NewsReaderLanding />
<ModernNewsNotification <ModernNewsNotification
news={newsData} news={notificationNews}
hasNewContent={hasNewContent} hasNewContent={hasNewContent}
newItemCount={newItemCount} newItemCount={newItemCount}
onSeen={handleSeen} onSeen={handleSeen}