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:
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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{debouncedQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user