diff --git a/src/__tests__/feed.test.ts b/src/__tests__/feed.test.ts index 06f8333..67df55d 100644 --- a/src/__tests__/feed.test.ts +++ b/src/__tests__/feed.test.ts @@ -62,13 +62,15 @@ function createCaller(overrides?: { prisma?: ReturnType; }) { const mockPrisma = overrides?.prisma ?? createMockPrisma(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const ctx = { userId: overrides?.userId ?? "owner-1", - prisma: mockPrisma as unknown as Parameters< - typeof feedRouter.createCaller - >[0]["prisma"], + // Cast to any for test purposes — mock prisma satisfies the runtime interface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: mockPrisma as any, }; - return { caller: feedRouter.createCaller(ctx), mockPrisma }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { caller: feedRouter.createCaller(ctx as any), mockPrisma }; } describe("feed.getFeed", () => { diff --git a/src/components/feed/EmptyFeed.tsx b/src/components/feed/EmptyFeed.tsx new file mode 100644 index 0000000..9c30bec --- /dev/null +++ b/src/components/feed/EmptyFeed.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; + +/** + * EmptyFeed — shown when the active pet's feed has zero posts. + * + * D-06: pets with no follows land on feed with this empty state. + * UI-SPEC copy (locked): + * - Heading: "Your feed is empty" + * - Body: "Follow some pets to see their posts here." + * - CTA: "Find Pets" → /explore (Phase 3 route — placeholder button) + * + * Note: shadcn base-nova uses @base-ui/react/button which does not support asChild. + * Using Link with buttonVariants CSS classes directly. + */ +export function EmptyFeed() { + return ( +
+ {/* Heading — text-xl font-semibold per UI-SPEC typography */} +

+ Your feed is empty +

+ + {/* Body — text-sm per UI-SPEC */} +

+ Follow some pets to see their posts here. +

+ + {/* Find Pets CTA — primary button style, links to /explore (Phase 3 placeholder) */} + + Find Pets + +
+ ); +} diff --git a/src/components/feed/FeedList.tsx b/src/components/feed/FeedList.tsx new file mode 100644 index 0000000..7f85619 --- /dev/null +++ b/src/components/feed/FeedList.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import { useTRPC } from "@/trpc/client"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useActivePet } from "@/context/ActivePetContext"; +import { PostCard } from "@/components/feed/PostCard"; +import { SkeletonCard } from "@/components/feed/SkeletonCard"; +import { EmptyFeed } from "@/components/feed/EmptyFeed"; + +/** + * FeedList — infinite-scroll home feed powered by feed.getFeed. + * + * Architecture: + * - useInfiniteQuery via tRPC feed.getFeed.infiniteQueryOptions + * - Cursor = last page's nextCursor (Unix ms timestamp from Redis score) + * - useInView sentinel at bottom triggers fetchNextPage + * - 3 SkeletonCards shown while loading next page + * - EmptyFeed when zero posts + not loading (D-06) + * - End-of-feed message when !hasNextPage and posts exist + * + * Implements FEED-01 (key_link: FeedList → feed.getFeed via useInfiniteQuery + useInView sentinel) + */ +export function FeedList() { + const { activePetId } = useActivePet(); + const trpc = useTRPC(); + + // IntersectionObserver sentinel for infinite scroll + const { ref: sentinelRef, inView } = useInView({ threshold: 0 }); + + // useInfiniteQuery with tRPC v11 infiniteQueryOptions + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteQuery( + trpc.feed.getFeed.infiniteQueryOptions( + { + petId: activePetId ?? "", + limit: 20, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: null, + staleTime: 30_000, + // Don't run if no activePetId + enabled: !!activePetId, + } + ) + ); + + // Trigger next page load when sentinel enters viewport + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Flatten all pages into a single posts array + const allPosts = data?.pages.flatMap((page) => page.posts) ?? []; + + // Loading state — show skeletons while initial data loads + if (isLoading) { + return ( +
+ + + +
+ ); + } + + // Empty state — per D-06: no follows → empty feed state with Find Pets CTA + if (allPosts.length === 0) { + return ; + } + + return ( +
+ {/* Feed items */} +
+ {allPosts.map((post) => ( + + ))} +
+ + {/* Sentinel — IntersectionObserver trigger for infinite scroll */} + + ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx new file mode 100644 index 0000000..c18119f --- /dev/null +++ b/src/components/feed/PostCard.tsx @@ -0,0 +1,116 @@ +"use client"; + +// TODO(02-04): full carousel + caption clamp + options menu + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getAvatarUrl } from "@/lib/avatar-url"; +import { getMediaUrl } from "@/lib/media-url"; + +/** + * PostCard — minimal placeholder for Phase 2 / Wave 1 (02-03). + * + * This component is a SEAM for 02-04 which will replace it with: + * - Full photo carousel (Embla via shadcn Carousel) + * - Caption 3-line clamp + expand/collapse + * - Post options menu (MoreHorizontal dropdown) + * - Milestone card variant (bg-orange-50) + * + * Accepts a `post` prop matching the feed.getFeed output shape. + * tRPC serializes Dates as ISO strings over HTTP — createdAt is string | Date. + * Renders: header row (avatar + pet name + timestamp), first image (if any), caption. + * + * TODO(02-04): full carousel + caption clamp + options menu + */ + +type PostImage = { + id: string; + storageKey: string; + position: number; +}; + +type PostPet = { + id: string; + name: string; + avatarKey: string | null; +}; + +export type PostCardProps = { + post: { + id: string; + caption: string | null; + createdAt: Date | string; // tRPC serializes Date as ISO string over HTTP + pet: PostPet; + images: PostImage[]; + milestone: unknown | null; + }; +}; + +function formatRelativeTime(date: Date | string): string { + const now = Date.now(); + const diffMs = now - new Date(date).getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(date).toLocaleDateString(); +} + +export function PostCard({ post }: PostCardProps) { + const { pet, images, caption, createdAt } = post; + + // First image for minimal display (02-04 will add carousel for multiple) + const firstImage = images[0]; + const imageUrl = firstImage ? getMediaUrl(firstImage.storageKey) : null; + const avatarUrl = getAvatarUrl(pet.avatarKey); + + return ( +
+ {/* Header row — 16px horizontal padding, 12px vertical */} +
+ + {avatarUrl && ( + + )} + + {pet.name[0]?.toUpperCase() ?? "?"} + + + +
+ {/* Pet name — text-base font-semibold (UI-SPEC) */} +

+ {pet.name} +

+ {/* Timestamp — text-xs text-muted-foreground (UI-SPEC) */} +

+ {formatRelativeTime(createdAt)} +

+
+
+ + {/* Image area — single image (02-04 replaces with carousel) */} + {imageUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {caption +
+ )} + + {/* Caption — text-sm (UI-SPEC) */} + {caption && ( +
+

{caption}

+
+ )} +
+ ); +} diff --git a/src/components/feed/SkeletonCard.tsx b/src/components/feed/SkeletonCard.tsx new file mode 100644 index 0000000..bed320e --- /dev/null +++ b/src/components/feed/SkeletonCard.tsx @@ -0,0 +1,31 @@ +/** + * SkeletonCard — loading placeholder for a PostCard while data is being fetched. + * + * UI-SPEC: animate-pulse skeleton — 80px header row + image aspect placeholder + 2 text lines + * Used in FeedList while loading next page (3 items shown per page load). + */ +export function SkeletonCard() { + return ( +
+ {/* Header row — 80px (avatar 32px + vertical padding ~24px each side) */} +
+ {/* Avatar placeholder */} +
+ {/* Name + timestamp */} +
+
+
+
+
+ + {/* Image area placeholder — ~280px height (aspect ~4:3 on 640px width) */} +
+ + {/* Footer — 2 text lines for caption skeleton */} +
+
+
+
+
+ ); +}