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:
1 parent
63d8c06b48
commit
95dfec7bdf
5 files changed
+372
-38
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}'s profile and all
|
||||
their posts. This can'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user