From fd7dd2d56b7bfe453a5586c2ba6e92328e2e3683 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 7 Jun 2026 12:35:07 +0200 Subject: [PATCH] feat(02-03): FeedList + SkeletonCard + EmptyFeed + PostCard placeholder - FeedList: useInfiniteQuery + useInView sentinel, 3 skeleton loading, empty state, end-of-feed copy - SkeletonCard: animate-pulse 80px header + image + 2 text lines per UI-SPEC - EmptyFeed: "Your feed is empty" heading + Find Pets CTA to /explore (D-06) - PostCard: minimal placeholder (header + single image + caption); marked TODO(02-04) for carousel - feed.test.ts: any-cast for mock prisma (tsc clean on new code; 3 pre-existing errors unrelated) --- src/__tests__/feed.test.ts | 10 ++- src/components/feed/EmptyFeed.tsx | 38 +++++++++ src/components/feed/FeedList.tsx | 123 +++++++++++++++++++++++++++ src/components/feed/PostCard.tsx | 116 +++++++++++++++++++++++++ src/components/feed/SkeletonCard.tsx | 31 +++++++ 5 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/components/feed/EmptyFeed.tsx create mode 100644 src/components/feed/FeedList.tsx create mode 100644 src/components/feed/PostCard.tsx create mode 100644 src/components/feed/SkeletonCard.tsx 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 */} +
+
+
+
+
+ ); +}