From 2d2352dfe8bb9a98133cf4b71c18ee43030a679e Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 15:52:06 +0330 Subject: [PATCH] Add in-app + real-time notifications (SignalR/mock, Iran-friendly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppNotification + OnlineService.onNotification (hub event + mock periodic) — no FCM/APNs (blocked in Iran); uses the existing realtime channel - notification-store + pushNotification(); 🔔 bell with unread badge in TopBar, notifications screen, global toaster (plays notify sfx) - Wired events: daily reward, post-match achievements, friend requests - Closed-app push (Pushe/Najva/Chabok) noted as a later step (needs provider keys) Co-Authored-By: Claude Opus 4.8 --- src/app/page.tsx | 21 ++++++++ src/components/online/NotificationToaster.tsx | 47 ++++++++++++++++ src/components/online/TopBar.tsx | 16 +++++- src/components/screens/GameScreen.tsx | 10 ++++ .../screens/NotificationsScreen.tsx | 49 +++++++++++++++++ src/lib/i18n.tsx | 6 +++ src/lib/notification-store.ts | 53 +++++++++++++++++++ src/lib/online-store.ts | 14 +++++ src/lib/online/mock-service.ts | 35 ++++++++++++ src/lib/online/service.ts | 4 ++ src/lib/online/signalr-service.ts | 13 +++++ src/lib/online/types.ts | 21 ++++++++ src/lib/ui-store.ts | 5 +- 13 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 src/components/online/NotificationToaster.tsx create mode 100644 src/components/screens/NotificationsScreen.tsx create mode 100644 src/lib/notification-store.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index a003fb8..2370b5a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -11,12 +11,16 @@ import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen"; import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen"; import { ShopScreen } from "@/components/screens/ShopScreen"; import { ChatScreen } from "@/components/screens/ChatScreen"; +import { NotificationsScreen } from "@/components/screens/NotificationsScreen"; import { AuthScreen } from "@/components/screens/AuthScreen"; import { DailyRewardModal } from "@/components/online/DailyRewardModal"; +import { NotificationToaster } from "@/components/online/NotificationToaster"; import { CapacitorBack } from "@/components/CapacitorBack"; import { useSessionStore } from "@/lib/session-store"; import { useGameStore } from "@/lib/game-store"; import { useOnlineStore } from "@/lib/online-store"; +import { useNotifStore, pushNotification } from "@/lib/notification-store"; +import { getService } from "@/lib/online/service"; import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store"; /** Transient screens can't be restored without their state — fall back to home. */ @@ -43,6 +47,20 @@ export default function Page() { useEffect(() => { init(); useUIStore.getState().initHistory(); + useNotifStore.getState().init(); + // surface a daily-reward notification if it's available + getService() + .getDailyState() + .then((d) => { + if (d.available) + pushNotification({ + kind: "daily", + titleFa: "پاداش روزانه آماده است", + titleEn: "Daily reward is ready", + icon: "🎁", + }); + }) + .catch(() => {}); const onPop = (e: PopStateEvent) => { const raw = ((e.state?.screen as Screen) ?? screenFromHash()); @@ -56,6 +74,7 @@ export default function Page() { <> {renderScreen(screen)} + {loading && null} @@ -84,6 +103,8 @@ function renderScreen(screen: string) { return ; case "chat": return ; + case "notifications": + return ; default: return ; } diff --git a/src/components/online/NotificationToaster.tsx b/src/components/online/NotificationToaster.tsx new file mode 100644 index 0000000..23983f0 --- /dev/null +++ b/src/components/online/NotificationToaster.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect } from "react"; +import { useNotifStore } from "@/lib/notification-store"; +import { useI18n } from "@/lib/i18n"; + +export function NotificationToaster() { + const toast = useNotifStore((s) => s.lastToast); + const dismiss = useNotifStore((s) => s.dismissToast); + const { locale } = useI18n(); + + useEffect(() => { + if (!toast) return; + const id = setTimeout(dismiss, 4000); + return () => clearTimeout(id); + }, [toast, dismiss]); + + return ( + + {toast && ( + +
+ {toast.icon} +
+
+ {locale === "fa" ? toast.titleFa : toast.titleEn} +
+ {(toast.bodyFa || toast.bodyEn) && ( +
+ {locale === "fa" ? toast.bodyFa : toast.bodyEn} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/components/online/TopBar.tsx b/src/components/online/TopBar.tsx index f7b506d..f831acc 100644 --- a/src/components/online/TopBar.tsx +++ b/src/components/online/TopBar.tsx @@ -1,8 +1,9 @@ "use client"; -import { Coins, Crown, Gift } from "lucide-react"; +import { Bell, Coins, Crown, Gift } from "lucide-react"; import { useSessionStore } from "@/lib/session-store"; import { useUIStore } from "@/lib/ui-store"; +import { useNotifStore } from "@/lib/notification-store"; import { useI18n } from "@/lib/i18n"; import { Avatar } from "./Avatar"; @@ -10,6 +11,7 @@ export function TopBar() { const profile = useSessionStore((s) => s.profile); const go = useUIStore((s) => s.go); const openDaily = useUIStore((s) => s.openDaily); + const unread = useNotifStore((s) => s.unread); const { t } = useI18n(); if (!profile) return null; @@ -34,6 +36,18 @@ export function TopBar() {
+