diff --git a/src/__tests__/blocks.test.ts b/src/__tests__/blocks.test.ts
index 79b4a60..e14ab18 100644
--- a/src/__tests__/blocks.test.ts
+++ b/src/__tests__/blocks.test.ts
@@ -64,9 +64,10 @@ async function createBlocksCaller(overrides?: {
blocks: blocksRouter,
});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({
userId,
- prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
+ prisma: mockPrisma as any,
});
return { caller, mockPrisma };
diff --git a/src/__tests__/follows.test.ts b/src/__tests__/follows.test.ts
index 737129f..f697009 100644
--- a/src/__tests__/follows.test.ts
+++ b/src/__tests__/follows.test.ts
@@ -74,9 +74,10 @@ async function createFollowsCaller(overrides?: {
});
// Use the appRouter caller pattern with injected context
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({
userId,
- prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
+ prisma: mockPrisma as any,
});
return { caller, mockPrisma };
diff --git a/src/__tests__/reports.test.ts b/src/__tests__/reports.test.ts
index d9f9de6..955b244 100644
--- a/src/__tests__/reports.test.ts
+++ b/src/__tests__/reports.test.ts
@@ -66,9 +66,10 @@ async function createReportsCaller(overrides?: {
reports: reportsRouter,
});
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const caller = testRouter.createCaller({
userId,
- prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient,
+ prisma: mockPrisma as any,
});
return { caller, mockPrisma };
diff --git a/src/app/(app)/pets/[petId]/ProfileActions.tsx b/src/app/(app)/pets/[petId]/ProfileActions.tsx
new file mode 100644
index 0000000..e41b55d
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/ProfileActions.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useState } from "react";
+import { MoreHorizontal } from "lucide-react";
+import { useActivePet } from "@/context/ActivePetContext";
+import { FollowButton } from "@/components/social/FollowButton";
+import { BlockDialog } from "@/components/safety/BlockDialog";
+import { ReportSheet } from "@/components/safety/ReportSheet";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Button } from "@/components/ui/button";
+
+/**
+ * ProfileActions — client component for the pet profile page.
+ *
+ * Renders:
+ * - FollowButton (hidden on own pet profile via followerPetId === followeePetId check)
+ * - MoreHorizontal options menu (DropdownMenu) for other pets' profiles:
+ * "Report profile" → opens ReportSheet
+ * "Block {pet name}" → opens BlockDialog
+ *
+ * Own pet: shows no options menu (edit link handled by RSC shell).
+ *
+ * UI-SPEC: base-nova preset — no asChild on DropdownMenu; use onClick+useRouter.
+ */
+
+interface ProfileActionsProps {
+ petId: string;
+ petName: string;
+ ownerId: string;
+}
+
+export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) {
+ const { activePetId } = useActivePet();
+ const [blockOpen, setBlockOpen] = useState(false);
+ const [reportOpen, setReportOpen] = useState(false);
+
+ // activePetId may be null during SSR hydration — skip rendering interactive elements
+ if (!activePetId) return null;
+
+ const isOwnPet = activePetId === petId;
+
+ return (
+
+ {/* FollowButton — hidden on own pet (FollowButton also guards this internally) */}
+ {!isOwnPet && (
+
+ )}
+
+ {/* Options menu — only show for other pets' profiles */}
+ {!isOwnPet && (
+ <>
+
+
+
+
+
+ setReportOpen(true)}
+ className="cursor-pointer"
+ >
+ Report profile
+
+
+ setBlockOpen(true)}
+ className="cursor-pointer text-destructive focus:text-destructive"
+ >
+ Block {petName}
+
+
+
+
+ {/* BlockDialog */}
+
+
+ {/* ReportSheet */}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/(app)/pets/[petId]/followers/FollowersList.tsx b/src/app/(app)/pets/[petId]/followers/FollowersList.tsx
new file mode 100644
index 0000000..ec68f40
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/followers/FollowersList.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useActivePet } from "@/context/ActivePetContext";
+import { PetListItem } from "@/components/social/PetListItem";
+
+interface FollowersListProps {
+ followers: Array<{
+ followerPetId: string;
+ followeePetId: string;
+ createdAt: Date;
+ follower: {
+ id: string;
+ name: string;
+ avatarKey: string | null;
+ species: { name: string };
+ };
+ }>;
+ petId: string;
+}
+
+/**
+ * Client component — reads ActivePetContext to supply followerPetId to FollowButton.
+ * RSC shell passes down prefetched followers data.
+ */
+export function FollowersList({ followers, petId }: FollowersListProps) {
+ const { activePetId } = useActivePet();
+
+ return (
+
+ {followers.map((follow) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(app)/pets/[petId]/followers/page.tsx b/src/app/(app)/pets/[petId]/followers/page.tsx
new file mode 100644
index 0000000..c26456b
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/followers/page.tsx
@@ -0,0 +1,53 @@
+import { notFound } from "next/navigation";
+import { createTRPCCaller } from "@/trpc/server";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { FollowersList } from "@/app/(app)/pets/[petId]/followers/FollowersList";
+
+/**
+ * Followers list page — /pets/[petId]/followers (SOCL-03)
+ *
+ * RSC shell: fetches follower count and pet name, passes data to client
+ * FollowersList component which handles the FollowButton interactions.
+ *
+ * UI-SPEC heading: "{N} Followers"
+ * Empty state: "{pet name} doesn't have any followers yet."
+ */
+
+interface PageProps {
+ params: Promise<{ petId: string }>;
+}
+
+export default async function FollowersPage({ params }: PageProps) {
+ const { petId } = await params;
+
+ const trpc = await createTRPCCaller();
+
+ let pet;
+ try {
+ pet = await trpc.pets.byId({ petId });
+ } catch {
+ notFound();
+ }
+
+ if (!pet) notFound();
+
+ const followers = await trpc.follows.listFollowers({ petId });
+
+ return (
+
+
+ {followers.length} Followers
+
+
+ {followers.length === 0 ? (
+
+ {pet.name} doesn't have any followers yet.
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(app)/pets/[petId]/following/FollowingList.tsx b/src/app/(app)/pets/[petId]/following/FollowingList.tsx
new file mode 100644
index 0000000..db92c63
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/following/FollowingList.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { useActivePet } from "@/context/ActivePetContext";
+import { PetListItem } from "@/components/social/PetListItem";
+
+interface FollowingListProps {
+ following: Array<{
+ followerPetId: string;
+ followeePetId: string;
+ createdAt: Date;
+ followee: {
+ id: string;
+ name: string;
+ avatarKey: string | null;
+ species: { name: string };
+ };
+ }>;
+ petId: string;
+}
+
+/**
+ * Client component — reads ActivePetContext to supply followerPetId to FollowButton.
+ * RSC shell passes down prefetched following data.
+ */
+export function FollowingList({ following, petId }: FollowingListProps) {
+ const { activePetId } = useActivePet();
+
+ return (
+
+ {following.map((follow) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(app)/pets/[petId]/following/page.tsx b/src/app/(app)/pets/[petId]/following/page.tsx
new file mode 100644
index 0000000..78e9fa4
--- /dev/null
+++ b/src/app/(app)/pets/[petId]/following/page.tsx
@@ -0,0 +1,53 @@
+import { notFound } from "next/navigation";
+import { createTRPCCaller } from "@/trpc/server";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { FollowingList } from "@/app/(app)/pets/[petId]/following/FollowingList";
+
+/**
+ * Following list page — /pets/[petId]/following (SOCL-03)
+ *
+ * RSC shell: fetches following list and pet name, passes data to client
+ * FollowingList component which handles the FollowButton interactions.
+ *
+ * UI-SPEC heading: "Following {N}"
+ * Empty state: "{pet name} isn't following anyone yet."
+ */
+
+interface PageProps {
+ params: Promise<{ petId: string }>;
+}
+
+export default async function FollowingPage({ params }: PageProps) {
+ const { petId } = await params;
+
+ const trpc = await createTRPCCaller();
+
+ let pet;
+ try {
+ pet = await trpc.pets.byId({ petId });
+ } catch {
+ notFound();
+ }
+
+ if (!pet) notFound();
+
+ const following = await trpc.follows.listFollowing({ petId });
+
+ return (
+
+
+ Following {following.length}
+
+
+ {following.length === 0 ? (
+
+ {pet.name} isn't following anyone yet.
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx
index 17006dc..dd0c78c 100644
--- a/src/app/(app)/pets/[petId]/page.tsx
+++ b/src/app/(app)/pets/[petId]/page.tsx
@@ -7,13 +7,19 @@ import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getAvatarUrl } from "@/lib/avatar-url";
+import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions";
/**
* Pet profile view — /pets/[petId]
*
- * Server component. Fetches the pet and the owner's Clerk profile.
+ * Server component. Fetches the pet, follower/following counts, and the owner's Clerk profile.
* If the pet doesn't exist or doesn't belong to the current owner, renders 404.
*
+ * Phase 2 additions:
+ * - Stats row: posts / followers / following with links to list pages
+ * - FollowButton: hidden on own pet profile
+ * - MoreHorizontal options menu wiring BlockDialog + ReportSheet for other pets' profiles
+ *
* Security (T-3-03): tRPC byId ownership assertion prevents cross-owner info disclosure.
* UI-SPEC: Owner Identity Subordination — "Managed by @username" is muted caption below pet.
*/
@@ -50,6 +56,15 @@ export default async function PetProfilePage({ params }: PageProps) {
user?.emailAddresses?.[0]?.emailAddress?.split("@")[0] ??
"owner";
+ // Fetch social graph counts for the stats row
+ const [followers, following] = await Promise.all([
+ trpc.follows.listFollowers({ petId }),
+ trpc.follows.listFollowing({ petId }),
+ ]);
+
+ const followerCount = followers.length;
+ const followingCount = following.length;
+
return (
{/* Pet identity — pet-first, owner-subordinate */}
@@ -84,6 +99,41 @@ export default async function PetProfilePage({ params }: PageProps) {
+ {/* Stats row — posts / followers / following (UI-SPEC Phase 2 addition) */}
+
+ {/* Posts count — placeholder until posts router is implemented */}
+
+ 0
+ posts
+
+
+ {/* Followers — tappable link to /pets/[petId]/followers */}
+
+
{followerCount}
+
followers
+
+
+ {/* Following — tappable link to /pets/[petId]/following */}
+
+
{followingCount}
+
following
+
+
+
+ {/* ProfileActions: FollowButton + MoreHorizontal options menu (client component) */}
+ {/* Passes petId as the viewed pet; active pet is resolved from context client-side */}
+
+
{/* Bio section */}
{pet.bio && (
<>
@@ -105,7 +155,7 @@ export default async function PetProfilePage({ params }: PageProps) {
>
)}
- {/* Posts placeholder — Phase 2 adds posts */}
+ {/* Posts placeholder — Phase 2 posts router implementation in 02-03+ */}
Posts
diff --git a/src/components/safety/BlockDialog.tsx b/src/components/safety/BlockDialog.tsx
new file mode 100644
index 0000000..b55d9bd
--- /dev/null
+++ b/src/components/safety/BlockDialog.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useTRPC } from "@/trpc/client";
+
+/**
+ * BlockDialog — confirm blocking a pet (SAFE-01)
+ *
+ * UI-SPEC copy:
+ * Heading: "Block {pet name}?"
+ * Body: "{pet name}'s posts and profile will be hidden from you. They won't know you blocked them."
+ * Confirm: "Block" (destructive)
+ * Cancel: "Don't block"
+ *
+ * Success toast: "You've blocked {pet name}"
+ */
+
+interface BlockDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** The active pet performing the block */
+ blockerPetId: string;
+ /** The target pet being blocked */
+ blockedPetId: string;
+ blockedPetName: string;
+}
+
+export function BlockDialog({
+ open,
+ onOpenChange,
+ blockerPetId,
+ blockedPetId,
+ blockedPetName,
+}: BlockDialogProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const blockMutation = useMutation(
+ trpc.blocks.create.mutationOptions({
+ onSuccess: () => {
+ toast.success(`You've blocked ${blockedPetName}`);
+ onOpenChange(false);
+ // Invalidate any cached block lists so feed hydration picks up the new block
+ queryClient.invalidateQueries({ queryKey: ["follows"] });
+ },
+ onError: () => {
+ toast.error("Couldn't block this pet. Please try again.");
+ },
+ })
+ );
+
+ function handleBlock() {
+ blockMutation.mutate({ blockerPetId, blockedPetId });
+ }
+
+ return (
+
+
+
+ Block {blockedPetName}?
+
+ {blockedPetName}'s posts and profile will be hidden from you.
+ They won't know you blocked them.
+
+
+
+ Don't block
+
+ Block
+
+
+
+
+ );
+}
diff --git a/src/components/safety/ReportSheet.tsx b/src/components/safety/ReportSheet.tsx
new file mode 100644
index 0000000..dcb907e
--- /dev/null
+++ b/src/components/safety/ReportSheet.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { ReportReason } from "@prisma/client";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Button } from "@/components/ui/button";
+import { useTRPC } from "@/trpc/client";
+
+/**
+ * ReportSheet — submit a content report (SAFE-02)
+ *
+ * UI-SPEC copy:
+ * Sheet title: "Report"
+ * Radio group: 5 reasons
+ * Notes textarea: placeholder "Add details (optional)", max 500 chars
+ * Submit CTA: "Submit Report" (destructive)
+ * Cancel: "Don't report"
+ *
+ * Success toast: "Report submitted. We'll review it shortly."
+ *
+ * Target is either targetPostId (report a post) or targetPetId (report a profile).
+ */
+
+interface ReportSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** The active pet submitting the report */
+ reporterPetId: string;
+ /** Mutually exclusive — set one or the other */
+ targetPostId?: string;
+ targetPetId?: string;
+}
+
+const REPORT_REASONS: { value: ReportReason; label: string }[] = [
+ { value: ReportReason.SPAM, label: "Spam" },
+ { value: ReportReason.HARASSMENT, label: "Harassment or bullying" },
+ { value: ReportReason.INAPPROPRIATE, label: "Inappropriate content" },
+ { value: ReportReason.MISINFORMATION, label: "False or misleading information" },
+ { value: ReportReason.OTHER, label: "Something else" },
+];
+
+export function ReportSheet({
+ open,
+ onOpenChange,
+ reporterPetId,
+ targetPostId,
+ targetPetId,
+}: ReportSheetProps) {
+ const trpc = useTRPC();
+ const [selectedReason, setSelectedReason] = useState("");
+ const [notes, setNotes] = useState("");
+
+ const reportMutation = useMutation(
+ trpc.reports.create.mutationOptions({
+ onSuccess: () => {
+ toast.success("Report submitted. We'll review it shortly.");
+ onOpenChange(false);
+ // Reset form state
+ setSelectedReason("");
+ setNotes("");
+ },
+ onError: () => {
+ toast.error("Couldn't submit your report. Please try again.");
+ },
+ })
+ );
+
+ function handleSubmit() {
+ if (!selectedReason) return;
+
+ reportMutation.mutate({
+ reporterPetId,
+ targetPostId,
+ targetPetId,
+ reason: selectedReason,
+ notes: notes.trim() || undefined,
+ });
+ }
+
+ function handleCancel() {
+ onOpenChange(false);
+ setSelectedReason("");
+ setNotes("");
+ }
+
+ return (
+
+
+
+ Report
+
+
+
+
+
+ Why are you reporting this?
+
+
setSelectedReason(v as ReportReason)}
+ className="gap-2"
+ >
+ {REPORT_REASONS.map(({ value, label }) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx
new file mode 100644
index 0000000..c2b5e89
--- /dev/null
+++ b/src/components/social/FollowButton.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { useState } from "react";
+import { Check, Loader2 } from "lucide-react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useTRPC } from "@/trpc/client";
+
+/**
+ * FollowButton — optimistic follow/unfollow toggle (SOCL-01, SOCL-02)
+ *
+ * Props:
+ * - followerPetId: the active pet performing the follow action
+ * - followeePetId: the target pet being followed
+ * - followeeName: display name for unfollow confirmation dialog
+ *
+ * Hidden when followerPetId === followeePetId (own pet profile).
+ *
+ * States per UI-SPEC:
+ * - Not following: "Follow" bg-orange-500 text-white
+ * - Following: "Following" variant outline + Check icon
+ * - Pending: disabled spinner opacity-60
+ *
+ * Optimistic update (RESEARCH Pattern 6):
+ * onMutate: cancel inflight + setData immediately
+ * onError: revert + toast "Something went wrong. Try again."
+ * onSettled: invalidate query
+ *
+ * Unfollow opens AlertDialog confirmation per UI-SPEC copy.
+ */
+
+interface FollowButtonProps {
+ followerPetId: string;
+ followeePetId: string;
+ followeeName: string;
+}
+
+export function FollowButton({
+ followerPetId,
+ followeePetId,
+ followeeName,
+}: FollowButtonProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const [showUnfollowDialog, setShowUnfollowDialog] = useState(false);
+
+ // Hidden on own pet profile (T-02-11 guard)
+ if (followerPetId === followeePetId) return null;
+
+ const isFollowingQueryOptions = trpc.follows.isFollowing.queryOptions({
+ followerPetId,
+ followeePetId,
+ });
+
+ const { data: isFollowing, isPending: isLoadingStatus } = useQuery(
+ isFollowingQueryOptions
+ );
+
+ const followMutation = useMutation(
+ trpc.follows.create.mutationOptions({
+ onMutate: async () => {
+ // Cancel any in-flight isFollowing queries
+ await queryClient.cancelQueries({ queryKey: isFollowingQueryOptions.queryKey });
+ // Snapshot previous value
+ const prev = queryClient.getQueryData(isFollowingQueryOptions.queryKey);
+ // Optimistically set to following
+ queryClient.setQueryData(isFollowingQueryOptions.queryKey, true);
+ return { prev };
+ },
+ onError: (_err, _vars, context) => {
+ // Revert on error
+ queryClient.setQueryData(
+ isFollowingQueryOptions.queryKey,
+ context?.prev
+ );
+ toast.error("Something went wrong. Try again.");
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
+ },
+ onSuccess: () => {
+ toast.success(`You're now following ${followeeName}`);
+ },
+ })
+ );
+
+ const unfollowMutation = useMutation(
+ trpc.follows.delete.mutationOptions({
+ onMutate: async () => {
+ await queryClient.cancelQueries({ queryKey: isFollowingQueryOptions.queryKey });
+ const prev = queryClient.getQueryData(isFollowingQueryOptions.queryKey);
+ queryClient.setQueryData(isFollowingQueryOptions.queryKey, false);
+ return { prev };
+ },
+ onError: (_err, _vars, context) => {
+ queryClient.setQueryData(
+ isFollowingQueryOptions.queryKey,
+ context?.prev
+ );
+ toast.error("Something went wrong. Try again.");
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
+ },
+ onSuccess: () => {
+ toast(`Unfollowed ${followeeName}`);
+ },
+ })
+ );
+
+ const isPending = followMutation.isPending || unfollowMutation.isPending;
+
+ function handleClick() {
+ if (isPending || isLoadingStatus) return;
+
+ if (isFollowing) {
+ // Show unfollow confirmation dialog
+ setShowUnfollowDialog(true);
+ } else {
+ followMutation.mutate({ followerPetId, followeePetId });
+ }
+ }
+
+ function handleUnfollowConfirm() {
+ unfollowMutation.mutate({ followerPetId, followeePetId });
+ setShowUnfollowDialog(false);
+ }
+
+ return (
+ <>
+
+
+ {/* Unfollow confirmation dialog per UI-SPEC copy */}
+
+
+
+ Unfollow {followeeName}?
+
+ You'll stop seeing {followeeName}'s posts in your feed.
+
+
+
+ Keep following
+
+ Unfollow
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/social/PetListItem.tsx b/src/components/social/PetListItem.tsx
new file mode 100644
index 0000000..a95bbee
--- /dev/null
+++ b/src/components/social/PetListItem.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { getAvatarUrl } from "@/lib/avatar-url";
+import { FollowButton } from "@/components/social/FollowButton";
+
+/**
+ * PetListItem — a single row in the followers/following list (SOCL-03)
+ *
+ * Layout per UI-SPEC PetListItem spec:
+ * flex row, items-center, gap-sm (8px), px-md (16px), py-sm (8px)
+ * Avatar sm (32px) + pet name text-sm font-semibold + species Badge secondary
+ * right-aligned FollowButton (44px min-height)
+ *
+ * The FollowButton handles its own follow/unfollow state with optimistic updates.
+ */
+
+interface PetListItemProps {
+ pet: {
+ id: string;
+ name: string;
+ avatarKey: string | null;
+ species: { name: string };
+ };
+ /** The active pet performing the follow action (from ActivePetContext) */
+ followerPetId: string;
+}
+
+export function PetListItem({ pet, followerPetId }: PetListItemProps) {
+ return (
+
+
+ {pet.avatarKey && (
+
+ )}
+
+ {pet.name.slice(0, 2).toUpperCase()}
+
+
+
+
+
+
+ {pet.species.name}
+
+
+
+
+ );
+}