diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22c33b3..ef7ee7c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,10 @@ model Pet { comments Comment[] @relation("PetComments") reposts Repost[] @relation("PetReposts") + // Phase 4 back-relations + receivedNotifications Notification[] @relation("ReceivedNotifications") + sentNotifications Notification[] @relation("SentNotifications") + @@index([ownerId]) } @@ -111,11 +115,14 @@ model Post { updatedAt DateTime @updatedAt // Phase 3 back-relations - reactions Reaction[] @relation("PostReactions") - comments Comment[] @relation("PostComments") - repost Repost? @relation("RepostEntry") - originalReposts Repost[] @relation("OriginalReposts") - hashtags PostHashtag[] @relation("PostHashtags") + reactions Reaction[] @relation("PostReactions") + comments Comment[] @relation("PostComments") + repost Repost? @relation("RepostEntry") + originalReposts Repost[] @relation("OriginalReposts") + hashtags PostHashtag[] @relation("PostHashtags") + + // Phase 4 back-relations + notifications Notification[] @@index([petId, createdAt(sort: Desc)]) } @@ -224,6 +231,9 @@ model Comment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Phase 4 back-relation + notifications Notification[] + @@index([postId, createdAt(sort: Asc)]) @@index([petId]) } @@ -258,3 +268,31 @@ model PostHashtag { @@id([postId, hashtagId]) @@index([hashtagId]) } + +// ─── Phase 4 Enums ─────────────────────────────────────────────────────────── + +enum NotificationType { + FOLLOW + REACTION + COMMENT + MESSAGE +} + +// ─── Phase 4 Models ────────────────────────────────────────────────────────── + +model Notification { + id String @id @default(cuid()) + recipientPet Pet @relation("ReceivedNotifications", fields: [recipientPetId], references: [id], onDelete: Cascade) + recipientPetId String + type NotificationType + actorPet Pet? @relation("SentNotifications", fields: [actorPetId], references: [id], onDelete: SetNull) + actorPetId String? + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String? + comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade) + commentId String? + read Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([recipientPetId, read, createdAt(sort: Desc)]) +} diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx new file mode 100644 index 0000000..00c4f1b --- /dev/null +++ b/src/app/(app)/notifications/page.tsx @@ -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 ; + case "REACTION": return ; + case "COMMENT": return ; + case "MESSAGE": return ; + } +} + +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 ( +
+

+

+ + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && notifications.length === 0 && ( +
+ +

No notifications yet

+

+ When pets follow you or react to your posts, you'll see it here. +

+
+ )} + + {!isLoading && notifications.length > 0 && ( +
    + {notifications.map((notif) => { + const actorAvatarUrl = getAvatarUrl(notif.actorPet?.avatarKey ?? null); + const postThumbUrl = notif.post?.images?.[0] + ? getMediaUrl(notif.post.images[0].storageKey) + : null; + + return ( +
  • + {/* Unread dot */} +
    + {!notif.read ? ( + + ) : ( + + )} +
    + + {/* Actor avatar + notification type icon */} +
    + + {actorAvatarUrl && ( + + )} + + {notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"} + + + + {notificationIcon(notif.type as NotificationType)} + +
    + + {/* Text */} +
    +

    + {notif.actorPet && ( + + {notif.actorPet.name} + + )}{" "} + + {notif.type === "FOLLOW" && "started following you"} + {notif.type === "REACTION" && "reacted to your post"} + {notif.type === "COMMENT" && notif.comment && ( + <> + commented: “ + {notif.comment.body.slice(0, 60)} + {notif.comment.body.length > 60 ? "…" : ""} + ” + + )} + {notif.type === "COMMENT" && !notif.comment && "commented on your post"} + {notif.type === "MESSAGE" && "sent you a message"} + +

    +

    + {formatRelativeTime(notif.createdAt)} +

    +
    + + {/* Post thumbnail (for REACTION / COMMENT) */} + {postThumbUrl && ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + Post +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index 1b923bc..84f31da 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -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 (
@@ -172,16 +172,8 @@ export function MobileNav({ pets }: MobileNavProps) { - {/* Search */} - -