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,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">
+103 -19
View File
@@ -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>
);
}