From 11725ee6ecceebb76c1d11871ec9a6b2b5b0de77 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 20 Oct 2024 18:13:56 -0700 Subject: [PATCH 1/3] improve search --- src/app/api/search/route.ts | 84 +++++++++++++++++++++++++ src/components/search-dropdown.tsx | 25 +++----- src/lib/actions.ts | 99 +----------------------------- 3 files changed, 94 insertions(+), 114 deletions(-) create mode 100644 src/app/api/search/route.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..00037f2 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,84 @@ +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) { + return []; + } + + 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; +}[] \ No newline at end of file diff --git a/src/components/search-dropdown.tsx b/src/components/search-dropdown.tsx index 7916852..77e15f6 100644 --- a/src/components/search-dropdown.tsx +++ b/src/components/search-dropdown.tsx @@ -7,35 +7,31 @@ import { Search, X, Loader2 } 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 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); - } - }); + fetch(`/api/search?q=${currentSearchTerm}`).then(async (results) => { + const json = await results.json(); + + setFilteredItems(json as ProductSearchResult); }); } }, [searchTerm]); @@ -84,11 +80,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 ? ( 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; -} From 814f1a11acfa95419e1c62f17f973bf3087c5b0b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 20 Oct 2024 18:39:26 -0700 Subject: [PATCH 2/3] fix search issue --- src/app/api/search/route.ts | 6 +++--- src/components/search-dropdown.tsx | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 00037f2..e4d2f83 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -5,9 +5,9 @@ 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) { - return []; + const searchTerm = request.nextUrl.searchParams.get("q") + if (!searchTerm || !searchTerm.length) { + return Response.json([]); } let results; diff --git a/src/components/search-dropdown.tsx b/src/components/search-dropdown.tsx index 77e15f6..33a5f2b 100644 --- a/src/components/search-dropdown.tsx +++ b/src/components/search-dropdown.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useRef, useTransition } 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"; @@ -18,6 +18,8 @@ export function SearchDropdownComponent() { const [filteredItems, setFilteredItems] = useState([]); const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); const inputRef = useRef(null); @@ -27,14 +29,20 @@ export function SearchDropdownComponent() { if (searchTerm.length === 0) { setFilteredItems([]); } else { - const currentSearchTerm = searchTerm; - fetch(`/api/search?q=${currentSearchTerm}`).then(async (results) => { - const json = await results.json(); + 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(() => { @@ -124,6 +132,10 @@ export function SearchDropdownComponent() {
)) + ) : isLoading ? ( +
+

Loading...

+
) : (

No results found

From 0da4272cccded0601ed7809a06485abe978e30a6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 20 Oct 2024 18:45:57 -0700 Subject: [PATCH 3/3] fmlint --- src/app/api/search/route.ts | 161 +++++++++++++++-------------- src/components/search-dropdown.tsx | 2 +- 2 files changed, 84 insertions(+), 79 deletions(-) diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index e4d2f83..f14b3e6 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,84 +1,89 @@ import { db } from "@/db"; -import { categories, products, subcategories, subcollection } from "@/db/schema"; +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; + // format is /api/search?q=term + const searchTerm = request.nextUrl.searchParams.get("q"); + if (!searchTerm || !searchTerm.length) { + return Response.json([]); } - - export type ProductSearchResult = { - href: string; - name: string; - slug: string; - image_url: string | null; - description: string; - price: string; - subcategory_slug: string; -}[] \ No newline at end of file + + 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 33a5f2b..8b3ad77 100644 --- a/src/components/search-dropdown.tsx +++ b/src/components/search-dropdown.tsx @@ -1,6 +1,6 @@ "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 { X } from "lucide-react";