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)
This commit is contained in:
admin committed 2026-06-06 10:17:34 +02:00
1 parent 63d8c06b48
commit 95dfec7bdf
5 files changed
+372 -38

No files matched your search

+67
View File
@@ -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 (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed border-muted p-12 text-center">
<p className="text-base font-semibold">Add your first pet</p>
<p className="text-sm text-muted-foreground max-w-sm">
Give your pet their own profile on OnlyPets.
</p>
<Button
onClick={() => router.push("/onboarding/pet")}
className="mt-2"
>
Add Pet
</Button>
</div>
);
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{pets.map((pet) => (
<PetCard
key={pet.id}
pet={pet}
isActive={pet.id === activePetId}
onSelect={(id) => setActivePet(id)}
/>
))}
{/* Add pet card */}
<Link
href="/onboarding/pet"
className="flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted p-6 text-sm text-muted-foreground hover:border-muted-foreground/50 transition-colors min-h-[180px]"
>
<span className="text-2xl font-light">+</span>
<span>Add Pet</span>
</Link>
</div>
);
}
+153
View File
@@ -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 (
<div className="container max-w-[512px] mx-auto px-6 py-8 flex flex-col gap-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
return (
<div className="container max-w-[512px] mx-auto px-6 py-8">
<h1 className="text-xl font-semibold mb-6">Edit {pet.name}</h1>
<PetForm
mode="edit"
petId={pet.id}
initialValues={{
name: pet.name,
speciesId: pet.speciesId,
breedId: pet.breedId ?? null,
bio: pet.bio ?? null,
adoptionStory: pet.adoptionStory ?? null,
}}
onSuccess={() => router.push(`/pets/${pet.id}`)}
/>
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">Danger zone</p>
<Button
variant="ghost"
className="text-destructive hover:text-destructive w-fit"
onClick={() => setDeleteDialogOpen(true)}
>
Delete pet
</Button>
</div>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">
Delete pet profile?
</DialogTitle>
<DialogDescription>
This will permanently delete {pet.name}&apos;s profile and all
their posts. This can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
{/* Keep pet — cancel action */}
<DialogClose
render={
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
/>
}
>
Keep pet
</DialogClose>
{/* Yes, delete — destructive confirm */}
<Button
variant="destructive"
onClick={() => deleteMutation.mutate({ petId: pet.id })}
disabled={deleteMutation.isPending}
>
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+120
View File
@@ -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 (
<div className="container max-w-[640px] mx-auto px-6 py-8">
{/* Pet identity — pet-first, owner-subordinate */}
<div className="flex flex-col items-center gap-3 mb-8">
{/* Avatar */}
<Avatar className="w-32 h-32 text-2xl">
{pet.avatarKey && (
<AvatarImage
src={`/api/avatar/${pet.avatarKey}`}
alt={pet.name}
/>
)}
<AvatarFallback className="text-2xl font-semibold">
{getInitials(pet.name)}
</AvatarFallback>
</Avatar>
{/* Pet name (primary identity) */}
<h1 className="text-xl font-semibold text-center">{pet.name}</h1>
{/* Species badge */}
<Badge variant="secondary">{pet.species.name}</Badge>
{/* Owner attribution — muted caption, subordinate per UI-SPEC */}
<p className="text-xs text-muted-foreground">
Managed by @{ownerUsername}
</p>
{/* Edit link — base-nova Button doesn't support asChild; use Link with button styles */}
<Link
href={`/pets/${pet.id}/edit`}
className={buttonVariants({ variant: "outline" })}
>
Edit profile
</Link>
</div>
{/* Bio section */}
{pet.bio && (
<>
<Separator className="mb-6" />
<section className="mb-6">
<p className="text-sm leading-relaxed">{pet.bio}</p>
</section>
</>
)}
{/* Adoption story section */}
{pet.adoptionStory && (
<>
<Separator className="mb-6" />
<section className="mb-6">
<h2 className="text-base font-semibold mb-2">Adoption story</h2>
<p className="text-sm leading-relaxed">{pet.adoptionStory}</p>
</section>
</>
)}
{/* Posts placeholder — Phase 2 adds posts */}
<Separator className="mb-6" />
<section>
<h2 className="text-base font-semibold mb-4">Posts</h2>
<p className="text-sm text-muted-foreground text-center py-8">
No posts yet
</p>
</section>
</div>
);
}
+16 -25
View File
@@ -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 (
<main className="container max-w-2xl mx-auto px-6 py-8">
<h1 className="text-xl font-semibold mb-6">My Pets</h1>
{pets.length === 0 ? (
<p className="text-muted-foreground">
You haven&apos;t added any pets yet.{" "}
<a href="/onboarding/pet" className="text-primary hover:underline">
Add your first pet
</a>
</p>
) : (
<ul className="space-y-2">
{pets.map((pet) => (
<li key={pet.id} className="p-4 border rounded-lg">
<span className="font-semibold">{pet.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
{pet.species.name}
</span>
</li>
))}
</ul>
)}
</main>
<div className="container max-w-3xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">My Pets</h1>
{/* base-nova Button doesn't support asChild — use Link with button styles */}
<Link href="/onboarding/pet" className={buttonVariants({ variant: "default" })}>
Add Pet
</Link>
</div>
<PetGrid pets={pets} />
</div>
);
}
+16 -13
View File
@@ -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 (
<main className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-[512px]">
<div className="w-full max-w-[512px] p-8">
<h1 className="text-xl font-semibold mb-2">Add your first pet</h1>
<p className="text-sm text-muted-foreground mb-8">
Your pet will be their own star on OnlyPets.
</p>
{/* PetForm goes here — Plan 03 */}
<div className="border-2 border-dashed border-muted rounded-lg p-12 flex items-center justify-center text-sm text-muted-foreground">
Pet profile form — coming in Plan 03
</div>
<PetForm
mode="create"
onSuccess={(petId) => router.push(`/pets/${petId}`)}
/>
</div>
</main>
);