Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi

This commit is contained in:
2025-12-05 14:30:53 +08:00
parent dad44c0537
commit ec3ad12531
5 changed files with 231 additions and 93 deletions

View File

@@ -828,11 +828,11 @@ model DokterdanTenagaMedis {
name String name String
specialist String specialist String
jadwal String jadwal String
jadwalLibur String jadwalLibur String?
jamBukaOperasional String jamBukaOperasional String?
jamTutupOperasional String jamTutupOperasional String?
jamBukaLibur String jamBukaLibur String?
jamTutupLibur String jamTutupLibur String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())

View File

@@ -0,0 +1,36 @@
// 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

@@ -15,6 +15,9 @@ interface NewsItem {
interface ModernNewsNotificationProps { interface ModernNewsNotificationProps {
news: NewsItem[]; news: NewsItem[];
hasNewContent?: boolean; // ✅ TAMBAHAN
newItemCount?: number; // ← tambahkan ini
onSeen?: () => void; // ✅ TAMBAHAN
autoShowDelay?: number; autoShowDelay?: number;
} }
@@ -29,57 +32,66 @@ function stripHtml(html: string): string {
export default function ModernNewsNotification({ export default function ModernNewsNotification({
news = [], news = [],
autoShowDelay = 2000 hasNewContent = false,
newItemCount = 0, // 👈 tambahkan ini
onSeen,
autoShowDelay = 2000,
}: ModernNewsNotificationProps) { }: ModernNewsNotificationProps) {
const router = useRouter(); const router = useRouter();
const [toastVisible, setToastVisible] = useState(false); const [toastVisible, setToastVisible] = useState(false);
const [widgetOpen, setWidgetOpen] = useState(false); const [widgetOpen, setWidgetOpen] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(true); 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(); const pathname = usePathname();
// Auto show toast on page load // Sinkronisasi dari luar
useEffect(() => {
if (hasNewContent) {
setHasNewNotifications(true);
// Jangan otomatis tampilkan toast di sini — biarkan saat page load saja
}
}, [hasNewContent]);
// Auto show toast hanya saat page pertama kali load
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?.();
}
}, autoShowDelay); }, autoShowDelay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [news.length, autoShowDelay, toastVisible, hasShownToast]); }, [news.length, autoShowDelay, toastVisible, hasShownToast, hasNewNotifications, onSeen]);
// Auto hide toast after 8 seconds // Auto hide toast
useEffect(() => { useEffect(() => {
if (toastVisible) { if (toastVisible) {
const timer = setTimeout(() => { const timer = setTimeout(() => setToastVisible(false), 8000);
setToastVisible(false);
}, 8000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [toastVisible]); }, [toastVisible]);
// Enhanced scroll handler with better thresholds // Scroll handler
useEffect(() => { useEffect(() => {
let lastScrollY = window.scrollY; let lastScrollY = window.scrollY;
const HIDE_THRESHOLD = 100; // Mulai hide saat scroll > 100px const HIDE_THRESHOLD = 100;
const SHOW_THRESHOLD = 50; // Hanya show ketika benar-benar di atas (< 50px) const SHOW_THRESHOLD = 50;
const handleScroll = () => { const handleScroll = () => {
const currentScrollY = window.scrollY; const currentScrollY = window.scrollY;
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up'; const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up';
// Logic untuk hide/show icon
if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) { if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) {
// Scroll ke bawah dan sudah melewati threshold → hide
setIconVisible(false); setIconVisible(false);
} else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) { } else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) {
// Scroll ke atas dan sudah di posisi paling atas → show
setIconVisible(true); setIconVisible(true);
} }
// Hide toast saat scroll ke bawah melewati 150px
if (currentScrollY > 150 && toastVisible) { if (currentScrollY > 150 && toastVisible) {
setToastVisible(false); setToastVisible(false);
} }
@@ -93,9 +105,9 @@ export default function ModernNewsNotification({
const currentNews = news[0]; const currentNews = news[0];
// Handle notification click
const handleNotificationClick = (item: NewsItem) => { const handleNotificationClick = (item: NewsItem) => {
setWidgetOpen(false); setWidgetOpen(false);
onSeen?.(); // ✅ tandai sebagai dilihat
if (item.type === "berita") { if (item.type === "berita") {
router.push("/darmasaba/desa/berita/semua"); router.push("/darmasaba/desa/berita/semua");
} else if (item.type === "pengumuman") { } else if (item.type === "pengumuman") {
@@ -107,6 +119,13 @@ export default function ModernNewsNotification({
setToastVisible(false); setToastVisible(false);
setWidgetOpen(true); setWidgetOpen(true);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.(); // ✅
};
const handleDismissToast = (e: React.MouseEvent) => {
e.stopPropagation();
setToastVisible(false);
onSeen?.(); // ✅
}; };
// Only show on landing page // Only show on landing page
@@ -119,14 +138,7 @@ export default function ModernNewsNotification({
{/* 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 <Box style={{ ...transitionStyles, position: "fixed", bottom: "24px", right: "24px" }}>
style={{
...transitionStyles,
position: "fixed",
bottom: "24px",
right: "24px",
}}
>
<ActionIcon <ActionIcon
size="xl" size="xl"
radius="xl" radius="xl"
@@ -135,6 +147,7 @@ export default function ModernNewsNotification({
onClick={() => { onClick={() => {
setWidgetOpen(!widgetOpen); setWidgetOpen(!widgetOpen);
setHasNewNotifications(false); setHasNewNotifications(false);
onSeen?.(); // ✅
}} }}
style={{ style={{
width: "60px", width: "60px",
@@ -146,20 +159,23 @@ export default function ModernNewsNotification({
<IconBell size={28} /> <IconBell size={28} />
{hasNewNotifications && news.length > 0 && ( {hasNewNotifications && news.length > 0 && (
<Badge <Badge
size="sm" size="sm"
variant="filled" variant="filled"
color="red" color="red"
style={{ style={{
position: "absolute", position: "absolute",
top: "6px", top: "6px",
right: "6px", right: "6px",
minWidth: "22px", minWidth: "22px",
height: "22px", height: "22px",
padding: "0 6px", padding: "0 6px",
}} display: "flex",
> alignItems: "center",
{news.length} justifyContent: "center",
</Badge> }}
>
{newItemCount || news.length}
</Badge>
)} )}
</ActionIcon> </ActionIcon>
</Box> </Box>
@@ -195,20 +211,17 @@ export default function ModernNewsNotification({
<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={() => setWidgetOpen(false)} onClick={() => {
setWidgetOpen(false);
onSeen?.(); // ✅
}}
variant="transparent" variant="transparent"
c="white" c="white"
/> />
</Group> </Group>
</Box> </Box>
<Box <Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}>
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>
@@ -303,13 +316,7 @@ export default function ModernNewsNotification({
> >
{currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"} {currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"}
</Badge> </Badge>
<CloseButton <CloseButton onClick={handleDismissToast} size="sm" />
onClick={(e) => {
e.stopPropagation();
setToastVisible(false);
}}
size="sm"
/>
</Group> </Group>
<Text fw={600} size="sm" mb={6}> <Text fw={600} size="sm" mb={6}>

View File

@@ -1,8 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan"; import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import { Stack, Box, Container, Button, Text, Loader, Paper } from "@mantine/core"; import { Stack, Box, Container, Button, Text, Loader, Paper, Center, ActionIcon } from "@mantine/core";
import { IconAward, IconArrowRight } from "@tabler/icons-react"; import { IconAward, IconArrowRight, IconPlayerPlay } from "@tabler/icons-react";
import { useTransitionRouter } from 'next-view-transitions'; import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useProxy } from "valtio/utils"; import { useProxy } from "valtio/utils";
@@ -13,6 +13,18 @@ function Penghargaan() {
const state = useProxy(penghargaanState); const state = useProxy(penghargaanState);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [showVideo, setShowVideo] = useState(true);
// Opsional: deteksi iOS
const isIOS = typeof window !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
useEffect(() => {
if (isIOS) {
// Di iOS, jangan andalkan autoplay — tampilkan kontrol
setShowVideo(false);
}
}, []);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -31,22 +43,36 @@ function Penghargaan() {
return ( return (
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}> <Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}>
<video {showVideo ? (
loop <video
autoPlay autoPlay
muted muted
style={{ loop
width: "100%", playsInline
height: "100%", webkit-playsinline="true"
objectFit: "cover", onLoadedData={() => setIsVideoLoaded(true)}
position: "absolute", style={{ opacity: isVideoLoaded ? 1 : 0, transition: 'opacity 0.5s' }}
top: 0, >
left: 0, <source src="/assets/videos/award.mp4" type="video/mp4" />
zIndex: 0, </video>
}} ) : (
> // Fallback: tampilkan poster + play button
<source src="/assets/videos/award.mp4" type="video/mp4" /> <Box
</video> onClick={() => setShowVideo(true)}
style={{
backgroundImage: "url('/assets/images/award-poster.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
cursor: 'pointer',
}}
>
<Center h="100%">
<ActionIcon size="lg" radius="xl" color="white">
<IconPlayerPlay size={32} />
</ActionIcon>
</Center>
</Box>
)}
<Box <Box
style={{ style={{

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client';
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi"; import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan"; import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
import LandingPage from "@/app/darmasaba/_com/main-page/landing-page"; import LandingPage from "@/app/darmasaba/_com/main-page/landing-page";
@@ -14,23 +15,43 @@ 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 } from "react"; import { useEffect, useMemo, 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 ModernNewsNotification from "./_com/ModernNeewsNotification";
import NewsReaderLanding from "./_com/NewsReaderalanding";
import NewsReaderLanding from "./_com/NewsReaderalanding";
import ModernNewsNotification from "./_com/ModernNewsNotification";
export default function Page() { export default function Page() {
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;
const pengumuman = snap2; const pengumuman = snap2;
const loadingFeatured = featured.loading; const loadingFeatured = featured.loading;
const loadingPengumuman = pengumuman.loading; const loadingPengumuman = pengumuman.loading;
const [hasNewContent, setHasNewContent] = useState(false);
const [newItemCount, setNewItemCount] = useState(0);
const lastBeritaId = useRef<string | null>(null);
const lastPengumumanId = useRef<string | null>(null);
// 🔁 Inisialisasi dari localStorage saat mount
useEffect(() => {
const savedBerita = localStorage.getItem("lastSeenBeritaId");
const savedPengumuman = localStorage.getItem("lastSeenPengumumanId");
if (savedBerita) lastBeritaId.current = savedBerita;
if (savedPengumuman) lastPengumumanId.current = savedPengumuman;
}, []);
// Simpan ID saat data dimuat (termasuk dari API)
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();
@@ -43,6 +64,49 @@ export default function Page() {
} }
}, []); }, []);
// 🔁 Polling untuk cek update setiap 30 detik
useEffect(() => {
const checkForUpdates = async () => {
try {
const res = await fetch("/api/check-update");
const result = await res.json();
if (!result.success) return;
const { berita, pengumuman } = result.data;
// Deteksi hanya jika sudah pernah ada data sebelumnya
const isNewBerita = berita && lastBeritaId.current !== null && berita.id !== lastBeritaId.current;
const isNewPengumuman = pengumuman && lastPengumumanId.current !== null && pengumuman.id !== lastPengumumanId.current;
if (isNewBerita || isNewPengumuman) {
// Hitung berapa yang benar-benar baru
const count = (isNewBerita ? 1 : 0) + (isNewPengumuman ? 1 : 0);
setNewItemCount(count);
setHasNewContent(true);
// Reload hanya yang berubah
if (isNewBerita) stateDashboardBerita.berita.findFirst.load();
if (isNewPengumuman) stateDesaPengumuman.pengumuman.findFirst.load();
} else {
// Jika ini adalah pertama kali (masih null), simpan ID tanpa notifikasi
if (lastBeritaId.current === null && berita) {
lastBeritaId.current = berita.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);
return () => clearInterval(interval);
}, []);
const newsData = useMemo(() => { const newsData = useMemo(() => {
const items = []; const items = [];
@@ -55,8 +119,8 @@ export default function Page() {
content: String(featured.data.content || ""), content: String(featured.data.content || ""),
timestamp: featured.data.createdAt timestamp: featured.data.createdAt
? (typeof featured.data.createdAt === 'string' ? (typeof featured.data.createdAt === 'string'
? featured.data.createdAt ? featured.data.createdAt
: new Date(featured.data.createdAt).toISOString()) : new Date(featured.data.createdAt).toISOString())
: new Date().toISOString(), : new Date().toISOString(),
}); });
} }
@@ -69,8 +133,8 @@ export default function Page() {
content: String(pengumuman.data.content || ""), content: String(pengumuman.data.content || ""),
timestamp: pengumuman.data.createdAt timestamp: pengumuman.data.createdAt
? (typeof pengumuman.data.createdAt === 'string' ? (typeof pengumuman.data.createdAt === 'string'
? pengumuman.data.createdAt ? pengumuman.data.createdAt
: new Date(pengumuman.data.createdAt).toISOString()) : new Date(pengumuman.data.createdAt).toISOString())
: new Date().toISOString(), : new Date().toISOString(),
}); });
} }
@@ -78,14 +142,17 @@ export default function Page() {
return items; return items;
}, [featured.data, pengumuman.data]); }, [featured.data, pengumuman.data]);
const handleSeen = () => {
setHasNewContent(false);
setNewItemCount(0);
// Simpan ke localStorage saat dilihat
if (featured.data?.id) localStorage.setItem("lastSeenBeritaId", featured.data.id);
if (pengumuman.data?.id) localStorage.setItem("lastSeenPengumumanId", pengumuman.data.id);
};
return ( return (
<Box id="page-root"> <Box id="page-root">
<Stack <Stack bg={colors.grey[1]} gap={0}>
bg={colors.grey[1]}
gap={0}
>
{/* HAPUS RUNNING TEXT, GANTI DENGAN MODERN NOTIFICATION */}
<LandingPage /> <LandingPage />
<Penghargaan /> <Penghargaan />
<Layanan /> <Layanan />
@@ -97,13 +164,15 @@ export default function Page() {
<Prestasi /> <Prestasi />
</Stack> </Stack>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton /> <ScrollToTopButton />
<NewsReaderLanding /> <NewsReaderLanding />
<ModernNewsNotification <ModernNewsNotification
news={newsData} news={newsData}
autoShowDelay={2000} // Muncul 2 detik setelah load hasNewContent={hasNewContent}
newItemCount={newItemCount}
onSeen={handleSeen}
autoShowDelay={2000}
/> />
</Box> </Box>
); );