diff --git a/README.md b/README.md index 066d3fe86..804a6a525 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,14 @@ The current release has been tested and is confirmed working with the following - Pretty URLs with builtin Nextjs functionality - Tailwind 3 for styling - JSDoc comments +- Product filtering: + - Dynamic color filtering using Tailwind's color system + - Mobile-optimized filter layout + - Accessible form controls with ARIA labels + - Price range slider + - Size and color filters + - Product type categorization + - Sorting options (popularity, price, newest) ## Troubleshooting diff --git a/package.json b/package.json index 96d516c31..8dfffec7c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.2.2", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/src/components/Product/ProductCard.component.tsx b/src/components/Product/ProductCard.component.tsx new file mode 100644 index 000000000..1635ea597 --- /dev/null +++ b/src/components/Product/ProductCard.component.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import Image from 'next/image'; + +interface ProductCardProps { + databaseId: number; + name: string; + price: string; + slug: string; + image?: { + sourceUrl?: string; + }; +} + +const ProductCard = ({ + databaseId, + name, + price, + slug, + image, +}: ProductCardProps) => { + return ( +
+
+ + {image?.sourceUrl ? ( + {name} + ) : ( +
+ No image +
+ )} + +
+ + +
+

+ {name} +

+
+ +
+ {price} +
+
+ ); +}; + +export default ProductCard; diff --git a/src/components/Product/ProductFilters.component.tsx b/src/components/Product/ProductFilters.component.tsx new file mode 100644 index 000000000..3b07ac255 --- /dev/null +++ b/src/components/Product/ProductFilters.component.tsx @@ -0,0 +1,156 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Product, ProductType } from '@/types/product'; + +interface ProductFiltersProps { + selectedSizes: string[]; + setSelectedSizes: Dispatch>; + selectedColors: string[]; + setSelectedColors: Dispatch>; + priceRange: [number, number]; + setPriceRange: Dispatch>; + productTypes: ProductType[]; + toggleProductType: (id: string) => void; + products: Product[]; + resetFilters: () => void; +} + +const ProductFilters = ({ + selectedSizes, + setSelectedSizes, + selectedColors, + setSelectedColors, + priceRange, + setPriceRange, + productTypes, + toggleProductType, + products, + resetFilters, +}: ProductFiltersProps) => { + // Get unique sizes from all products + const sizes = Array.from( + new Set( + products.flatMap( + (product: Product) => + product.allPaSizes?.nodes.map( + (node: { name: string }) => node.name, + ) || [], + ), + ), + ).sort((a, b) => a.localeCompare(b)); + + // Get unique colors from all products + const availableColors = products + .flatMap((product: Product) => 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` + })); + + const toggleSize = (size: string) => { + setSelectedSizes((prev) => + prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size], + ); + }; + + const toggleColor = (color: string) => { + setSelectedColors((prev) => + prev.includes(color) ? prev.filter((c) => c !== color) : [...prev, color], + ); + }; + + return ( +
+
+
+

PRODUKT TYPE

+
+ {productTypes.map((type) => ( + + ))} +
+
+ +
+

PRIS

+ + + setPriceRange([priceRange[0], parseInt(e.target.value)]) + } + className="w-full" + /> +
+ kr {priceRange[0]} + kr {priceRange[1]} +
+
+ +
+

STØRRELSE

+
+ {sizes.map((size) => ( + + ))} +
+
+ +
+

FARGE

+
+ {colors.map((color) => ( +
+
+ + +
+
+ ); +}; + +export default ProductFilters; diff --git a/src/components/Product/ProductList.component.tsx b/src/components/Product/ProductList.component.tsx new file mode 100644 index 000000000..f2e04a849 --- /dev/null +++ b/src/components/Product/ProductList.component.tsx @@ -0,0 +1,84 @@ +import { Product } from '@/types/product'; +import { useProductFilters } from '@/hooks/useProductFilters'; +import ProductCard from './ProductCard.component'; +import ProductFilters from './ProductFilters.component'; + +interface ProductListProps { + products: Product[]; + title: string; +} + +const ProductList = ({ products, title }: ProductListProps) => { + const { + sortBy, + setSortBy, + selectedSizes, + setSelectedSizes, + selectedColors, + setSelectedColors, + priceRange, + setPriceRange, + productTypes, + toggleProductType, + resetFilters, + filterProducts + } = useProductFilters(products); + + const filteredProducts = filterProducts(products); + + return ( +
+ + + {/* Main Content */} +
+
+

+ {title} ({filteredProducts.length}) +

+ +
+ + +
+
+ +
+ {filteredProducts.map((product: Product) => ( + + ))} +
+
+
+ ); +}; + +export default ProductList; diff --git a/src/hooks/useProductFilters.ts b/src/hooks/useProductFilters.ts new file mode 100644 index 000000000..ab046e758 --- /dev/null +++ b/src/hooks/useProductFilters.ts @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { Product, ProductType } from '@/types/product'; +import { getUniqueProductTypes } from '@/utils/functions/productUtils'; + +export const useProductFilters = (products: Product[]) => { + const [sortBy, setSortBy] = useState('popular'); + const [selectedSizes, setSelectedSizes] = useState([]); + const [selectedColors, setSelectedColors] = useState([]); + const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]); + const [productTypes, setProductTypes] = useState(() => + products ? getUniqueProductTypes(products) : [], + ); + + const toggleProductType = (id: string) => { + setProductTypes((prev) => + prev.map((type) => + type.id === id ? { ...type, checked: !type.checked } : type, + ), + ); + }; + + const resetFilters = () => { + setSelectedSizes([]); + setSelectedColors([]); + setPriceRange([0, 1000]); + setProductTypes((prev) => + prev.map((type) => ({ ...type, checked: false })), + ); + }; + + const filterProducts = (products: Product[]) => { + const filtered = products?.filter((product: Product) => { + // Filter by price + const productPrice = parseFloat(product.price.replace(/[^0-9.]/g, '')); + const withinPriceRange = + productPrice >= priceRange[0] && productPrice <= priceRange[1]; + if (!withinPriceRange) return false; + + // Filter by product type + 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; + } + + // Filter by size + if (selectedSizes.length > 0) { + const productSizes = + product.allPaSizes?.nodes.map((node) => node.name) || []; + if (!selectedSizes.some((size) => productSizes.includes(size))) + return false; + } + + // Filter by color + if (selectedColors.length > 0) { + const productColors = + product.allPaColors?.nodes.map((node) => node.name) || []; + if (!selectedColors.some((color) => productColors.includes(color))) + return false; + } + + return true; + }); + + // Sort products + 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: // 'popular' + return 0; + } + }); + }; + + return { + sortBy, + setSortBy, + selectedSizes, + setSelectedSizes, + selectedColors, + setSelectedColors, + priceRange, + setPriceRange, + productTypes, + toggleProductType, + resetFilters, + filterProducts, + }; +}; diff --git a/src/pages/produkter.tsx b/src/pages/produkter.tsx index 6bc984d1e..5c350b224 100644 --- a/src/pages/produkter.tsx +++ b/src/pages/produkter.tsx @@ -1,30 +1,44 @@ -// Components -import DisplayProducts from '@/components/Product/DisplayProducts.component'; +import Head from 'next/head'; import Layout from '@/components/Layout/Layout.component'; - -// GraphQL -import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES'; - -// Utilities +import ProductList from '@/components/Product/ProductList.component'; import client from '@/utils/apollo/ApolloClient'; - -// Types +import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES'; import type { NextPage, GetStaticProps, InferGetStaticPropsType } from 'next'; -/** - * Displays all of the products. - * @function HomePage - * @param {InferGetStaticPropsType} products - * @returns {JSX.Element} - Rendered component - */ - const Produkter: NextPage = ({ products, -}: InferGetStaticPropsType) => ( - - {products && } - -); + loading, +}: InferGetStaticPropsType) => { + if (loading) + return ( + +
+
+
+
+ ); + + if (!products) + return ( + +
+

Ingen produkter funnet

+
+
+ ); + + return ( + + + Produkter | WooCommerce Next.js + + +
+ +
+
+ ); +}; export default Produkter; diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 000000000..37432d8a9 --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,67 @@ +export interface Image { + __typename: string; + sourceUrl?: string; +} + +export interface Node { + __typename: string; + price: string; + regularPrice: string; + salePrice?: string; +} + +export interface ProductCategory { + name: string; + slug: string; +} + +export interface ColorNode { + name: string; + slug: string; +} + +export interface SizeNode { + name: string; +} + +export interface AttributeNode { + name: string; + value: string; +} + +export interface Product { + __typename: string; + databaseId: number; + name: string; + onSale: boolean; + slug: string; + image: Image; + price: string; + regularPrice: string; + salePrice?: string; + productCategories?: { + nodes: ProductCategory[]; + }; + allPaColors?: { + nodes: ColorNode[]; + }; + allPaSizes?: { + nodes: SizeNode[]; + }; + variations: { + nodes: Array<{ + price: string; + regularPrice: string; + salePrice?: string; + attributes?: { + nodes: AttributeNode[]; + }; + }>; + }; +} + +export interface ProductType { + id: string; + name: string; + checked: boolean; +} diff --git a/src/utils/functions/productUtils.ts b/src/utils/functions/productUtils.ts new file mode 100644 index 000000000..0bd240cb6 --- /dev/null +++ b/src/utils/functions/productUtils.ts @@ -0,0 +1,23 @@ +import { Product, ProductCategory, ProductType } from '@/types/product'; + +export const getUniqueProductTypes = (products: Product[]): ProductType[] => { + // Use Map to ensure unique categories by slug + const categoryMap = new Map(); + + products?.forEach((product) => { + product.productCategories?.nodes.forEach((cat: ProductCategory) => { + if (!categoryMap.has(cat.slug)) { + categoryMap.set(cat.slug, { + id: cat.slug, + name: cat.name, + checked: false, + }); + } + }); + }); + + // Convert Map values to array and sort by name + return Array.from(categoryMap.values()).sort((a, b) => + a.name.localeCompare(b.name), + ); +}; diff --git a/src/utils/gql/GQL_QUERIES.ts b/src/utils/gql/GQL_QUERIES.ts index 22e7e8b4b..b3edca9dd 100644 --- a/src/utils/gql/GQL_QUERIES.ts +++ b/src/utils/gql/GQL_QUERIES.ts @@ -117,6 +117,12 @@ export const FETCH_ALL_PRODUCTS_QUERY = gql` image { sourceUrl } + productCategories { + nodes { + name + slug + } + } ... on SimpleProduct { databaseId price @@ -128,11 +134,28 @@ export const FETCH_ALL_PRODUCTS_QUERY = gql` price regularPrice salePrice + allPaColors { + nodes { + name + slug + } + } + allPaSizes { + nodes { + name + } + } variations { nodes { price regularPrice salePrice + attributes { + nodes { + name + value + } + } } } }