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
+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>
);
}