Add in-app + real-time notifications (SignalR/mock, Iran-friendly)

- 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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 15:52:06 +03:30
parent e02d976dda
commit 2d2352dfe8
13 changed files with 291 additions and 3 deletions
@@ -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 (
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: -24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -24 }}
onClick={dismiss}
className="fixed top-3 inset-x-0 z-[60] flex justify-center px-4 pointer-events-none"
>
<div className="glass rounded-2xl px-4 py-3 flex items-center gap-3 max-w-sm w-full pointer-events-auto shadow-xl">
<span className="text-2xl">{toast.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate">
{locale === "fa" ? toast.titleFa : toast.titleEn}
</div>
{(toast.bodyFa || toast.bodyEn) && (
<div className="text-[11px] text-cream/55 truncate">
{locale === "fa" ? toast.bodyFa : toast.bodyEn}
</div>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+15 -1
View File
@@ -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() {
</button>
<div className="flex items-center gap-2">
<button
onClick={() => go("notifications")}
className="glass rounded-full p-2 hover:bg-navy-800/80 transition relative"
title={t("notif.title")}
>
<Bell className="size-4 text-gold-400" />
{unread > 0 && (
<span className="absolute -top-0.5 ltr:-right-0.5 rtl:-left-0.5 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white flex items-center justify-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
<button
onClick={openDaily}
className="glass rounded-full p-2 hover:bg-navy-800/80 transition"
+10
View File
@@ -7,6 +7,7 @@ import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store";
import { MatchSummary, RewardResult } from "@/lib/online/types";
export function GameScreen() {
@@ -45,6 +46,15 @@ export function GameScreen() {
.then((r) => {
setReward(r);
refreshProfile();
for (const a of r.newAchievements)
pushNotification({
kind: "achievement",
titleFa: "دستاورد جدید",
titleEn: "New achievement",
bodyFa: a.nameFa,
bodyEn: a.nameEn,
icon: a.icon,
});
});
}
}, [mode, game.phase, game.matchWinner, game.matchScore, game.trump, meta, tally, refreshProfile]);
@@ -0,0 +1,49 @@
"use client";
import { useEffect } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useNotifStore } from "@/lib/notification-store";
import { useI18n } from "@/lib/i18n";
export function NotificationsScreen() {
const { t, locale } = useI18n();
const items = useNotifStore((s) => s.items);
const markAllRead = useNotifStore((s) => s.markAllRead);
useEffect(() => {
markAllRead();
}, [markAllRead]);
const fmtTime = (ts: number) =>
new Date(ts).toLocaleTimeString(locale === "fa" ? "fa-IR" : "en-US", {
hour: "2-digit",
minute: "2-digit",
});
return (
<ScreenShell>
<ScreenHeader title={t("notif.title")} />
{items.length === 0 && (
<p className="text-center text-cream/40 py-16">{t("notif.empty")}</p>
)}
<div className="space-y-2 pb-6">
{items.map((n) => (
<div key={n.id} className="glass rounded-xl p-3 flex items-center gap-3">
<span className="text-2xl">{n.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream">
{locale === "fa" ? n.titleFa : n.titleEn}
</div>
{(n.bodyFa || n.bodyEn) && (
<div className="text-[11px] text-cream/50">
{locale === "fa" ? n.bodyFa : n.bodyEn}
</div>
)}
</div>
<span className="text-[10px] text-cream/35 tabular-nums">{fmtTime(n.ts)}</span>
</div>
))}
</div>
</ScreenShell>
);
}