From 99e43bdee352860c2a45637805237d9af590efc5 Mon Sep 17 00:00:00 2001 From: ddaoxuan Date: Wed, 30 Oct 2024 23:27:02 +0100 Subject: [PATCH] refactor: lib search --- .../components/modals/login-modal.tsx | 93 +++++ .../components/modals/modals.tsx | 6 + .../components/modals/signup-modal.tsx | 92 +++++ .../shopify-algolia/stores/modal-store.ts | 2 +- .../app/actions/product.actions.ts | 4 +- .../app/api/feed/sync/route.ts | 30 +- .../app/api/reviews/ai-summary/route.ts | 27 +- .../app/api/reviews/sync/route.ts | 54 +-- .../app/category/clp/[slug]/page.tsx | 15 +- .../app/favorites/page.tsx | 2 +- .../app/home/[bucket]/page.tsx | 29 +- .../app/product/[slug]/metadata.ts | 2 +- .../app/product/[slug]/opengraph-image.tsx | 2 +- .../app/product/[slug]/page.tsx | 14 +- .../app/reviews/[slug]/metadata.ts | 2 +- .../app/reviews/[slug]/page.tsx | 2 +- starters/shopify-meilisearch/app/sitemap.ts | 67 ++-- .../shopify-meilisearch/clients/search.ts | 121 ------ .../lib/meilisearch/client.ts | 112 ++++++ .../lib/meilisearch/index.ts | 361 ++++++++++++++---- .../shopify-meilisearch/report-bundle-size.js | 131 +++++++ .../shopify-meilisearch/utils/demo-utils.ts | 32 +- .../views/category/category-view.tsx | 2 +- .../views/homepage/categories-section.tsx | 26 +- .../views/homepage/products-week-section.tsx | 24 +- .../views/product/reviews-section.tsx | 2 +- .../product/similar-products-section.tsx | 2 +- .../views/search/search-view.tsx | 67 +--- 28 files changed, 834 insertions(+), 489 deletions(-) create mode 100644 starters/shopify-algolia/components/modals/login-modal.tsx create mode 100644 starters/shopify-algolia/components/modals/signup-modal.tsx delete mode 100644 starters/shopify-meilisearch/clients/search.ts create mode 100644 starters/shopify-meilisearch/lib/meilisearch/client.ts create mode 100644 starters/shopify-meilisearch/report-bundle-size.js diff --git a/starters/shopify-algolia/components/modals/login-modal.tsx b/starters/shopify-algolia/components/modals/login-modal.tsx new file mode 100644 index 00000000..d7017cd1 --- /dev/null +++ b/starters/shopify-algolia/components/modals/login-modal.tsx @@ -0,0 +1,93 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { getCurrentUser, loginUser } from "app/actions/user.actions" +import { Button } from "components/ui/button-old" +import { DialogFooter } from "components/ui/dialog" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "components/ui/form" +import { GenericModal } from "components/generic-modal" +import { Input } from "components/ui/input" +import { Logo } from "components/logo" +import { useModalStore } from "stores/modal-store" +import { useUserStore } from "stores/user-store" + +const passwordRegexp = new RegExp(/(?=.*\d)(?=.*\W)(?=.*[a-z])(?=.*[A-Z]).{8,20}$/) + +const formSchema = z.object({ + email: z.string().email().min(3).max(64), + password: z.string().min(8).max(20).regex(passwordRegexp, "Password must have at least one number, one symbol, one uppercase letter, and be at least 8 characters"), +}) + +const formFields = [ + { label: "Email", name: "email", type: "text", placeholder: "Enter email..." }, + { label: "Password", name: "password", type: "password", placeholder: "Enter password..." }, +] as const + +export function LoginModal() { + const setUser = useUserStore((s) => s.setUser) + const modals = useModalStore((s) => s.modals) + const closeModal = useModalStore((s) => s.closeModal) + const form = useForm>({ + resolver: zodResolver(formSchema), + }) + + async function onSubmit(payload: z.infer) { + const { email, password } = payload + const user = await loginUser({ email, password }) + + if (user) { + const currentUser = await getCurrentUser() + currentUser && setUser(currentUser) + + toast.success("Successfully logged in") + closeModal("login") + + return + } + + form.setError("root", { message: "Couldn't log in. The email address or password is incorrect." }) + } + + return ( + closeModal("login")}> +
+ + {form.formState.errors.root?.message &&

{form.formState.errors.root?.message}

} + + {formFields.map((singleField) => ( + ( + + {singleField.label} + + + + + + )} + /> + ))} + + + + + + +
+ ) +} diff --git a/starters/shopify-algolia/components/modals/modals.tsx b/starters/shopify-algolia/components/modals/modals.tsx index b32eefd7..1b5de009 100644 --- a/starters/shopify-algolia/components/modals/modals.tsx +++ b/starters/shopify-algolia/components/modals/modals.tsx @@ -5,6 +5,8 @@ import React from "react" import { type Modal, useModalStore } from "stores/modal-store" import { ReviewModal } from "./review-modal" +const LoginModal = dynamic(() => import("./login-modal").then((m) => m.LoginModal), { loading: Placeholder }) +const SignupModal = dynamic(() => import("./signup-modal").then((m) => m.SignupModal), { loading: Placeholder }) const SearchModal = dynamic(() => import("./search-modal").then((m) => m.SearchModal), { loading: Placeholder }) export function Modals() { @@ -21,6 +23,10 @@ export function Modals() { function ModalsFactory({ type }: { type: Modal }) { switch (type) { + case "login": + return + case "signup": + return case "search": return case "review": diff --git a/starters/shopify-algolia/components/modals/signup-modal.tsx b/starters/shopify-algolia/components/modals/signup-modal.tsx new file mode 100644 index 00000000..9fad8759 --- /dev/null +++ b/starters/shopify-algolia/components/modals/signup-modal.tsx @@ -0,0 +1,92 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { getCurrentUser, signupUser } from "app/actions/user.actions" +import { Button } from "components/ui/button-old" +import { DialogFooter } from "components/ui/dialog" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "components/ui/form" +import { GenericModal } from "components/generic-modal" +import { Input } from "components/ui/input" +import { Logo } from "components/logo" +import { useModalStore } from "stores/modal-store" +import { useUserStore } from "stores/user-store" + +const passwordRegexp = new RegExp(/(?=.*\d)(?=.*\W)(?=.*[a-z])(?=.*[A-Z]).{8,20}$/) + +const formSchema = z.object({ + email: z.string().email().min(3).max(64), + password: z.string().min(8).max(20).regex(passwordRegexp, "Password must have at least one number, one symbol, one uppercase letter, and be at least 8 characters"), +}) + +const formFields = [ + { label: "Email", name: "email", type: "text", placeholder: "Enter email..." }, + { label: "Password", name: "password", type: "password", placeholder: "Enter password..." }, +] as const + +export function SignupModal() { + const modals = useModalStore((s) => s.modals) + const setUser = useUserStore((s) => s.setUser) + const closeModal = useModalStore((s) => s.closeModal) + const form = useForm>({ + resolver: zodResolver(formSchema), + }) + + async function onSubmit(payload: z.infer) { + const { email, password } = payload + const user = await signupUser({ email, password }) + + if (user) { + const currentUser = await getCurrentUser() + currentUser && setUser(currentUser) + + closeModal("signup") + toast.success("You have successfully signed up! You can now log in.") + return + } + + toast.error("Couldn't create user. The email address may be already in use.") + } + + return ( + closeModal("signup")}> +
+ + {form.formState.errors.root?.message &&

{form.formState.errors.root?.message}

} + + {formFields.map((singleField) => ( + ( + + {singleField.label} + + + + + + )} + /> + ))} + + + + + + +
+ ) +} diff --git a/starters/shopify-algolia/stores/modal-store.ts b/starters/shopify-algolia/stores/modal-store.ts index f613fd19..cc57ad55 100644 --- a/starters/shopify-algolia/stores/modal-store.ts +++ b/starters/shopify-algolia/stores/modal-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand" -export type Modal = "search" | "facets-mobile" | "review" +export type Modal = "login" | "signup" | "search" | "facets-mobile" | "review" interface ModalStore { modals: Partial> diff --git a/starters/shopify-meilisearch/app/actions/product.actions.ts b/starters/shopify-meilisearch/app/actions/product.actions.ts index 5b5f0ac8..9ce9b67b 100644 --- a/starters/shopify-meilisearch/app/actions/product.actions.ts +++ b/starters/shopify-meilisearch/app/actions/product.actions.ts @@ -1,6 +1,6 @@ "use server" -import { meilisearch } from "clients/search" +import { searchClient } from "lib/meilisearch/client" import { env } from "env.mjs" import { unstable_cache } from "next/cache" import type { CommerceProduct } from "types" @@ -14,7 +14,7 @@ export const searchProducts = unstable_cache( hasMore: false, } - const { hits, estimatedTotalHits } = await meilisearch.searchDocuments({ + const { hits, estimatedTotalHits } = await searchClient.searchDocuments({ indexName: env.MEILISEARCH_PRODUCTS_INDEX, query, options: { diff --git a/starters/shopify-meilisearch/app/api/feed/sync/route.ts b/starters/shopify-meilisearch/app/api/feed/sync/route.ts index d0512146..04fba023 100644 --- a/starters/shopify-meilisearch/app/api/feed/sync/route.ts +++ b/starters/shopify-meilisearch/app/api/feed/sync/route.ts @@ -1,9 +1,9 @@ import type { PlatformProduct } from "lib/shopify/types" -import { meilisearch } from "clients/search" import { storefrontClient } from "clients/storefrontClient" import { env } from "env.mjs" import { compareHmac } from "utils/compare-hmac" import { enrichProduct } from "utils/enrich-product" +import { deleteCategories, deleteProducts, updateCategories, updateProducts } from "lib/meilisearch" type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create" @@ -54,21 +54,13 @@ async function handleCollectionTopics(topic: SupportedTopic, { id }: Record({ - indexName: env.MEILISEARCH_REVIEWS_INDEX, - options: { - limit: 10000, - fields: ["body", "title", "product_handle", "rating"], - filter: "published=true AND hidden=false", - }, + const [{ reviews }, allProducts] = await Promise.all([ + getAllReviews({ + fields: ["body", "title", "product_handle", "rating"], + filter: "published=true AND hidden=false", }), - meilisearch.getDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - limit: 10000, - fields: ["handle", "title", "id", "totalReviews"], - }, + getAllProducts({ + fields: ["handle", "title", "id", "totalReviews"], }), ]) - const mappedReviews: Record = allReviews?.results.reduce( + const mappedReviews: Record = reviews.reduce( (acc, review) => { const productHandle = review.product_handle if (acc[productHandle]) { @@ -126,7 +117,7 @@ export async function GET(req: Request) { }) .filter(Boolean) - await meilisearch.updateDocuments({ indexName: env.MEILISEARCH_PRODUCTS_INDEX, documents: updatedProducts, options: { primaryKey: "id" } }) + await updateProducts(updatedProducts) return new Response(JSON.stringify({ message: "Reviews synced" }), { status: 200 }) } diff --git a/starters/shopify-meilisearch/app/api/reviews/sync/route.ts b/starters/shopify-meilisearch/app/api/reviews/sync/route.ts index c681d2d7..34c984f4 100644 --- a/starters/shopify-meilisearch/app/api/reviews/sync/route.ts +++ b/starters/shopify-meilisearch/app/api/reviews/sync/route.ts @@ -1,12 +1,10 @@ import { unstable_noStore } from "next/cache" -import { meilisearch } from "clients/search" import { reviewsClient } from "clients/reviews" import { env } from "env.mjs" import { authenticate } from "utils/authenticate-api-route" import { isOptIn, notifyOptIn } from "utils/opt-in" -import type { Review } from "lib/reviews/types" -import type { CommerceProduct } from "types" import { isDemoMode } from "utils/demo-utils" +import { getAllProducts, getAllReviews, updateProducts, updateReviews } from "lib/meilisearch" export const maxDuration = 60 @@ -31,31 +29,23 @@ export async function GET(req: Request) { return new Response(JSON.stringify({ message: "Sorry, something went wrong" }), { status: 500 }) } - const [allReviews, allProducts, allIndexReviews] = await Promise.all([ + const [allReviews, { results: allProducts }, { reviews }] = await Promise.all([ reviewsClient.getAllProductReviews(), - meilisearch.getDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - limit: 10000, - fields: ["handle", "totalReviews", "avgRating", "id"], - }, + getAllProducts({ + fields: ["handle", "title", "avgRating", "totalReviews"], }), - meilisearch.getDocuments({ - indexName: env.MEILISEARCH_REVIEWS_INDEX, - options: { - limit: 10000, - fields: ["updated_at", "id"], - }, + getAllReviews({ + fields: ["updated_at", "id"], }), ]) const reviewsDelta = allReviews.filter((review) => { - const indexReview = allIndexReviews?.results.find((r) => r.id === review.id) + const indexReview = reviews?.find((r) => r.id === review.id) return indexReview?.updated_at !== review.updated_at }) - const productTotalReviewsDelta = allProducts?.results - .map((product) => { + const productTotalReviewsDelta = allProducts + ?.map((product) => { const productReviews = allReviews.filter((review) => review.product_handle === product.handle && review.published && !review.hidden) if (!!productReviews.length && productReviews.length !== product.totalReviews) { const avgRating = productReviews.reduce((acc, review) => acc + review.rating, 0) / productReviews.length || 0 @@ -66,32 +56,12 @@ export async function GET(req: Request) { }) .filter(Boolean) - if (!reviewsDelta.length && !productTotalReviewsDelta.length) { + if (!reviewsDelta.length && !productTotalReviewsDelta?.length) { return new Response(JSON.stringify({ message: "Nothing to sync" }), { status: 200 }) } - !!reviewsDelta.length && - (async () => { - meilisearch.updateDocuments({ - indexName: env.MEILISEARCH_REVIEWS_INDEX!, - documents: reviewsDelta, - options: { - primaryKey: "id", - }, - }) - console.log("API/sync: Reviews synced", reviewsDelta.length) - })() - !!productTotalReviewsDelta.length && - (async () => { - meilisearch.updateDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - documents: productTotalReviewsDelta, - options: { - primaryKey: "id", - }, - }) - console.log("API/sync:Products synced", productTotalReviewsDelta.length) - })() + !!reviewsDelta.length && updateReviews(reviewsDelta) + !!productTotalReviewsDelta?.length && updateProducts(productTotalReviewsDelta) return new Response(JSON.stringify({ message: "All synced" }), { status: 200 }) } diff --git a/starters/shopify-meilisearch/app/category/clp/[slug]/page.tsx b/starters/shopify-meilisearch/app/category/clp/[slug]/page.tsx index e92ff154..6dd126a2 100644 --- a/starters/shopify-meilisearch/app/category/clp/[slug]/page.tsx +++ b/starters/shopify-meilisearch/app/category/clp/[slug]/page.tsx @@ -1,9 +1,7 @@ -import { PlatformCollection } from "lib/shopify/types" -import { meilisearch } from "clients/search" -import { env } from "env.mjs" import type { Metadata } from "next" import { isDemoMode } from "utils/demo-utils" import { CategoryView } from "views/category/category-view" +import { getCategories } from "lib/meilisearch" export const revalidate = 86400 export const dynamic = "force-static" @@ -22,15 +20,12 @@ export async function generateMetadata({ params }: CategoryPageProps): Promise({ - indexName: env.MEILISEARCH_CATEGORIES_INDEX, - options: { - limit: 50, - attributesToRetrieve: ["handle"], - }, + const { results } = await getCategories({ + limit: 50, + fields: ["handle"], }) - return hits.map(({ handle }) => ({ slug: handle })) + return results.map(({ handle }) => ({ slug: handle })) } export default async function CategoryPage({ params }: CategoryPageProps) { diff --git a/starters/shopify-meilisearch/app/favorites/page.tsx b/starters/shopify-meilisearch/app/favorites/page.tsx index 77b1740e..c0157809 100644 --- a/starters/shopify-meilisearch/app/favorites/page.tsx +++ b/starters/shopify-meilisearch/app/favorites/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react" import { ProductCard } from "components/product-card" import { Skeleton } from "components/ui/skeleton" import { COOKIE_FAVORITES } from "constants/index" -import { getProduct } from "clients/search" +import { getProduct } from "lib/meilisearch" export const revalidate = 86400 diff --git a/starters/shopify-meilisearch/app/home/[bucket]/page.tsx b/starters/shopify-meilisearch/app/home/[bucket]/page.tsx index 0ea16893..b68d11c8 100644 --- a/starters/shopify-meilisearch/app/home/[bucket]/page.tsx +++ b/starters/shopify-meilisearch/app/home/[bucket]/page.tsx @@ -1,13 +1,9 @@ import { BUCKETS } from "constants/index" import { AnnouncementBar } from "components/announcement-bar" import { HeroSection } from "views/homepage/hero-section" -import { meilisearch } from "clients/search" -import { CommerceProduct } from "types" -import { env } from "env.mjs" -import type { Hits } from "meilisearch" import { CategoriesSection } from "views/homepage/categories-section" import { FeaturedProductsSection } from "views/homepage/featured-products-section" -import { PlatformCollection } from "lib/shopify/types" +import { getFeaturedProducts } from "lib/meilisearch" export const revalidate = 86400 @@ -21,13 +17,13 @@ export default async function Homepage({ params: { bucket } }: { params: { bucke b: "Shop the best Deals on Top Brands & Unique Finds", } - const { products } = await fetchFeaturedData() + const results = await getFeaturedProducts() return (
- +
) @@ -36,22 +32,3 @@ export default async function Homepage({ params: { bucket } }: { params: { bucke export async function generateStaticParams() { return BUCKETS.HOME.map((bucket) => ({ bucket })) } - -const fetchFeaturedData = async () => { - const results = await meilisearch?.multiSearch({ - queries: [ - { - indexUid: env.MEILISEARCH_FEATURED_PRODUCTS_INDEX, - q: "", - limit: 6, - attributesToRetrieve: ["id", "title", "featuredImage", "minPrice", "variants", "avgRating", "totalReviews", "vendor", "handle"], - }, - { indexUid: env.MEILISEARCH_CATEGORIES_INDEX, q: "", limit: 4, attributesToRetrieve: ["id", "title", "handle"] }, - ], - }) - - return { - products: results[0].hits as Hits, - categories: results[1].hits as Hits, - } -} diff --git a/starters/shopify-meilisearch/app/product/[slug]/metadata.ts b/starters/shopify-meilisearch/app/product/[slug]/metadata.ts index 9cdeaa0f..71e13017 100644 --- a/starters/shopify-meilisearch/app/product/[slug]/metadata.ts +++ b/starters/shopify-meilisearch/app/product/[slug]/metadata.ts @@ -1,4 +1,4 @@ -import { getProduct } from "clients/search" +import { getProduct } from "lib/meilisearch" import { env } from "env.mjs" import { Metadata } from "next" import { Product, WithContext } from "schema-dts" diff --git a/starters/shopify-meilisearch/app/product/[slug]/opengraph-image.tsx b/starters/shopify-meilisearch/app/product/[slug]/opengraph-image.tsx index f7860bbd..1e2420bc 100644 --- a/starters/shopify-meilisearch/app/product/[slug]/opengraph-image.tsx +++ b/starters/shopify-meilisearch/app/product/[slug]/opengraph-image.tsx @@ -4,7 +4,7 @@ import { ImageResponse } from "next/og" import { removeOptionsFromUrl } from "utils/product-options-utils" import { env } from "env.mjs" -import { getProduct } from "clients/search" +import { getProduct } from "lib/meilisearch" export const revalidate = 86400 diff --git a/starters/shopify-meilisearch/app/product/[slug]/page.tsx b/starters/shopify-meilisearch/app/product/[slug]/page.tsx index 0a680416..3384f2e5 100644 --- a/starters/shopify-meilisearch/app/product/[slug]/page.tsx +++ b/starters/shopify-meilisearch/app/product/[slug]/page.tsx @@ -1,10 +1,6 @@ import { Suspense } from "react" import { notFound } from "next/navigation" -import { getProduct, meilisearch } from "clients/search" - -import { env } from "env.mjs" - import { isDemoMode } from "utils/demo-utils" import { slugToName } from "utils/slug-name" import { CurrencyType, mapCurrencyToSign } from "utils/map-currency-to-sign" @@ -27,6 +23,7 @@ import { ReviewsSection } from "views/product/reviews-section" import type { CommerceProduct } from "types" import { generateJsonLd } from "./metadata" +import { getProduct, getProducts } from "lib/meilisearch" export const revalidate = 86400 export const dynamic = "force-static" @@ -39,12 +36,9 @@ interface ProductProps { export async function generateStaticParams() { if (isDemoMode()) return [] - const { results } = await meilisearch.getDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - limit: 50, - fields: ["handle"], - }, + const { results } = await getProducts({ + limit: 50, + fields: ["handle"], }) return results.map(({ handle }) => ({ slug: handle })) diff --git a/starters/shopify-meilisearch/app/reviews/[slug]/metadata.ts b/starters/shopify-meilisearch/app/reviews/[slug]/metadata.ts index 1705d919..0b5a4c1b 100644 --- a/starters/shopify-meilisearch/app/reviews/[slug]/metadata.ts +++ b/starters/shopify-meilisearch/app/reviews/[slug]/metadata.ts @@ -2,7 +2,7 @@ import { makeKeywords } from "utils/make-keywords" import { removeOptionsFromUrl } from "utils/product-options-utils" import type { ProductReviewsPageProps } from "./page" import { Metadata } from "next" -import { getProduct } from "clients/search" +import { getProduct } from "lib/meilisearch" import { env } from "env.mjs" export async function generateMetadata({ params: { slug } }: ProductReviewsPageProps): Promise { diff --git a/starters/shopify-meilisearch/app/reviews/[slug]/page.tsx b/starters/shopify-meilisearch/app/reviews/[slug]/page.tsx index 67bdae7e..30a81d78 100644 --- a/starters/shopify-meilisearch/app/reviews/[slug]/page.tsx +++ b/starters/shopify-meilisearch/app/reviews/[slug]/page.tsx @@ -1,6 +1,6 @@ import { notFound, redirect } from "next/navigation" -import { getProduct, getProductReviews } from "clients/search" +import { getProduct, getProductReviews } from "lib/meilisearch" import { Breadcrumbs } from "components/breadcrumbs" diff --git a/starters/shopify-meilisearch/app/sitemap.ts b/starters/shopify-meilisearch/app/sitemap.ts index 138a07d2..d98d37c9 100644 --- a/starters/shopify-meilisearch/app/sitemap.ts +++ b/starters/shopify-meilisearch/app/sitemap.ts @@ -1,76 +1,71 @@ import { env } from "env.mjs" import { MetadataRoute } from "next" -import { meilisearch } from "clients/search" -import { getDemoCategories, getDemoProducts, isDemoMode } from "utils/demo-utils" -import type { PlatformCollection } from "lib/shopify/types" -import type { CommerceProduct } from "types" - -export const revalidate = 604800 -export const runtime = "nodejs" - -const BASE_URL = env.LIVE_URL -const HITS_PER_PAGE = 24 +import { getCategories, getProducts } from "lib/meilisearch" +import { HITS_PER_PAGE } from "constants/index" export default async function sitemap(): Promise { const staticRoutes: MetadataRoute.Sitemap = [ { - url: `${BASE_URL}/`, + url: `${env.LIVE_URL}/`, lastModified: new Date(new Date().setHours(0, 0, 0, 0)), changeFrequency: "daily", priority: 1, }, { - url: `${BASE_URL}/`, + url: `${env.LIVE_URL}/`, lastModified: new Date(new Date().setHours(0, 0, 0, 0)), changeFrequency: "daily", priority: 1, }, { - url: `${BASE_URL}/terms-conditions`, + url: `${env.LIVE_URL}/terms-conditions`, lastModified: new Date(), priority: 0.1, }, { - url: `${BASE_URL}/privacy-policy`, + url: `${env.LIVE_URL}/privacy-policy`, lastModified: new Date(), priority: 0.1, }, ] - let allHits: CommerceProduct[] = [] - let allCollections: PlatformCollection[] = [] + const allHits = ( + await getProducts({ + limit: 50, + fields: ["handle", "updatedAt"], + }) + ).results - if (!isDemoMode()) { - allHits = await getResults(env.MEILISEARCH_PRODUCTS_INDEX) - allCollections = await getResults(env.MEILISEARCH_CATEGORIES_INDEX) - } else { - allHits = getDemoProducts().hits - allCollections = getDemoCategories() - } + const allCollections = ( + await getCategories({ + limit: 50, + fields: ["handle", "updatedAt"], + }) + ).results const paginationRoutes = Array.from({ length: allHits.length / HITS_PER_PAGE }, (_, i) => { const item: MetadataRoute.Sitemap[0] = { - url: `${BASE_URL}/search?page=${i + 1}`, + url: `${env.LIVE_URL}/search?page=${i + 1}`, priority: 0.5, changeFrequency: "monthly", } return item }) - const productRoutes = allHits.map((hit) => { + const productRoutes = allHits.map(({ handle, updatedAt }) => { const item: MetadataRoute.Sitemap[0] = { - url: `${BASE_URL}/product/${hit.handle}`, - lastModified: hit.updatedAt, + url: `${env.LIVE_URL}/product/${handle}`, + lastModified: updatedAt, priority: 0.5, changeFrequency: "monthly", } return item }) - const collectionsRoutes = allCollections.map((collection) => { + const collectionsRoutes = allCollections.map(({ handle, updatedAt }) => { const item: MetadataRoute.Sitemap[0] = { - url: `${BASE_URL}/category/${collection.handle}`, - lastModified: collection.updatedAt, + url: `${env.LIVE_URL}/category/${handle}`, + lastModified: updatedAt, priority: 0.5, changeFrequency: "monthly", } @@ -79,15 +74,3 @@ export default async function sitemap(): Promise { return [...staticRoutes, ...paginationRoutes, ...productRoutes, ...collectionsRoutes] } - -// Pull only 100 results for the case of the demo -async function getResults>(indexName: string) { - const response = await meilisearch.getDocuments({ - indexName, - options: { - limit: 100, - }, - }) - - return response.results as T[] -} diff --git a/starters/shopify-meilisearch/clients/search.ts b/starters/shopify-meilisearch/clients/search.ts deleted file mode 100644 index c1c8acc8..00000000 --- a/starters/shopify-meilisearch/clients/search.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { unstable_cache } from "next/cache" - -import { env } from "env.mjs" - -import { getDemoProductReviews, getDemoProducts, getDemoSingleCategory, getDemoSingleProduct, isDemoMode } from "utils/demo-utils" -import { notifyOptIn } from "utils/opt-in" - -import { meilisearch as searchClient } from "lib/meilisearch" -import { ComparisonOperators, FilterBuilder } from "lib/meilisearch/filter-builder" - -import type { Review } from "lib/reviews/types" - -import type { CommerceProduct } from "types" -import { PlatformCollection } from "lib/shopify/types" - -export const meilisearch: ReturnType = searchClient({ - host: env.MEILISEARCH_HOST || "", - adminApiKey: env.MEILISEARCH_ADMIN_KEY || "", -}) - -export const getProduct = unstable_cache( - async (handle: string) => { - if (isDemoMode()) return getDemoSingleProduct(handle) - - const { results } = await meilisearch.getDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - filter: new FilterBuilder().where("handle", ComparisonOperators.Equal, handle).build(), - limit: 1, - }, - }) - - return results.find(Boolean) || null - }, - ["product-by-handle"], - { revalidate: 3600 } -) - -export const getProductReviews = unstable_cache( - async (handle: string, { page = 1, limit = 10 } = { page: 1, limit: 10 }) => { - if (isDemoMode()) return getDemoProductReviews() - - if (!env.MEILISEARCH_REVIEWS_INDEX) { - notifyOptIn({ feature: "reviews", source: "product.actions.ts" }) - return { reviews: [], total: 0 } - } - - const { results, total } = await meilisearch.getDocuments({ - indexName: env.MEILISEARCH_REVIEWS_INDEX, - options: { - filter: new FilterBuilder() - .where("product_handle", ComparisonOperators.Equal, handle) - .and() - .where("published", ComparisonOperators.Equal, "true") - .and() - .where("hidden", ComparisonOperators.Equal, "false") - .build(), - limit, - offset: (page - 1) * limit, - fields: ["body", "rating", "verified", "reviewer", "published", "created_at", "hidden", "featured"], - }, - }) - - return { reviews: results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()), total } - }, - ["product-reviews-by-handle"], - { revalidate: 3600 } -) - -export const getCollection = unstable_cache( - async (slug: string) => { - if (isDemoMode()) return getDemoSingleCategory(slug) - - const results = await meilisearch.searchDocuments({ - indexName: env.MEILISEARCH_CATEGORIES_INDEX, - options: { - filter: new FilterBuilder().where("handle", ComparisonOperators.Equal, slug).build(), - limit: 1, - attributesToRetrieve: ["handle", "title", "seo"], - }, - }) - - return results.hits.find(Boolean) || null - }, - ["category-by-handle"], - { revalidate: 3600 } -) - -export const getSimilarProducts = unstable_cache( - async (handle: string, collection: string | undefined) => { - const limit = 8 - - if (isDemoMode()) return getDemoProducts().hits.slice(0, limit) - - const similarSearchResults = await meilisearch.searchDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - query: handle, - options: { - matchingStrategy: "last", - limit, - hybrid: { semanticRatio: 1 }, - }, - }) - - let collectionSearchResults: { hits: CommerceProduct[] } = { hits: [] } - if (similarSearchResults.hits.length < limit) { - collectionSearchResults = await meilisearch.searchDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - matchingStrategy: "last", - limit: limit - similarSearchResults.hits.length, - filter: new FilterBuilder().where("collections.handle", ComparisonOperators.Equal, collection).build(), - }, - }) - } - - return [...similarSearchResults.hits, ...collectionSearchResults.hits] - }, - ["product-by-handle"], - { revalidate: 3600 } -) diff --git a/starters/shopify-meilisearch/lib/meilisearch/client.ts b/starters/shopify-meilisearch/lib/meilisearch/client.ts new file mode 100644 index 00000000..71eb5428 --- /dev/null +++ b/starters/shopify-meilisearch/lib/meilisearch/client.ts @@ -0,0 +1,112 @@ +import { env } from "env.mjs" +import { type DocumentOptions, type DocumentsDeletionQuery, type DocumentsIds, type DocumentsQuery, MeiliSearch, type MultiSearchParams, type SearchParams } from "meilisearch" + +type MeilisearchArgsType = { + host: string + adminApiKey: string +} + +const meilisearchClient = ({ host, adminApiKey }: Pick) => { + return new MeiliSearch({ + host, + apiKey: adminApiKey, + }) +} + +const meilisearch = ({ host, adminApiKey }: MeilisearchArgsType) => { + const client = meilisearchClient({ + host, + adminApiKey, + }) + + return { + searchDocuments: , S extends SearchParams = SearchParams>(args: SearchDocumentsArgs) => searchDocuments(args, client), + updateDocuments: >(args: UpdateDocumentsArgs) => updateDocuments(args, client), + deleteDocuments: >(args: DeleteDocumentsArgs) => deleteDocuments(args, client), + createDocuments: >(args: CreateDocumentsArgs) => createDocuments(args, client), + getDocuments: >(args: GetDocumentsArgs) => getDocuments(args, client), + multiSearch: >(args: MultiSearchParams) => multiSearch(args, client), + } +} + +type SearchDocumentsArgs = { + indexName: string + query?: string + options: S +} + +const searchDocuments = async , S extends SearchParams>(args: SearchDocumentsArgs, client: MeiliSearch) => { + const { indexName, query, options } = args + + const results = await client.index(indexName).search(query, options) + + return results +} + +type UpdateDocumentsArgs> = { + documents: Partial[] + indexName: string + options?: DocumentOptions +} + +const updateDocuments = async >(args: UpdateDocumentsArgs, client: MeiliSearch) => { + const { documents, indexName, options } = args + + const result = await client.index(indexName).updateDocuments(documents, options) + + return result +} + +type DeleteDocumentsArgs = { + indexName: string + params: DocumentsDeletionQuery | DocumentsIds +} + +const deleteDocuments = async >(args: DeleteDocumentsArgs, client: MeiliSearch) => { + const { params, indexName } = args + + const result = await client.index(indexName).deleteDocuments(params) + + return result +} + +type CreateDocumentsArgs> = { + documents: Document[] + indexName: string + options?: DocumentOptions +} + +const createDocuments = async >(args: CreateDocumentsArgs, client: MeiliSearch) => { + const { documents, indexName, options } = args + + const result = await client.index(indexName).addDocuments(documents, options) + + return result +} + +type GetDocumentsArgs> = { + indexName: string + options?: DocumentsQuery +} + +const getDocuments = async >(args: GetDocumentsArgs, client: MeiliSearch) => { + const { indexName, options } = args + + const result = await client.index(indexName).getDocuments(options) + + return result +} + +const multiSearch = async >(args: MultiSearchParams, client: MeiliSearch) => { + const { queries } = args + const { results } = await client.multiSearch({ + queries, + }) + + return results +} + +export const searchClient = meilisearch({ + host: env.MEILISEARCH_HOST || "", + adminApiKey: env.MEILISEARCH_ADMIN_KEY || "", +}) diff --git a/starters/shopify-meilisearch/lib/meilisearch/index.ts b/starters/shopify-meilisearch/lib/meilisearch/index.ts index e0c14f98..eaa25e76 100644 --- a/starters/shopify-meilisearch/lib/meilisearch/index.ts +++ b/starters/shopify-meilisearch/lib/meilisearch/index.ts @@ -1,106 +1,319 @@ -import { type DocumentOptions, type DocumentsDeletionQuery, type DocumentsIds, type DocumentsQuery, MeiliSearch, type MultiSearchParams, type SearchParams } from "meilisearch" +import { unstable_cache } from "next/cache" +import type { CategoriesDistribution, DocumentsQuery, Facet } from "meilisearch" -type MeilisearchArgsType = { - host: string - adminApiKey: string -} +import { env } from "env.mjs" + +import { getDemoCategories, getDemoProductReviews, getDemoProducts, getDemoSingleCategory, getDemoSingleProduct, isDemoMode } from "utils/demo-utils" +import { notifyOptIn } from "utils/opt-in" + +import { ComparisonOperators, FilterBuilder } from "lib/meilisearch/filter-builder" +import type { Review } from "lib/reviews/types" +import { PlatformCollection } from "lib/shopify/types" + +import { HITS_PER_PAGE } from "constants/index" + +import { searchClient as meilisearch } from "./client" + +import type { CommerceProduct } from "types" + +export const getProduct = unstable_cache( + async (handle: string) => { + if (isDemoMode()) return getDemoSingleProduct(handle) + + const { results } = await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + options: { + filter: new FilterBuilder().where("handle", ComparisonOperators.Equal, handle).build(), + limit: 1, + }, + }) + + return results.find(Boolean) || null + }, + ["product-by-handle"], + { revalidate: 86400 } +) + +export const getProducts = unstable_cache( + async ( + options: DocumentsQuery = { + limit: 50, + } + ) => { + if (isDemoMode()) return getDemoCategories() + + return await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + options, + }) + }, + ["search-products"], + { revalidate: 86400 } +) + +export const getFeaturedProducts = unstable_cache( + async () => { + if (isDemoMode()) return getDemoProducts().results.slice(0, 6) -const meilisearchClient = ({ host, adminApiKey }: Pick) => { - return new MeiliSearch({ - host, - apiKey: adminApiKey, + const { results } = await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_FEATURED_PRODUCTS_INDEX, + options: { + fields: ["id", "title", "featuredImage", "minPrice", "variants", "avgRating", "totalReviews", "vendor", "handle"], + limit: 6, + }, + }) + + return results + }, + ["featured-products"], + { revalidate: 86400 } +) + +export const getAllProducts = async (options?: Omit, "limit">) => { + if (isDemoMode()) return getDemoProducts() + + return await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + options: { + ...options, + limit: 10000, + }, }) } -export const meilisearch = ({ host, adminApiKey }: MeilisearchArgsType) => { - const client = meilisearchClient({ - host, - adminApiKey, - }) +export const getSimilarProducts = unstable_cache( + async (handle: string, collection: string | undefined) => { + const limit = 8 - return { - searchDocuments: , S extends SearchParams = SearchParams>(args: SearchDocumentsArgs) => searchDocuments(args, client), - updateDocuments: >(args: UpdateDocumentsArgs) => updateDocuments(args, client), - deleteDocuments: >(args: DeleteDocumentsArgs) => deleteDocuments(args, client), - createDocuments: >(args: CreateDocumentsArgs) => createDocuments(args, client), - getDocuments: >(args: GetDocumentsArgs) => getDocuments(args, client), - multiSearch: >(args: MultiSearchParams) => multiSearch(args, client), - } -} + if (isDemoMode()) return getDemoProducts().results.slice(0, limit) -type SearchDocumentsArgs = { - indexName: string - query?: string - options: S -} + const similarSearchResults = await meilisearch.searchDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + query: handle, + options: { + matchingStrategy: "last", + limit, + hybrid: { semanticRatio: 1 }, + }, + }) -const searchDocuments = async , S extends SearchParams>(args: SearchDocumentsArgs, client: MeiliSearch) => { - const { indexName, query, options } = args + let collectionSearchResults: { hits: CommerceProduct[] } = { hits: [] } + if (similarSearchResults.hits.length < limit) { + collectionSearchResults = await meilisearch.searchDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + options: { + matchingStrategy: "last", + limit: limit - similarSearchResults.hits.length, + filter: new FilterBuilder().where("collections.handle", ComparisonOperators.Equal, collection).build(), + }, + }) + } - const results = await client.index(indexName).search(query, options) + return [...similarSearchResults.hits, ...collectionSearchResults.hits] + }, + ["product-by-handle"], + { revalidate: 86400 } +) - return results -} +export const getNewestProducts = unstable_cache( + async () => { + if (isDemoMode()) return getDemoProducts().results.slice(0, 8) -type UpdateDocumentsArgs> = { - documents: Partial[] - indexName: string - options?: DocumentOptions -} + const results = await meilisearch.searchDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + options: { + matchingStrategy: "last", + limit: 8, + sort: ["updatedAtTimestamp:desc"], + }, + }) -const updateDocuments = async >(args: UpdateDocumentsArgs, client: MeiliSearch) => { - const { documents, indexName, options } = args + return [...results.hits] + }, + ["newest-products"], + { revalidate: 86400 } +) - const result = await client.index(indexName).updateDocuments(documents, options) +export const getCollection = unstable_cache( + async (slug: string) => { + if (isDemoMode()) return getDemoSingleCategory(slug) - return result -} + const results = await meilisearch.searchDocuments({ + indexName: env.MEILISEARCH_CATEGORIES_INDEX, + options: { + filter: new FilterBuilder().where("handle", ComparisonOperators.Equal, slug).build(), + limit: 1, + attributesToRetrieve: ["handle", "title", "seo"], + }, + }) -type DeleteDocumentsArgs = { - indexName: string - params: DocumentsDeletionQuery | DocumentsIds -} + return results.hits.find(Boolean) || null + }, + ["category-by-handle"], + { revalidate: 86400 } +) -const deleteDocuments = async >(args: DeleteDocumentsArgs, client: MeiliSearch) => { - const { params, indexName } = args +export const getProductReviews = unstable_cache( + async (handle: string, { page = 1, limit = 10 } = { page: 1, limit: 10 }) => { + if (isDemoMode()) return getDemoProductReviews() - const result = await client.index(indexName).deleteDocuments(params) + if (!env.MEILISEARCH_REVIEWS_INDEX) { + notifyOptIn({ feature: "reviews", source: "product.actions.ts" }) + return { reviews: [], total: 0 } + } - return result -} + const { results, total } = await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_REVIEWS_INDEX, + options: { + filter: new FilterBuilder() + .where("product_handle", ComparisonOperators.Equal, handle) + .and() + .where("published", ComparisonOperators.Equal, "true") + .and() + .where("hidden", ComparisonOperators.Equal, "false") + .build(), + limit, + offset: (page - 1) * limit, + fields: ["body", "rating", "verified", "reviewer", "published", "created_at", "hidden", "featured"], + }, + }) -type CreateDocumentsArgs> = { - documents: Document[] - indexName: string - options?: DocumentOptions -} + return { reviews: results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()), total } + }, + ["product-reviews-by-handle"], + { revalidate: 86400 } +) -const createDocuments = async >(args: CreateDocumentsArgs, client: MeiliSearch) => { - const { documents, indexName, options } = args +export const getAllReviews = async (options: Omit, "limit"> = {}) => { + if (isDemoMode()) return getDemoProductReviews() - const result = await client.index(indexName).addDocuments(documents, options) + if (!env.MEILISEARCH_REVIEWS_INDEX) { + notifyOptIn({ feature: "reviews", source: "product.actions.ts" }) + return { reviews: [], total: 0 } + } - return result -} + const { results, total } = await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_REVIEWS_INDEX, + options: { + ...options, + limit: 10000, + }, + }) -type GetDocumentsArgs> = { - indexName: string - options?: DocumentsQuery + return { reviews: results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()), total } } -const getDocuments = async >(args: GetDocumentsArgs, client: MeiliSearch) => { - const { indexName, options } = args +export const updateProducts = async (products: Partial[]) => { + if (isDemoMode()) return - const result = await client.index(indexName).getDocuments(options) + return meilisearch.updateDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + documents: products.filter(Boolean), + options: { + primaryKey: "id", + }, + }) +} + +export const updateReviews = async (reviews: Review[]) => { + if (isDemoMode() || !env.MEILISEARCH_REVIEWS_INDEX) return - return result + return meilisearch.updateDocuments({ + indexName: env.MEILISEARCH_REVIEWS_INDEX, + documents: reviews, + options: { + primaryKey: "id", + }, + }) } -const multiSearch = async >(args: MultiSearchParams, client: MeiliSearch) => { - const { queries } = args - const { results } = await client.multiSearch({ - queries, +export const getCategories = unstable_cache( + async ( + options: DocumentsQuery = { + limit: 50, + } + ) => { + if (isDemoMode()) return getDemoCategories() + + return await meilisearch.getDocuments({ + indexName: env.MEILISEARCH_CATEGORIES_INDEX, + options, + }) + }, + ["search-categories"], + { revalidate: 86400 } +) + +export const updateCategories = unstable_cache( + async (categories: PlatformCollection[]) => { + if (isDemoMode()) return + + return meilisearch.updateDocuments({ + indexName: env.MEILISEARCH_CATEGORIES_INDEX, + documents: categories.filter(Boolean), + options: { + primaryKey: "id", + }, + }) + }, + ["update-categories"], + { revalidate: 86400 } +) + +export const deleteCategories = async (ids: string[]) => { + if (isDemoMode()) return + + return meilisearch.deleteDocuments({ + indexName: env.MEILISEARCH_CATEGORIES_INDEX, + params: ids, }) +} + +export const deleteProducts = async (ids: string[]) => { + if (isDemoMode()) return - return results + return meilisearch.deleteDocuments({ + indexName: env.MEILISEARCH_PRODUCTS_INDEX, + params: ids, + }) } + +export const getFilteredProducts = unstable_cache( + async (query: string, sortBy: string, page: number, filter: string) => { + if (isDemoMode()) return getDemoProducts() + + const response = await meilisearch.multiSearch({ + queries: [ + { + indexUid: env.MEILISEARCH_PRODUCTS_INDEX, + q: query, + facets: ["hierarchicalCategories.lvl0", "hierarchicalCategories.lvl1", "hierarchicalCategories.lvl2"], + }, + { + indexUid: env.MEILISEARCH_PRODUCTS_INDEX, + sort: sortBy ? [sortBy] : undefined, + limit: HITS_PER_PAGE, + hitsPerPage: HITS_PER_PAGE, + facets: ["vendor", "variants.availableForSale", "flatOptions.Color", "minPrice", "avgRating"].concat( + !!env.SHOPIFY_HIERARCHICAL_NAV_HANDLE ? [`hierarchicalCategories.lvl0`, `hierarchicalCategories.lvl1`, `hierarchicalCategories.lvl2`] : [] + ), + filter, + page, + attributesToRetrieve: ["id", "handle", "title", "priceRange", "featuredImage", "minPrice", "variants", "images", "avgRating", "totalReviews", "vendor"], + }, + ], + }) + + const [independentFacets, res] = response || [] + + const results = res?.hits || [] + const totalPages = res?.totalPages || 0 + const facetDistribution = res?.facetDistribution || {} + const totalHits = res.totalHits || 0 + const independentFacetDistribution: Record = independentFacets.facetDistribution || {} + + return { results, totalPages, facetDistribution, totalHits, independentFacetDistribution } + }, + ["filtered-products"], + { revalidate: 86400 } +) diff --git a/starters/shopify-meilisearch/report-bundle-size.js b/starters/shopify-meilisearch/report-bundle-size.js new file mode 100644 index 00000000..39bec7d1 --- /dev/null +++ b/starters/shopify-meilisearch/report-bundle-size.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// edited to work with the appdir by @raphaelbadia + +const gzSize = require("gzip-size") +const mkdirp = require("mkdirp") +const fs = require("fs") +const path = require("path") + +// Pull options from `package.json` +const options = getOptions() +const BUILD_OUTPUT_DIRECTORY = getBuildOutputDirectory(options) + +// first we check to make sure that the build output directory exists +const nextMetaRoot = path.join(process.cwd(), BUILD_OUTPUT_DIRECTORY) +try { + fs.accessSync(nextMetaRoot, fs.constants.R_OK) +} catch (err) { + console.error(`No build output found at "${nextMetaRoot}" - you may not have your working directory set correctly, or not have run "next build".`) + process.exit(1) +} + +// if so, we can import the build manifest +const buildMeta = require(path.join(nextMetaRoot, "build-manifest.json")) +const appDirMeta = require(path.join(nextMetaRoot, "app-build-manifest.json")) + +// this memory cache ensures we dont read any script file more than once +// bundles are often shared between pages +const memoryCache = {} + +// since _app is the template that all other pages are rendered into, +// every page must load its scripts. we'll measure its size here +const globalBundle = buildMeta.pages["/_app"] +const globalBundleSizes = getScriptSizes(globalBundle) + +// next, we calculate the size of each page's scripts, after +// subtracting out the global scripts +const allPageSizes = Object.values(buildMeta.pages).reduce((acc, scriptPaths, i) => { + const pagePath = Object.keys(buildMeta.pages)[i] + const scriptSizes = getScriptSizes(scriptPaths.filter((scriptPath) => !globalBundle.includes(scriptPath))) + + acc[pagePath] = scriptSizes + + return acc +}, {}) + +const globalAppDirBundle = buildMeta.rootMainFiles +const globalAppDirBundleSizes = getScriptSizes(globalAppDirBundle) + +const allAppDirSizes = Object.values(appDirMeta.pages).reduce((acc, scriptPaths, i) => { + const pagePath = Object.keys(appDirMeta.pages)[i] + const scriptSizes = getScriptSizes(scriptPaths.filter((scriptPath) => !globalAppDirBundle.includes(scriptPath))) + acc[pagePath] = scriptSizes + + return acc +}, {}) + +// format and write the output +const rawData = JSON.stringify({ + ...allAppDirSizes, + __global: globalAppDirBundleSizes, +}) + +// log ouputs to the gh actions panel +console.log(rawData) + +mkdirp.sync(path.join(nextMetaRoot, "analyze/")) +fs.writeFileSync(path.join(nextMetaRoot, "analyze/__bundle_analysis.json"), rawData) + +// -------------- +// Util Functions +// -------------- + +// given an array of scripts, return the total of their combined file sizes +function getScriptSizes(scriptPaths) { + const res = scriptPaths.reduce( + (acc, scriptPath) => { + const [rawSize, gzipSize] = getScriptSize(scriptPath) + acc.raw += rawSize + acc.gzip += gzipSize + + return acc + }, + { raw: 0, gzip: 0 } + ) + + return res +} + +// given an individual path to a script, return its file size +function getScriptSize(scriptPath) { + const encoding = "utf8" + const p = path.join(nextMetaRoot, scriptPath) + + let rawSize, gzipSize + if (Object.keys(memoryCache).includes(p)) { + rawSize = memoryCache[p][0] + gzipSize = memoryCache[p][1] + } else { + const textContent = fs.readFileSync(p, encoding) + rawSize = Buffer.byteLength(textContent, encoding) + gzipSize = gzSize.sync(textContent) + memoryCache[p] = [rawSize, gzipSize] + } + + return [rawSize, gzipSize] +} + +/** + * Reads options from `package.json` + */ +function getOptions(pathPrefix = process.cwd()) { + const pkg = require(path.join(pathPrefix, "package.json")) + + return { ...pkg.nextBundleAnalysis, name: pkg.name } +} + +/** + * Gets the output build directory, defaults to `.next` + * + * @param {object} options the options parsed from package.json.nextBundleAnalysis using `getOptions` + * @returns {string} + */ +function getBuildOutputDirectory(options) { + return options.buildOutputDirectory || ".next" +} diff --git a/starters/shopify-meilisearch/utils/demo-utils.ts b/starters/shopify-meilisearch/utils/demo-utils.ts index 5de27c0f..db65398b 100644 --- a/starters/shopify-meilisearch/utils/demo-utils.ts +++ b/starters/shopify-meilisearch/utils/demo-utils.ts @@ -1,14 +1,18 @@ import { Review } from "lib/reviews/types" -import { PlatformCollection } from "lib/shopify/types" +import type { PlatformCollection } from "lib/shopify/types" +import type { CategoriesDistribution, Facet } from "meilisearch" import type { CommerceProduct } from "types" -export function getDemoProducts() { - if (!isDemoMode()) return { hits: [], totalPages: 0, facetDistribution: {}, totalHits: 0, independentFacetDistribution: {} } - - const allProducts = require("public/demo-data.json") as { results: CommerceProduct[]; offset: number; limit: number; total: number } - +export function getDemoProducts(): { + results: CommerceProduct[] + totalPages: number + facetDistribution: Record + totalHits: number + independentFacetDistribution: Record +} { + const allProducts = require("public/demo-data.json") return { - hits: allProducts.results, + results: allProducts.results, totalPages: 1, facetDistribution: {}, totalHits: allProducts.results.length, @@ -17,15 +21,23 @@ export function getDemoProducts() { } export function getDemoSingleProduct(handle: string) { - return getDemoProducts().hits.find((p) => p.handle === handle) || null + return getDemoProducts()?.results?.find((p) => p.handle === handle) || null } export function getDemoCategories() { - return require("public/demo-categories-data.json") as PlatformCollection[] + const allCategories = require("public/demo-categories-data.json") as PlatformCollection[] + + return { + results: allCategories, + totalPages: 1, + facetDistribution: {}, + totalHits: allCategories.length, + independentFacetDistribution: {}, + } } export function getDemoSingleCategory(handle: string) { - return getDemoCategories().find((c: { handle: string }) => c.handle === handle) || null + return getDemoCategories().results.find((c: { handle: string }) => c.handle === handle) || null } export function getDemoProductReviews() { diff --git a/starters/shopify-meilisearch/views/category/category-view.tsx b/starters/shopify-meilisearch/views/category/category-view.tsx index 3e1b7caa..7e8c32cd 100644 --- a/starters/shopify-meilisearch/views/category/category-view.tsx +++ b/starters/shopify-meilisearch/views/category/category-view.tsx @@ -1,6 +1,6 @@ import { notFound } from "next/navigation" import { SearchParamsType } from "types" -import { getCollection } from "clients/search" +import { getCollection } from "lib/meilisearch" import { SearchView } from "views/search/search-view" interface CategoryViewProps { diff --git a/starters/shopify-meilisearch/views/homepage/categories-section.tsx b/starters/shopify-meilisearch/views/homepage/categories-section.tsx index a3228d19..03d2b252 100644 --- a/starters/shopify-meilisearch/views/homepage/categories-section.tsx +++ b/starters/shopify-meilisearch/views/homepage/categories-section.tsx @@ -1,12 +1,11 @@ -import { meilisearch } from "clients/search" import { CategoryCard } from "components/category-card" -import { unstable_cache } from "next/cache" -import { env } from "env.mjs" import { cn } from "utils/cn" -import { getDemoCategories, isDemoMode } from "utils/demo-utils" +import { getCategories } from "lib/meilisearch" export async function CategoriesSection() { - const categories = await getCategories() + const { results: categories } = await getCategories({ + limit: 4, + }) if (!categories?.length) return null @@ -35,20 +34,3 @@ export async function CategoriesSection() { ) } - -const getCategories = unstable_cache( - async () => { - if (isDemoMode()) return getDemoCategories().slice(0, 4) - - const results = await meilisearch.searchDocuments({ - indexName: env.MEILISEARCH_CATEGORIES_INDEX, - options: { - limit: 4, - }, - }) - - return results.hits || [] - }, - ["categories-section"], - { revalidate: 3600 } -) diff --git a/starters/shopify-meilisearch/views/homepage/products-week-section.tsx b/starters/shopify-meilisearch/views/homepage/products-week-section.tsx index cf8cbdfe..083aa3f4 100644 --- a/starters/shopify-meilisearch/views/homepage/products-week-section.tsx +++ b/starters/shopify-meilisearch/views/homepage/products-week-section.tsx @@ -1,12 +1,8 @@ -import { meilisearch } from "clients/search" import { Carousel, CarouselContent } from "components/ui/carousel" import { Skeleton } from "components/ui/skeleton" -import { env } from "env.mjs" -import { unstable_cache } from "next/cache" import Image from "next/image" import Link from "next/link" -import { getDemoProducts, isDemoMode } from "utils/demo-utils" -import type { CommerceProduct } from "types" +import { getNewestProducts } from "lib/meilisearch" export async function ProductsWeekSection() { const items = await getNewestProducts() @@ -42,24 +38,6 @@ export async function ProductsWeekSection() { ) } -const getNewestProducts = unstable_cache( - async () => { - if (isDemoMode()) return getDemoProducts().hits.slice(0, 8) - const results = await meilisearch.searchDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - options: { - matchingStrategy: "last", - limit: 8, - sort: ["updatedAtTimestamp:desc"], - }, - }) - - return [...results.hits] - }, - ["newest-products"], - { revalidate: 3600 } -) - export function ProductsWeekSectionSkeleton() { return (
diff --git a/starters/shopify-meilisearch/views/product/reviews-section.tsx b/starters/shopify-meilisearch/views/product/reviews-section.tsx index d4073338..7953e623 100644 --- a/starters/shopify-meilisearch/views/product/reviews-section.tsx +++ b/starters/shopify-meilisearch/views/product/reviews-section.tsx @@ -8,7 +8,7 @@ import { isOptIn, notifyOptIn } from "utils/opt-in" import { StarIcon } from "components/icons/star-icon" import { cn } from "utils/cn" import { buttonVariants } from "components/ui/button" -import { getProductReviews } from "clients/search" +import { getProductReviews } from "lib/meilisearch" import { removeOptionsFromUrl } from "utils/product-options-utils" type ReviewsSectionProps = { diff --git a/starters/shopify-meilisearch/views/product/similar-products-section.tsx b/starters/shopify-meilisearch/views/product/similar-products-section.tsx index 7c4d3069..51e50d95 100644 --- a/starters/shopify-meilisearch/views/product/similar-products-section.tsx +++ b/starters/shopify-meilisearch/views/product/similar-products-section.tsx @@ -1,6 +1,6 @@ import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "components/ui/carousel" import { ProductCard } from "components/product-card" -import { getSimilarProducts } from "clients/search" +import { getSimilarProducts } from "lib/meilisearch" interface SimilarProductsSectionProps { slug: string diff --git a/starters/shopify-meilisearch/views/search/search-view.tsx b/starters/shopify-meilisearch/views/search/search-view.tsx index 14497f1c..ad7be045 100644 --- a/starters/shopify-meilisearch/views/search/search-view.tsx +++ b/starters/shopify-meilisearch/views/search/search-view.tsx @@ -1,20 +1,17 @@ import { Suspense } from "react" import type { PlatformCollection } from "lib/shopify/types" -import { unstable_cache } from "next/cache" import { createSearchParamsCache, parseAsArrayOf, parseAsInteger, parseAsString } from "nuqs/server" -import { meilisearch } from "clients/search" import { ComparisonOperators, FilterBuilder } from "lib/meilisearch/filter-builder" import { composeFilters } from "views/listing/compose-filters" import { FacetsDesktop } from "views/listing/facets-desktop" import { HitsSection } from "views/listing/hits-section" import { PaginationSection } from "views/listing/pagination-section" -import { getDemoProducts, isDemoMode } from "utils/demo-utils" -import { env } from "env.mjs" -import { CommerceProduct, SearchParamsType } from "types" -import { HIERARCHICAL_SEPARATOR, HITS_PER_PAGE } from "constants/index" +import { SearchParamsType } from "types" +import { HIERARCHICAL_SEPARATOR } from "constants/index" import { Controls } from "views/listing/controls" import { FacetsMobile } from "views/listing/facets-mobile" +import { getFilteredProducts } from "lib/meilisearch" interface SearchViewProps { searchParams: SearchParamsType @@ -56,12 +53,13 @@ export async function SearchView({ searchParams, disabledFacets, collection }: S filterBuilder.where("collections.handle", ComparisonOperators.Equal, collection.handle) } - const { facetDistribution, hits, totalPages, totalHits, independentFacetDistribution } = await searchProducts( - q, - sortBy, - page, - composeFilters(filterBuilder, rest, HIERARCHICAL_SEPARATOR).build() - ) + const { + facetDistribution, + results: hits, + totalPages, + totalHits, + independentFacetDistribution, + } = await getFilteredProducts(q, sortBy, page, composeFilters(filterBuilder, rest, HIERARCHICAL_SEPARATOR).build()) return (
@@ -95,48 +93,3 @@ export async function SearchView({ searchParams, disabledFacets, collection }: S
) } - -const searchProducts = unstable_cache( - async (query: string, sortBy: string, page: number, filter: string) => { - if (isDemoMode()) return getDemoProducts() - - try { - // use a single http request to search for products and facets, utilize separate query for facet values that should be independent from the search query - const res = await meilisearch?.multiSearch({ - queries: [ - { - indexUid: env.MEILISEARCH_PRODUCTS_INDEX, - q: query, - facets: ["hierarchicalCategories.lvl0", "hierarchicalCategories.lvl1", "hierarchicalCategories.lvl2"], - }, - { - indexUid: env.MEILISEARCH_PRODUCTS_INDEX, - sort: sortBy ? [sortBy] : undefined, - limit: HITS_PER_PAGE, - hitsPerPage: HITS_PER_PAGE, - facets: ["vendor", "variants.availableForSale", "flatOptions.Color", "minPrice", "avgRating"].concat( - !!env.SHOPIFY_HIERARCHICAL_NAV_HANDLE ? [`hierarchicalCategories.lvl0`, `hierarchicalCategories.lvl1`, `hierarchicalCategories.lvl2`] : [] - ), - filter, - page, - attributesToRetrieve: ["id", "handle", "title", "priceRange", "featuredImage", "minPrice", "variants", "images", "avgRating", "totalReviews", "vendor"], - }, - ], - }) - - const [independentFacets, results] = res || [] - - const hits = results?.hits || [] - const totalPages = results?.totalPages || 0 - const facetDistribution = results?.facetDistribution || {} - const totalHits = results.totalHits || 0 - const independentFacetDistribution = independentFacets.facetDistribution || {} - - return { hits, totalPages, facetDistribution, totalHits, independentFacetDistribution } - } catch (err) { - return { hits: [], totalPages: 0, facetDistribution: {}, totalHits: 0, independentFacetDistribution: {} } - } - }, - ["products-search"], - { revalidate: 3600 } -)