From 8d0d4dc991dadbec8530181cda3cc603bcd77747 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 7 Jun 2026 21:38:43 +0330 Subject: [PATCH] Notifications: deep-link on tap + swipe-to-dismiss 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 --- src/app/page.tsx | 2 + src/components/online/NotificationToaster.tsx | 12 +- .../screens/NotificationsScreen.tsx | 122 +++++++++++++++--- src/lib/i18n.tsx | 6 + src/lib/notification-store.ts | 55 +++++++- src/lib/online/types.ts | 5 + 6 files changed, 179 insertions(+), 23 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index d535cf8..8e69f73 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -95,6 +95,7 @@ export default function Page() { bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined, bodyEn: coins ? `${coins} coins added` : undefined, icon: "💰", + route: "shop", }); useSessionStore.getState().refreshProfile(); } else { @@ -118,6 +119,7 @@ export default function Page() { bodyFa: v.coins ? `${v.coins.toLocaleString()} سکه اضافه شد` : undefined, bodyEn: v.coins ? `${v.coins.toLocaleString()} coins added` : undefined, icon: "💰", + route: "shop", }); } else { pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" }); diff --git a/src/components/online/NotificationToaster.tsx b/src/components/online/NotificationToaster.tsx index 23983f0..77da728 100644 --- a/src/components/online/NotificationToaster.tsx +++ b/src/components/online/NotificationToaster.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { useEffect } from "react"; -import { useNotifStore } from "@/lib/notification-store"; +import { openNotification, useNotifStore } from "@/lib/notification-store"; import { useI18n } from "@/lib/i18n"; export function NotificationToaster() { @@ -24,10 +24,16 @@ export function NotificationToaster() { initial={{ opacity: 0, y: -24 }} animate={{ opacity: 1, y: 0 }} 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" > -
+
{toast.icon}
diff --git a/src/components/screens/NotificationsScreen.tsx b/src/components/screens/NotificationsScreen.tsx index 650fc35..40f9a58 100644 --- a/src/components/screens/NotificationsScreen.tsx +++ b/src/components/screens/NotificationsScreen.tsx @@ -1,16 +1,21 @@ "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 { 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 { AppNotification } from "@/lib/online/types"; export function NotificationsScreen() { const { t, locale } = useI18n(); const items = useNotifStore((s) => s.items); + const remove = useNotifStore((s) => s.remove); + const clearAll = useNotifStore((s) => s.clearAll); const markAllRead = useNotifStore((s) => s.markAllRead); + // Opening the list clears the bell badge; per-item navigate/dismiss still work. useEffect(() => { markAllRead(); }, [markAllRead]); @@ -23,7 +28,21 @@ export function NotificationsScreen() { return ( - + 0 ? ( + + ) : undefined + } + /> + {items.length === 0 && (
@@ -32,24 +51,89 @@ export function NotificationsScreen() {

{t("notif.empty")}

)} + + {items.length > 0 && ( +

+ + {t("notif.swipeHint")} +

+ )} +
- {items.map((n) => ( -
- {n.icon} -
-
- {locale === "fa" ? n.titleFa : n.titleEn} -
- {(n.bodyFa || n.bodyEn) && ( -
- {locale === "fa" ? n.bodyFa : n.bodyEn} -
- )} -
- {fmtTime(n.ts)} -
- ))} + + {items.map((n) => ( + openNotification(n)} + onRemove={() => remove(n.id)} + /> + ))} +
); } + +function NotifRow({ + n, + locale, + time, + hint, + onOpen, + onRemove, +}: { + n: AppNotification; + locale: string; + time: string; + hint?: string; + onOpen: () => void; + onRemove: () => void; +}) { + return ( + { + if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 500) onRemove(); + }} + whileDrag={{ cursor: "grabbing" }} + className="relative" + > + + + + ); +} diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 5877f85..e638e82 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -320,6 +320,9 @@ const fa: Dict = { "notif.title": "اعلان‌ها", "notif.empty": "اعلانی ندارید", + "notif.clearAll": "پاک کردن همه", + "notif.swipeHint": "برای حذف، اعلان را به کناری بکشید", + "notif.tapToOpen": "برای مشاهده بزنید", "settings.audio": "تنظیمات صدا", "settings.sound": "افکت صدا", @@ -653,6 +656,9 @@ const en: Dict = { "notif.title": "Notifications", "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.sound": "Sound effects", diff --git a/src/lib/notification-store.ts b/src/lib/notification-store.ts index 62d5195..30146d5 100644 --- a/src/lib/notification-store.ts +++ b/src/lib/notification-store.ts @@ -4,6 +4,7 @@ import { create } from "zustand"; import { getService } from "./online/service"; import { AppNotification } from "./online/types"; import { sound } from "./sound"; +import { Screen, useUIStore } from "./ui-store"; interface NotifStore { items: AppNotification[]; @@ -11,6 +12,9 @@ interface NotifStore { lastToast: AppNotification | null; add: (n: AppNotification) => void; markAllRead: () => void; + markRead: (id: string) => void; + remove: (id: string) => void; + clearAll: () => void; dismissToast: () => void; init: () => void; } @@ -18,6 +22,8 @@ interface NotifStore { let unsub: (() => void) | null = null; let started = false; +const countUnread = (items: AppNotification[]) => items.filter((x) => !x.read).length; + export const useNotifStore = create((set, get) => ({ items: [], unread: 0, @@ -25,13 +31,26 @@ export const useNotifStore = create((set, get) => ({ add: (n) => { 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"); }, markAllRead: () => 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 }), init: () => { @@ -50,4 +69,38 @@ export function pushNotification(n: Omit) }); } +/** 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; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index 46d5f6b..300d181 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -593,6 +593,7 @@ export type NotificationKind = | "friend_request" | "invite" | "achievement" + | "reward" | "daily" | "system"; @@ -606,6 +607,10 @@ export interface AppNotification { icon: string; // emoji ts: number; 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 ------------------------------ */