) => {
+ 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 */}
-
Explore
-
+
{/* Post — center, accent-filled. D-01: opens PostTypeSheet */}
- {/* Notifications — placeholder, Phase 1 */}
-
-
- Alerts
-
+
+ Search
+
{/* Profile (My Pets) */}
[]; 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,
+ };
}),
});
diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts
index cdac4c6..e57bfaa 100644
--- a/src/trpc/routers/search.ts
+++ b/src/trpc/routers/search.ts
@@ -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[]; 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(