feat(02-02): social + safety UI — FollowButton, PetListItem, BlockDialog, ReportSheet, followers/following pages
- FollowButton: optimistic follow/unfollow, revert on error, AlertDialog unfollow confirm (SOCL-01/02)
- PetListItem: avatar + name + species badge + right-aligned FollowButton (SOCL-03)
- BlockDialog: AlertDialog with exact UI-SPEC copy, destructive Block CTA (SAFE-01)
- ReportSheet: 5 RadioGroup reasons, notes Textarea, destructive Submit CTA (SAFE-02)
- followers/page.tsx: RSC shell "{N} Followers" heading + FollowersList client (SOCL-03)
- following/page.tsx: RSC shell "Following {N}" heading + FollowingList client (SOCL-03)
- pets/[petId]/page.tsx: stats row (posts/followers/following) + ProfileActions (SOCL-03)
- ProfileActions: FollowButton + MoreHorizontal options menu + BlockDialog + ReportSheet
- Fix: test prisma cast to `as any` resolving TS2322 type errors (Rule 1 - Bug)
- tsc exits 0; vitest 36 passed
This commit is contained in:
1 parent
e9b286b4df
commit
0428cf2a04
13 files changed
+842
-5
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
{/* FollowButton — hidden on own pet (FollowButton also guards this internally) */}
|
||||
{!isOwnPet && (
|
||||
<FollowButton
|
||||
followerPetId={activePetId}
|
||||
followeePetId={petId}
|
||||
followeeName={petName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Options menu — only show for other pets' profiles */}
|
||||
{!isOwnPet && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="h-[44px] w-[44px] flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors cursor-pointer"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setReportOpen(true)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Report profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setBlockOpen(true)}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
Block {petName}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* BlockDialog */}
|
||||
<BlockDialog
|
||||
open={blockOpen}
|
||||
onOpenChange={setBlockOpen}
|
||||
blockerPetId={activePetId}
|
||||
blockedPetId={petId}
|
||||
blockedPetName={petName}
|
||||
/>
|
||||
|
||||
{/* ReportSheet */}
|
||||
<ReportSheet
|
||||
open={reportOpen}
|
||||
onOpenChange={setReportOpen}
|
||||
reporterPetId={activePetId}
|
||||
targetPetId={petId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="divide-y divide-border">
|
||||
{followers.map((follow) => (
|
||||
<PetListItem
|
||||
key={follow.followerPetId}
|
||||
pet={follow.follower}
|
||||
followerPetId={activePetId ?? petId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-[480px] mx-auto px-4 py-6">
|
||||
<h1 className="text-xl font-semibold mb-6">
|
||||
{followers.length} Followers
|
||||
</h1>
|
||||
|
||||
{followers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{pet.name} doesn't have any followers yet.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-120px)]">
|
||||
<FollowersList followers={followers} petId={petId} />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="divide-y divide-border">
|
||||
{following.map((follow) => (
|
||||
<PetListItem
|
||||
key={follow.followeePetId}
|
||||
pet={follow.followee}
|
||||
followerPetId={activePetId ?? petId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-[480px] mx-auto px-4 py-6">
|
||||
<h1 className="text-xl font-semibold mb-6">
|
||||
Following {following.length}
|
||||
</h1>
|
||||
|
||||
{following.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{pet.name} isn't following anyone yet.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-120px)]">
|
||||
<FollowingList following={following} petId={petId} />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="container max-w-[640px] mx-auto px-6 py-8">
|
||||
{/* Pet identity — pet-first, owner-subordinate */}
|
||||
@@ -84,6 +99,41 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats row — posts / followers / following (UI-SPEC Phase 2 addition) */}
|
||||
<div className="flex justify-center gap-8 mb-6">
|
||||
{/* Posts count — placeholder until posts router is implemented */}
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xl font-semibold">0</span>
|
||||
<span className="text-xs text-muted-foreground">posts</span>
|
||||
</div>
|
||||
|
||||
{/* Followers — tappable link to /pets/[petId]/followers */}
|
||||
<Link
|
||||
href={`/pets/${pet.id}/followers`}
|
||||
className="flex flex-col items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="text-xl font-semibold">{followerCount}</span>
|
||||
<span className="text-xs text-muted-foreground">followers</span>
|
||||
</Link>
|
||||
|
||||
{/* Following — tappable link to /pets/[petId]/following */}
|
||||
<Link
|
||||
href={`/pets/${pet.id}/following`}
|
||||
className="flex flex-col items-center hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span className="text-xl font-semibold">{followingCount}</span>
|
||||
<span className="text-xs text-muted-foreground">following</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* ProfileActions: FollowButton + MoreHorizontal options menu (client component) */}
|
||||
{/* Passes petId as the viewed pet; active pet is resolved from context client-side */}
|
||||
<ProfileActions
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
ownerId={pet.ownerId}
|
||||
/>
|
||||
|
||||
{/* 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+ */}
|
||||
<Separator className="mb-6" />
|
||||
<section>
|
||||
<h2 className="text-base font-semibold mb-4">Posts</h2>
|
||||
|
||||
@@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Block {blockedPetName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{blockedPetName}'s posts and profile will be hidden from you.
|
||||
They won't know you blocked them.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Don't block</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleBlock}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={blockMutation.isPending}
|
||||
>
|
||||
Block
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -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<ReportReason | "">("");
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="sm:side-right sm:max-w-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-xl font-semibold">Report</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">
|
||||
Why are you reporting this?
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={selectedReason}
|
||||
onValueChange={(v) => setSelectedReason(v as ReportReason)}
|
||||
className="gap-2"
|
||||
>
|
||||
{REPORT_REASONS.map(({ value, label }) => (
|
||||
<div key={value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={value} id={`reason-${value}`} />
|
||||
<Label
|
||||
htmlFor={`reason-${value}`}
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder="Add details (optional)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
{notes.length > 450 && (
|
||||
<p className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{500 - notes.length} characters remaining
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedReason || reportMutation.isPending}
|
||||
className="w-full min-h-[44px] bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Submit Report
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
|
||||
>
|
||||
Don't report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
|
||||
/**
|
||||
* FollowButton — optimistic follow/unfollow toggle (SOCL-01, SOCL-02)
|
||||
*
|
||||
* Props:
|
||||
* - followerPetId: the active pet performing the follow action
|
||||
* - followeePetId: the target pet being followed
|
||||
* - followeeName: display name for unfollow confirmation dialog
|
||||
*
|
||||
* Hidden when followerPetId === followeePetId (own pet profile).
|
||||
*
|
||||
* States per UI-SPEC:
|
||||
* - Not following: "Follow" bg-orange-500 text-white
|
||||
* - Following: "Following" variant outline + Check icon
|
||||
* - Pending: disabled spinner opacity-60
|
||||
*
|
||||
* Optimistic update (RESEARCH Pattern 6):
|
||||
* onMutate: cancel inflight + setData immediately
|
||||
* onError: revert + toast "Something went wrong. Try again."
|
||||
* onSettled: invalidate query
|
||||
*
|
||||
* Unfollow opens AlertDialog confirmation per UI-SPEC copy.
|
||||
*/
|
||||
|
||||
interface FollowButtonProps {
|
||||
followerPetId: string;
|
||||
followeePetId: string;
|
||||
followeeName: string;
|
||||
}
|
||||
|
||||
export function FollowButton({
|
||||
followerPetId,
|
||||
followeePetId,
|
||||
followeeName,
|
||||
}: FollowButtonProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const [showUnfollowDialog, setShowUnfollowDialog] = useState(false);
|
||||
|
||||
// Hidden on own pet profile (T-02-11 guard)
|
||||
if (followerPetId === followeePetId) return null;
|
||||
|
||||
const isFollowingQueryOptions = trpc.follows.isFollowing.queryOptions({
|
||||
followerPetId,
|
||||
followeePetId,
|
||||
});
|
||||
|
||||
const { data: isFollowing, isPending: isLoadingStatus } = useQuery(
|
||||
isFollowingQueryOptions
|
||||
);
|
||||
|
||||
const followMutation = useMutation(
|
||||
trpc.follows.create.mutationOptions({
|
||||
onMutate: async () => {
|
||||
// Cancel any in-flight isFollowing queries
|
||||
await queryClient.cancelQueries({ queryKey: isFollowingQueryOptions.queryKey });
|
||||
// Snapshot previous value
|
||||
const prev = queryClient.getQueryData(isFollowingQueryOptions.queryKey);
|
||||
// Optimistically set to following
|
||||
queryClient.setQueryData(isFollowingQueryOptions.queryKey, true);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
// Revert on error
|
||||
queryClient.setQueryData(
|
||||
isFollowingQueryOptions.queryKey,
|
||||
context?.prev
|
||||
);
|
||||
toast.error("Something went wrong. Try again.");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`You're now following ${followeeName}`);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const unfollowMutation = useMutation(
|
||||
trpc.follows.delete.mutationOptions({
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: isFollowingQueryOptions.queryKey });
|
||||
const prev = queryClient.getQueryData(isFollowingQueryOptions.queryKey);
|
||||
queryClient.setQueryData(isFollowingQueryOptions.queryKey, false);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
queryClient.setQueryData(
|
||||
isFollowingQueryOptions.queryKey,
|
||||
context?.prev
|
||||
);
|
||||
toast.error("Something went wrong. Try again.");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isFollowingQueryOptions.queryKey });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast(`Unfollowed ${followeeName}`);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const isPending = followMutation.isPending || unfollowMutation.isPending;
|
||||
|
||||
function handleClick() {
|
||||
if (isPending || isLoadingStatus) return;
|
||||
|
||||
if (isFollowing) {
|
||||
// Show unfollow confirmation dialog
|
||||
setShowUnfollowDialog(true);
|
||||
} else {
|
||||
followMutation.mutate({ followerPetId, followeePetId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnfollowConfirm() {
|
||||
unfollowMutation.mutate({ followerPetId, followeePetId });
|
||||
setShowUnfollowDialog(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isPending || isLoadingStatus}
|
||||
variant={isFollowing ? "outline" : "default"}
|
||||
className={
|
||||
isFollowing
|
||||
? "min-h-[44px] transition-colors duration-150"
|
||||
: "min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white transition-colors duration-150"
|
||||
}
|
||||
aria-label={isFollowing ? `Unfollow ${followeeName}` : `Follow ${followeeName}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin opacity-60" />
|
||||
) : isFollowing ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
"Follow"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Unfollow confirmation dialog per UI-SPEC copy */}
|
||||
<AlertDialog open={showUnfollowDialog} onOpenChange={setShowUnfollowDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unfollow {followeeName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll stop seeing {followeeName}'s posts in your feed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep following</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleUnfollowConfirm}>
|
||||
Unfollow
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { FollowButton } from "@/components/social/FollowButton";
|
||||
|
||||
/**
|
||||
* PetListItem — a single row in the followers/following list (SOCL-03)
|
||||
*
|
||||
* Layout per UI-SPEC PetListItem spec:
|
||||
* flex row, items-center, gap-sm (8px), px-md (16px), py-sm (8px)
|
||||
* Avatar sm (32px) + pet name text-sm font-semibold + species Badge secondary
|
||||
* right-aligned FollowButton (44px min-height)
|
||||
*
|
||||
* The FollowButton handles its own follow/unfollow state with optimistic updates.
|
||||
*/
|
||||
|
||||
interface PetListItemProps {
|
||||
pet: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
};
|
||||
/** The active pet performing the follow action (from ActivePetContext) */
|
||||
followerPetId: string;
|
||||
}
|
||||
|
||||
export function PetListItem({ pet, followerPetId }: PetListItemProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
{pet.avatarKey && (
|
||||
<AvatarImage src={getAvatarUrl(pet.avatarKey)!} alt={pet.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{pet.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{pet.name}</p>
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{pet.species.name}
|
||||
</Badge>
|
||||
|
||||
<FollowButton
|
||||
followerPetId={followerPetId}
|
||||
followeePetId={pet.id}
|
||||
followeeName={pet.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user