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 ? (
+
+ ) : (
+
+ No image
+
+ )}
+
+
+
+
+
+
+
+ {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
+ }
+ }
}
}
}