feat(03-02): Paw reactions GREEN — toggle/count router + PawButton on PostCard

- reactions.toggle: assertPetOwnership → findUnique(postId_petId) → create or delete, return {reacted}
- reactions.hasReacted, getCount implemented
- feed.ts + posts.byPetId: add _count{reactions,comments} to hydration include
- PawButton: optimistic toggle, filled/outline PawPrint icon, count badge
- PostCard: replace "0 likes/0 comments" placeholder with action row (PawButton + comment chip + Paw-Back stub)
- PostCardPost type: add optional _count field
- 4/4 reactions tests GREEN; 56 passing total (+3 from 53)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 11:43:31 +02:00
1 parent b71edfa758
commit 6fbb99e7c9
5 files changed
+147 -12

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState, useCallback } from "react";
import { PawPrint } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
interface PawButtonProps {
postId: string;
petId: string;
initialCount: number;
initialReacted: boolean;
}
export function PawButton({ postId, petId, initialCount, initialReacted }: PawButtonProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [reacted, setReacted] = useState(initialReacted);
const [count, setCount] = useState(initialCount);
const toggleMutation = useMutation(
trpc.reactions.toggle.mutationOptions({
onMutate: () => {
// Optimistic update
const nextReacted = !reacted;
setReacted(nextReacted);
setCount((c) => (nextReacted ? c + 1 : Math.max(0, c - 1)));
},
onSuccess: (data) => {
// Sync with server truth
setReacted(data.reacted);
},
onError: () => {
// Revert optimistic update
setReacted(reacted);
setCount(initialCount);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] });
},
})
);
const handleClick = useCallback(() => {
if (toggleMutation.isPending) return;
toggleMutation.mutate({ postId, petId });
}, [postId, petId, toggleMutation]);
return (
<button
type="button"
onClick={handleClick}
disabled={toggleMutation.isPending}
aria-label={reacted ? "Remove paw" : "Paw this post"}
className="flex items-center gap-1.5 text-sm transition-colors disabled:opacity-60"
>
<PawPrint
className={`h-4 w-4 transition-colors ${
reacted
? "fill-orange-500 text-orange-500"
: "text-muted-foreground hover:text-orange-500"
}`}
aria-hidden="true"
/>
{count > 0 && (
<span className={reacted ? "text-orange-500" : "text-muted-foreground"}>
{count}
</span>
)}
</button>
);
}
+31 -6
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { MoreHorizontal } from "lucide-react";
import { MoreHorizontal, MessageCircle, Repeat2 } from "lucide-react";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -38,6 +38,7 @@ import { getAvatarUrl } from "@/lib/avatar-url";
import { getMediaUrl } from "@/lib/media-url";
import { PawRing } from "@/components/stories/PawRing";
import { MilestoneCard } from "@/components/feed/MilestoneCard";
import { PawButton } from "@/components/feed/PawButton";
import { BlockDialog } from "@/components/safety/BlockDialog";
import { ReportSheet } from "@/components/safety/ReportSheet";
@@ -76,6 +77,7 @@ export type PostCardPost = {
pet: PostPet;
images: PostImage[];
milestone: { milestoneType: import("@prisma/client").MilestoneType; notes: string | null } | null;
_count?: { reactions: number; comments: number } | null;
};
export type PostCardProps = {
@@ -388,11 +390,34 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
)
)}
{/* Likes/comments placeholder — Phase 3 */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>0 likes</span>
<span>0 comments</span>
</div>
{/* Action row — paw reaction + comment count + paw-back (Phase 3) */}
{post.type !== "REPOST" && activePetId && (
<div className="flex items-center gap-4 pt-1">
<PawButton
postId={post.id}
petId={activePetId}
initialCount={post._count?.reactions ?? 0}
initialReacted={false}
/>
<button
type="button"
aria-label="View comments"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-4 w-4" aria-hidden="true" />
{(post._count?.comments ?? 0) > 0 && (
<span>{post._count!.comments}</span>
)}
</button>
<button
type="button"
aria-label="Paw-Back this post"
className="ml-auto flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Repeat2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
)}
</div>
{/* Delete confirmation AlertDialog */}