chore: transfer repo
This commit is contained in:
62
components/products/AddToBoxButton.tsx
Normal file
62
components/products/AddToBoxButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useAppDispatch } from "@/lib/redux/hooks";
|
||||
import { addToBox } from "@/lib/redux/slices/boxSlice";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AddToBoxButtonProps {
|
||||
productId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
image: string;
|
||||
variantId?: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AddToBoxButton({
|
||||
productId,
|
||||
name,
|
||||
price,
|
||||
image,
|
||||
variantId,
|
||||
color,
|
||||
className = ""
|
||||
}: AddToBoxButtonProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleAddToBox = () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
dispatch(addToBox({
|
||||
id: productId,
|
||||
name,
|
||||
price,
|
||||
image,
|
||||
quantity: 1,
|
||||
variantId,
|
||||
color
|
||||
}));
|
||||
|
||||
// Add a small delay to simulate adding to cart for better UX
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleAddToBox}
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className={`w-full ${className}`}
|
||||
>
|
||||
{isLoading ? "Dodaje se..." : "Dodaj u box"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
477
components/products/FilterSidebar.tsx
Normal file
477
components/products/FilterSidebar.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { RadioButton } from '@/components/ui/RadioButton';
|
||||
import { Heading } from '@/components/ui/Typography';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SortDropdown } from './client/SortDropdown';
|
||||
|
||||
interface FilterSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApplyFilters: (filters: string[]) => void;
|
||||
activeFilters: string[];
|
||||
}
|
||||
|
||||
interface FilterCategory {
|
||||
name: string;
|
||||
type?: 'checkbox' | 'radio' | 'dropdown';
|
||||
options: string[];
|
||||
}
|
||||
|
||||
const FILTER_CATEGORIES: FilterCategory[] = [
|
||||
{
|
||||
name: 'Filter One',
|
||||
type: 'checkbox',
|
||||
options: ['Test One', 'Test Two', 'Test Three', 'Test Four', 'Test Five']
|
||||
},
|
||||
{
|
||||
name: 'Filter Two',
|
||||
type: 'radio',
|
||||
options: ['All', 'Option One', 'Option Two', 'Option Three', 'Option Four', 'Option Five']
|
||||
},
|
||||
{
|
||||
name: 'Filter Six',
|
||||
type: 'dropdown',
|
||||
options: ['Select 1', 'Select 2', 'Select 3']
|
||||
}
|
||||
];
|
||||
|
||||
// Helper function to create a unique filter key
|
||||
const getFilterKey = (category: string, option: string) => {
|
||||
return `${category}:${option}`;
|
||||
};
|
||||
|
||||
// Helper function to extract the option from a filter key
|
||||
const getOptionFromKey = (filterKey: string) => {
|
||||
const parts = filterKey.split(':');
|
||||
return parts.length > 1 ? parts[1] : filterKey;
|
||||
};
|
||||
|
||||
// Helper function to extract the category from a filter key
|
||||
const getCategoryFromKey = (filterKey: string) => {
|
||||
const parts = filterKey.split(':');
|
||||
return parts.length > 1 ? parts[0] : '';
|
||||
};
|
||||
|
||||
export function FilterSidebar({ isOpen, onClose, onApplyFilters, activeFilters }: FilterSidebarProps) {
|
||||
const [minPrice, setMinPrice] = useState(5);
|
||||
const [maxPrice, setMaxPrice] = useState(100);
|
||||
const [localFilters, setLocalFilters] = useState<string[]>([]);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const rangeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update the range slider fill with primary color
|
||||
const updateRangeStyle = () => {
|
||||
if (rangeRef.current) {
|
||||
const percentage1 = Math.min((minPrice / 100) * 100, 100);
|
||||
const percentage2 = Math.min((maxPrice / 100) * 100, 100);
|
||||
rangeRef.current.style.background = `linear-gradient(to right, #e5e7eb ${percentage1}%, var(--primary-color, #D94D72) ${percentage1}%, var(--primary-color, #D94D72) ${percentage2}%, #e5e7eb ${percentage2}%)`;
|
||||
}
|
||||
};
|
||||
|
||||
// Update range fill on mount and when prices change
|
||||
useEffect(() => {
|
||||
updateRangeStyle();
|
||||
}, [minPrice, maxPrice]);
|
||||
|
||||
// Sync activeFilters with localFilters when sidebar opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalFilters([...activeFilters]);
|
||||
|
||||
// Check for price filter in active filters
|
||||
const priceFilter = activeFilters.find(filter => filter.startsWith('Cijena:'));
|
||||
if (priceFilter) {
|
||||
const priceMatch = priceFilter.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
setMinPrice(parseInt(priceMatch[1]));
|
||||
setMaxPrice(parseInt(priceMatch[2]));
|
||||
}
|
||||
} else {
|
||||
// Reset to default values if no price filter
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
}
|
||||
|
||||
// Reset scroll position when sidebar opens
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeFilters]);
|
||||
|
||||
// Lock body scroll when sidebar is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const toggleFilter = (category: string, option: string) => {
|
||||
const filterKey = getFilterKey(category, option);
|
||||
if (localFilters.includes(filterKey)) {
|
||||
setLocalFilters(localFilters.filter(f => f !== filterKey));
|
||||
} else {
|
||||
setLocalFilters([...localFilters, filterKey]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setLocalFilters([]);
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
};
|
||||
|
||||
const clearFilterCategory = (category: string) => {
|
||||
setLocalFilters(localFilters.filter(f => !f.startsWith(`${category}:`)));
|
||||
};
|
||||
|
||||
const clearPriceRange = () => {
|
||||
// Reset price values
|
||||
setMinPrice(5);
|
||||
setMaxPrice(100);
|
||||
|
||||
// Remove any price filter tags
|
||||
setLocalFilters(localFilters.filter(f => !f.startsWith('Cijena:')));
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
// Create a copy of localFilters, removing any existing price filters
|
||||
let filtersToApply = localFilters.filter(f => !f.startsWith('Cijena:'));
|
||||
|
||||
// Ensure min price doesn't exceed max price
|
||||
let finalMinPrice = minPrice;
|
||||
let finalMaxPrice = maxPrice;
|
||||
|
||||
if (finalMinPrice > finalMaxPrice) {
|
||||
finalMinPrice = finalMaxPrice;
|
||||
}
|
||||
|
||||
// Add price range as a filter tag
|
||||
if (finalMinPrice > 5 || finalMaxPrice !== 100) {
|
||||
filtersToApply.push(`Cijena: ${finalMinPrice}€ - ${finalMaxPrice}€`);
|
||||
}
|
||||
|
||||
onApplyFilters(filtersToApply);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle price range min input change
|
||||
const handleMinPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
setMinPrice(value);
|
||||
// If min exceeds max, adjust max as well
|
||||
if (value > maxPrice) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle price range max input change
|
||||
const handleMaxPriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value >= 0) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract filter option handlers for cleaner code
|
||||
const handleRadioChange = (category: string, option: string, categoryOptions: string[]) => {
|
||||
// Get all filter keys for this category
|
||||
const categoryFilterPrefix = `${category}:`;
|
||||
|
||||
// Remove previous selections from this category
|
||||
const filtersWithoutCategory = localFilters.filter(f => !f.startsWith(categoryFilterPrefix));
|
||||
|
||||
if (option === 'All') {
|
||||
// If 'All' is selected, don't add any new filter for this category
|
||||
setLocalFilters(filtersWithoutCategory);
|
||||
} else {
|
||||
// Add the new selected option with category prefix
|
||||
setLocalFilters([...filtersWithoutCategory, getFilterKey(category, option)]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (category: string, value: string) => {
|
||||
if (value) {
|
||||
// Get all filter keys for this category
|
||||
const categoryFilterPrefix = `${category}:`;
|
||||
|
||||
// Remove previous selections from this category
|
||||
const filtersWithoutCategory = localFilters.filter(f => !f.startsWith(categoryFilterPrefix));
|
||||
|
||||
// Add the new selected option with category prefix
|
||||
setLocalFilters([...filtersWithoutCategory, getFilterKey(category, value)]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay - adjusted to only cover the product grid area */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 bg-black bg-opacity-50 z-30"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
top: '0', // Same as sidebar top position
|
||||
bottom: '0', // Allow footer to be visible
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - positioned exactly below navbar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`fixed left-0 h-[calc(100vh-64px)] w-[350px] bg-white z-40 transform transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} overflow-y-auto shadow-lg`}
|
||||
style={{ top: '64px', margin: 0, padding: 0 }} // Exactly at navbar bottom with no space
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Heading level={4} className="font-semibold">Filteri</Heading>
|
||||
<button onClick={onClose} className="text-black">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<hr className="border-gray-200 mb-4" />
|
||||
|
||||
{/* Filter sections */}
|
||||
<div className="space-y-4">
|
||||
{/* Checkbox Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[0]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[0]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{FILTER_CATEGORIES[0]?.options.map((option) => (
|
||||
<div key={option}>
|
||||
<Checkbox
|
||||
id={option}
|
||||
checked={localFilters.includes(getFilterKey(FILTER_CATEGORIES[0]?.name || '', option))}
|
||||
onChange={() => toggleFilter(FILTER_CATEGORIES[0]?.name || '', option)}
|
||||
label={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Radio Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[1]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[1]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{FILTER_CATEGORIES[1]?.options.map((option) => (
|
||||
<div key={option}>
|
||||
<RadioButton
|
||||
id={`radio-${option}`}
|
||||
name="filter-two"
|
||||
value={option}
|
||||
checked={
|
||||
option === 'All'
|
||||
? !localFilters.some(f => f.startsWith(`${FILTER_CATEGORIES[1]?.name}:`) && !f.endsWith(':All'))
|
||||
: localFilters.includes(getFilterKey(FILTER_CATEGORIES[1]?.name || '', option))
|
||||
}
|
||||
onChange={() => handleRadioChange(FILTER_CATEGORIES[1]?.name || '', option, FILTER_CATEGORIES[1]?.options || [])}
|
||||
label={option}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Dropdown Filter */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">{FILTER_CATEGORIES[2]?.name}</Heading>
|
||||
<button
|
||||
onClick={() => clearFilterCategory(FILTER_CATEGORIES[2]?.name || '')}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SortDropdown
|
||||
value={localFilters.find(f => f.startsWith(`${FILTER_CATEGORIES[2]?.name}:`))?.split(':')[1] || ''}
|
||||
onChange={(value) => handleDropdownChange(FILTER_CATEGORIES[2]?.name || '', value)}
|
||||
label=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Price Range */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Heading level={4} className="font-medium">Cijena</Heading>
|
||||
<button
|
||||
onClick={clearPriceRange}
|
||||
className="text-sm text-gray-500 hover:text-black"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Min-Max Range Inputs */}
|
||||
<div className="flex space-x-4 mb-4">
|
||||
<div className="w-1/2">
|
||||
<label htmlFor="min-price" className="text-sm block mb-1">Od</label>
|
||||
<input
|
||||
type="number"
|
||||
id="min-price"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={handleMinPriceChange}
|
||||
className="w-full border border-gray-300 rounded-md p-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<label htmlFor="max-price" className="text-sm block mb-1">Do</label>
|
||||
<input
|
||||
type="number"
|
||||
id="max-price"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={handleMaxPriceChange}
|
||||
className="w-full border border-gray-300 rounded-md p-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dual Range Slider */}
|
||||
<div className="relative pt-5 pb-8">
|
||||
<div
|
||||
ref={rangeRef}
|
||||
className="w-full h-1 bg-gray-200 rounded absolute top-6"
|
||||
></div>
|
||||
|
||||
{/* Min range slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.min(minPrice, 100)}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value < maxPrice) {
|
||||
setMinPrice(value);
|
||||
}
|
||||
}}
|
||||
className="absolute top-4 w-full pointer-events-none appearance-none bg-transparent z-30"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Max range slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.min(maxPrice, 100)}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (value > minPrice) {
|
||||
setMaxPrice(value);
|
||||
}
|
||||
}}
|
||||
className="absolute top-4 w-full pointer-events-none appearance-none bg-transparent z-30"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
<style jsx>{`
|
||||
:root {
|
||||
--primary-color: #D94D72;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
pointer-events: all;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--primary-color, #D94D72);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
pointer-events: all;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--primary-color, #D94D72);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-sm">{minPrice}€</span>
|
||||
<span className="text-sm">{maxPrice}€</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="mt-8">
|
||||
<hr className="border-gray-200 mb-4" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Obriši sve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={applyFilters}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Filtriraj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
components/products/ProductComponents/FilterButton.tsx
Normal file
25
components/products/ProductComponents/FilterButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
interface FilterButtonProps {
|
||||
onClick: () => void;
|
||||
filterText?: string;
|
||||
}
|
||||
|
||||
export function FilterButton({ onClick, filterText = "Filteri" }: FilterButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="border border-gray-300 px-4 py-2 rounded-md flex items-center"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h18M3 12h18M3 20h18" />
|
||||
</svg>
|
||||
<span>{filterText}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
52
components/products/ProductComponents/FilterTagList.tsx
Normal file
52
components/products/ProductComponents/FilterTagList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
interface FilterTagListProps {
|
||||
filters: string[];
|
||||
formatTag: (filter: string) => string;
|
||||
onRemove: (filter: string) => void;
|
||||
onClearAll: () => void;
|
||||
clearAllText?: string;
|
||||
}
|
||||
|
||||
export function FilterTagList({
|
||||
filters,
|
||||
formatTag,
|
||||
onRemove,
|
||||
onClearAll,
|
||||
clearAllText = "Očisti sve"
|
||||
}: FilterTagListProps) {
|
||||
if (!filters || filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((filter) => (
|
||||
<div
|
||||
key={filter}
|
||||
className="bg-gray-100 text-sm py-1 px-3 rounded-full flex items-center"
|
||||
>
|
||||
<span>{formatTag(filter)}</span>
|
||||
<button
|
||||
onClick={() => onRemove(filter)}
|
||||
className="ml-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 8.586l3.293-3.293a1 1 0 111.414 1.414L11.414 10l3.293 3.293a1 1 0 01-1.414 1.414L10 11.414l-3.293 3.293a1 1 0 01-1.414-1.414L8.586 10 5.293 6.707a1 1 0 011.414-1.414L10 8.586z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-primary text-sm font-medium hover:underline"
|
||||
>
|
||||
{clearAllText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/products/ProductComponents/ProductCounter.tsx
Normal file
13
components/products/ProductComponents/ProductCounter.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
interface ProductCounterProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ProductCounter({ count }: ProductCounterProps) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
Prikazuje se {count} proizvoda
|
||||
</p>
|
||||
);
|
||||
}
|
||||
29
components/products/ProductComponents/ProductsList.tsx
Normal file
29
components/products/ProductComponents/ProductsList.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { getProductColors } from "@/components/products/utils/colorUtils";
|
||||
import { ProductCard } from "@/components/ui/ProductCard";
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
interface ProductsListProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductsList({ products }: ProductsListProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-12">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} className="h-full">
|
||||
<ProductCard
|
||||
title={product.title}
|
||||
variant={product.variants[0]?.title || ""}
|
||||
price={parseFloat(product.priceRange.maxVariantPrice.amount)}
|
||||
imageSrc={product.featuredImage?.url || "/assets/images/placeholder_image.svg"}
|
||||
slug={product.handle}
|
||||
product={product}
|
||||
colors={getProductColors(product)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/products/ProductComponents/SortDropdown.tsx
Normal file
25
components/products/ProductComponents/SortDropdown.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SortDropdown({ value, onChange }: SortDropdownProps) {
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
className="border border-gray-300 px-4 py-2 rounded-md"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="default">Poredaj po</option>
|
||||
<option value="newest">Najnovije</option>
|
||||
<option value="price-asc">Cijena: Od niže prema višoj</option>
|
||||
<option value="price-desc">Cijena: Od više prema nižoj</option>
|
||||
<option value="name-asc">Naziv: A-Z</option>
|
||||
<option value="name-desc">Naziv: Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/products/ProductGrid.tsx
Normal file
77
components/products/ProductGrid.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { Heading } from "../ui/Typography";
|
||||
import { FilterSidebar } from "./FilterSidebar";
|
||||
import { FilterButton } from "./ProductComponents/FilterButton";
|
||||
import { FilterTagList } from "./ProductComponents/FilterTagList";
|
||||
import { ProductCounter } from "./ProductComponents/ProductCounter";
|
||||
import { ProductsList } from "./ProductComponents/ProductsList";
|
||||
import { SortDropdown } from "./client/SortDropdown";
|
||||
import { useProductFilters } from "./hooks/useProductFilters";
|
||||
|
||||
interface ProductGridProps {
|
||||
products: Product[];
|
||||
title?: string;
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
export function ProductGrid({ products, title = "Gotovi proizvodi", showControls = true }: ProductGridProps) {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
{title && <Heading level={2} className="mb-8">{title}</Heading>}
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
{showControls && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton onClick={() => setSidebarOpen(true)} />
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter tags and product count */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<FilterTagList
|
||||
filters={activeFilters}
|
||||
formatTag={formatFilterTag}
|
||||
onRemove={removeFilter}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<ProductCounter count={sortedProducts.length} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Products grid */}
|
||||
<ProductsList products={showControls ? sortedProducts : products} />
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
{showControls && (
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
components/products/ProductsPage.tsx
Normal file
11
components/products/ProductsPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getRegularProducts } from "lib/shopify";
|
||||
import { ProductsPageContent } from "./client/ProductsPageContent";
|
||||
|
||||
export async function ProductsPage() {
|
||||
const products = await getRegularProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
return <ProductsPageContent products={products} />;
|
||||
}
|
||||
17
components/products/client/ProductCounter.tsx
Normal file
17
components/products/client/ProductCounter.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
|
||||
interface ProductCounterProps {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function ProductCounter({ count }: ProductCounterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('products.counter.showing').replace('{count}', count.toString())}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
75
components/products/client/ProductGridWithTranslation.tsx
Normal file
75
components/products/client/ProductGridWithTranslation.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useState } from "react";
|
||||
import { Heading } from "../../ui/Typography";
|
||||
import { FilterSidebar } from "../FilterSidebar";
|
||||
import { FilterButton } from "../ProductComponents/FilterButton";
|
||||
import { FilterTagList } from "../ProductComponents/FilterTagList";
|
||||
import { ProductsList } from "../ProductComponents/ProductsList";
|
||||
import { ProductCounter } from "../client/ProductCounter";
|
||||
import { SortDropdown } from "../client/SortDropdown";
|
||||
import { useProductFilters } from "../hooks/useProductFilters";
|
||||
|
||||
interface ProductGridWithTranslationProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductGridWithTranslation({ products }: ProductGridWithTranslationProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag
|
||||
} = useProductFilters(products);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div>
|
||||
<Heading level={2} className="mb-8">{t('products.title')}</Heading>
|
||||
|
||||
{/* Filter and Sort Controls */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<FilterButton
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
filterText={t('products.filters.button')}
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortOption}
|
||||
onChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter tags and product count */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<FilterTagList
|
||||
filters={activeFilters}
|
||||
formatTag={formatFilterTag}
|
||||
onRemove={removeFilter}
|
||||
onClearAll={clearAllFilters}
|
||||
clearAllText={t('products.filters.clearAll')}
|
||||
/>
|
||||
<ProductCounter count={sortedProducts.length} />
|
||||
</div>
|
||||
|
||||
{/* Products grid */}
|
||||
<ProductsList products={sortedProducts} />
|
||||
</div>
|
||||
|
||||
{/* Filter Sidebar */}
|
||||
<FilterSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
activeFilters={activeFilters}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/products/client/ProductsPageContent.tsx
Normal file
31
components/products/client/ProductsPageContent.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { ProductGridWithTranslation } from "./ProductGridWithTranslation";
|
||||
|
||||
interface ProductsPageContentProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function ProductsPageContent({ products }: ProductsPageContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// If no products, display a message
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<Section>
|
||||
<Heading level={1} align="center" className="mb-4">{t('products.title')}</Heading>
|
||||
<Text className="text-center">{t('products.emptyMessage')}</Text>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section spacing="xs">
|
||||
<ProductGridWithTranslation products={products} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
117
components/products/client/SortDropdown.tsx
Normal file
117
components/products/client/SortDropdown.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from "@/lib/hooks/useTranslation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SortDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function SortDropdown({ value, onChange, label = "Label" }: SortDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get the currently selected option's display text
|
||||
const getSelectedOptionText = () => {
|
||||
switch(value) {
|
||||
case 'newest': return t('products.sort.newest');
|
||||
case 'price-asc': return t('products.sort.priceAsc');
|
||||
case 'price-desc': return t('products.sort.priceDesc');
|
||||
case 'name-asc': return t('products.sort.nameAsc');
|
||||
case 'name-desc': return t('products.sort.nameDesc');
|
||||
default: return t('products.sort.title');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleSelect = (value: string) => {
|
||||
onChange(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Label */}
|
||||
<div className="text-sm text-gray-500 mb-1">{label}</div>
|
||||
|
||||
{/* Dropdown button */}
|
||||
<div
|
||||
className="flex items-center justify-between border border-gray-300 rounded px-4 py-2 bg-white cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="text-gray-800">{getSelectedOptionText()}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-2 transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded shadow-lg">
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'default' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('default')}
|
||||
>
|
||||
{t('products.sort.title')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'newest' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('newest')}
|
||||
>
|
||||
{t('products.sort.newest')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'price-asc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('price-asc')}
|
||||
>
|
||||
{t('products.sort.priceAsc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'price-desc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('price-desc')}
|
||||
>
|
||||
{t('products.sort.priceDesc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'name-asc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('name-asc')}
|
||||
>
|
||||
{t('products.sort.nameAsc')}
|
||||
</div>
|
||||
<div
|
||||
className={`px-4 py-2 cursor-pointer hover:bg-primary/10 ${value === 'name-desc' ? 'bg-primary/10 text-primary' : 'text-gray-800'}`}
|
||||
onClick={() => handleSelect('name-desc')}
|
||||
>
|
||||
{t('products.sort.nameDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/products/hooks/useProductFilters.ts
Normal file
69
components/products/hooks/useProductFilters.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { filterAndSortProducts, filterUtils } from "../utils/productHelpers";
|
||||
|
||||
/**
|
||||
* Custom hook for managing product filtering and sorting state
|
||||
*/
|
||||
export function useProductFilters(products: Product[]) {
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
const [sortOption, setSortOption] = useState('default');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Load filters from URL on initial render
|
||||
useEffect(() => {
|
||||
const newActiveFilters = filterUtils.getFiltersFromUrl(searchParams as URLSearchParams);
|
||||
const sort = searchParams.get('sort');
|
||||
|
||||
// Set active filters if there are any
|
||||
if (newActiveFilters.length > 0) {
|
||||
setActiveFilters(newActiveFilters);
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
setSortOption(sort);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleApplyFilters = (filters: string[]) => {
|
||||
setActiveFilters(filters);
|
||||
filterUtils.updateUrlParams(filters, sortOption);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortOption: string) => {
|
||||
setSortOption(newSortOption);
|
||||
filterUtils.updateUrlParams(activeFilters, newSortOption);
|
||||
};
|
||||
|
||||
const removeFilter = (filter: string) => {
|
||||
let newFilters: string[];
|
||||
|
||||
if (filter.startsWith('Cijena:')) {
|
||||
newFilters = activeFilters.filter(f => !f.startsWith('Cijena:'));
|
||||
} else {
|
||||
newFilters = activeFilters.filter(f => f !== filter);
|
||||
}
|
||||
|
||||
setActiveFilters(newFilters);
|
||||
filterUtils.updateUrlParams(newFilters, sortOption);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters([]);
|
||||
filterUtils.updateUrlParams([], sortOption);
|
||||
};
|
||||
|
||||
const sortedProducts = filterAndSortProducts(products, activeFilters, sortOption);
|
||||
|
||||
return {
|
||||
activeFilters,
|
||||
sortOption,
|
||||
sortedProducts,
|
||||
handleApplyFilters,
|
||||
handleSortChange,
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
formatFilterTag: filterUtils.formatFilterTag
|
||||
};
|
||||
}
|
||||
81
components/products/utils/colorUtils.ts
Normal file
81
components/products/utils/colorUtils.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
/**
|
||||
* Color mapping from color names to hex codes
|
||||
*/
|
||||
export const colorHexMap: Record<string, string> = {
|
||||
'red': '#FF0000',
|
||||
'blue': '#0000FF',
|
||||
'green': '#00FF00',
|
||||
'black': '#000000',
|
||||
'white': '#FFFFFF',
|
||||
'yellow': '#FFFF00',
|
||||
'purple': '#800080',
|
||||
'pink': '#FFC0CB',
|
||||
'orange': '#FFA500',
|
||||
'gray': '#808080',
|
||||
'crvena': '#FF0000',
|
||||
'plava': '#0000FF',
|
||||
'zelena': '#00FF00',
|
||||
'crna': '#000000',
|
||||
'bijela': '#FFFFFF',
|
||||
'žuta': '#FFFF00',
|
||||
'ljubičasta': '#800080',
|
||||
'roza': '#FFC0CB',
|
||||
'narančasta': '#FFA500',
|
||||
'siva': '#808080'
|
||||
};
|
||||
|
||||
export type ColorVariant = {
|
||||
color: string; // Hex code
|
||||
variantId: string;
|
||||
price: number;
|
||||
colorName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract color options from a product if they exist
|
||||
* Returns an array of hex color codes, or undefined if no colors
|
||||
*/
|
||||
export function getProductColors(product: Product) {
|
||||
// Get colors directly from variants
|
||||
const colorVariants = getProductColorVariants(product);
|
||||
if (!colorVariants) return undefined;
|
||||
|
||||
// Return just the color codes
|
||||
return colorVariants.map(variant => variant.color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed color variants including price and variant ID
|
||||
* Extracts directly from the variants array
|
||||
*/
|
||||
export function getProductColorVariants(product: Product): ColorVariant[] | undefined {
|
||||
if (!product?.variants || product.variants.length === 0) return undefined;
|
||||
|
||||
const result: ColorVariant[] = [];
|
||||
|
||||
// Iterate through all variants looking for color options
|
||||
product.variants.forEach(variant => {
|
||||
// Find any color option in the selectedOptions
|
||||
const colorSelectedOption = variant.selectedOptions?.find(
|
||||
option => option.name.toLowerCase() === 'color' ||
|
||||
option.name.toLowerCase() === 'colour' ||
|
||||
option.name.toLowerCase() === 'boja'
|
||||
);
|
||||
|
||||
if (colorSelectedOption) {
|
||||
const colorName = colorSelectedOption.value;
|
||||
const colorHex = colorHexMap[colorName.toLowerCase()] || colorName;
|
||||
|
||||
result.push({
|
||||
color: colorHex,
|
||||
variantId: variant.id,
|
||||
price: parseFloat(variant.price.amount),
|
||||
colorName: colorName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
177
components/products/utils/productHelpers.ts
Normal file
177
components/products/utils/productHelpers.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Product } from "lib/shopify/types";
|
||||
|
||||
/**
|
||||
* Sorts products based on the selected option
|
||||
*/
|
||||
export function sortProducts(products: Product[], sortOption: string) {
|
||||
const productsCopy = [...products];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'price-asc':
|
||||
return productsCopy.sort((a, b) =>
|
||||
parseFloat(a.priceRange.maxVariantPrice.amount) - parseFloat(b.priceRange.maxVariantPrice.amount)
|
||||
);
|
||||
case 'price-desc':
|
||||
return productsCopy.sort((a, b) =>
|
||||
parseFloat(b.priceRange.maxVariantPrice.amount) - parseFloat(a.priceRange.maxVariantPrice.amount)
|
||||
);
|
||||
case 'newest':
|
||||
return productsCopy.sort((a, b) => a.handle.localeCompare(b.handle));
|
||||
case 'name-asc':
|
||||
return productsCopy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
case 'name-desc':
|
||||
return productsCopy.sort((a, b) => b.title.localeCompare(a.title));
|
||||
default:
|
||||
return productsCopy;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters products based on active filters and sorts them
|
||||
*/
|
||||
export function filterAndSortProducts(products: Product[], activeFilters: string[], sortOption: string) {
|
||||
// Filter products based on active filters
|
||||
const filteredProducts = products.filter(product => {
|
||||
if (activeFilters.length === 0) return true;
|
||||
|
||||
// Check for price filter
|
||||
const priceFilter = activeFilters.find(filter => filter.startsWith('Cijena:'));
|
||||
if (priceFilter) {
|
||||
// Extract min and max prices from filter string (format: "Cijena: 5€ - 100€")
|
||||
const priceMatch = priceFilter.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
const minPrice = parseInt(priceMatch[1]);
|
||||
const maxPrice = parseInt(priceMatch[2]);
|
||||
const productPrice = parseFloat(product.priceRange.maxVariantPrice.amount);
|
||||
|
||||
// If product price is outside the filter range, exclude it
|
||||
if (productPrice < minPrice || productPrice > maxPrice) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For now, we're only implementing price filtering
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort filtered products
|
||||
return sortProducts(filteredProducts, sortOption);
|
||||
}
|
||||
|
||||
// URL parameter constants
|
||||
const PARAM_FILTERS = 'filters';
|
||||
const PARAM_MIN_PRICE = 'min_price';
|
||||
const PARAM_MAX_PRICE = 'max_price';
|
||||
const PARAM_SORT = 'sort';
|
||||
const PRICE_PREFIX = 'Cijena:';
|
||||
|
||||
/**
|
||||
* Functions for handling filter tags
|
||||
*/
|
||||
export const filterUtils = {
|
||||
// Format filter tag for display
|
||||
formatFilterTag: (filter: string) => {
|
||||
// If it's a price filter, show as is
|
||||
if (filter.startsWith(PRICE_PREFIX)) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
// For other filters, split by colon and show in format "Category: Option"
|
||||
const parts = filter.split(':');
|
||||
if (parts.length > 1) {
|
||||
return `${parts[0]}: ${parts[1]}`;
|
||||
}
|
||||
|
||||
// Fallback for any non-formatted filters
|
||||
return filter;
|
||||
},
|
||||
|
||||
extractPriceFilter: (priceFilter: string) => {
|
||||
const priceMatch = priceFilter?.match(/Cijena: (\d+)€ - (\d+)€/);
|
||||
if (priceMatch && priceMatch[1] && priceMatch[2]) {
|
||||
return {
|
||||
minPrice: priceMatch[1],
|
||||
maxPrice: priceMatch[2]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Create a price filter string from min and max value
|
||||
createPriceFilter: (minPrice: string, maxPrice: string) => {
|
||||
return `${PRICE_PREFIX} ${minPrice}€ - ${maxPrice}€`;
|
||||
},
|
||||
|
||||
// Separate price and non-price filters
|
||||
separateFilters: (filters: string[]) => {
|
||||
const priceFilter = filters.find(f => f.startsWith(PRICE_PREFIX));
|
||||
const nonPriceFilters = filters.filter(f => !f.startsWith(PRICE_PREFIX));
|
||||
return { priceFilter, nonPriceFilters };
|
||||
},
|
||||
|
||||
encodeNonPriceFilters: (nonPriceFilters: string[]) => {
|
||||
return nonPriceFilters.length > 0
|
||||
? encodeURIComponent(JSON.stringify(nonPriceFilters))
|
||||
: '';
|
||||
},
|
||||
|
||||
decodeNonPriceFilters: (encodedFilters: string | null) => {
|
||||
if (!encodedFilters) return [];
|
||||
|
||||
try {
|
||||
const decodedFilters = JSON.parse(decodeURIComponent(encodedFilters));
|
||||
return Array.isArray(decodedFilters) ? decodedFilters : [];
|
||||
} catch (e) {
|
||||
console.error('Error parsing filters from URL', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
updateUrlParams: (filters: string[], sort: string) => {
|
||||
const params = new URLSearchParams();
|
||||
const { priceFilter, nonPriceFilters } = filterUtils.separateFilters(filters);
|
||||
|
||||
// Add non-price filters
|
||||
const encodedFilters = filterUtils.encodeNonPriceFilters(nonPriceFilters);
|
||||
if (encodedFilters) {
|
||||
params.set(PARAM_FILTERS, encodedFilters);
|
||||
}
|
||||
|
||||
// Add price filter
|
||||
if (priceFilter) {
|
||||
const priceValues = filterUtils.extractPriceFilter(priceFilter);
|
||||
if (priceValues) {
|
||||
params.set(PARAM_MIN_PRICE, priceValues.minPrice);
|
||||
params.set(PARAM_MAX_PRICE, priceValues.maxPrice);
|
||||
}
|
||||
}
|
||||
|
||||
if (sort !== 'default') {
|
||||
params.set(PARAM_SORT, sort);
|
||||
}
|
||||
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.pushState({}, '', newUrl);
|
||||
},
|
||||
|
||||
getFiltersFromUrl: (searchParams: URLSearchParams) => {
|
||||
const encodedFilters = searchParams.get(PARAM_FILTERS);
|
||||
const minPrice = searchParams.get(PARAM_MIN_PRICE);
|
||||
const maxPrice = searchParams.get(PARAM_MAX_PRICE);
|
||||
|
||||
// Start with decoded non-price filters
|
||||
const nonPriceFilters = filterUtils.decodeNonPriceFilters(encodedFilters);
|
||||
|
||||
// Add price filter if present
|
||||
if (minPrice && maxPrice) {
|
||||
const priceFilter = filterUtils.createPriceFilter(minPrice, maxPrice);
|
||||
return [...nonPriceFilters, priceFilter];
|
||||
}
|
||||
|
||||
return nonPriceFilters;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user