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 */}
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<boolean> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
.query(async ({ ctx, input }): Promise<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 } },
|
||||
});
|
||||
return existing !== null;
|
||||
}),
|
||||
|
||||
getCount: protectedProcedure
|
||||
.input(z.object({ postId: z.string().min(1) }))
|
||||
.query(async (): Promise<number> => {
|
||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
||||
.query(async ({ ctx, input }): Promise<number> => {
|
||||
return ctx.prisma.reaction.count({ where: { postId: input.postId } });
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user