diff --git a/src/__tests__/blocks.test.ts b/src/__tests__/blocks.test.ts index 79b4a60..e14ab18 100644 --- a/src/__tests__/blocks.test.ts +++ b/src/__tests__/blocks.test.ts @@ -64,9 +64,10 @@ async function createBlocksCaller(overrides?: { blocks: blocksRouter, }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const caller = testRouter.createCaller({ userId, - prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, + prisma: mockPrisma as any, }); return { caller, mockPrisma }; diff --git a/src/__tests__/follows.test.ts b/src/__tests__/follows.test.ts index 737129f..f697009 100644 --- a/src/__tests__/follows.test.ts +++ b/src/__tests__/follows.test.ts @@ -74,9 +74,10 @@ async function createFollowsCaller(overrides?: { }); // Use the appRouter caller pattern with injected context + // eslint-disable-next-line @typescript-eslint/no-explicit-any const caller = testRouter.createCaller({ userId, - prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, + prisma: mockPrisma as any, }); return { caller, mockPrisma }; diff --git a/src/__tests__/reports.test.ts b/src/__tests__/reports.test.ts index d9f9de6..955b244 100644 --- a/src/__tests__/reports.test.ts +++ b/src/__tests__/reports.test.ts @@ -66,9 +66,10 @@ async function createReportsCaller(overrides?: { reports: reportsRouter, }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const caller = testRouter.createCaller({ userId, - prisma: mockPrisma as unknown as import("@prisma/client").PrismaClient, + prisma: mockPrisma as any, }); return { caller, mockPrisma }; diff --git a/src/app/(app)/pets/[petId]/ProfileActions.tsx b/src/app/(app)/pets/[petId]/ProfileActions.tsx new file mode 100644 index 0000000..e41b55d --- /dev/null +++ b/src/app/(app)/pets/[petId]/ProfileActions.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal } from "lucide-react"; +import { useActivePet } from "@/context/ActivePetContext"; +import { FollowButton } from "@/components/social/FollowButton"; +import { BlockDialog } from "@/components/safety/BlockDialog"; +import { ReportSheet } from "@/components/safety/ReportSheet"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; + +/** + * ProfileActions — client component for the pet profile page. + * + * Renders: + * - FollowButton (hidden on own pet profile via followerPetId === followeePetId check) + * - MoreHorizontal options menu (DropdownMenu) for other pets' profiles: + * "Report profile" → opens ReportSheet + * "Block {pet name}" → opens BlockDialog + * + * Own pet: shows no options menu (edit link handled by RSC shell). + * + * UI-SPEC: base-nova preset — no asChild on DropdownMenu; use onClick+useRouter. + */ + +interface ProfileActionsProps { + petId: string; + petName: string; + ownerId: string; +} + +export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) { + const { activePetId } = useActivePet(); + const [blockOpen, setBlockOpen] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + + // activePetId may be null during SSR hydration — skip rendering interactive elements + if (!activePetId) return null; + + const isOwnPet = activePetId === petId; + + return ( +
+ {/* FollowButton — hidden on own pet (FollowButton also guards this internally) */} + {!isOwnPet && ( + + )} + + {/* Options menu — only show for other pets' profiles */} + {!isOwnPet && ( + <> + + + + + + setReportOpen(true)} + className="cursor-pointer" + > + Report profile + + + setBlockOpen(true)} + className="cursor-pointer text-destructive focus:text-destructive" + > + Block {petName} + + + + + {/* BlockDialog */} + + + {/* ReportSheet */} + + + )} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/followers/FollowersList.tsx b/src/app/(app)/pets/[petId]/followers/FollowersList.tsx new file mode 100644 index 0000000..ec68f40 --- /dev/null +++ b/src/app/(app)/pets/[petId]/followers/FollowersList.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useActivePet } from "@/context/ActivePetContext"; +import { PetListItem } from "@/components/social/PetListItem"; + +interface FollowersListProps { + followers: Array<{ + followerPetId: string; + followeePetId: string; + createdAt: Date; + follower: { + id: string; + name: string; + avatarKey: string | null; + species: { name: string }; + }; + }>; + petId: string; +} + +/** + * Client component — reads ActivePetContext to supply followerPetId to FollowButton. + * RSC shell passes down prefetched followers data. + */ +export function FollowersList({ followers, petId }: FollowersListProps) { + const { activePetId } = useActivePet(); + + return ( +
+ {followers.map((follow) => ( + + ))} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/followers/page.tsx b/src/app/(app)/pets/[petId]/followers/page.tsx new file mode 100644 index 0000000..c26456b --- /dev/null +++ b/src/app/(app)/pets/[petId]/followers/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; +import { createTRPCCaller } from "@/trpc/server"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FollowersList } from "@/app/(app)/pets/[petId]/followers/FollowersList"; + +/** + * Followers list page — /pets/[petId]/followers (SOCL-03) + * + * RSC shell: fetches follower count and pet name, passes data to client + * FollowersList component which handles the FollowButton interactions. + * + * UI-SPEC heading: "{N} Followers" + * Empty state: "{pet name} doesn't have any followers yet." + */ + +interface PageProps { + params: Promise<{ petId: string }>; +} + +export default async function FollowersPage({ params }: PageProps) { + const { petId } = await params; + + const trpc = await createTRPCCaller(); + + let pet; + try { + pet = await trpc.pets.byId({ petId }); + } catch { + notFound(); + } + + if (!pet) notFound(); + + const followers = await trpc.follows.listFollowers({ petId }); + + return ( +
+

+ {followers.length} Followers +

+ + {followers.length === 0 ? ( +

+ {pet.name} doesn't have any followers yet. +

+ ) : ( + + + + )} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/following/FollowingList.tsx b/src/app/(app)/pets/[petId]/following/FollowingList.tsx new file mode 100644 index 0000000..db92c63 --- /dev/null +++ b/src/app/(app)/pets/[petId]/following/FollowingList.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useActivePet } from "@/context/ActivePetContext"; +import { PetListItem } from "@/components/social/PetListItem"; + +interface FollowingListProps { + following: Array<{ + followerPetId: string; + followeePetId: string; + createdAt: Date; + followee: { + id: string; + name: string; + avatarKey: string | null; + species: { name: string }; + }; + }>; + petId: string; +} + +/** + * Client component — reads ActivePetContext to supply followerPetId to FollowButton. + * RSC shell passes down prefetched following data. + */ +export function FollowingList({ following, petId }: FollowingListProps) { + const { activePetId } = useActivePet(); + + return ( +
+ {following.map((follow) => ( + + ))} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/following/page.tsx b/src/app/(app)/pets/[petId]/following/page.tsx new file mode 100644 index 0000000..78e9fa4 --- /dev/null +++ b/src/app/(app)/pets/[petId]/following/page.tsx @@ -0,0 +1,53 @@ +import { notFound } from "next/navigation"; +import { createTRPCCaller } from "@/trpc/server"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { FollowingList } from "@/app/(app)/pets/[petId]/following/FollowingList"; + +/** + * Following list page — /pets/[petId]/following (SOCL-03) + * + * RSC shell: fetches following list and pet name, passes data to client + * FollowingList component which handles the FollowButton interactions. + * + * UI-SPEC heading: "Following {N}" + * Empty state: "{pet name} isn't following anyone yet." + */ + +interface PageProps { + params: Promise<{ petId: string }>; +} + +export default async function FollowingPage({ params }: PageProps) { + const { petId } = await params; + + const trpc = await createTRPCCaller(); + + let pet; + try { + pet = await trpc.pets.byId({ petId }); + } catch { + notFound(); + } + + if (!pet) notFound(); + + const following = await trpc.follows.listFollowing({ petId }); + + return ( +
+

+ Following {following.length} +

+ + {following.length === 0 ? ( +

+ {pet.name} isn't following anyone yet. +

+ ) : ( + + + + )} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 17006dc..dd0c78c 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -7,13 +7,19 @@ import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { getAvatarUrl } from "@/lib/avatar-url"; +import { ProfileActions } from "@/app/(app)/pets/[petId]/ProfileActions"; /** * Pet profile view — /pets/[petId] * - * Server component. Fetches the pet and the owner's Clerk profile. + * Server component. Fetches the pet, follower/following counts, and the owner's Clerk profile. * If the pet doesn't exist or doesn't belong to the current owner, renders 404. * + * Phase 2 additions: + * - Stats row: posts / followers / following with links to list pages + * - FollowButton: hidden on own pet profile + * - MoreHorizontal options menu wiring BlockDialog + ReportSheet for other pets' profiles + * * Security (T-3-03): tRPC byId ownership assertion prevents cross-owner info disclosure. * UI-SPEC: Owner Identity Subordination — "Managed by @username" is muted caption below pet. */ @@ -50,6 +56,15 @@ export default async function PetProfilePage({ params }: PageProps) { user?.emailAddresses?.[0]?.emailAddress?.split("@")[0] ?? "owner"; + // Fetch social graph counts for the stats row + const [followers, following] = await Promise.all([ + trpc.follows.listFollowers({ petId }), + trpc.follows.listFollowing({ petId }), + ]); + + const followerCount = followers.length; + const followingCount = following.length; + return (
{/* Pet identity — pet-first, owner-subordinate */} @@ -84,6 +99,41 @@ export default async function PetProfilePage({ params }: PageProps) {
+ {/* Stats row — posts / followers / following (UI-SPEC Phase 2 addition) */} +
+ {/* Posts count — placeholder until posts router is implemented */} +
+ 0 + posts +
+ + {/* Followers — tappable link to /pets/[petId]/followers */} + + {followerCount} + followers + + + {/* Following — tappable link to /pets/[petId]/following */} + + {followingCount} + following + +
+ + {/* ProfileActions: FollowButton + MoreHorizontal options menu (client component) */} + {/* Passes petId as the viewed pet; active pet is resolved from context client-side */} + + {/* Bio section */} {pet.bio && ( <> @@ -105,7 +155,7 @@ export default async function PetProfilePage({ params }: PageProps) { )} - {/* Posts placeholder — Phase 2 adds posts */} + {/* Posts placeholder — Phase 2 posts router implementation in 02-03+ */}

Posts

diff --git a/src/components/safety/BlockDialog.tsx b/src/components/safety/BlockDialog.tsx new file mode 100644 index 0000000..b55d9bd --- /dev/null +++ b/src/components/safety/BlockDialog.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useTRPC } from "@/trpc/client"; + +/** + * BlockDialog — confirm blocking a pet (SAFE-01) + * + * UI-SPEC copy: + * Heading: "Block {pet name}?" + * Body: "{pet name}'s posts and profile will be hidden from you. They won't know you blocked them." + * Confirm: "Block" (destructive) + * Cancel: "Don't block" + * + * Success toast: "You've blocked {pet name}" + */ + +interface BlockDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The active pet performing the block */ + blockerPetId: string; + /** The target pet being blocked */ + blockedPetId: string; + blockedPetName: string; +} + +export function BlockDialog({ + open, + onOpenChange, + blockerPetId, + blockedPetId, + blockedPetName, +}: BlockDialogProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const blockMutation = useMutation( + trpc.blocks.create.mutationOptions({ + onSuccess: () => { + toast.success(`You've blocked ${blockedPetName}`); + onOpenChange(false); + // Invalidate any cached block lists so feed hydration picks up the new block + queryClient.invalidateQueries({ queryKey: ["follows"] }); + }, + onError: () => { + toast.error("Couldn't block this pet. Please try again."); + }, + }) + ); + + function handleBlock() { + blockMutation.mutate({ blockerPetId, blockedPetId }); + } + + return ( + + + + Block {blockedPetName}? + + {blockedPetName}'s posts and profile will be hidden from you. + They won't know you blocked them. + + + + Don't block + + Block + + + + + ); +} diff --git a/src/components/safety/ReportSheet.tsx b/src/components/safety/ReportSheet.tsx new file mode 100644 index 0000000..dcb907e --- /dev/null +++ b/src/components/safety/ReportSheet.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ReportReason } from "@prisma/client"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/trpc/client"; + +/** + * ReportSheet — submit a content report (SAFE-02) + * + * UI-SPEC copy: + * Sheet title: "Report" + * Radio group: 5 reasons + * Notes textarea: placeholder "Add details (optional)", max 500 chars + * Submit CTA: "Submit Report" (destructive) + * Cancel: "Don't report" + * + * Success toast: "Report submitted. We'll review it shortly." + * + * Target is either targetPostId (report a post) or targetPetId (report a profile). + */ + +interface ReportSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The active pet submitting the report */ + reporterPetId: string; + /** Mutually exclusive — set one or the other */ + targetPostId?: string; + targetPetId?: string; +} + +const REPORT_REASONS: { value: ReportReason; label: string }[] = [ + { value: ReportReason.SPAM, label: "Spam" }, + { value: ReportReason.HARASSMENT, label: "Harassment or bullying" }, + { value: ReportReason.INAPPROPRIATE, label: "Inappropriate content" }, + { value: ReportReason.MISINFORMATION, label: "False or misleading information" }, + { value: ReportReason.OTHER, label: "Something else" }, +]; + +export function ReportSheet({ + open, + onOpenChange, + reporterPetId, + targetPostId, + targetPetId, +}: ReportSheetProps) { + const trpc = useTRPC(); + const [selectedReason, setSelectedReason] = useState(""); + const [notes, setNotes] = useState(""); + + const reportMutation = useMutation( + trpc.reports.create.mutationOptions({ + onSuccess: () => { + toast.success("Report submitted. We'll review it shortly."); + onOpenChange(false); + // Reset form state + setSelectedReason(""); + setNotes(""); + }, + onError: () => { + toast.error("Couldn't submit your report. Please try again."); + }, + }) + ); + + function handleSubmit() { + if (!selectedReason) return; + + reportMutation.mutate({ + reporterPetId, + targetPostId, + targetPetId, + reason: selectedReason, + notes: notes.trim() || undefined, + }); + } + + function handleCancel() { + onOpenChange(false); + setSelectedReason(""); + setNotes(""); + } + + return ( + + + + Report + + +
+
+

+ Why are you reporting this? +

+ setSelectedReason(v as ReportReason)} + className="gap-2" + > + {REPORT_REASONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+
+ +
+