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