From 6fbb99e7c97cc6c72e92c19d739247eccc89212d Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 13 Jun 2026 11:43:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(03-02):=20Paw=20reactions=20GREEN=20?= =?UTF-8?q?=E2=80=94=20toggle/count=20router=20+=20PawButton=20on=20PostCa?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/feed/PawButton.tsx | 72 +++++++++++++++++++++++++++++++ src/components/feed/PostCard.tsx | 37 +++++++++++++--- src/trpc/routers/feed.ts | 1 + src/trpc/routers/posts.ts | 1 + src/trpc/routers/reactions.ts | 48 ++++++++++++++++++--- 5 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 src/components/feed/PawButton.tsx diff --git a/src/components/feed/PawButton.tsx b/src/components/feed/PawButton.tsx new file mode 100644 index 0000000..dcfb4a5 --- /dev/null +++ b/src/components/feed/PawButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index 2bd4df0..2cab8d2 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -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 */} -
- 0 likes - 0 comments -
+ {/* Action row — paw reaction + comment count + paw-back (Phase 3) */} + {post.type !== "REPOST" && activePetId && ( +
+ + + +
+ )} {/* Delete confirmation AlertDialog */} diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index 7671388..d8cb4a7 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -82,6 +82,7 @@ export const feedRouter = router({ pet: { select: { id: true, name: true, avatarKey: true } }, images: { orderBy: { position: "asc" } }, milestone: true, + _count: { select: { reactions: true, comments: true } }, }, }); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index aad3637..585a3cb 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -209,6 +209,7 @@ export const postsRouter = router({ images: { orderBy: { position: "asc" } }, pet: { select: { id: true, name: true, avatarKey: true } }, milestone: true, + _count: { select: { reactions: true, comments: true } }, }, }); diff --git a/src/trpc/routers/reactions.ts b/src/trpc/routers/reactions.ts index f38f162..47a05f6 100644 --- a/src/trpc/routers/reactions.ts +++ b/src/trpc/routers/reactions.ts @@ -2,23 +2,59 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "@/trpc/init"; +import type { PrismaClient } from "@prisma/client"; + +async function assertPetOwnership( + prisma: PrismaClient, + petId: string, + userId: string +) { + const pet = await prisma.pet.findUnique({ where: { id: petId } }); + if (!pet || pet.ownerId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to access this pet.", + }); + } + return pet; +} export const reactionsRouter = router({ toggle: protectedProcedure .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) })) - .mutation(async (): Promise<{ reacted: boolean }> => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }): Promise<{ reacted: boolean }> => { + await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + + const existing = await ctx.prisma.reaction.findUnique({ + where: { postId_petId: { postId: input.postId, petId: input.petId } }, + }); + + if (existing) { + await ctx.prisma.reaction.delete({ + where: { postId_petId: { postId: input.postId, petId: input.petId } }, + }); + return { reacted: false }; + } + + await ctx.prisma.reaction.create({ + data: { postId: input.postId, petId: input.petId }, + }); + return { reacted: true }; }), hasReacted: protectedProcedure .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) })) - .query(async (): Promise => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }): Promise => { + await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + const existing = await ctx.prisma.reaction.findUnique({ + where: { postId_petId: { postId: input.postId, petId: input.petId } }, + }); + return existing !== null; }), getCount: protectedProcedure .input(z.object({ postId: z.string().min(1) })) - .query(async (): Promise => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }): Promise => { + return ctx.prisma.reaction.count({ where: { postId: input.postId } }); }), });