chore: transfer repo

This commit is contained in:
Danijel
2026-01-19 20:21:14 +01:00
commit 7d2fb0c737
213 changed files with 18085 additions and 0 deletions

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
};
}

View 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;
}

View 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;
}
}