Skip to content

Commit

Permalink
improve search
Browse files Browse the repository at this point in the history
  • Loading branch information
RhysSullivan committed Oct 21, 2024
1 parent 5646a38 commit 11725ee
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 114 deletions.
84 changes: 84 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}[]
25 changes: 8 additions & 17 deletions src/components/search-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(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]);
Expand Down Expand Up @@ -84,11 +80,6 @@ export function SearchDropdownComponent() {
onKeyDown={handleKeyDown}
className="font-sans font-medium sm:w-[300px] md:w-[375px]"
/>
{isPending ? (
<Loader2 className="absolute right-2 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Search className="absolute right-2 top-2.5 h-4 w-4 text-muted-foreground" />
)}
<X
className={cn(
"absolute right-7 top-2 h-5 w-5 text-muted-foreground",
Expand All @@ -102,7 +93,7 @@ export function SearchDropdownComponent() {
}}
/>
</div>
{((isOpen && committedSearchTerm === searchTerm) || false) && (
{isOpen && (
<div className="absolute z-10 w-full border border-gray-200 bg-white shadow-lg">
<ScrollArea className="h-[300px]">
{filteredItems.length > 0 ? (
Expand Down
99 changes: 2 additions & 97 deletions src/lib/actions.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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;
}

0 comments on commit 11725ee

Please sign in to comment.