🫢Step by Step Study (part 2)👆
PreviousBài toán bổ trợ - xác định interface typescirpt (ok)NextTìm hiểu về Map và Set trong javascript (ok)
Last updated
Was this helpful?
Last updated
Was this helpful?
import Link from 'next/link';
import Image from 'next/image';
import { paddedPrice } from './../../utils/functions/functions';
const ProductCard = ({ databaseId, name, price, slug, image, }: ProductCardProps) => {
if (price) {
price = paddedPrice(price as string, ' ');
}
return (
<div className="group">
<div className="aspect-[3/4] overflow-hidden bg-gray-100">
<Link href={`/product/${slug}?id=${databaseId}`} className='relative block h-full'>
{image?.sourceUrl ? (
<Image
src={image.sourceUrl}
alt={name}
fill
className="w-full h-full object-cover object-center transition duration-300 group-hover:scale-105"
priority={databaseId === 1}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
/>
) : (
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
<span className="text-gray-400">No image</span>
</div>
)}
</Link>
</div>
<Link href={`/product/${slug}?id=${databaseId}`}>
<div className="mt-4">
<p className="text-base font-bold text-center cursor-pointer hover:text-gray-600 transition-colors">
{name}
</p>
</div>
</Link>
<div className="mt-2 text-center">
<span className="text-gray-900">{price}</span>
</div>
</div>
)
}
export default ProductCard;
console.log(productTypes);
[
{
"id": "accessories",
"name": "Accessories",
"checked": false
},
{
"id": "hoodies",
"name": "Hoodies",
"checked": true
},
{
"id": "tshirts",
"name": "Tshirts",
"checked": false
}
]
src\components\Product\ProductFilters.component.tsx
import { Dispatch, SetStateAction } from 'react';
import Button from '@/components/UI/Button.component';
import Checkbox from '@/components/UI/Checkbox.component';
const ProductFilters = ({ productTypes, toggleProductType }: ProductFiltersProps) => {
return (
<div className="w-full md:w-64 flex-shrink-0">
<div className="bg-white px-8 pb-8 sm:px-6 sm:pb-6 rounded-lg shadow-sm">
<div className="mb-8">
<h3 className="font-semibold mb-4">PRODUCT TYPE</h3>
<div className="space-y-2">
{productTypes.map((type) => (
<Checkbox
key={type.id}
id={type.id}
label={type.name}
checked={type.checked}
onChange={() => toggleProductType(type.id)}
/>
))}
</div>
</div>
</div>
</div>
)
}
export default ProductFilters;
src\components\Product\ProductList.component.tsx
import { useProductFilters } from "@/hooks/useProductFilters";
import ProductCard from "./ProductCard.component";
import ProductFilters from "./ProductFilters.component";
const ProductList = ({ products, title }: ProductListProps) => {
const { productTypes, toggleProductType, filterProducts} = useProductFilters(products);
const filteredProducts = filterProducts(products);
return (
<div className="flex flex-col md:flex-row gap-8">
<ProductFilters
productTypes={productTypes}
toggleProductType={toggleProductType}
products={products}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500">({filteredProducts.length})</span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sortering:</label>
<select
id="sort-select"
value={"desc"}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: ProductDP) => (
<ProductCard
key={product.databaseId}
databaseId={product.databaseId}
name={product.name}
price={product.price}
slug={product.slug}
image={product.image}
/>
))}
</div>
</div>
</div>
)
}
export default ProductList;
<!DOCTYPE html>
<html>
<body>
<h1>JavaScript Maps</h1>
<h2>The new Map() Method</h2>
<p>Creating a map from an array:</p>
<p id="demo"></p>
<script>
// Create a Map
const fruits = new Map([["apples", 500],["bananas", 300],["oranges", 200]]);
let x = fruits.get("bananas");
document.getElementById("demo").innerHTML = "The number of apples in fruits are " + x;
</script>
</body>
</html>
=================================
JavaScript Maps
The new Map() Method
Creating a map from an array:
The number of apples in fruits are 300
[
{
"__typename": "SimpleProduct",
"databaseId": 1846,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/polo-2.jpg"
},
"onSale": false,
"name": "Polo",
"slug": "polo-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Tshirts",
"slug": "tshirts"
}
]
},
"price": "20 ₫",
"salePrice": null,
"regularPrice": "20 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 1845,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/long-sleeve-tee-2.jpg"
},
"onSale": false,
"name": "Long Sleeve Tee",
"slug": "long-sleeve-tee-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Tshirts",
"slug": "tshirts"
}
]
},
"price": "25 ₫",
"salePrice": null,
"regularPrice": "25 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 15,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-zipper-2.jpg"
},
"onSale": false,
"name": "Hoodie with Zipper",
"slug": "hoodie-with-zipper-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Hoodies",
"slug": "hoodies"
}
]
},
"price": "45 ₫",
"salePrice": null,
"regularPrice": "45 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 14,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-pocket-2.jpg"
},
"onSale": true,
"name": "Hoodie with Pocket",
"slug": "hoodie-with-pocket-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Hoodies",
"slug": "hoodies"
}
]
},
"price": "35 ₫",
"salePrice": "35 ₫",
"regularPrice": "45 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 13,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/sunglasses-2.jpg"
},
"onSale": false,
"name": "Sunglasses",
"slug": "sunglasses-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Accessories",
"slug": "accessories"
}
]
},
"price": "90 ₫",
"salePrice": null,
"regularPrice": "90 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 12,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/cap-2.jpg"
},
"onSale": true,
"name": "Cap",
"slug": "cap-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Accessories",
"slug": "accessories"
}
]
},
"price": "16 ₫",
"salePrice": "16 ₫",
"regularPrice": "18 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 11,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/belt-2.jpg"
},
"onSale": true,
"name": "Belt",
"slug": "belt-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Accessories",
"slug": "accessories"
}
]
},
"price": "55 ₫",
"salePrice": "55 ₫",
"regularPrice": "65 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 10,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/beanie-2.jpg"
},
"onSale": true,
"name": "Beanie",
"slug": "beanie-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Accessories",
"slug": "accessories"
}
]
},
"price": "18 ₫",
"salePrice": "18 ₫",
"regularPrice": "20 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 9,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/tshirt-2.jpg"
},
"onSale": false,
"name": "T-Shirt",
"slug": "t-shirt-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Tshirts",
"slug": "tshirts"
}
]
},
"price": "18 ₫",
"salePrice": null,
"regularPrice": "18 ₫"
},
{
"__typename": "SimpleProduct",
"databaseId": 8,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-with-logo-2.jpg"
},
"onSale": false,
"name": "Hoodie with Logo",
"slug": "hoodie-with-logo-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Hoodies",
"slug": "hoodies"
}
]
},
"price": "45 ₫",
"salePrice": null,
"regularPrice": "45 ₫"
},
{
"__typename": "VariableProduct",
"databaseId": 7,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg"
},
"onSale": false,
"name": "Hoodie",
"slug": "hoodie-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Hoodies",
"slug": "hoodies"
}
]
},
"price": "45 ₫",
"salePrice": null,
"regularPrice": "45, 45",
"productTypes": {
"__typename": "ProductToProductTypeConnection",
"nodes": [
{
"__typename": "ProductType",
"databaseId": 21,
"description": null,
"name": "variable",
"products": {
"__typename": "ProductTypeToProductConnection",
"nodes": [
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": true,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-2.jpg"
},
"price": "42 ₫ - 45 ₫",
"salePrice": "42 ₫",
"regularPrice": "45 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/vneck-tee-2.jpg"
},
"price": "15 ₫ - 20 ₫",
"salePrice": null,
"regularPrice": "15 ₫ - 20 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg"
},
"price": "45 ₫",
"salePrice": null,
"regularPrice": "45 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/vneck-tee-2.jpg"
},
"price": "15 ₫ - 20 ₫",
"salePrice": null,
"regularPrice": "15 ₫ - 20 ₫"
}
]
}
}
]
}
},
{
"__typename": "VariableProduct",
"databaseId": 6,
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"image": {
"__typename": "MediaItem",
"sourceUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/vneck-tee-2.jpg"
},
"onSale": false,
"name": "V-Neck T-Shirt Two",
"slug": "v-neck-t-shirt-2",
"productCategories": {
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Tshirts",
"slug": "tshirts"
}
]
},
"price": "15 ₫ - 20 ₫",
"salePrice": null,
"regularPrice": "15, 20, 20",
"productTypes": {
"__typename": "ProductToProductTypeConnection",
"nodes": [
{
"__typename": "ProductType",
"databaseId": 21,
"description": null,
"name": "variable",
"products": {
"__typename": "ProductTypeToProductConnection",
"nodes": [
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": true,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/hoodie-2.jpg"
},
"price": "42 ₫ - 45 ₫",
"salePrice": "42 ₫",
"regularPrice": "45 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2025/02/vneck-tee-2.jpg"
},
"price": "15 ₫ - 20 ₫",
"salePrice": null,
"regularPrice": "15 ₫ - 20 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/hoodie-2.jpg"
},
"price": "45 ₫",
"salePrice": null,
"regularPrice": "45 ₫"
},
{
"__typename": "VariableProduct",
"description": "<p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>\n",
"onSale": false,
"image": {
"__typename": "MediaItem",
"mediaItemUrl": "https://wpclidemo.dev/wp-content/uploads/2019/01/vneck-tee-2.jpg"
},
"price": "15 ₫ - 20 ₫",
"salePrice": null,
"regularPrice": "15 ₫ - 20 ₫"
}
]
}
}
]
}
}
]
console.log(product.productCategories);
{
"__typename": "ProductToProductCategoryConnection",
"nodes": [
{
"__typename": "ProductCategory",
"name": "Tshirts",
"slug": "tshirts"
}
]
}
console.log(categoryMap);
new Map([
[
"tshirts",
{
"id": "tshirts",
"name": "Tshirts",
"checked": false
}
],
[
"hoodies",
{
"id": "hoodies",
"name": "Hoodies",
"checked": false
}
],
[
"accessories",
{
"id": "accessories",
"name": "Accessories",
"checked": false
}
]
])
console.log(categoryMap.values());
MapIterator {
{ "value": { "id": "tshirts", "name": "Tshirts", "checked": false } }
{ "value": { "id": "hoodies", "name": "Hoodies", "checked": false } }
{ "value": { "id": "accessories", "name": "Accessories", "checked": false } }
}
console.log(Array.from(categoryMap.values()));
[
{
"id": "tshirts",
"name": "Tshirts",
"checked": false
},
{
"id": "hoodies",
"name": "Hoodies",
"checked": false
},
{
"id": "accessories",
"name": "Accessories",
"checked": false
}
]
src\hooks\useProductFilters.ts
import { getUniqueProductTypes } from '@/utils/functions/productUtils';
import { useState } from 'react';
export const useProductFilters = (products: ProductDP[]) => {
let productPrice;
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
products ? getUniqueProductTypes(products) : [],
);
const toggleProductType = (id: string) => {
setProductTypes((prev) =>
prev.map((type) =>
type.id === id ? { ...type, checked: !type.checked } : type,
),
);
};
const filterProducts = (products: ProductDP[]) => {
const filtered = products?.filter((product: ProductDP) => {
if (product.__typename === "VariableProduct") {
productPrice = parseFloat(product?.regularPrice.split(",").reverse()[0]);
}else {
productPrice = parseFloat(product?.regularPrice?.replace(/[^0-9]/g, ''));
}
const withinPriceRange = productPrice >= priceRange[0] && productPrice <= priceRange[1];
if (!withinPriceRange) return false;
const selectedTypes = productTypes.filter((t) => t.checked).map((t) => t.name.toLowerCase());
return true;
});
return [...(filtered || [])].sort((a, b) => {
return 0;
})
};
return {
productTypes,
toggleProductType,
filterProducts
}
}
src\components\Product\ProductList.component.tsx
import { useProductFilters } from "@/hooks/useProductFilters";
import ProductCard from "./ProductCard.component";
import ProductFilters from "./ProductFilters.component";
const ProductList = ({ products, title }: ProductListProps) => {
const { productTypes, toggleProductType, filterProducts} = useProductFilters(products);
const filteredProducts = filterProducts(products);
return (
<div className="flex flex-col md:flex-row gap-8">
<ProductFilters
productTypes={productTypes}
toggleProductType={toggleProductType}
products={products}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500">({filteredProducts.length})</span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sortering:</label>
<select
id="sort-select"
value={"desc"}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: ProductDP) => (
<ProductCard
key={product.databaseId}
databaseId={product.databaseId}
name={product.name}
price={product.price}
slug={product.slug}
image={product.image}
/>
))}
</div>
</div>
</div>
)
}
export default ProductList;
src\components\Product\ProductFilters.component.tsx
import { Dispatch, SetStateAction } from 'react';
import Button from '@/components/UI/Button.component';
import Checkbox from '@/components/UI/Checkbox.component';
const ProductFilters = ({ productTypes, toggleProductType }: ProductFiltersProps) => {
return (
<div className="w-full md:w-64 flex-shrink-0">
<div className="bg-white px-8 pb-8 sm:px-6 sm:pb-6 rounded-lg shadow-sm">
<div className="mb-8">
<h3 className="font-semibold mb-4">PRODUCT TYPE</h3>
<div className="space-y-2">
{productTypes.map((type) => (
<Checkbox
key={type.id}
id={type.id}
label={type.name}
checked={type.checked}
onChange={() => toggleProductType(type.id)}
/>
))}
</div>
</div>
</div>
</div>
)
}
export default ProductFilters;
src\components\UI\Checkbox.component.tsx
import { ChangeEvent } from 'react';
const Checkbox = ({ id, label, checked, onChange }: ICheckboxProps) => {
return (
<label htmlFor={id} className="flex items-center py-2 cursor-pointer">
<input
id={id}
type="checkbox"
className="form-checkbox h-5 w-5 cursor-pointer"
checked={checked}
onChange={onChange}
/>
<span className="ml-3 text-base">{label}</span>
</label>
);
}
export default Checkbox;
console.log(selectedTypes);
console.log(product.productCategories?.nodes);
console.log(productCategories);
Kết quả bộ lọc
src\hooks\useProductFilters.ts
import { getUniqueProductTypes } from '@/utils/functions/productUtils';
import { useState } from 'react';
export const useProductFilters = (products: ProductDP[]) => {
let productPrice;
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
products ? getUniqueProductTypes(products) : [],
);
const toggleProductType = (id: string) => {
setProductTypes((prev) =>
prev.map((type) =>
type.id === id ? { ...type, checked: !type.checked } : type,
),
);
};
const filterProducts = (products: ProductDP[]) => {
const filtered = products?.filter((product: ProductDP) => {
if (product.__typename === "VariableProduct") {
productPrice = parseFloat(product?.regularPrice.split(",").reverse()[0]);
}else {
productPrice = parseFloat(product?.regularPrice?.replace(/[^0-9]/g, ''));
}
const withinPriceRange = productPrice >= priceRange[0] && productPrice <= priceRange[1];
if (!withinPriceRange) return false;
const selectedTypes = productTypes.filter((t) => t.checked).map((t) => t.name.toLowerCase());
if (selectedTypes.length > 0) {
const productCategories = product.productCategories?.nodes.map((cat) => cat.name.toLowerCase()) || [];
if (!selectedTypes.some((type) => productCategories.includes(type))) return false;
}
return true;
});
return [...(filtered || [])].sort((a, b) => {
return 0;
})
};
return {
productTypes,
toggleProductType,
filterProducts
}
}
src\utils\gql\GQL_QUERIES.ts
import { gql } from '@apollo/client';
export const FETCH_ALL_PRODUCTS_QUERY = gql`
query MyQuery {
products(last: 36) {
nodes {
__typename
databaseId
description(format: RENDERED)
image {
__typename
sourceUrl
}
onSale
name
slug
productCategories {
nodes {
name
slug
}
}
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice(format: RAW)
allPaColors {
nodes {
name
slug
}
}
allPaSizes {
nodes {
name
}
}
productTypes {
nodes {
databaseId
description
name
products {
__typename
nodes {
description
onSale
image {
__typename
mediaItemUrl
}
... on SimpleProduct {
price
salePrice
regularPrice
}
... on VariableProduct {
price
salePrice
regularPrice
}
}
}
}
}
}
}
}
}
`;
export const GET_SINGLE_PRODUCT = gql`
query Product($id: ID!) {
product(id: $id, idType: DATABASE_ID) {
databaseId
name
description
slug
averageRating
image {
uri
title
srcSet
sourceUrl
}
... on SimpleProduct {
stockQuantity
price
regularPrice
salePrice
}
... on VariableProduct {
price
regularPrice
salePrice
allPaColors {
nodes {
name
}
}
allPaSizes {
nodes {
name
}
}
variations {
nodes {
databaseId
name
image {
sourceUrl
}
stockStatus
stockQuantity
price
salePrice
regularPrice
onSale
}
}
}
... on ExternalProduct {
name
price
externalUrl
}
... on GroupProduct {
products {
nodes {
... on SimpleProduct {
price
regularPrice
salePrice
}
}
}
}
}
}
`;
export const GET_CART = gql`
query GET_CART {
cart {
contents {
nodes {
key
product {
node {
id
databaseId
name
description
type
onSale
slug
averageRating
reviewCount
image {
id
sourceUrl
srcSet
altText
title
}
galleryImages {
nodes {
id
sourceUrl
srcSet
altText
title
}
}
}
}
variation {
node {
id
databaseId
name
description
type
onSale
price
regularPrice
salePrice
image {
id
sourceUrl
srcSet
altText
title
}
attributes {
nodes {
id
name
value
}
}
}
}
quantity
total
subtotal
subtotalTax
}
}
subtotal
subtotalTax
shippingTax
shippingTotal
total
totalTax
feeTax
feeTotal
discountTax
discountTotal
}
}
`;
console.log(sizes);
console.log(colors);
src\components\Product\ProductFilters.component.tsx
import Button from '@/components/UI/Button.component';
import Checkbox from '@/components/UI/Checkbox.component';
const ProductFilters = ({
productTypes,
toggleProductType,
products,
selectedSizes,
setSelectedSizes,
selectedColors,
setSelectedColors,
}: ProductFiltersProps) => {
const sizes = Array.from(
new Set(
products.flatMap(
(product: ProductDP) => product.allPaSizes?.nodes.map((node: { name: string }) => node.name,) || []
),
),
).sort((a, b) => a.localeCompare(b));
const toggleSize = (size: string) => {
setSelectedSizes((prev) =>
prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size],
);
};
const availableColors = products
.flatMap((product: ProductDP) => product.allPaColors?.nodes || [])
.filter((color, index, self) =>
index === self.findIndex((c) => c.slug === color.slug)
)
.sort((a, b) => a.name.localeCompare(b.name));
const colors = availableColors.map((color) => ({
name: color.name,
class: `bg-${color.slug}-500`
}));
console.log(colors);
const toggleColor = (color: string) => {
setSelectedColors((prev) =>
prev.includes(color) ? prev.filter((c) => c !== color) : [...prev, color],
);
};
return (
<div className="w-72 flex-shrink-0">
<div className="bg-white px-8 pb-8 sm:px-6 sm:pb-6 rounded-lg shadow-sm">
<div className="mb-4">
<h3 className="font-semibold mb-4">PRODUCT TYPE</h3>
<div className="space-y-2">
{productTypes.map((type) => (
<Checkbox
key={type.id}
id={type.id}
label={type.name}
checked={type.checked}
onChange={() => toggleProductType(type.id)}
/>
))}
</div>
</div>
<div className="mb-4">
<h3 className="font-semibold mb-4">SIZE</h3>
<div className="grid grid-cols-3 gap-2">
{sizes.map((size) => (
<Button
key={size}
handleButtonClick={() => toggleSize(size)}
variant="filter"
selected={selectedSizes.includes(size)}
>
{size}
</Button>
))}
</div>
</div>
<div className="mb-4">
<h3 className="font-semibold mb-4">COLOR</h3>
<div className="grid grid-cols-3 gap-2">
{colors.map((color) => (
<button
key={color.name}
onClick={() => toggleColor(color.name)}
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs ${color.class} ${selectedColors.includes(color.name)
? 'ring-2 ring-offset-2 ring-gray-900'
: ''
}`}
title={color.name}
/>
))}
</div>
</div>
</div>
</div>
)
}
export default ProductFilters;
src\components\Product\ProductList.component.tsx
import { useProductFilters } from "@/hooks/useProductFilters";
import ProductCard from "./ProductCard.component";
import ProductFilters from "./ProductFilters.component";
const ProductList = ({ products, title }: ProductListProps) => {
const {
productTypes,
toggleProductType,
filterProducts,
selectedSizes,
setSelectedSizes,
selectedColors,
setSelectedColors,
} = useProductFilters(products);
const filteredProducts = filterProducts(products);
return (
<div className="flex flex-col md:flex-row gap-8">
<ProductFilters
selectedColors={selectedColors}
setSelectedColors={setSelectedColors}
selectedSizes={selectedSizes}
setSelectedSizes={setSelectedSizes}
productTypes={productTypes}
toggleProductType={toggleProductType}
products={products}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8 bg-pink">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500">({filteredProducts.length})</span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sortering:</label>
<select
id="sort-select"
value={"desc"}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: ProductDP) => (
<ProductCard
key={product.databaseId}
databaseId={product.databaseId}
name={product.name}
price={product.price}
slug={product.slug}
image={product.image}
/>
))}
</div>
</div>
</div>
)
}
export default ProductList;
src\hooks\useProductFilters.ts
import { getUniqueProductTypes } from '@/utils/functions/productUtils';
import { useState } from 'react';
export const useProductFilters = (products: ProductDP[]) => {
let productPrice;
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
products ? getUniqueProductTypes(products) : [],
);
const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const toggleProductType = (id: string) => {
setProductTypes((prev) =>
prev.map((type) =>
type.id === id ? { ...type, checked: !type.checked } : type,
),
);
};
const filterProducts = (products: ProductDP[]) => {
const filtered = products?.filter((product: ProductDP) => {
if (product.__typename === "VariableProduct") {
productPrice = parseFloat(product?.regularPrice.split(",").reverse()[0]);
}else {
productPrice = parseFloat(product?.regularPrice?.replace(/[^0-9]/g, ''));
}
const withinPriceRange = productPrice >= priceRange[0] && productPrice <= priceRange[1];
if (!withinPriceRange) return false;
const selectedTypes = productTypes.filter((t) => t.checked).map((t) => t.name.toLowerCase());
if (selectedTypes.length > 0) {
const productCategories = product.productCategories?.nodes.map((cat) => cat.name.toLowerCase()) || [];
if (!selectedTypes.some((type) => productCategories.includes(type))) return false;
}
if (selectedSizes.length > 0) {
const productSizes = product.allPaSizes?.nodes.map((node) => node.name) || [];
if (!selectedSizes.some((size) => productSizes.includes(size))) return false;
}
if (selectedColors.length > 0) {
const productColors =
product.allPaColors?.nodes.map((node) => node.name) || [];
if (!selectedColors.some((color) => productColors.includes(color))) return false;
}
return true;
});
return [...(filtered || [])].sort((a, b) => {
return 0;
})
};
return {
selectedColors,
setSelectedColors,
selectedSizes,
setSelectedSizes,
productTypes,
toggleProductType,
filterProducts
}
}
src\hooks\useProductFilters.ts
import { getUniqueProductTypes } from '@/utils/functions/productUtils';
import { useState } from 'react';
export const useProductFilters = (products: ProductDP[]) => {
let productPrice;
const [sortBy, setSortBy] = useState('popular');
const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]);
const [productTypes, setProductTypes] = useState<ProductType[]>(() =>
products ? getUniqueProductTypes(products) : [],
);
const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const toggleProductType = (id: string) => {
setProductTypes((prev) =>
prev.map((type) =>
type.id === id ? { ...type, checked: !type.checked } : type,
),
);
};
const filterProducts = (products: ProductDP[]) => {
const filtered = products?.filter((product: ProductDP) => {
if (product.__typename === "VariableProduct") {
productPrice = parseFloat(product?.regularPrice?.split(",").reverse()[0]);
}else {
productPrice = parseFloat(product?.regularPrice?.replace(/[^0-9]/g, ''));
}
const withinPriceRange = productPrice >= priceRange[0] && productPrice <= priceRange[1];
if (!withinPriceRange) return false;
const selectedTypes = productTypes.filter((t) => t.checked).map((t) => t.name.toLowerCase());
if (selectedTypes.length > 0) {
const productCategories = product.productCategories?.nodes.map((cat) => cat.name.toLowerCase()) || [];
if (!selectedTypes.some((type) => productCategories.includes(type))) return false;
}
if (selectedSizes.length > 0) {
const productSizes = product.allPaSizes?.nodes.map((node) => node.name) || [];
if (!selectedSizes.some((size) => productSizes.includes(size))) return false;
}
if (selectedColors.length > 0) {
const productColors =
product.allPaColors?.nodes.map((node) => node.name) || [];
if (!selectedColors.some((color) => productColors.includes(color))) return false;
}
return true;
});
return [...(filtered || [])].sort((a, b) => {
const priceA = parseFloat(a.price.replace(/[^0-9.]/g, ''));
const priceB = parseFloat(b.price.replace(/[^0-9.]/g, ''));
switch (sortBy) {
case 'price-low':
return priceA - priceB;
case 'price-high':
return priceB - priceA;
case 'newest':
return b.databaseId - a.databaseId;
default:
return 0;
}
});
};
return {
sortBy,
setSortBy,
priceRange,
setPriceRange,
selectedColors,
setSelectedColors,
selectedSizes,
setSelectedSizes,
productTypes,
toggleProductType,
filterProducts
}
}
src\components\Product\ProductList.component.tsx
import { useProductFilters } from "@/hooks/useProductFilters";
import ProductCard from "./ProductCard.component";
import ProductFilters from "./ProductFilters.component";
const ProductList = ({ products, title }: ProductListProps) => {
const {
productTypes,
toggleProductType,
filterProducts,
selectedSizes,
setSelectedSizes,
selectedColors,
setSelectedColors,
priceRange,
setPriceRange,
sortBy,
setSortBy
} = useProductFilters(products);
const filteredProducts = filterProducts(products);
return (
<div className="flex flex-col md:flex-row gap-8">
<ProductFilters
selectedColors={selectedColors}
setSelectedColors={setSelectedColors}
selectedSizes={selectedSizes}
setSelectedSizes={setSelectedSizes}
productTypes={productTypes}
toggleProductType={toggleProductType}
products={products}
priceRange={priceRange}
setPriceRange={setPriceRange}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-8 bg-pink">
<h1 className="text-xl sm:text-2xl font-medium text-center sm:text-left">
{title} <span className="text-gray-500">({filteredProducts.length})</span>
</h1>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-2 sm:gap-4">
<label htmlFor="sort-select" className="text-sm font-medium">Sorting:</label>
<select
id="sort-select"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Popular</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="newest">Newest</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: ProductDP) => (
<ProductCard
key={product.databaseId}
databaseId={product.databaseId}
name={product.name}
price={product.price}
slug={product.slug}
image={product.image}
/>
))}
</div>
</div>
</div>
)
}
export default ProductList;
src\pages\categories.tsx
import { NextPage, InferGetStaticPropsType, GetStaticProps } from 'next';
import Categories from '@/components/Category/Categories.component';
import Layout from '@/components/Layout/Layout.component';
import client from '@/utils/apollo/ApolloClient';
import { FETCH_ALL_CATEGORIES_QUERY } from '@/utils/gql/GQL_QUERIES';
/**
* Category page displays all of the categories
*/
const Category: NextPage = ({
categories,
}: InferGetStaticPropsType<typeof getStaticProps>) => (
<Layout title="Categories">
{categories && <Categories categories={categories} />}
</Layout>
);
export default Category;
export const getStaticProps: GetStaticProps = async () => {
const result = await client.query({
query: FETCH_ALL_CATEGORIES_QUERY,
});
return {
props: {
categories: result.data.productCategories.nodes,
},
revalidate: 10,
};
};
src\components\Category\Categories.component.tsx
import Link from 'next/link';
import { v4 as uuidv4 } from 'uuid';
const Categories = ({ categories }: ICategoriesProps) => (
<section className="container mx-auto bg-white">
<div className="grid gap-2 px-2 pt-2 pb-2 lg:px-0 xl:px-0 md:px-0 lg:grid-cols-3 sm:grid-cols-1 md:grid-cols-3 xs:grid-cols-3">
{
categories.map(({ id, name, slug }) => (
<Link
key={uuidv4()}
href={`/category/${encodeURIComponent(slug)}?id=${encodeURIComponent(id)}`}
>
<div className="p-6 cursor-pointer">
<div className="flex items-center justify-center w-full h-16 text-center border border-gray-300 rounded-lg shadow hover:shadow-outline">
<p className="text-lg">{name}</p>
</div>
</div>
</Link>
))}
</div>
</section>
);
export default Categories;
src\pages\category\[slug].tsx
import { withRouter } from 'next/router';
import Layout from '@/components/Layout/Layout.component';
import DisplayProducts from '@/components/Product/DisplayProducts.component';
import client from '@/utils/apollo/ApolloClient';
import { GET_PRODUCTS_FROM_CATEGORY } from '@/utils/gql/GQL_QUERIES';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
const Produkt = ({ categoryName, products}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
return (
<Layout title={`${categoryName ? categoryName : ''}`}>
{products ? (
<DisplayProducts products={products} />
) : (
<div className="mt-8 text-2xl text-center">Loading product ...</div>
)}
</Layout>
);
};
export default withRouter(Produkt);
export const getServerSideProps: GetServerSideProps = async ({ query: { id }}) => {
const res = await client.query({
query: GET_PRODUCTS_FROM_CATEGORY,
variables: { id }
});
return {
props: {
categoryName: res.data.productCategory.name,
products: res.data.productCategory.products.nodes,
},
};
};
Source code gốc