feat(03-05): Explore + pet search — routers GREEN + pages + nav

- explore.listPets: publicProcedure, speciesId/breedId filter, cursor pagination
- search.pets: protectedProcedure, ILIKE name+breed OR, cursor pagination
- ExploreCard: avatar, breed badge, follower count, FollowButton
- /explore: species tabs + breed filter Select + infinite pet grid
- /search: debounced input, Pets tab with FollowButton rows, Tags stub
- Sidebar: Explore activated, Search added
- MobileNav: Explore activated, Notifications placeholder → Search

Tests: explore 3/3 GREEN, search.pets 2/2 GREEN; 65 total passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-13 12:09:24 +02:00
1 parent 8507fe9489
commit 59a3be27b0
8 files changed
+496 -25

No files matched your search

@@ -0,0 +1,54 @@
---
phase: 03-engagement-discovery
plan: 05
status: complete
completed: 2026-06-13
---
# Plan 03-05 Summary — Explore + Pet Search
## What Was Built
**explore.ts router (GREEN):**
- `listPets` (publicProcedure): speciesId?, breedId?, limit, cursor? (petId cursor)
- Prisma: `pet.findMany` with spread filters + `limit+1` for pagination
- Includes: `species`, `breed`, `_count: { followers }`
- Returns `{ pets, nextCursor }`
**search.ts — `pets` procedure (GREEN):**
- `protectedProcedure`, query: min 1, max 100 chars
- Prisma OR: `name ilike` + `breed.name ilike` (both case-insensitive)
- Same cursor + limit pattern
- `byHashtag` stays NOT_IMPLEMENTED stub for Plan 03-06
**ExploreCard.tsx:**
- Avatar 56px, pet name (truncated), breed/species Badge, follower count
- `FollowButton` when not own pet; "Your pet" muted label when own
**/explore/page.tsx:**
- Species tabs via `trpc.pets.listSpecies` (All + per species)
- Breed `Select` filter visible only when non-All species active; cleared on species switch
- `useInfiniteQuery(trpc.explore.listPets)` — ExploreCard grid `grid-cols-3 sm:grid-cols-4`
- "Load more" Button when `hasNextPage`
- Empty state per UI-SPEC
**/search/page.tsx:**
- Debounced (300ms) input, enabled when query ≥ 2 chars
- Tabs: "Pets" | "Tags" (Tags stub → Plan 03-06)
- Pets tab: `useQuery(trpc.search.pets)` → PetSearchResultItem rows with Avatar + name + breed Badge + FollowButton
- Empty/loading states per UI-SPEC
**Sidebar.tsx:**
- Explore (Compass) activated as real Link
- Search (Search icon) added as new active Link after Explore
**MobileNav.tsx:**
- Explore span (aria-disabled) → real `<Link href="/explore">` with active orange state
- Notifications placeholder → Search `<Link href="/search">` with active orange state
## Test Results
- explore.test.ts: 3/3 GREEN
- search.test.ts: 2/2 pets GREEN; 2/2 byHashtag still RED (planned stub, 03-06)
- Full suite: 65 passing, 2 failing RED (search.byHashtag)
- `npx tsc --noEmit`: clean
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { ExploreCard } from "@/components/explore/ExploreCard";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
export default function ExplorePage() {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const [activeSpeciesId, setActiveSpeciesId] = useState<string | null>(null);
const [activeBreedId, setActiveBreedId] = useState<string | null>(null);
const { data: speciesList, isLoading: loadingSpecies } = useQuery(
trpc.pets.listSpecies.queryOptions()
);
const { data: breedData } = useQuery(
trpc.pets.listBreeds.queryOptions(
{ speciesId: activeSpeciesId ?? "" },
{ enabled: !!activeSpeciesId }
)
);
const breeds = breedData ?? [];
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery(
trpc.explore.listPets.infiniteQueryOptions(
{
limit: 24,
...(activeSpeciesId ? { speciesId: activeSpeciesId } : {}),
...(activeBreedId ? { breedId: activeBreedId } : {}),
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialCursor: undefined,
}
)
);
const pets = data?.pages.flatMap((p) => p.pets) ?? [];
function handleSpeciesChange(speciesId: string | null) {
setActiveSpeciesId(speciesId);
setActiveBreedId(null); // reset breed when switching species
}
if (loadingSpecies) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Explore</h1>
{/* Species tabs */}
<Tabs
value={activeSpeciesId ?? "all"}
onValueChange={(v) => handleSpeciesChange(v === "all" ? null : v)}
className="mb-4"
>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
{(speciesList ?? []).map((s) => (
<TabsTrigger key={s.id} value={s.id}>
{s.name}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{/* Breed filter — only visible when a species is selected */}
{activeSpeciesId && breeds.length > 0 && (
<div className="mb-4">
<Select
value={activeBreedId ?? "all"}
onValueChange={(v) => setActiveBreedId(v === "all" ? null : v)}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All breeds" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All breeds</SelectItem>
{breeds.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Pet grid */}
{isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No{" "}
{activeBreedId
? breeds.find((b) => b.id === activeBreedId)?.name ?? "breed"
: activeSpeciesId
? (speciesList ?? []).find((s) => s.id === activeSpeciesId)?.name ?? "species"
: ""}
{" "}pets yet
</p>
) : (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{pets.map((pet) => (
<ExploreCard
key={pet.id}
pet={pet as Parameters<typeof ExploreCard>[0]["pet"]}
activePetId={activePetId ?? ""}
/>
))}
</div>
{hasNextPage && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-muted"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Load more
</Button>
</div>
)}
</>
)}
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search, Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FollowButton } from "@/components/social/FollowButton";
import { getAvatarUrl } from "@/lib/avatar-url";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useState<ReturnType<typeof setTimeout> | null>(null);
const update = useCallback(
(v: T) => {
if (timeoutRef[0]) clearTimeout(timeoutRef[0]);
timeoutRef[1](setTimeout(() => setDebounced(v), delay));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[delay]
);
// Update when value changes
useState(() => {
update(value);
});
return debounced;
}
export default function SearchPage() {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
// Simple debounce via timeout
const handleQueryChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
const timer = setTimeout(() => setDebouncedQuery(val), 300);
return () => clearTimeout(timer);
}, []);
const enabled = debouncedQuery.trim().length >= 2;
const { data: petResults, isLoading: loadingPets } = useQuery(
trpc.search.pets.queryOptions(
{ query: debouncedQuery.trim() },
{ enabled }
)
);
const pets = petResults?.pets ?? [];
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Search</h1>
{/* Search input */}
<div className="relative mb-4">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Input
placeholder="Search pets or hashtags…"
value={query}
onChange={handleQueryChange}
className="pl-9"
autoFocus
/>
</div>
<Tabs defaultValue="pets">
<TabsList className="mb-4">
<TabsTrigger value="pets">Pets</TabsTrigger>
<TabsTrigger value="tags">Tags</TabsTrigger>
</TabsList>
{/* Pets tab */}
<TabsContent value="pets">
{!enabled ? (
<p className="text-sm text-muted-foreground text-center py-10">
Type at least 2 characters to search
</p>
) : loadingPets ? (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No pets found for &ldquo;{debouncedQuery}&rdquo;
</p>
) : (
<div className="space-y-2">
{pets.map((pet) => {
const p = pet as {
id: string;
name: string;
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
_count: { followers: number };
};
const avatarUrl = getAvatarUrl(p.avatarKey);
return (
<div
key={p.id}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors"
>
<Avatar className="w-10 h-10 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={p.name} />}
<AvatarFallback className="text-sm bg-muted">
{p.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{p.name}</p>
<Badge variant="secondary" className="text-xs mt-0.5">
{p.breed?.name ?? p.species.name}
</Badge>
</div>
{activePetId && activePetId !== p.id && (
<FollowButton
followerPetId={activePetId}
followeePetId={p.id}
followeeName={p.name}
/>
)}
</div>
);
})}
</div>
)}
</TabsContent>
{/* Tags tab — implemented in Plan 03-06 */}
<TabsContent value="tags">
<p className="text-sm text-muted-foreground text-center py-10">
Hashtag search coming soon
</p>
</TabsContent>
</Tabs>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { FollowButton } from "@/components/social/FollowButton";
import { getAvatarUrl } from "@/lib/avatar-url";
interface ExploreCardPet {
id: string;
name: string;
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
_count: { followers: number };
}
interface ExploreCardProps {
pet: ExploreCardPet;
activePetId: string;
}
export function ExploreCard({ pet, activePetId }: ExploreCardProps) {
const avatarUrl = getAvatarUrl(pet.avatarKey);
const isOwnPet = pet.id === activePetId;
const labelName = pet.breed?.name ?? pet.species.name;
const followerCount = pet._count.followers;
return (
<div className="flex flex-col items-center gap-2 p-3 rounded-xl border bg-card hover:bg-muted/50 transition-colors">
<Avatar className="w-14 h-14">
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
<AvatarFallback className="text-sm bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<p className="text-sm font-medium text-center truncate max-w-full">
{pet.name}
</p>
<Badge variant="secondary" className="text-xs">
{labelName}
</Badge>
<p className="text-xs text-muted-foreground">
{followerCount === 1 ? "1 follower" : `${followerCount} followers`}
</p>
{isOwnPet ? (
<p className="text-xs text-muted-foreground">Your pet</p>
) : (
<FollowButton
followerPetId={activePetId}
followeePetId={pet.id}
followeeName={pet.name}
/>
)}
</div>
);
}
+19 -13
View File
@@ -6,8 +6,8 @@ import { usePathname, useRouter } from "next/navigation";
import {
Home,
Compass,
Search,
Plus,
Bell,
User,
ChevronDown,
} from "lucide-react";
@@ -61,6 +61,8 @@ export function MobileNav({ pets }: MobileNavProps) {
// D-05: Home → /feed; also highlight when on / (which redirects to /feed)
const isHomePage = pathname === "/feed" || pathname === "/";
const isPetsPage = pathname.startsWith("/pets");
const isExplorePage = pathname.startsWith("/explore");
const isSearchPage = pathname.startsWith("/search");
return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
@@ -147,14 +149,16 @@ export function MobileNav({ pets }: MobileNavProps) {
<span>Home</span>
</Link>
{/* Explore — placeholder, Phase 1 */}
<span
aria-disabled="true"
className="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-muted-foreground/50 cursor-not-allowed"
{/* Explore */}
<Link
href="/explore"
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
isExplorePage ? "text-orange-500" : "text-muted-foreground"
}`}
>
<Compass className="h-5 w-5" aria-hidden="true" />
<span>Explore</span>
</span>
</Link>
{/* Post — center, accent-filled. D-01: opens PostTypeSheet */}
<button
@@ -168,14 +172,16 @@ export function MobileNav({ pets }: MobileNavProps) {
</span>
</button>
{/* Notifications — placeholder, Phase 1 */}
<span
aria-disabled="true"
className="flex flex-col items-center gap-0.5 px-3 py-2 text-xs text-muted-foreground/50 cursor-not-allowed"
{/* Search */}
<Link
href="/search"
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
isSearchPage ? "text-orange-500" : "text-muted-foreground"
}`}
>
<Bell className="h-5 w-5" aria-hidden="true" />
<span>Alerts</span>
</span>
<Search className="h-5 w-5" aria-hidden="true" />
<span>Search</span>
</Link>
{/* Profile (My Pets) */}
<Link
+4 -2
View File
@@ -7,6 +7,7 @@ import {
Home,
Heart,
Compass,
Search,
Bell,
MessageCircle,
PawPrint,
@@ -39,9 +40,10 @@ type SidebarProps = {
};
const navLinks = [
{ href: "/feed", label: "Home", icon: Home, active: true }, // D-05: Home → /feed
{ href: "/feed", label: "Home", icon: Home, active: true },
{ href: "/pets", label: "My Pets", icon: Heart, active: true },
{ href: "/explore", label: "Explore", icon: Compass, active: false },
{ href: "/explore", label: "Explore", icon: Compass, active: true },
{ href: "/search", label: "Search", icon: Search, active: true },
{ href: "/notifications", label: "Notifications", icon: Bell, active: false },
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false },
];
+23 -4
View File
@@ -1,6 +1,5 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, publicProcedure } from "@/trpc/init";
export const exploreRouter = router({
@@ -10,10 +9,30 @@ export const exploreRouter = router({
speciesId: z.string().optional(),
breedId: z.string().optional(),
limit: z.number().int().min(1).max(40).default(20),
cursor: z.string().optional(),
cursor: z.string().optional(), // petId cursor
})
)
.query(async (): Promise<{ pets: Record<string, unknown>[]; nextCursor: string | null }> => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.query(async ({ ctx, input }) => {
const pets = await ctx.prisma.pet.findMany({
where: {
...(input.speciesId ? { speciesId: input.speciesId } : {}),
...(input.breedId ? { breedId: input.breedId } : {}),
...(input.cursor ? { id: { gt: input.cursor } } : {}),
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
include: {
species: true,
breed: true,
_count: { select: { followers: true } },
},
});
const hasNextPage = pets.length > input.limit;
const items = hasNextPage ? pets.slice(0, input.limit) : pets;
return {
pets: items,
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
};
}),
});
+26 -6
View File
@@ -9,14 +9,34 @@ export const searchRouter = router({
z.object({
query: z.string().min(1).max(100),
limit: z.number().int().min(1).max(40).default(20),
cursor: z.string().optional(),
cursor: z.string().optional(), // petId cursor
})
)
.query(
async (): Promise<{ pets: Record<string, unknown>[]; nextCursor: string | null }> => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
}
),
.query(async ({ ctx, input }) => {
const pets = await ctx.prisma.pet.findMany({
where: {
OR: [
{ name: { contains: input.query, mode: "insensitive" } },
{ breed: { name: { contains: input.query, mode: "insensitive" } } },
],
...(input.cursor ? { id: { gt: input.cursor } } : {}),
},
orderBy: { name: "asc" },
take: input.limit + 1,
include: {
species: true,
breed: true,
_count: { select: { followers: true } },
},
});
const hasNextPage = pets.length > input.limit;
const items = hasNextPage ? pets.slice(0, input.limit) : pets;
return {
pets: items,
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
};
}),
byHashtag: protectedProcedure
.input(