Files
petfeed/src/components/social/FollowButton.tsx
T
admin 0428cf2a04 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
2026-06-07 12:37:45 +02:00

185 lines
5.7 KiB
TypeScript

"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>
</>
);
}