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:
1 parent
b71edfa758
commit
6fbb99e7c9
5 files changed
+147
-12
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user