);
}
+
+function SearchTermFromParams({
+ setSearchTerm,
+}: {
+ setSearchTerm: (term: string) => void;
+}) {
+ const params = useParams();
+ useEffect(() => {
+ if (!params.product) {
+ const subcategory = params.subcategory;
+ setSearchTerm(
+ typeof subcategory === "string" ? subcategory.replaceAll("-", " ") : "",
+ );
+ }
+ }, [params, setSearchTerm]);
+
+ return null;
+}
diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx
index b358c424..335890d9 100644
--- a/src/components/ui/link.tsx
+++ b/src/components/ui/link.tsx
@@ -80,30 +80,37 @@ export const Link: typeof NextLink = (({ children, ...props }) => {
};
}, [props.href, props.prefetch]);
+ const handledRef = useRef(false);
+
return (
{
+ handledRef.current = false;
+
+ // Only left-button mouse. Ignore touch/pen (scroll), and ignore keyboard.
+ if (e.pointerType !== "mouse") return;
+ if (e.button !== 0) return;
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
+
+ const url = new URL(String(props.href), window.location.href);
+ if (url.origin !== window.location.origin) return;
+
+ e.preventDefault(); // cancel native navigation + focus quirks
+ handledRef.current = true;
+ router.push(String(props.href)); // earliest safe point for mouse
+ }}
+ onClick={(e) => {
+ // Only cancel default if we *actually* handled it on pointerdown.
+ if (!handledRef.current) return;
+ handledRef.current = false;
+ e.preventDefault();
+ }}
onMouseEnter={() => {
router.prefetch(String(props.href));
const images = imageCache.get(String(props.href)) || [];
- for (const image of images) {
- prefetchImage(image);
- }
- }}
- onMouseDown={(e) => {
- const url = new URL(String(props.href), window.location.href);
- if (
- url.origin === window.location.origin &&
- e.button === 0 &&
- !e.altKey &&
- !e.ctrlKey &&
- !e.metaKey &&
- !e.shiftKey
- ) {
- e.preventDefault();
- router.push(String(props.href));
- }
+ for (const image of images) prefetchImage(image);
}}
{...props}
>
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 01a4a4fb..b997d21c 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -9,7 +9,7 @@ import {
} from "@/db/schema";
import { db } from "@/db";
import { eq, and, count } from "drizzle-orm";
-import { unstable_cache } from "./unstable-cache";
+import { cacheTag, cacheLife } from "next/cache";
import { sql } from "drizzle-orm";
export async function getUser() {
@@ -44,186 +44,175 @@ export async function getUser() {
return user[0];
}
-export const getProductsForSubcategory = unstable_cache(
- (subcategorySlug: string) =>
- db.query.products.findMany({
- where: (products, { eq, and }) =>
- and(eq(products.subcategory_slug, subcategorySlug)),
- orderBy: (products, { asc }) => asc(products.slug),
- }),
- ["subcategory-products"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getCollections = unstable_cache(
- () =>
- db.query.collections.findMany({
- with: {
- categories: true,
- },
- orderBy: (collections, { asc }) => asc(collections.name),
- }),
- ["collections"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getProductDetails = unstable_cache(
- (productSlug: string) =>
- db.query.products.findFirst({
- where: (products, { eq }) => eq(products.slug, productSlug),
- }),
- ["product"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getSubcategory = unstable_cache(
- (subcategorySlug: string) =>
- db.query.subcategories.findFirst({
- where: (subcategories, { eq }) => eq(subcategories.slug, subcategorySlug),
- }),
- ["subcategory"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getCategory = unstable_cache(
- (categorySlug: string) =>
- db.query.categories.findFirst({
- where: (categories, { eq }) => eq(categories.slug, categorySlug),
- with: {
- subcollections: {
- with: {
- subcategories: true,
- },
+export async function getProductsForSubcategory(subcategorySlug: string) {
+ "use cache: remote";
+ cacheTag("subcategory-products");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.products.findMany({
+ where: (products, { eq, and }) =>
+ and(eq(products.subcategory_slug, subcategorySlug)),
+ orderBy: (products, { asc }) => asc(products.slug),
+ });
+}
+
+export async function getCollections() {
+ "use cache: remote";
+ cacheTag("collections");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.collections.findMany({
+ with: {
+ categories: true,
+ },
+ orderBy: (collections, { asc }) => asc(collections.name),
+ });
+}
+
+export async function getProductDetails(productSlug: string) {
+ "use cache: remote";
+ cacheTag("product");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.products.findFirst({
+ where: (products, { eq }) => eq(products.slug, productSlug),
+ });
+}
+
+export async function getSubcategory(subcategorySlug: string) {
+ "use cache: remote";
+ cacheTag("subcategory");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.subcategories.findFirst({
+ where: (subcategories, { eq }) => eq(subcategories.slug, subcategorySlug),
+ });
+}
+
+export async function getCategory(categorySlug: string) {
+ "use cache: remote";
+ cacheTag("category");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.categories.findFirst({
+ where: (categories, { eq }) => eq(categories.slug, categorySlug),
+ with: {
+ subcollections: {
+ with: {
+ subcategories: true,
},
},
- }),
- ["category"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getCollectionDetails = unstable_cache(
- async (collectionSlug: string) =>
- db.query.collections.findMany({
- with: {
- categories: true,
- },
- where: (collections, { eq }) => eq(collections.slug, collectionSlug),
- orderBy: (collections, { asc }) => asc(collections.slug),
- }),
- ["collection"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getProductCount = unstable_cache(
- () => db.select({ count: count() }).from(products),
- ["total-product-count"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
+ },
+ });
+}
+
+export async function getCollectionDetails(collectionSlug: string) {
+ "use cache: remote";
+ cacheTag("collection");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.query.collections.findMany({
+ with: {
+ categories: true,
+ },
+ where: (collections, { eq }) => eq(collections.slug, collectionSlug),
+ orderBy: (collections, { asc }) => asc(collections.slug),
+ });
+}
+
+export async function getProductCount() {
+ "use cache: remote";
+ cacheTag("total-product-count");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db.select({ count: count() }).from(products);
+}
// could be optimized by storing category slug on the products table
-export const getCategoryProductCount = unstable_cache(
- (categorySlug: string) =>
- db
- .select({ count: count() })
- .from(categories)
- .leftJoin(
+export async function getCategoryProductCount(categorySlug: string) {
+ "use cache: remote";
+ cacheTag("category-product-count");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db
+ .select({ count: count() })
+ .from(categories)
+ .leftJoin(subcollections, eq(categories.slug, subcollections.category_slug))
+ .leftJoin(
+ subcategories,
+ eq(subcollections.id, subcategories.subcollection_id),
+ )
+ .leftJoin(products, eq(subcategories.slug, products.subcategory_slug))
+ .where(eq(categories.slug, categorySlug));
+}
+
+export async function getSubcategoryProductCount(subcategorySlug: string) {
+ "use cache: remote";
+ cacheTag("subcategory-product-count");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ return db
+ .select({ count: count() })
+ .from(products)
+ .where(eq(products.subcategory_slug, subcategorySlug));
+}
+
+export async function getSearchResults(searchTerm: string) {
+ "use cache: remote";
+ cacheTag("search-results");
+ cacheLife({ revalidate: 60 * 60 * 2 }); // two hours
+
+ let results;
+
+ // do we really need to do this hybrid search pattern?
+
+ 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(
subcollections,
- eq(categories.slug, subcollections.category_slug),
+ sql`${subcategories.subcollection_id} = ${subcollections.id}`,
+ )
+ .innerJoin(
+ categories,
+ sql`${subcollections.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})`,
)
- .leftJoin(
+ .limit(5)
+ .innerJoin(
subcategories,
- eq(subcollections.id, subcategories.subcollection_id),
+ sql`${products.subcategory_slug} = ${subcategories.slug}`,
)
- .leftJoin(products, eq(subcategories.slug, products.subcategory_slug))
- .where(eq(categories.slug, categorySlug)),
- ["category-product-count"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getSubcategoryProductCount = unstable_cache(
- (subcategorySlug: string) =>
- db
- .select({ count: count() })
- .from(products)
- .where(eq(products.subcategory_slug, subcategorySlug)),
- ["subcategory-product-count"],
- {
- revalidate: 60 * 60 * 2, // two hours,
- },
-);
-
-export const getSearchResults = unstable_cache(
- async (searchTerm: string) => {
- let results;
-
- // do we really need to do this hybrid search pattern?
-
- 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(
- subcollections,
- sql`${subcategories.subcollection_id} = ${subcollections.id}`,
- )
- .innerJoin(
- categories,
- sql`${subcollections.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(
- subcollections,
- sql`${subcategories.subcollection_id} = ${subcollections.id}`,
- )
- .innerJoin(
- categories,
- sql`${subcollections.category_slug} = ${categories.slug}`,
- );
- }
-
- return results;
- },
- ["search-results"],
- { revalidate: 60 * 60 * 2 }, // two hours
-);
+ .innerJoin(
+ subcollections,
+ sql`${subcategories.subcollection_id} = ${subcollections.id}`,
+ )
+ .innerJoin(
+ categories,
+ sql`${subcollections.category_slug} = ${categories.slug}`,
+ );
+ }
+
+ return results;
+}
diff --git a/src/lib/unstable-cache.ts b/src/lib/unstable-cache.ts
index 3e25c715..d4ff48ef 100644
--- a/src/lib/unstable-cache.ts
+++ b/src/lib/unstable-cache.ts
@@ -1,4 +1,8 @@
-import { unstable_cache as next_unstable_cache } from "next/cache";
+import {
+ cacheLife,
+ cacheTag,
+ unstable_cache as next_unstable_cache,
+} from "next/cache";
import { cache } from "react";
// next_unstable_cache doesn't handle deduplication, so we wrap it in React's cache
@@ -6,4 +10,6 @@ export const unstable_cache = (
callback: (...args: Inputs) => Promise