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:
1 parent
7fafc6d17f
commit
08fd0dfb71
10 files changed
+472
-43
No files matched your search
@@ -6,11 +6,11 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Home,
|
||||
Compass,
|
||||
Search,
|
||||
Plus,
|
||||
User,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -62,7 +62,7 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
const isHomePage = pathname === "/feed" || pathname === "/";
|
||||
const isPetsPage = pathname.startsWith("/pets");
|
||||
const isExplorePage = pathname.startsWith("/explore");
|
||||
const isSearchPage = pathname.startsWith("/search");
|
||||
const isNotificationsPage = pathname.startsWith("/notifications");
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
|
||||
@@ -172,16 +172,8 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<Link
|
||||
href="/search"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isSearchPage ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Search className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
{/* Notifications — with live unread badge */}
|
||||
<NotificationBell variant="mobile" isActive={isNotificationsPage} />
|
||||
|
||||
{/* Profile (My Pets) */}
|
||||
<Link
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -40,12 +41,12 @@ type SidebarProps = {
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/feed", label: "Home", icon: Home, active: true },
|
||||
{ href: "/pets", label: "My Pets", icon: Heart, active: true },
|
||||
{ href: "/explore", label: "Explore", icon: Compass, active: true },
|
||||
{ href: "/search", label: "Search", icon: Search, active: true },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: false },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false },
|
||||
{ href: "/feed", label: "Home", icon: Home, active: true, special: false },
|
||||
{ href: "/pets", label: "My Pets", icon: Heart, active: true, special: false },
|
||||
{ href: "/explore", label: "Explore", icon: Compass, active: true, special: false },
|
||||
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false, special: false },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -150,17 +151,20 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 px-3 py-2 space-y-1">
|
||||
{navLinks.map(({ href, label, icon: Icon, active }) => {
|
||||
// Active detection: /feed highlights when pathname is /feed or /
|
||||
// (/ redirects to /feed, so treat both as Home active state)
|
||||
{navLinks.map(({ href, label, icon: Icon, active, special }) => {
|
||||
const isCurrentPage =
|
||||
href === "/feed"
|
||||
? pathname === "/feed" || pathname === "/"
|
||||
: pathname.startsWith(href);
|
||||
const isActive = isCurrentPage;
|
||||
|
||||
// Notifications: delegate to NotificationBell for live badge
|
||||
if (special) {
|
||||
return (
|
||||
<NotificationBell key={href} variant="sidebar" isActive={isCurrentPage} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
// Placeholder links — visually present but non-navigable in Phase 1
|
||||
return (
|
||||
<span
|
||||
key={href}
|
||||
@@ -178,7 +182,7 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
isCurrentPage
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
interface NotificationBellProps {
|
||||
/** "sidebar" = full row with label; "mobile" = icon + label stacked */
|
||||
variant: "sidebar" | "mobile";
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data } = useQuery({
|
||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
const label = count > 99 ? "99+" : count > 0 ? String(count) : null;
|
||||
|
||||
if (variant === "sidebar") {
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
{label && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-0.5 rounded-full bg-orange-500 text-[10px] text-white font-semibold flex items-center justify-center leading-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Notifications</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile variant — icon stacked above label
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isActive ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{label && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-0.5 rounded-full bg-orange-500 text-[10px] text-white font-semibold flex items-center justify-center leading-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Alerts</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user