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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user