Skip to content

Commit

Permalink
Merge pull request #22 from ethanniser/improve-search
Browse files Browse the repository at this point in the history
improve search
  • Loading branch information
RhysSullivan authored Oct 21, 2024
2 parents 5646a38 + 0da4272 commit 68b11e5
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 118 deletions.
89 changes: 89 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}[];
45 changes: 24 additions & 21 deletions src/components/search-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResult[]>([]);
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<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);
}
});
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(() => {
Expand Down Expand Up @@ -84,11 +88,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 +101,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 Expand Up @@ -133,6 +132,10 @@ export function SearchDropdownComponent() {
</div>
</Link>
))
) : isLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-500">Loading...</p>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-gray-500">No results found</p>
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 68b11e5

Please sign in to comment.