feat(02-02): social + safety UI — FollowButton, PetListItem, BlockDialog, ReportSheet, followers/following pages

- FollowButton: optimistic follow/unfollow, revert on error, AlertDialog unfollow confirm (SOCL-01/02)
- PetListItem: avatar + name + species badge + right-aligned FollowButton (SOCL-03)
- BlockDialog: AlertDialog with exact UI-SPEC copy, destructive Block CTA (SAFE-01)
- ReportSheet: 5 RadioGroup reasons, notes Textarea, destructive Submit CTA (SAFE-02)
- followers/page.tsx: RSC shell "{N} Followers" heading + FollowersList client (SOCL-03)
- following/page.tsx: RSC shell "Following {N}" heading + FollowingList client (SOCL-03)
- pets/[petId]/page.tsx: stats row (posts/followers/following) + ProfileActions (SOCL-03)
- ProfileActions: FollowButton + MoreHorizontal options menu + BlockDialog + ReportSheet
- Fix: test prisma cast to `as any` resolving TS2322 type errors (Rule 1 - Bug)
- tsc exits 0; vitest 36 passed
This commit is contained in:
admin committed 2026-06-07 12:37:45 +02:00
1 parent e9b286b4df
commit 0428cf2a04
13 files changed
+842 -5

No files matched your search

+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -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 };
+2 -1
View File
@@ -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 };
@@ -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 (
<div className="flex items-center justify-center gap-3 mb-6">
{/* FollowButton — hidden on own pet (FollowButton also guards this internally) */}
{!isOwnPet && (
<FollowButton
followerPetId={activePetId}
followeePetId={petId}
followeeName={petName}
/>
)}
{/* Options menu — only show for other pets' profiles */}
{!isOwnPet && (
<>
<DropdownMenu>
<DropdownMenuTrigger
className="h-[44px] w-[44px] flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors cursor-pointer"
aria-label="More options"
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setReportOpen(true)}
className="cursor-pointer"
>
Report profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setBlockOpen(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
Block {petName}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* BlockDialog */}
<BlockDialog
open={blockOpen}
onOpenChange={setBlockOpen}
blockerPetId={activePetId}
blockedPetId={petId}
blockedPetName={petName}
/>
{/* ReportSheet */}
<ReportSheet
open={reportOpen}
onOpenChange={setReportOpen}
reporterPetId={activePetId}
targetPetId={petId}
/>
</>
)}
</div>
);
}
@@ -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 (
<div className="divide-y divide-border">
{followers.map((follow) => (
<PetListItem
key={follow.followerPetId}
pet={follow.follower}
followerPetId={activePetId ?? petId}
/>
))}
</div>
);
}
@@ -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 (
<div className="max-w-[480px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-6">
{followers.length} Followers
</h1>
{followers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{pet.name} doesn&apos;t have any followers yet.
</p>
) : (
<ScrollArea className="h-[calc(100vh-120px)]">
<FollowersList followers={followers} petId={petId} />
</ScrollArea>
)}
</div>
);
}
@@ -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 (
<div className="divide-y divide-border">
{following.map((follow) => (
<PetListItem
key={follow.followeePetId}
pet={follow.followee}
followerPetId={activePetId ?? petId}
/>
))}
</div>
);
}
@@ -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 (
<div className="max-w-[480px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-6">
Following {following.length}
</h1>
{following.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{pet.name} isn&apos;t following anyone yet.
</p>
) : (
<ScrollArea className="h-[calc(100vh-120px)]">
<FollowingList following={following} petId={petId} />
</ScrollArea>
)}
</div>
);
}
+52 -2
View File
@@ -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 (
<div className="container max-w-[640px] mx-auto px-6 py-8">
{/* Pet identity — pet-first, owner-subordinate */}
@@ -84,6 +99,41 @@ export default async function PetProfilePage({ params }: PageProps) {
</Link>
</div>
{/* Stats row — posts / followers / following (UI-SPEC Phase 2 addition) */}
<div className="flex justify-center gap-8 mb-6">
{/* Posts count — placeholder until posts router is implemented */}
<div className="flex flex-col items-center">
<span className="text-xl font-semibold">0</span>
<span className="text-xs text-muted-foreground">posts</span>
</div>
{/* Followers — tappable link to /pets/[petId]/followers */}
<Link
href={`/pets/${pet.id}/followers`}
className="flex flex-col items-center hover:opacity-80 transition-opacity"
>
<span className="text-xl font-semibold">{followerCount}</span>
<span className="text-xs text-muted-foreground">followers</span>
</Link>
{/* Following — tappable link to /pets/[petId]/following */}
<Link
href={`/pets/${pet.id}/following`}
className="flex flex-col items-center hover:opacity-80 transition-opacity"
>
<span className="text-xl font-semibold">{followingCount}</span>
<span className="text-xs text-muted-foreground">following</span>
</Link>
</div>
{/* ProfileActions: FollowButton + MoreHorizontal options menu (client component) */}
{/* Passes petId as the viewed pet; active pet is resolved from context client-side */}
<ProfileActions
petId={pet.id}
petName={pet.name}
ownerId={pet.ownerId}
/>
{/* 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+ */}
<Separator className="mb-6" />
<section>
<h2 className="text-base font-semibold mb-4">Posts</h2>
+90
View File
@@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Block {blockedPetName}?</AlertDialogTitle>
<AlertDialogDescription>
{blockedPetName}&apos;s posts and profile will be hidden from you.
They won&apos;t know you blocked them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Don&apos;t block</AlertDialogCancel>
<AlertDialogAction
onClick={handleBlock}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={blockMutation.isPending}
>
Block
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+163
View File
@@ -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<ReportReason | "">("");
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="sm:side-right sm:max-w-[400px]">
<SheetHeader>
<SheetTitle className="text-xl font-semibold">Report</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-4">
<div>
<p className="text-sm font-medium mb-3">
Why are you reporting this?
</p>
<RadioGroup
value={selectedReason}
onValueChange={(v) => setSelectedReason(v as ReportReason)}
className="gap-2"
>
{REPORT_REASONS.map(({ value, label }) => (
<div key={value} className="flex items-center gap-2">
<RadioGroupItem value={value} id={`reason-${value}`} />
<Label
htmlFor={`reason-${value}`}
className="text-sm cursor-pointer"
>
{label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div>
<Textarea
placeholder="Add details (optional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={500}
rows={3}
className="text-sm resize-none"
/>
{notes.length > 450 && (
<p className="text-xs text-muted-foreground mt-1 text-right">
{500 - notes.length} characters remaining
</p>
)}
</div>
<div className="space-y-2">
<Button
onClick={handleSubmit}
disabled={!selectedReason || reportMutation.isPending}
className="w-full min-h-[44px] bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Submit Report
</Button>
<button
type="button"
onClick={handleCancel}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Don&apos;t report
</button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
+184
View File
@@ -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 (
<>
<Button
onClick={handleClick}
disabled={isPending || isLoadingStatus}
variant={isFollowing ? "outline" : "default"}
className={
isFollowing
? "min-h-[44px] transition-colors duration-150"
: "min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white transition-colors duration-150"
}
aria-label={isFollowing ? `Unfollow ${followeeName}` : `Follow ${followeeName}`}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin opacity-60" />
) : isFollowing ? (
<>
<Check className="h-4 w-4 mr-1" />
Following
</>
) : (
"Follow"
)}
</Button>
{/* Unfollow confirmation dialog per UI-SPEC copy */}
<AlertDialog open={showUnfollowDialog} onOpenChange={setShowUnfollowDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unfollow {followeeName}?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;ll stop seeing {followeeName}&apos;s posts in your feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep following</AlertDialogCancel>
<AlertDialogAction onClick={handleUnfollowConfirm}>
Unfollow
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+57
View File
@@ -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 (
<div className="flex items-center gap-2 px-4 py-2">
<Avatar className="h-8 w-8 shrink-0">
{pet.avatarKey && (
<AvatarImage src={getAvatarUrl(pet.avatarKey)!} alt={pet.name} />
)}
<AvatarFallback className="text-xs bg-muted">
{pet.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{pet.name}</p>
</div>
<Badge variant="secondary" className="text-xs shrink-0">
{pet.species.name}
</Badge>
<FollowButton
followerPetId={followerPetId}
followeePetId={pet.id}
followeeName={pet.name}
/>
</div>
);
}