Notifications: deep-link on tap + swipe-to-dismiss
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 5m49s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 49s

Each notification now navigates to its related screen when tapped (toast or
list): friend_request/invite -> Friends, achievement/reward -> Achievements,
daily -> opens the daily-reward modal, coin-purchase success -> Shop. An
explicit per-notification 'route' overrides the kind default.

List rows are swipeable (drag aside) and have an X to dismiss individually,
plus a Clear-all button; the toast can be flicked up to dismiss or tapped to
open. New store actions: markRead/remove/clearAll + openNotification navigator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-07 21:38:43 +03:30
parent 72efc03e2d
commit 8d0d4dc991
6 changed files with 179 additions and 23 deletions
+2
View File
@@ -95,6 +95,7 @@ export default function Page() {
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined, bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined,
bodyEn: coins ? `${coins} coins added` : undefined, bodyEn: coins ? `${coins} coins added` : undefined,
icon: "💰", icon: "💰",
route: "shop",
}); });
useSessionStore.getState().refreshProfile(); useSessionStore.getState().refreshProfile();
} else { } else {
@@ -118,6 +119,7 @@ export default function Page() {
bodyFa: v.coins ? `${v.coins.toLocaleString()} سکه اضافه شد` : undefined, bodyFa: v.coins ? `${v.coins.toLocaleString()} سکه اضافه شد` : undefined,
bodyEn: v.coins ? `${v.coins.toLocaleString()} coins added` : undefined, bodyEn: v.coins ? `${v.coins.toLocaleString()} coins added` : undefined,
icon: "💰", icon: "💰",
route: "shop",
}); });
} else { } else {
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" }); pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
@@ -2,7 +2,7 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNotifStore } from "@/lib/notification-store"; import { openNotification, useNotifStore } from "@/lib/notification-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
export function NotificationToaster() { export function NotificationToaster() {
@@ -24,10 +24,16 @@ export function NotificationToaster() {
initial={{ opacity: 0, y: -24 }} initial={{ opacity: 0, y: -24 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -24 }} exit={{ opacity: 0, y: -24 }}
onClick={dismiss} drag="y"
dragSnapToOrigin
dragConstraints={{ top: 0, bottom: 0 }}
onDragEnd={(_, info) => {
if (info.offset.y < -40) dismiss();
}}
onClick={() => toast && openNotification(toast)}
className="fixed top-3 inset-x-0 z-[60] flex justify-center px-4 pointer-events-none" 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"> <div className="glass rounded-2xl px-4 py-3 flex items-center gap-3 max-w-sm w-full pointer-events-auto shadow-xl cursor-pointer active:scale-[0.99] transition-transform">
<span className="text-2xl">{toast.icon}</span> <span className="text-2xl">{toast.icon}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-cream truncate"> <div className="text-sm font-semibold text-cream truncate">
+102 -18
View File
@@ -1,16 +1,21 @@
"use client"; "use client";
import { BellOff } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion";
import { BellOff, ChevronLeft, Trash2, X } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader"; import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
import { useNotifStore } from "@/lib/notification-store"; import { openNotification, useNotifStore } from "@/lib/notification-store";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { AppNotification } from "@/lib/online/types";
export function NotificationsScreen() { export function NotificationsScreen() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const items = useNotifStore((s) => s.items); const items = useNotifStore((s) => s.items);
const remove = useNotifStore((s) => s.remove);
const clearAll = useNotifStore((s) => s.clearAll);
const markAllRead = useNotifStore((s) => s.markAllRead); const markAllRead = useNotifStore((s) => s.markAllRead);
// Opening the list clears the bell badge; per-item navigate/dismiss still work.
useEffect(() => { useEffect(() => {
markAllRead(); markAllRead();
}, [markAllRead]); }, [markAllRead]);
@@ -23,7 +28,21 @@ export function NotificationsScreen() {
return ( return (
<ScreenShell> <ScreenShell>
<ScreenHeader title={t("notif.title")} /> <ScreenHeader
title={t("notif.title")}
right={
items.length > 0 ? (
<button
onClick={clearAll}
className="inline-flex items-center gap-1 text-[11px] text-cream/55 hover:text-cream rounded-lg px-2 py-1 bg-navy-900/50"
>
<Trash2 className="size-3.5" />
{t("notif.clearAll")}
</button>
) : undefined
}
/>
{items.length === 0 && ( {items.length === 0 && (
<div className="flex flex-col items-center text-center py-16 gap-3"> <div className="flex flex-col items-center text-center py-16 gap-3">
<span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border"> <span className="grid size-16 place-items-center rounded-2xl bg-navy-900/60 gold-border">
@@ -32,24 +51,89 @@ export function NotificationsScreen() {
<p className="text-cream/45 text-sm">{t("notif.empty")}</p> <p className="text-cream/45 text-sm">{t("notif.empty")}</p>
</div> </div>
)} )}
<div className="space-y-2 pb-6">
{items.map((n) => ( {items.length > 0 && (
<div key={n.id} className="glass rounded-xl p-3 flex items-center gap-3"> <p className="text-[10px] text-cream/35 flex items-center gap-1 mb-2">
<span className="text-2xl">{n.icon}</span> <ChevronLeft className="size-3" />
<div className="flex-1 min-w-0"> {t("notif.swipeHint")}
<div className="text-sm font-semibold text-cream"> </p>
{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 className="space-y-2 pb-6">
</div> <AnimatePresence initial={false}>
{items.map((n) => (
<NotifRow
key={n.id}
n={n}
locale={locale}
time={fmtTime(n.ts)}
hint={n.kind !== "system" ? t("notif.tapToOpen") : undefined}
onOpen={() => openNotification(n)}
onRemove={() => remove(n.id)}
/>
))} ))}
</AnimatePresence>
</div> </div>
</ScreenShell> </ScreenShell>
); );
} }
function NotifRow({
n,
locale,
time,
hint,
onOpen,
onRemove,
}: {
n: AppNotification;
locale: string;
time: string;
hint?: string;
onOpen: () => void;
onRemove: () => void;
}) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0, margin: 0, transition: { duration: 0.18 } }}
drag="x"
dragSnapToOrigin
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.6}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 500) onRemove();
}}
whileDrag={{ cursor: "grabbing" }}
className="relative"
>
<button
onClick={onOpen}
className="w-full glass rounded-xl p-3 pe-9 flex items-center gap-3 text-start hover:bg-navy-800/60 transition-colors"
>
<span className="text-2xl shrink-0">{n.icon}</span>
<div className="flex-1 min-w-0 text-start">
<div className="text-sm font-semibold text-cream truncate">
{locale === "fa" ? n.titleFa : n.titleEn}
</div>
{(n.bodyFa || n.bodyEn) && (
<div className="text-[11px] text-cream/50 truncate">
{locale === "fa" ? n.bodyFa : n.bodyEn}
</div>
)}
{hint && <div className="text-[10px] text-gold-300/70 mt-0.5">{hint} </div>}
</div>
<span className="text-[10px] text-cream/35 tabular-nums shrink-0">{time}</span>
</button>
<button
onClick={onRemove}
aria-label="dismiss"
className="absolute top-1.5 end-1.5 grid size-6 place-items-center rounded-full bg-navy-900/70 text-cream/40 hover:text-cream hover:bg-navy-900"
>
<X className="size-3.5" />
</button>
</motion.div>
);
}
+6
View File
@@ -320,6 +320,9 @@ const fa: Dict = {
"notif.title": "اعلان‌ها", "notif.title": "اعلان‌ها",
"notif.empty": "اعلانی ندارید", "notif.empty": "اعلانی ندارید",
"notif.clearAll": "پاک کردن همه",
"notif.swipeHint": "برای حذف، اعلان را به کناری بکشید",
"notif.tapToOpen": "برای مشاهده بزنید",
"settings.audio": "تنظیمات صدا", "settings.audio": "تنظیمات صدا",
"settings.sound": "افکت صدا", "settings.sound": "افکت صدا",
@@ -653,6 +656,9 @@ const en: Dict = {
"notif.title": "Notifications", "notif.title": "Notifications",
"notif.empty": "No notifications yet", "notif.empty": "No notifications yet",
"notif.clearAll": "Clear all",
"notif.swipeHint": "Swipe a notification aside to dismiss it",
"notif.tapToOpen": "Tap to open",
"settings.audio": "Audio", "settings.audio": "Audio",
"settings.sound": "Sound effects", "settings.sound": "Sound effects",
+54 -1
View File
@@ -4,6 +4,7 @@ import { create } from "zustand";
import { getService } from "./online/service"; import { getService } from "./online/service";
import { AppNotification } from "./online/types"; import { AppNotification } from "./online/types";
import { sound } from "./sound"; import { sound } from "./sound";
import { Screen, useUIStore } from "./ui-store";
interface NotifStore { interface NotifStore {
items: AppNotification[]; items: AppNotification[];
@@ -11,6 +12,9 @@ interface NotifStore {
lastToast: AppNotification | null; lastToast: AppNotification | null;
add: (n: AppNotification) => void; add: (n: AppNotification) => void;
markAllRead: () => void; markAllRead: () => void;
markRead: (id: string) => void;
remove: (id: string) => void;
clearAll: () => void;
dismissToast: () => void; dismissToast: () => void;
init: () => void; init: () => void;
} }
@@ -18,6 +22,8 @@ interface NotifStore {
let unsub: (() => void) | null = null; let unsub: (() => void) | null = null;
let started = false; let started = false;
const countUnread = (items: AppNotification[]) => items.filter((x) => !x.read).length;
export const useNotifStore = create<NotifStore>((set, get) => ({ export const useNotifStore = create<NotifStore>((set, get) => ({
items: [], items: [],
unread: 0, unread: 0,
@@ -25,13 +31,26 @@ export const useNotifStore = create<NotifStore>((set, get) => ({
add: (n) => { add: (n) => {
const items = [n, ...get().items].slice(0, 50); const items = [n, ...get().items].slice(0, 50);
set({ items, unread: items.filter((x) => !x.read).length, lastToast: n }); set({ items, unread: countUnread(items), lastToast: n });
sound.play("notify"); sound.play("notify");
}, },
markAllRead: () => markAllRead: () =>
set({ items: get().items.map((x) => ({ ...x, read: true })), unread: 0 }), set({ items: get().items.map((x) => ({ ...x, read: true })), unread: 0 }),
markRead: (id) => {
const items = get().items.map((x) => (x.id === id ? { ...x, read: true } : x));
set({ items, unread: countUnread(items) });
},
remove: (id) => {
const items = get().items.filter((x) => x.id !== id);
const lastToast = get().lastToast?.id === id ? null : get().lastToast;
set({ items, unread: countUnread(items), lastToast });
},
clearAll: () => set({ items: [], unread: 0, lastToast: null }),
dismissToast: () => set({ lastToast: null }), dismissToast: () => set({ lastToast: null }),
init: () => { init: () => {
@@ -50,4 +69,38 @@ export function pushNotification(n: Omit<AppNotification, "id" | "ts" | "read">)
}); });
} }
/** Default destination for a notification kind when no explicit `route` is set. */
function defaultRoute(kind: AppNotification["kind"]): Screen | "daily" | null {
switch (kind) {
case "friend_request":
case "invite":
return "friends";
case "achievement":
case "reward":
return "achievements";
case "daily":
return "daily"; // opens the daily-reward modal
case "system":
default:
return null;
}
}
/**
* Act on a tapped notification: mark it read and deep-link to its related screen.
* `daily` opens the daily-reward modal (mounted globally) over the current screen.
*/
export function openNotification(n: AppNotification) {
useNotifStore.getState().markRead(n.id);
useNotifStore.getState().dismissToast();
const target = (n.route as Screen | "daily" | undefined) ?? defaultRoute(n.kind);
if (!target) return;
const ui = useUIStore.getState();
if (target === "daily") {
ui.openDaily();
return;
}
ui.go(target);
}
void unsub; void unsub;
+5
View File
@@ -593,6 +593,7 @@ export type NotificationKind =
| "friend_request" | "friend_request"
| "invite" | "invite"
| "achievement" | "achievement"
| "reward"
| "daily" | "daily"
| "system"; | "system";
@@ -606,6 +607,10 @@ export interface AppNotification {
icon: string; // emoji icon: string; // emoji
ts: number; ts: number;
read: boolean; read: boolean;
/** explicit deep-link target screen name (overrides the kind default) */
route?: string;
/** optional payload, e.g. a user id (friend_request) the target screen can use */
payload?: string;
} }
/* ------------------------------ Avatars ------------------------------ */ /* ------------------------------ Avatars ------------------------------ */