feat(04): Phase 4 — in-app notifications

Schema:
- Add NotificationType enum (FOLLOW, REACTION, COMMENT, MESSAGE)
- Add Notification model with recipientPet, actorPet, post, comment
  relations; index on [recipientPetId, read, createdAt desc]

Backend:
- notifications router: list (cursor-paginated), unreadCount,
  markRead, markAllRead
- follows.create: fire-and-forget FOLLOW notification to followee
- reactions.toggle: REACTION notification on create (skip self)
- comments.create: COMMENT notification with commentId (skip self)
  All triggers use .catch(()=>{}) to never fail the main action

UI:
- NotificationBell component with orange badge, polls every 30s
- /notifications page: list with avatar, type icon, text, thumbnail,
  read/unread dot; auto-marks all read on visit
- Sidebar: Notifications link activated with NotificationBell
- MobileNav: Bell replaces Search tab (Search still at /search)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 12:59:23 +02:00
1 parent 7fafc6d17f
commit 08fd0dfb71
10 files changed
+472 -43

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Bell, UserPlus, Heart, MessageCircle } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE";
function notificationIcon(type: NotificationType) {
switch (type) {
case "FOLLOW": return <UserPlus className="h-4 w-4 text-orange-500" />;
case "REACTION": return <Heart className="h-4 w-4 text-orange-500" />;
case "COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
case "MESSAGE": return <MessageCircle className="h-4 w-4 text-blue-500" />;
}
}
function notificationText(
type: NotificationType,
actorName: string | undefined,
commentBody: string | undefined
): string {
const name = actorName ?? "Someone";
switch (type) {
case "FOLLOW": return `${name} started following you`;
case "REACTION": return `${name} reacted to your post`;
case "COMMENT": return commentBody
? `${name} commented: "${commentBody.slice(0, 60)}${commentBody.length > 60 ? "…" : ""}"`
: `${name} commented on your post`;
case "MESSAGE": return `${name} sent you a message`;
}
}
export default function NotificationsPage() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const { data, isLoading } = useQuery({
...trpc.notifications.list.queryOptions({ petId: activePetId ?? "", limit: 30 }),
enabled: !!activePetId,
});
const markAllReadMutation = useMutation(
trpc.notifications.markAllRead.mutationOptions({
onSuccess: () => {
// Invalidate badge count
void queryClient.invalidateQueries(
trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" })
);
},
})
);
// Mark all read when landing on the page
useEffect(() => {
if (activePetId && data && data.notifications.some((n) => !n.read)) {
markAllReadMutation.mutate({ petId: activePetId });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePetId, data]);
const notifications = data?.notifications ?? [];
return (
<div className="max-w-[640px] mx-auto px-4 py-8">
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
<Bell className="h-5 w-5 text-orange-500" aria-hidden="true" />
Notifications
</h1>
{isLoading && (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg animate-pulse">
<div className="w-10 h-10 rounded-full bg-muted shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/3" />
</div>
</div>
))}
</div>
)}
{!isLoading && notifications.length === 0 && (
<div className="flex flex-col items-center gap-3 py-20 text-muted-foreground">
<Bell className="h-10 w-10 opacity-30" />
<p className="text-sm">No notifications yet</p>
<p className="text-xs text-center max-w-[240px]">
When pets follow you or react to your posts, you&apos;ll see it here.
</p>
</div>
)}
{!isLoading && notifications.length > 0 && (
<ul className="space-y-1">
{notifications.map((notif) => {
const actorAvatarUrl = getAvatarUrl(notif.actorPet?.avatarKey ?? null);
const postThumbUrl = notif.post?.images?.[0]
? getMediaUrl(notif.post.images[0].storageKey)
: null;
return (
<li
key={notif.id}
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
}`}
>
{/* Unread dot */}
<div className="shrink-0 mt-3">
{!notif.read ? (
<span className="w-2 h-2 rounded-full bg-orange-500 block" />
) : (
<span className="w-2 h-2 block" />
)}
</div>
{/* Actor avatar + notification type icon */}
<div className="relative shrink-0">
<Avatar className="w-10 h-10">
{actorAvatarUrl && (
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
)}
<AvatarFallback className="text-xs bg-muted">
{notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-card border flex items-center justify-center">
{notificationIcon(notif.type as NotificationType)}
</span>
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug">
{notif.actorPet && (
<Link
href={`/pets/${notif.actorPet.id}`}
className="font-semibold hover:underline"
>
{notif.actorPet.name}
</Link>
)}{" "}
<span className="text-muted-foreground">
{notif.type === "FOLLOW" && "started following you"}
{notif.type === "REACTION" && "reacted to your post"}
{notif.type === "COMMENT" && notif.comment && (
<>
commented: &ldquo;
{notif.comment.body.slice(0, 60)}
{notif.comment.body.length > 60 ? "…" : ""}
&rdquo;
</>
)}
{notif.type === "COMMENT" && !notif.comment && "commented on your post"}
{notif.type === "MESSAGE" && "sent you a message"}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatRelativeTime(notif.createdAt)}
</p>
</div>
{/* Post thumbnail (for REACTION / COMMENT) */}
{postThumbUrl && (
<div className="shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={postThumbUrl}
alt="Post"
className="w-full h-full object-cover"
/>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}