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 <noreply@anthropic.com>
This commit is contained in:
@@ -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: "⚠️" });
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-cream truncate">
|
||||
|
||||
@@ -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 (
|
||||
<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 && (
|
||||
<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">
|
||||
@@ -32,24 +51,89 @@ export function NotificationsScreen() {
|
||||
<p className="text-cream/45 text-sm">{t("notif.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<p className="text-[10px] text-cream/35 flex items-center gap-1 mb-2">
|
||||
<ChevronLeft className="size-3" />
|
||||
{t("notif.swipeHint")}
|
||||
</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>
|
||||
))}
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<NotifStore>((set, get) => ({
|
||||
items: [],
|
||||
unread: 0,
|
||||
@@ -25,13 +31,26 @@ export const useNotifStore = create<NotifStore>((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<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;
|
||||
|
||||
@@ -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 ------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user