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 } });
}),
});