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:
1 parent
f85230a074
commit
fd7dd2d56b
5 files changed
+314
-4
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user