- 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
185 lines
5.7 KiB
TypeScript
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'll stop seeing {followeeName}'s posts in your feed.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep following</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleUnfollowConfirm}>
|
|
Unfollow
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|