diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..f14b3e6 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,89 @@ +import { db } from "@/db"; +import { + categories, + products, + subcategories, + subcollection, +} from "@/db/schema"; +import { sql } from "drizzle-orm"; +import { NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + // format is /api/search?q=term + const searchTerm = request.nextUrl.searchParams.get("q"); + if (!searchTerm || !searchTerm.length) { + return Response.json([]); + } + + let results; + + if (searchTerm.length <= 2) { + // If the search term is short (e.g., "W"), use ILIKE for prefix matching + results = await db + .select() + .from(products) + .where(sql`${products.name} ILIKE ${searchTerm + "%"}`) // Prefix match + .limit(5) + .innerJoin( + subcategories, + sql`${products.subcategory_slug} = ${subcategories.slug}`, + ) + .innerJoin( + subcollection, + sql`${subcategories.subcollection_id} = ${subcollection.id}`, + ) + .innerJoin( + categories, + sql`${subcollection.category_slug} = ${categories.slug}`, + ); + } else { + // For longer search terms, use full-text search with tsquery + const formattedSearchTerm = searchTerm + .split(" ") + .filter((term) => term.trim() !== "") // Filter out empty terms + .map((term) => `${term}:*`) + .join(" & "); + + results = await db + .select() + .from(products) + .where( + sql`to_tsvector('english', ${products.name}) @@ to_tsquery('english', ${formattedSearchTerm})`, + ) + .limit(5) + .innerJoin( + subcategories, + sql`${products.subcategory_slug} = ${subcategories.slug}`, + ) + .innerJoin( + subcollection, + sql`${subcategories.subcollection_id} = ${subcollection.id}`, + ) + .innerJoin( + categories, + sql`${subcollection.category_slug} = ${categories.slug}`, + ); + } + + const searchResults: ProductSearchResult = results.map((item) => { + const href = `/products/${item.categories.slug}/${item.subcategories.slug}/${item.products.slug}`; + return { + ...item.products, + href, + }; + }); + const response = Response.json(searchResults); + // cache for 10 minutes + response.headers.set("Cache-Control", "public, max-age=600"); + return response; +} + +export type ProductSearchResult = { + href: string; + name: string; + slug: string; + image_url: string | null; + description: string; + price: string; + subcategory_slug: string; +}[]; diff --git a/src/components/search-dropdown.tsx b/src/components/search-dropdown.tsx index 7916852..8b3ad77 100644 --- a/src/components/search-dropdown.tsx +++ b/src/components/search-dropdown.tsx @@ -1,44 +1,48 @@ "use client"; -import { useEffect, useState, useRef, useTransition } from "react"; +import { useEffect, useState, useRef } from "react"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { Search, X, Loader2 } from "lucide-react"; +import { X } from "lucide-react"; import Image from "next/image"; import { cn } from "@/lib/utils"; import { Product } from "../db/schema"; -import { searchProducts } from "../lib/actions"; import { Link } from "@/components/ui/link"; import { useParams, useRouter } from "next/navigation"; +import { ProductSearchResult } from "@/app/api/search/route"; type SearchResult = Product & { href: string }; export function SearchDropdownComponent() { const [searchTerm, setSearchTerm] = useState(""); - const [committedSearchTerm, setCommittedSearchTerm] = useState(""); const [filteredItems, setFilteredItems] = useState([]); const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); - const [isPending, startTransition] = useTransition(); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); const inputRef = useRef(null); + // we don't need react query, we have react query at home + // react query at home: useEffect(() => { if (searchTerm.length === 0) { setFilteredItems([]); - setCommittedSearchTerm(""); } else { - const currentSearchTerm = searchTerm; - startTransition(() => { - searchProducts(currentSearchTerm).then((results) => { - if (currentSearchTerm === searchTerm) { - setFilteredItems(results); - setCommittedSearchTerm(currentSearchTerm); - } - }); + setIsLoading(true); + + const searchedFor = searchTerm; + fetch(`/api/search?q=${searchTerm}`).then(async (results) => { + const currentSearchTerm = inputRef.current?.value; + if (currentSearchTerm !== searchedFor) { + return; + } + const json = await results.json(); + setIsLoading(false); + setFilteredItems(json as ProductSearchResult); }); } - }, [searchTerm]); + }, [searchTerm, inputRef]); const params = useParams(); useEffect(() => { @@ -84,11 +88,6 @@ export function SearchDropdownComponent() { onKeyDown={handleKeyDown} className="font-sans font-medium sm:w-[300px] md:w-[375px]" /> - {isPending ? ( - - ) : ( - - )} - {((isOpen && committedSearchTerm === searchTerm) || false) && ( + {isOpen && (
{filteredItems.length > 0 ? ( @@ -133,6 +132,10 @@ export function SearchDropdownComponent() {
)) + ) : isLoading ? ( +
+

Loading...

+
) : (

No results found

diff --git a/src/lib/actions.ts b/src/lib/actions.ts index c6830b3..b569a13 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -1,16 +1,7 @@ "use server"; -import { sql } from "drizzle-orm"; -import { db } from "../db"; -import { - categories, - products, - subcategories, - subcollection, -} from "../db/schema"; + import { getCart, updateCart } from "./cart"; -import { kv } from "@vercel/kv"; -import { z } from "zod"; -import { waitUntil } from "@vercel/functions"; + export async function addToCart(prevState: unknown, formData: FormData) { const prevCart = await getCart(); const productSlug = formData.get("productSlug"); @@ -61,89 +52,3 @@ export async function removeFromCart(formData: FormData) { const newCart = prevCart.filter((item) => item.productSlug !== productSlug); await updateCart(newCart); } - -const searchResultSchema = z.array( - z.object({ - href: z.string(), - name: z.string(), - slug: z.string(), - image_url: z.string().nullable(), - description: z.string(), - price: z.string(), - subcategory_slug: z.string(), - }), -); - -export async function searchProducts(searchTerm: string) { - const kvKey = `search:${searchTerm}`; - - const rawCachedResults = await kv.get(kvKey); - const parsedCachedResults = searchResultSchema.safeParse(rawCachedResults); - if (parsedCachedResults.success) { - return parsedCachedResults.data; - } - - // cache miss, run the search - - let results; - - if (searchTerm.length <= 2) { - // If the search term is short (e.g., "W"), use ILIKE for prefix matching - results = await db - .select() - .from(products) - .where(sql`${products.name} ILIKE ${searchTerm + "%"}`) // Prefix match - .limit(5) - .innerJoin( - subcategories, - sql`${products.subcategory_slug} = ${subcategories.slug}`, - ) - .innerJoin( - subcollection, - sql`${subcategories.subcollection_id} = ${subcollection.id}`, - ) - .innerJoin( - categories, - sql`${subcollection.category_slug} = ${categories.slug}`, - ); - } else { - // For longer search terms, use full-text search with tsquery - const formattedSearchTerm = searchTerm - .split(" ") - .filter((term) => term.trim() !== "") // Filter out empty terms - .map((term) => `${term}:*`) - .join(" & "); - - results = await db - .select() - .from(products) - .where( - sql`to_tsvector('english', ${products.name}) @@ to_tsquery('english', ${formattedSearchTerm})`, - ) - .limit(5) - .innerJoin( - subcategories, - sql`${products.subcategory_slug} = ${subcategories.slug}`, - ) - .innerJoin( - subcollection, - sql`${subcategories.subcollection_id} = ${subcollection.id}`, - ) - .innerJoin( - categories, - sql`${subcollection.category_slug} = ${categories.slug}`, - ); - } - - const searchResults = results.map((item) => { - const href = `/products/${item.categories.slug}/${item.subcategories.slug}/${item.products.slug}`; - return { - ...item.products, - href, - }; - }); - - waitUntil(kv.set(kvKey, searchResults, { ex: 60 * 60 })); // 1 hour - - return searchResults; -}