Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/components/Product/ProductCard.component.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="group">
<div className="aspect-[3/4] overflow-hidden bg-gray-100 relative">
<Link href={`/produkt/${slug}?id=${databaseId}`}>
{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={`/produkt/${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;
156 changes: 156 additions & 0 deletions src/components/Product/ProductFilters.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { Dispatch, SetStateAction } from 'react';
import { Product, ProductType } from '@/types/product';

interface ProductFiltersProps {
selectedSizes: string[];
setSelectedSizes: Dispatch<SetStateAction<string[]>>;
selectedColors: string[];
setSelectedColors: Dispatch<SetStateAction<string[]>>;
priceRange: [number, number];
setPriceRange: Dispatch<SetStateAction<[number, number]>>;
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 (
<div className="w-full md:w-64 flex-shrink-0">
<div className="bg-white p-8 sm:p-6 rounded-lg shadow-sm">
<div className="mb-8">
<h3 className="font-semibold mb-4">PRODUKT TYPE</h3>
<div className="space-y-2">
{productTypes.map((type) => (
<label key={type.id} className="flex items-center">
<input
type="checkbox"
className="form-checkbox"
checked={type.checked}
onChange={() => toggleProductType(type.id)}
/>
<span className="ml-2">{type.name}</span>
</label>
))}
</div>
</div>

<div className="mb-8">
<h3 className="font-semibold mb-4">PRIS</h3>
<label htmlFor="price-range" className="sr-only">Pris</label>
<input
id="price-range"
type="range"
min="0"
max="1000"
value={priceRange[1]}
onChange={(e) =>
setPriceRange([priceRange[0], parseInt(e.target.value)])
}
className="w-full"
/>
<div className="flex justify-between mt-2">
<span>kr {priceRange[0]}</span>
<span>kr {priceRange[1]}</span>
</div>
</div>

<div className="mb-8">
<h3 className="font-semibold mb-4">STØRRELSE</h3>
<div className="grid grid-cols-3 gap-2">
{sizes.map((size) => (
<button
key={size}
onClick={() => toggleSize(size)}
className={`px-3 py-1 border rounded ${
selectedSizes.includes(size)
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'
}`}
>
{size}
</button>
))}
</div>
</div>

<div className="mb-8">
<h3 className="font-semibold mb-4">FARGE</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>

<button
onClick={resetFilters}
className="w-full mt-8 py-2 px-4 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
Resett filter
</button>
</div>
</div>
);
};

export default ProductFilters;
84 changes: 84 additions & 0 deletions src/components/Product/ProductList.component.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col md:flex-row gap-8">
<ProductFilters
selectedSizes={selectedSizes}
setSelectedSizes={setSelectedSizes}
selectedColors={selectedColors}
setSelectedColors={setSelectedColors}
priceRange={priceRange}
setPriceRange={setPriceRange}
productTypes={productTypes}
toggleProductType={toggleProductType}
products={products}
resetFilters={resetFilters}
/>

{/* Main Content */}
<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={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="min-w-[140px] border rounded-md px-3 py-1.5 text-sm"
>
<option value="popular">Populær</option>
<option value="price-low">Pris: Lav til Høy</option>
<option value="price-high">Pris: Høy til Lav</option>
<option value="newest">Nyeste</option>
</select>
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product: Product) => (
<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;
Loading
Loading