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)
This commit is contained in:
admin committed 2026-06-07 12:35:07 +02:00
1 parent f85230a074
commit fd7dd2d56b
5 files changed
+314 -4

No files matched your search

+6 -4
View File
@@ -62,13 +62,15 @@ function createCaller(overrides?: {
prisma?: ReturnType<typeof createMockPrisma>;
}) {
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", () => {
+38
View File
@@ -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 (
<div className="flex flex-col items-center justify-center py-20 px-6 text-center">
{/* Heading — text-xl font-semibold per UI-SPEC typography */}
<h2 className="text-xl font-semibold text-foreground">
Your feed is empty
</h2>
{/* Body — text-sm per UI-SPEC */}
<p className="mt-3 text-sm text-muted-foreground max-w-xs">
Follow some pets to see their posts here.
</p>
{/* Find Pets CTA — primary button style, links to /explore (Phase 3 placeholder) */}
<Link
href="/explore"
className={buttonVariants({ variant: "default" }) + " mt-6 bg-orange-500 hover:bg-orange-600 text-white border-transparent"}
>
Find Pets
</Link>
</div>
);
}
+123
View File
@@ -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 (
<div className="max-w-[640px] mx-auto px-6 py-8 flex flex-col gap-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
);
}
// Empty state — per D-06: no follows → empty feed state with Find Pets CTA
if (allPosts.length === 0) {
return <EmptyFeed />;
}
return (
<div className="max-w-[640px] mx-auto px-6 py-8">
{/* Feed items */}
<div className="flex flex-col gap-8">
{allPosts.map((post) => (
<PostCard
key={post.id}
post={{
...post,
// tRPC serializes Date as string over HTTP — PostCard accepts Date | string
createdAt: post.createdAt,
}}
/>
))}
</div>
{/* Sentinel — IntersectionObserver trigger for infinite scroll */}
<div ref={sentinelRef} aria-hidden="true" className="h-4" />
{/* Loading more skeletons — 3 per UI-SPEC */}
{isFetchingNextPage && (
<div className="flex flex-col gap-8 mt-8">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)}
{/* End of feed message — shown when all pages loaded and posts exist */}
{!hasNextPage && !isFetchingNextPage && allPosts.length > 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
{/* UI-SPEC copy (locked) */}
{"You're all caught up. Follow more pets to see more posts."}
</p>
)}
{/* Screen-reader loading indicator */}
{isFetchingNextPage && (
<span className="sr-only">Loading more posts…</span>
)}
</div>
);
}
+116
View File
@@ -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 (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header row — 16px horizontal padding, 12px vertical */}
<div className="flex items-center gap-3 px-4 py-3">
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && (
<AvatarImage src={avatarUrl} alt={pet.name} />
)}
<AvatarFallback className="text-xs bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
{/* Pet name — text-base font-semibold (UI-SPEC) */}
<p className="text-base font-semibold text-foreground leading-tight truncate">
{pet.name}
</p>
{/* Timestamp — text-xs text-muted-foreground (UI-SPEC) */}
<p className="text-xs text-muted-foreground">
{formatRelativeTime(createdAt)}
</p>
</div>
</div>
{/* Image area — single image (02-04 replaces with carousel) */}
{imageUrl && (
<div className="w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={caption ?? `${pet.name}'s post`}
className="w-full object-cover max-h-[560px]"
/>
</div>
)}
{/* Caption — text-sm (UI-SPEC) */}
{caption && (
<div className="px-4 py-3">
<p className="text-sm text-foreground">{caption}</p>
</div>
)}
</article>
);
}
+31
View File
@@ -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 (
<div className="bg-card border border-border rounded-lg overflow-hidden animate-pulse">
{/* Header row — 80px (avatar 32px + vertical padding ~24px each side) */}
<div className="flex items-center gap-3 px-4 py-3">
{/* Avatar placeholder */}
<div className="w-8 h-8 rounded-full bg-muted shrink-0" />
{/* Name + timestamp */}
<div className="flex-1 space-y-1">
<div className="h-3.5 bg-muted rounded w-24" />
<div className="h-3 bg-muted rounded w-16" />
</div>
</div>
{/* Image area placeholder — ~280px height (aspect ~4:3 on 640px width) */}
<div className="w-full h-64 bg-muted" />
{/* Footer — 2 text lines for caption skeleton */}
<div className="px-4 py-3 space-y-2">
<div className="h-3 bg-muted rounded w-full" />
<div className="h-3 bg-muted rounded w-3/4" />
</div>
</div>
);
}