From 59a3be27b043124803371d298027ccf61565e4fa Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sat, 13 Jun 2026 12:09:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(03-05):=20Explore=20+=20pet=20search=20?= =?UTF-8?q?=E2=80=94=20routers=20GREEN=20+=20pages=20+=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../03-engagement-discovery/03-05-SUMMARY.md | 54 ++++++ src/app/(app)/explore/page.tsx | 161 ++++++++++++++++++ src/app/(app)/search/page.tsx | 149 ++++++++++++++++ src/components/explore/ExploreCard.tsx | 60 +++++++ src/components/layout/MobileNav.tsx | 32 ++-- src/components/layout/Sidebar.tsx | 6 +- src/trpc/routers/explore.ts | 27 ++- src/trpc/routers/search.ts | 32 +++- 8 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 .planning/phases/03-engagement-discovery/03-05-SUMMARY.md create mode 100644 src/app/(app)/explore/page.tsx create mode 100644 src/app/(app)/search/page.tsx create mode 100644 src/components/explore/ExploreCard.tsx diff --git a/.planning/phases/03-engagement-discovery/03-05-SUMMARY.md b/.planning/phases/03-engagement-discovery/03-05-SUMMARY.md new file mode 100644 index 0000000..0eddd75 --- /dev/null +++ b/.planning/phases/03-engagement-discovery/03-05-SUMMARY.md @@ -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 `` with active orange state +- Notifications placeholder → 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 diff --git a/src/app/(app)/explore/page.tsx b/src/app/(app)/explore/page.tsx new file mode 100644 index 0000000..c445894 --- /dev/null +++ b/src/app/(app)/explore/page.tsx @@ -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(null); + const [activeBreedId, setActiveBreedId] = useState(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 ( +
+ +
+ ); + } + + return ( +
+

Explore

+ + {/* Species tabs */} + handleSpeciesChange(v === "all" ? null : v)} + className="mb-4" + > + + All + {(speciesList ?? []).map((s) => ( + + {s.name} + + ))} + + + + {/* Breed filter — only visible when a species is selected */} + {activeSpeciesId && breeds.length > 0 && ( +
+ +
+ )} + + {/* Pet grid */} + {isLoading ? ( +
+ +
+ ) : pets.length === 0 ? ( +

+ No{" "} + {activeBreedId + ? breeds.find((b) => b.id === activeBreedId)?.name ?? "breed" + : activeSpeciesId + ? (speciesList ?? []).find((s) => s.id === activeSpeciesId)?.name ?? "species" + : ""} + {" "}pets yet +

+ ) : ( + <> +
+ {pets.map((pet) => ( + [0]["pet"]} + activePetId={activePetId ?? ""} + /> + ))} +
+ + {hasNextPage && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx new file mode 100644 index 0000000..5b0c148 --- /dev/null +++ b/src/app/(app)/search/page.tsx @@ -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(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + const timeoutRef = useState | 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) => { + 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 ( +
+

Search

+ + {/* Search input */} +
+
+ + + + Pets + Tags + + + {/* Pets tab */} + + {!enabled ? ( +

+ Type at least 2 characters to search +

+ ) : loadingPets ? ( +
+ +
+ ) : pets.length === 0 ? ( +

+ No pets found for “{debouncedQuery}” +

+ ) : ( +
+ {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 ( +
+ + {avatarUrl && } + + {p.name[0]?.toUpperCase() ?? "?"} + + +
+

{p.name}

+ + {p.breed?.name ?? p.species.name} + +
+ {activePetId && activePetId !== p.id && ( + + )} +
+ ); + })} +
+ )} +
+ + {/* Tags tab — implemented in Plan 03-06 */} + +

+ Hashtag search coming soon +

+
+
+
+ ); +} diff --git a/src/components/explore/ExploreCard.tsx b/src/components/explore/ExploreCard.tsx new file mode 100644 index 0000000..4e7305d --- /dev/null +++ b/src/components/explore/ExploreCard.tsx @@ -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 ( +
+ + {avatarUrl && } + + {pet.name[0]?.toUpperCase() ?? "?"} + + + +

+ {pet.name} +

+ + + {labelName} + + +

+ {followerCount === 1 ? "1 follower" : `${followerCount} followers`} +

+ + {isOwnPet ? ( +

Your pet

+ ) : ( + + )} +
+ ); +} diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx index d1701ce..1b923bc 100644 --- a/src/components/layout/MobileNav.tsx +++ b/src/components/layout/MobileNav.tsx @@ -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 (
@@ -147,14 +149,16 @@ export function MobileNav({ pets }: MobileNavProps) { Home - {/* Explore — placeholder, Phase 1 */} - + {/* Post — center, accent-filled. D-01: opens PostTypeSheet */} - {/* Notifications — placeholder, Phase 1 */} - - +