From 95dfec7bdf17fa130703b2bcddb000e5a5284bd2 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 6 Jun 2026 10:17:34 +0200 Subject: [PATCH] feat(01-03): add pet profile pages (My Pets grid, profile view, edit, onboarding) - My Pets: server-fetched list + PetGrid client island for active pet selection - Pet profile view: avatar/name/species badge, "Managed by @owner" subordination - Edit pet: PetForm in edit mode + delete confirmation dialog (UI-SPEC copy) - Delete dialog: "Delete pet profile?", "Yes, delete", "Keep pet" per UI-SPEC - Onboarding page: replaced stub with PetForm mode=create, redirects to /pets/[petId] - Used buttonVariants for Link-as-button (base-nova lacks asChild) --- src/app/(app)/pets/PetGrid.tsx | 67 ++++++++++ src/app/(app)/pets/[petId]/edit/page.tsx | 153 +++++++++++++++++++++++ src/app/(app)/pets/[petId]/page.tsx | 120 ++++++++++++++++++ src/app/(app)/pets/page.tsx | 41 +++--- src/app/onboarding/pet/page.tsx | 29 +++-- 5 files changed, 372 insertions(+), 38 deletions(-) create mode 100644 src/app/(app)/pets/PetGrid.tsx create mode 100644 src/app/(app)/pets/[petId]/edit/page.tsx create mode 100644 src/app/(app)/pets/[petId]/page.tsx diff --git a/src/app/(app)/pets/PetGrid.tsx b/src/app/(app)/pets/PetGrid.tsx new file mode 100644 index 0000000..bb31846 --- /dev/null +++ b/src/app/(app)/pets/PetGrid.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { PetCard } from "@/components/pet/PetCard"; +import { Button } from "@/components/ui/button"; +import { useActivePet } from "@/context/ActivePetContext"; + +/** + * PetGrid — client island that renders the My Pets card grid with active pet state. + * Server page fetches data; this island handles interactivity (pet selection). + */ + +type Pet = { + id: string; + name: string; + avatarKey?: string | null; + species: { id: string; name: string }; + breed: { id: string; name: string } | null; +}; + +interface PetGridProps { + pets: Pet[]; +} + +export function PetGrid({ pets }: PetGridProps) { + const { activePetId, setActivePet } = useActivePet(); + const router = useRouter(); + + if (pets.length === 0) { + return ( +
+

Add your first pet

+

+ Give your pet their own profile on OnlyPets. +

+ +
+ ); + } + + return ( +
+ {pets.map((pet) => ( + setActivePet(id)} + /> + ))} + {/* Add pet card */} + + + + Add Pet + +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/edit/page.tsx b/src/app/(app)/pets/[petId]/edit/page.tsx new file mode 100644 index 0000000..9a97b2d --- /dev/null +++ b/src/app/(app)/pets/[petId]/edit/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { PetForm } from "@/components/pet/PetForm"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; + +/** + * Edit pet page — /pets/[petId]/edit + * + * Client component: handles router navigation for post-delete redirect + * and the delete confirmation dialog interaction. + * + * Security (T-3-01): ownership assertion is enforced in tRPC update/delete procedures. + * If the pet doesn't belong to the current owner, the procedures throw FORBIDDEN and + * we redirect to /pets (graceful 403 handling, not a generic error screen). + */ + +export default function EditPetPage() { + const router = useRouter(); + const params = useParams<{ petId: string }>(); + const petId = params.petId; + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { activePetId, setActivePet } = useActivePet(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { + data: pet, + isLoading, + isError, + } = useQuery({ + ...trpc.pets.byId.queryOptions({ petId }), + }); + + // Gracefully handle ownership violation — redirect instead of showing error screen + useEffect(() => { + if (isError) { + router.replace("/pets"); + } + }, [isError, router]); + + const deleteMutation = useMutation({ + ...trpc.pets.delete.mutationOptions(), + onSuccess: async () => { + // If the deleted pet was active, clear or switch active pet + if (activePetId === petId) { + setActivePet(""); + } + await queryClient.invalidateQueries(); + toast.success("Pet profile deleted"); + router.push("/pets"); + }, + onError: () => { + toast.error("Something went wrong. Please try again."); + setDeleteDialogOpen(false); + }, + }); + + if (isLoading || !pet) { + return ( +
+ + + + +
+ ); + } + + return ( +
+

Edit {pet.name}

+ + router.push(`/pets/${pet.id}`)} + /> + + {/* Destructive zone */} + +
+

Danger zone

+ +
+ + {/* Delete confirmation dialog */} + + + + + Delete pet profile? + + + This will permanently delete {pet.name}'s profile and all + their posts. This can't be undone. + + + + {/* Keep pet — cancel action */} + setDeleteDialogOpen(false)} + /> + } + > + Keep pet + + {/* Yes, delete — destructive confirm */} + + + + +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx new file mode 100644 index 0000000..099a2a5 --- /dev/null +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -0,0 +1,120 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { currentUser } from "@clerk/nextjs/server"; +import { createTRPCCaller } from "@/trpc/server"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; + +/** + * Pet profile view — /pets/[petId] + * + * Server component. Fetches the pet and the owner's Clerk profile. + * If the pet doesn't exist or doesn't belong to the current owner, renders 404. + * + * 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. + */ + +interface PageProps { + params: Promise<{ petId: string }>; +} + +function getInitials(name: string): string { + return name.slice(0, 2).toUpperCase(); +} + +export default async function PetProfilePage({ params }: PageProps) { + const { petId } = await params; + + const trpc = await createTRPCCaller(); + + let pet; + try { + pet = await trpc.pets.byId({ petId }); + } catch { + notFound(); + } + + if (!pet) { + notFound(); + } + + // Get owner's Clerk username for attribution (subordinate identity) + const user = await currentUser(); + const ownerUsername = + user?.username ?? + user?.firstName ?? + user?.emailAddresses?.[0]?.emailAddress?.split("@")[0] ?? + "owner"; + + return ( +
+ {/* Pet identity — pet-first, owner-subordinate */} +
+ {/* Avatar */} + + {pet.avatarKey && ( + + )} + + {getInitials(pet.name)} + + + + {/* Pet name (primary identity) */} +

{pet.name}

+ + {/* Species badge */} + {pet.species.name} + + {/* Owner attribution — muted caption, subordinate per UI-SPEC */} +

+ Managed by @{ownerUsername} +

+ + {/* Edit link — base-nova Button doesn't support asChild; use Link with button styles */} + + Edit profile + +
+ + {/* Bio section */} + {pet.bio && ( + <> + +
+

{pet.bio}

+
+ + )} + + {/* Adoption story section */} + {pet.adoptionStory && ( + <> + +
+

Adoption story

+

{pet.adoptionStory}

+
+ + )} + + {/* Posts placeholder — Phase 2 adds posts */} + +
+

Posts

+

+ No posts yet +

+
+
+ ); +} diff --git a/src/app/(app)/pets/page.tsx b/src/app/(app)/pets/page.tsx index 2904e66..c8f1116 100644 --- a/src/app/(app)/pets/page.tsx +++ b/src/app/(app)/pets/page.tsx @@ -1,38 +1,29 @@ +import Link from "next/link"; import { createTRPCCaller } from "@/trpc/server"; +import { buttonVariants } from "@/components/ui/button"; +import { PetGrid } from "./PetGrid"; /** * My Pets page — /pets - * Lists all pets belonging to the authenticated owner. * - * This is a skeleton page for Phase 1 walking skeleton. - * Full UI (grid, pet cards, add pet CTA) is implemented in Plan 02/03. + * Server component: prefetches pets.list, renders heading + Add Pet button. + * PetGrid is a client island for active pet selection via useActivePet(). */ export default async function PetsPage() { const trpc = await createTRPCCaller(); const pets = await trpc.pets.list(); return ( -
-

My Pets

- {pets.length === 0 ? ( -

- You haven't added any pets yet.{" "} - - Add your first pet - -

- ) : ( - - )} -
+
+
+

My Pets

+ {/* base-nova Button doesn't support asChild — use Link with button styles */} + + Add Pet + +
+ + +
); } diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx index 0ff8751..85bfd55 100644 --- a/src/app/onboarding/pet/page.tsx +++ b/src/app/onboarding/pet/page.tsx @@ -1,31 +1,34 @@ -import { auth } from "@clerk/nextjs/server"; -import { redirect } from "next/navigation"; +"use client"; + +import { useRouter } from "next/navigation"; +import { PetForm } from "@/components/pet/PetForm"; /** * Onboarding pet page — /onboarding/pet * - * Shown when an authenticated owner has no pets yet. - * Lives OUTSIDE the (app) layout group to avoid the redirect loop: + * Shown when an authenticated owner has no pets yet (redirected from app layout). + * Lives OUTSIDE the (app) layout group to avoid redirect loop: * app layout sees 0 pets → redirects here → this page has no pet check. * - * PetForm is wired in Plan 03. + * On successful pet creation, redirects to /pets/[petId] (the new pet's profile). + * + * This is a client component because onSuccess uses router.push. */ -export default async function OnboardingPetPage() { - const { userId } = await auth(); - if (!userId) redirect("/log-in"); +export default function OnboardingPetPage() { + const router = useRouter(); return (
-
+

Add your first pet

Your pet will be their own star on OnlyPets.

- {/* PetForm goes here — Plan 03 */} -
- Pet profile form — coming in Plan 03 -
+ router.push(`/pets/${petId}`)} + />
);