Skip to content

Commit 773562e

Browse files
committed
feat: add product category support
1 parent ee43930 commit 773562e

File tree

9 files changed

+347
-9
lines changed

9 files changed

+347
-9
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import SkeletonCollectionPage from "@modules/skeletons/templates/skeleton-collection-page"
2+
3+
export default function Loading() {
4+
return <SkeletonCollectionPage />
5+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { getCategoryByHandle } from "@lib/data"
2+
import CategoryTemplate from "@modules/categories/templates"
3+
import { Metadata } from "next"
4+
import { notFound } from "next/navigation"
5+
6+
type Props = {
7+
params: { handle: string }
8+
}
9+
10+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
11+
const { product_categories } = await getCategoryByHandle(params.handle)
12+
13+
const category = product_categories[0]
14+
15+
if (!category) {
16+
notFound()
17+
}
18+
19+
return {
20+
title: `${category.title} | Acme Store`,
21+
description: `${category.title} collection`,
22+
}
23+
}
24+
25+
export default async function CategoryPage({ params }: Props) {
26+
const { product_categories } = await getCategoryByHandle(params.handle)
27+
28+
const category = product_categories[0]
29+
30+
return <CategoryTemplate category={category} />
31+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { NextRequest, NextResponse } from "next/server"
2+
import { initialize as initializeProductModule } from "@medusajs/product"
3+
import { ProductCategoryDTO, ProductDTO } from "@medusajs/types/dist/product"
4+
import { notFound } from "next/navigation"
5+
import getPrices from "@lib/util/get-product-prices"
6+
import filterProductsByStatus from "@lib/util/filter-products-by-status"
7+
8+
type ProductCategoryResponse = ProductCategoryDTO & {
9+
products: ProductDTO[]
10+
}
11+
12+
/**
13+
* This endpoint uses the serverless Product Module to retrieve a category and its products by handle.
14+
* The module connects directly to you Medusa database to retrieve and manipulate data, without the need for a dedicated server.
15+
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
16+
*/
17+
export async function GET(
18+
request: NextRequest,
19+
{ params }: { params: Record<string, any> }
20+
) {
21+
const productService = await initializeProductModule()
22+
23+
const { handle } = params
24+
25+
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
26+
const { offset, limit, cart_id } = searchParams
27+
28+
const {
29+
[0]: { products, ...categoryMeta },
30+
} = (await productService
31+
.listCategories(
32+
{
33+
handle,
34+
},
35+
{
36+
relations: [
37+
"products",
38+
"products.variants",
39+
"products.variants.options",
40+
"products.tags",
41+
"products.options",
42+
"products.status",
43+
],
44+
select: ["id", "handle", "name", "description"],
45+
take: 1,
46+
}
47+
)
48+
.catch((e) => {
49+
return notFound()
50+
})) as ProductCategoryResponse[]
51+
52+
const publishedProducts = filterProductsByStatus(products, "published")
53+
54+
const count = publishedProducts.length || 0
55+
56+
const offsetInt = parseInt(offset) || 0
57+
const limitInt = parseFloat(limit) || 12
58+
59+
const productsSlice = publishedProducts.slice(offsetInt, offsetInt + limitInt)
60+
61+
const productsWithPrices = await getPrices(productsSlice, cart_id)
62+
63+
const nextPage = offsetInt + limitInt
64+
65+
return NextResponse.json({
66+
product_categories: [categoryMeta],
67+
response: {
68+
products: productsWithPrices,
69+
count,
70+
},
71+
nextPage: count > nextPage ? nextPage : null,
72+
})
73+
}

src/app/api/categories/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server"
2+
import { initialize as initializeProductModule } from "@medusajs/product"
3+
import { notFound } from "next/navigation"
4+
5+
/**
6+
* This endpoint uses the serverless Product Module to list and count all product categories.
7+
* The module connects directly to you Medusa database to retrieve and manipulate data, without the need for a dedicated server.
8+
* Read more about the Product Module here: https://docs.medusajs.com/modules/products/serverless-module
9+
*/
10+
export async function GET(request: NextRequest) {
11+
const productService = await initializeProductModule()
12+
13+
const { offset } = Object.fromEntries(request.nextUrl.searchParams)
14+
15+
const [categories, count] = await productService
16+
.listAndCountCategories(
17+
{},
18+
{
19+
select: ["id", "handle", "name", "description"],
20+
skip: parseInt(offset) || 0,
21+
take: 100,
22+
}
23+
)
24+
.catch((e) => {
25+
return notFound()
26+
})
27+
28+
return NextResponse.json({
29+
categories,
30+
count,
31+
})
32+
}

src/app/api/products/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse, NextRequest } from "next/server"
22
import getPrices from "@lib/util/get-product-prices"
3+
import filterProductsByStatus from "@lib/util/filter-products-by-status"
34

45
import { initialize as initializeProductModule } from "@medusajs/product"
56
import {
@@ -59,7 +60,7 @@ async function getProductsByCollectionId(queryParams: Record<string, any>) {
5960

6061
const products = data.map((c) => c.products).flat() as ProductDTO[]
6162

62-
const publishedProducts = filterPublishedProducts(products)
63+
const publishedProducts = filterProductsByStatus(products, "published")
6364

6465
const count = publishedProducts.length
6566

@@ -97,7 +98,7 @@ async function getProducts(params: Record<string, any>) {
9798
withDeleted: false,
9899
})
99100

100-
const publishedProducts = filterPublishedProducts(data)
101+
const publishedProducts = filterProductsByStatus(data, "published")
101102

102103
const productsWithPrices = await getPrices(publishedProducts, cart_id)
103104

@@ -109,7 +110,3 @@ async function getProducts(params: Record<string, any>) {
109110
nextPage: count > nextPage ? nextPage : null,
110111
}
111112
}
112-
113-
function filterPublishedProducts(products: ProductDTO[]) {
114-
return products.filter((product) => product.status === "published")
115-
}

src/lib/data/index.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export async function getProductsList({
6666
const limit = queryParams.limit || 12
6767

6868
if (PRODUCT_MODULE_ENABLED) {
69-
console.log("PRODUCT_MODULE_ENABLED")
69+
DEBUG && console.log("PRODUCT_MODULE_ENABLED")
7070
const params = new URLSearchParams(queryParams as Record<string, string>)
7171

7272
const { products, count, nextPage } = await fetch(
@@ -237,3 +237,89 @@ export async function getProductsByCollectionHandle({
237237
nextPage,
238238
}
239239
}
240+
241+
/**
242+
* Fetches a category by handle, using the Medusa API or the Medusa Product Module, depending on the feature flag.
243+
* @param handle (string) - The handle of the category to retrieve
244+
* @returns collections (array) - An array of categories (should only be one)
245+
* @returns response (object) - An object containing the products and the number of products in the category
246+
* @returns nextPage (number) - The offset of the next page of products
247+
*/
248+
export async function getCategoryByHandle(handle: string) {
249+
if (PRODUCT_MODULE_ENABLED) {
250+
DEBUG && console.log("PRODUCT_MODULE_ENABLED")
251+
const data = await fetch(`${API_BASE_URL}/api/categories/${handle}`)
252+
.then((res) => res.json())
253+
.catch((err) => {
254+
throw err
255+
})
256+
257+
return data
258+
}
259+
260+
DEBUG && console.log("PRODUCT_MODULE_DISABLED")
261+
const data = await medusaRequest("GET", "/product-categories", {
262+
query: {
263+
handle: handle,
264+
},
265+
})
266+
.then((res) => res.body)
267+
.catch((err) => {
268+
throw err
269+
})
270+
271+
return data
272+
}
273+
274+
/**
275+
* Fetches a list of products in a collection, using the Medusa API or the Medusa Product Module, depending on the feature flag.
276+
* @param pageParam (number) - The offset of the products to retrieve
277+
* @param handle (string) - The handle of the collection to retrieve
278+
* @param cartId (string) - The ID of the cart
279+
* @returns response (object) - An object containing the products and the number of products in the collection
280+
* @returns nextPage (number) - The offset of the next page of products
281+
*/
282+
export async function getProductsByCategoryHandle({
283+
pageParam = 0,
284+
handle,
285+
cartId,
286+
}: {
287+
pageParam?: number
288+
handle: string
289+
cartId?: string
290+
}) {
291+
if (PRODUCT_MODULE_ENABLED) {
292+
DEBUG && console.log("PRODUCT_MODULE_ENABLED")
293+
const { response, nextPage } = await fetch(
294+
`${API_BASE_URL}/api/categories/${handle}?cart_id=${cartId}&page=${pageParam.toString()}`
295+
)
296+
.then((res) => res.json())
297+
.catch((err) => {
298+
throw err
299+
})
300+
301+
return {
302+
response,
303+
nextPage,
304+
}
305+
}
306+
307+
DEBUG && console.log("PRODUCT_MODULE_DISABLED")
308+
const { id } = await getCategoryByHandle(handle).then(
309+
(res) => res.product_categories[0]
310+
)
311+
312+
const { response, nextPage } = await getProductsList({
313+
pageParam,
314+
queryParams: { category_id: [id], cart_id: cartId },
315+
})
316+
.then((res) => res)
317+
.catch((err) => {
318+
throw err
319+
})
320+
321+
return {
322+
response,
323+
nextPage,
324+
}
325+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ProductDTO } from "@medusajs/types/dist/product/common"
2+
3+
export default function filterProductsByStatus(
4+
products: ProductDTO[],
5+
status: string
6+
) {
7+
return products.filter((product) => product.status === status)
8+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"use client"
2+
3+
import usePreviews from "@lib/hooks/use-previews"
4+
import { getProductsByCategoryHandle } from "@lib/data"
5+
import getNumberOfSkeletons from "@lib/util/get-number-of-skeletons"
6+
import repeat from "@lib/util/repeat"
7+
import ProductPreview from "@modules/products/components/product-preview"
8+
import SkeletonProductPreview from "@modules/skeletons/components/skeleton-product-preview"
9+
import { useInfiniteQuery } from "@tanstack/react-query"
10+
import { useCart } from "medusa-react"
11+
import React, { useEffect } from "react"
12+
import { useInView } from "react-intersection-observer"
13+
14+
type CategoryTemplateProps = {
15+
category: {
16+
handle: string
17+
name: string
18+
id: string
19+
}
20+
}
21+
22+
const CategoryTemplate: React.FC<CategoryTemplateProps> = ({ category }) => {
23+
const { cart } = useCart()
24+
const { ref, inView } = useInView()
25+
26+
const {
27+
data: infiniteData,
28+
hasNextPage,
29+
fetchNextPage,
30+
isFetchingNextPage,
31+
refetch,
32+
} = useInfiniteQuery(
33+
[`get_category_products`, category.handle, cart?.id],
34+
({ pageParam }) =>
35+
getProductsByCategoryHandle({
36+
pageParam,
37+
handle: category.handle,
38+
cartId: cart?.id,
39+
}),
40+
{
41+
getNextPageParam: (lastPage) => lastPage.nextPage,
42+
}
43+
)
44+
45+
useEffect(() => {
46+
if (cart?.region_id) {
47+
refetch()
48+
}
49+
}, [cart?.region_id, refetch])
50+
51+
const previews = usePreviews({
52+
pages: infiniteData?.pages,
53+
region: cart?.region,
54+
})
55+
56+
useEffect(() => {
57+
if (inView && hasNextPage) {
58+
fetchNextPage()
59+
}
60+
// eslint-disable-next-line react-hooks/exhaustive-deps
61+
}, [inView, hasNextPage])
62+
63+
return (
64+
<div className="content-container py-6">
65+
<div className="mb-8 text-2xl-semi">
66+
<h1>{category.name}</h1>
67+
</div>
68+
<ul className="grid grid-cols-2 small:grid-cols-3 medium:grid-cols-4 gap-x-4 gap-y-8">
69+
{previews.map((p) => (
70+
<li key={p.id}>
71+
<ProductPreview {...p} />
72+
</li>
73+
))}
74+
{isFetchingNextPage &&
75+
repeat(getNumberOfSkeletons(infiniteData?.pages)).map((index) => (
76+
<li key={index}>
77+
<SkeletonProductPreview />
78+
</li>
79+
))}
80+
</ul>
81+
<div
82+
className="py-16 flex justify-center items-center text-small-regular text-gray-700"
83+
ref={ref}
84+
>
85+
<span ref={ref}></span>
86+
</div>
87+
</div>
88+
)
89+
}
90+
91+
export default CategoryTemplate

0 commit comments

Comments
 (0)