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
specialist String
jadwal String
jadwalLibur String
jamBukaOperasional String
jamTutupOperasional String
jamBukaLibur String
jamTutupLibur String
jadwalLibur String?
jamBukaOperasional String?
jamTutupOperasional String?
jamBukaLibur String?
jamTutupLibur String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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 {
news: NewsItem[];
hasNewContent?: boolean; // ✅ TAMBAHAN
newItemCount?: number; // ← tambahkan ini
onSeen?: () => void; // ✅ TAMBAHAN
autoShowDelay?: number;
}
@@ -29,57 +32,66 @@ function stripHtml(html: string): string {
export default function ModernNewsNotification({
news = [],
autoShowDelay = 2000
hasNewContent = false,
newItemCount = 0, // 👈 tambahkan ini
onSeen,
autoShowDelay = 2000,
}: ModernNewsNotificationProps) {
const router = useRouter();
const [toastVisible, setToastVisible] = useState(false);
const [widgetOpen, setWidgetOpen] = useState(false);
const [hasNewNotifications, setHasNewNotifications] = useState(true);
const [hasNewNotifications, setHasNewNotifications] = useState(hasNewContent);
const [hasShownToast, setHasShownToast] = useState(false);
const [iconVisible, setIconVisible] = useState(true);
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(() => {
if (news.length > 0 && !toastVisible && !hasShownToast) {
const timer = setTimeout(() => {
setToastVisible(true);
setHasShownToast(true);
// Jika ada new content, anggap sudah "dilihat" setelah toast muncul
if (hasNewNotifications) {
onSeen?.();
}
}, autoShowDelay);
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(() => {
if (toastVisible) {
const timer = setTimeout(() => {
setToastVisible(false);
}, 8000);
const timer = setTimeout(() => setToastVisible(false), 8000);
return () => clearTimeout(timer);
}
}, [toastVisible]);
// Enhanced scroll handler with better thresholds
// Scroll handler
useEffect(() => {
let lastScrollY = window.scrollY;
const HIDE_THRESHOLD = 100; // Mulai hide saat scroll > 100px
const SHOW_THRESHOLD = 50; // Hanya show ketika benar-benar di atas (< 50px)
const HIDE_THRESHOLD = 100;
const SHOW_THRESHOLD = 50;
const handleScroll = () => {
const currentScrollY = window.scrollY;
const scrollDirection = currentScrollY > lastScrollY ? 'down' : 'up';
// Logic untuk hide/show icon
if (scrollDirection === 'down' && currentScrollY > HIDE_THRESHOLD) {
// Scroll ke bawah dan sudah melewati threshold → hide
setIconVisible(false);
} else if (scrollDirection === 'up' && currentScrollY < SHOW_THRESHOLD) {
// Scroll ke atas dan sudah di posisi paling atas → show
setIconVisible(true);
}
// Hide toast saat scroll ke bawah melewati 150px
if (currentScrollY > 150 && toastVisible) {
setToastVisible(false);
}
@@ -93,9 +105,9 @@ export default function ModernNewsNotification({
const currentNews = news[0];
// Handle notification click
const handleNotificationClick = (item: NewsItem) => {
setWidgetOpen(false);
onSeen?.(); // ✅ tandai sebagai dilihat
if (item.type === "berita") {
router.push("/darmasaba/desa/berita/semua");
} else if (item.type === "pengumuman") {
@@ -107,6 +119,13 @@ export default function ModernNewsNotification({
setToastVisible(false);
setWidgetOpen(true);
setHasNewNotifications(false);
onSeen?.(); // ✅
};
const handleDismissToast = (e: React.MouseEvent) => {
e.stopPropagation();
setToastVisible(false);
onSeen?.(); // ✅
};
// Only show on landing page
@@ -119,14 +138,7 @@ export default function ModernNewsNotification({
{/* Floating Bell Icon */}
<Transition mounted={iconVisible} transition="slide-down" duration={200}>
{(transitionStyles) => (
<Box
style={{
...transitionStyles,
position: "fixed",
bottom: "24px",
right: "24px",
}}
>
<Box style={{ ...transitionStyles, position: "fixed", bottom: "24px", right: "24px" }}>
<ActionIcon
size="xl"
radius="xl"
@@ -135,6 +147,7 @@ export default function ModernNewsNotification({
onClick={() => {
setWidgetOpen(!widgetOpen);
setHasNewNotifications(false);
onSeen?.(); // ✅
}}
style={{
width: "60px",
@@ -146,20 +159,23 @@ export default function ModernNewsNotification({
<IconBell size={28} />
{hasNewNotifications && news.length > 0 && (
<Badge
size="sm"
variant="filled"
color="red"
style={{
position: "absolute",
top: "6px",
right: "6px",
minWidth: "22px",
height: "22px",
padding: "0 6px",
}}
>
{news.length}
</Badge>
size="sm"
variant="filled"
color="red"
style={{
position: "absolute",
top: "6px",
right: "6px",
minWidth: "22px",
height: "22px",
padding: "0 6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{newItemCount || news.length}
</Badge>
)}
</ActionIcon>
</Box>
@@ -195,20 +211,17 @@ export default function ModernNewsNotification({
<Text c="white" fw={600} size="md">Berita & Pengumuman</Text>
</Group>
<CloseButton
onClick={() => setWidgetOpen(false)}
onClick={() => {
setWidgetOpen(false);
onSeen?.(); // ✅
}}
variant="transparent"
c="white"
/>
</Group>
</Box>
<Box
style={{
maxHeight: "400px",
overflowY: "auto",
padding: "12px",
}}
>
<Box style={{ maxHeight: "400px", overflowY: "auto", padding: "12px" }}>
{news.length === 0 ? (
<Box p="xl" style={{ textAlign: "center" }}>
<Text c="dimmed" size="sm">Tidak ada berita terbaru</Text>
@@ -303,13 +316,7 @@ export default function ModernNewsNotification({
>
{currentNews?.type === "berita" ? "Berita Terbaru" : "Pengumuman"}
</Badge>
<CloseButton
onClick={(e) => {
e.stopPropagation();
setToastVisible(false);
}}
size="sm"
/>
<CloseButton onClick={handleDismissToast} size="sm" />
</Group>
<Text fw={600} size="sm" mb={6}>

View File

@@ -1,8 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import penghargaanState from "@/app/admin/(dashboard)/_state/desa/penghargaan";
import { Stack, Box, Container, Button, Text, Loader, Paper } from "@mantine/core";
import { IconAward, IconArrowRight } from "@tabler/icons-react";
import { Stack, Box, Container, Button, Text, Loader, Paper, Center, ActionIcon } from "@mantine/core";
import { IconAward, IconArrowRight, IconPlayerPlay } from "@tabler/icons-react";
import { useTransitionRouter } from 'next-view-transitions';
import { useEffect, useState } from "react";
import { useProxy } from "valtio/utils";
@@ -13,6 +13,18 @@ function Penghargaan() {
const state = useProxy(penghargaanState);
const [loading, setLoading] = useState(false);
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(() => {
const loadData = async () => {
@@ -31,22 +43,36 @@ function Penghargaan() {
return (
<Stack pos="relative" h="auto" mih={{ base: 500, md: 720 }}>
<video
loop
autoPlay
muted
style={{
width: "100%",
height: "100%",
objectFit: "cover",
position: "absolute",
top: 0,
left: 0,
zIndex: 0,
}}
>
<source src="/assets/videos/award.mp4" type="video/mp4" />
</video>
{showVideo ? (
<video
autoPlay
muted
loop
playsInline
webkit-playsinline="true"
onLoadedData={() => setIsVideoLoaded(true)}
style={{ opacity: isVideoLoaded ? 1 : 0, transition: 'opacity 0.5s' }}
>
<source src="/assets/videos/award.mp4" type="video/mp4" />
</video>
) : (
// Fallback: tampilkan poster + play button
<Box
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
style={{

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
'use client';
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
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 ScrollToTopButton from "./_com/scrollToTopButton";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSnapshot } from "valtio";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
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() {
const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
const featured = snap1;
const pengumuman = snap2;
const loadingFeatured = featured.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(() => {
if (!featured.data && !loadingFeatured) {
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 items = [];
@@ -55,8 +119,8 @@ export default function Page() {
content: String(featured.data.content || ""),
timestamp: featured.data.createdAt
? (typeof featured.data.createdAt === 'string'
? featured.data.createdAt
: new Date(featured.data.createdAt).toISOString())
? featured.data.createdAt
: new Date(featured.data.createdAt).toISOString())
: new Date().toISOString(),
});
}
@@ -69,8 +133,8 @@ export default function Page() {
content: String(pengumuman.data.content || ""),
timestamp: pengumuman.data.createdAt
? (typeof pengumuman.data.createdAt === 'string'
? pengumuman.data.createdAt
: new Date(pengumuman.data.createdAt).toISOString())
? pengumuman.data.createdAt
: new Date(pengumuman.data.createdAt).toISOString())
: new Date().toISOString(),
});
}
@@ -78,14 +142,17 @@ export default function Page() {
return items;
}, [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 (
<Box id="page-root">
<Stack
bg={colors.grey[1]}
gap={0}
>
{/* HAPUS RUNNING TEXT, GANTI DENGAN MODERN NOTIFICATION */}
<Stack bg={colors.grey[1]} gap={0}>
<LandingPage />
<Penghargaan />
<Layanan />
@@ -97,13 +164,15 @@ export default function Page() {
<Prestasi />
</Stack>
{/* Tombol Scroll ke Atas */}
<ScrollToTopButton />
<NewsReaderLanding />
<ModernNewsNotification
news={newsData}
autoShowDelay={2000} // Muncul 2 detik setelah load
hasNewContent={hasNewContent}
newItemCount={newItemCount}
onSeen={handleSeen}
autoShowDelay={2000}
/>
</Box>
);