feat: implement compare functionality, checkout flow, and add 30 blog posts
This commit is contained in:
+502
-142
@@ -1,160 +1,520 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Product } from '@/types/types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronUp, Phone, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/content/products';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { SlidersHorizontal, X, Search } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products }: ProductsGridProps) {
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<string>('recommended');
|
||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||
const PER_PAGE = 24;
|
||||
|
||||
// Pobranie unikalnych wartości do filtrów
|
||||
const brands = useMemo(() => Array.from(new Set(products.map(p => p.brand))), [products]);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'scienne': 'Ścienne',
|
||||
'multisplit': 'Multisplit',
|
||||
'kasetonowe': 'Kasetonowe',
|
||||
'przenosne': 'Przenośne',
|
||||
};
|
||||
const categories = useMemo(() => Array.from(new Set(products.map(p => p.category))), [products]);
|
||||
// Parsuj moc chłodniczą — zwróć wartość MAX z zakresu
|
||||
function parseMocMax(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.replace(/[^\d,.-]/g, ' ').split(/[\s\/–-]+/).map(s => parseFloat(s.replace(',', '.'))).filter(n => !isNaN(n));
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
}
|
||||
|
||||
// Logika filtrowania i sortowania
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = [...products];
|
||||
// Parsuj hałas — zwróć wartość MIN z zakresu
|
||||
function parseHalasMin(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.match(/\d+/g);
|
||||
return nums ? Math.min(...nums.map(Number)) : null;
|
||||
}
|
||||
|
||||
if (selectedBrands.length > 0) {
|
||||
result = result.filter(p => selectedBrands.includes(p.brand));
|
||||
}
|
||||
if (selectedCategories.length > 0) {
|
||||
result = result.filter(p => selectedCategories.includes(p.category));
|
||||
}
|
||||
// Mapowanie wykończenia na grupy kolorystyczne
|
||||
function mapWykonczenie(val: string): string {
|
||||
const v = val.toLowerCase();
|
||||
if (v.includes('white') || v === 'biały') return 'Biały';
|
||||
if (v.includes('black') || v === 'czarny' || v === 'dark' || v.includes('satin')) return 'Czarny';
|
||||
if (v.includes('silver') || v === 'srebrny') return 'Srebrny';
|
||||
if (v.includes('gold') || v === 'złoty' || v === 'champagne') return 'Złoty';
|
||||
if (v.includes('graphite') || v.includes('grey') || v.includes('gray')) return 'Grafitowy';
|
||||
if (v.includes('wood') || v.includes('fabric') || v.includes('textile') || v.includes('leather') || v.includes('stone') || v.includes('mirror') || v.includes('caramel') || v.includes('navy') || v.includes('miedziany') || v === 'beige stone') return 'Inne';
|
||||
if (v === 'matt' || v === 'shiny') return 'Biały';
|
||||
return 'Inne';
|
||||
}
|
||||
|
||||
if (sortBy === 'price-asc') {
|
||||
result.sort((a, b) => a.price - b.price);
|
||||
} else if (sortBy === 'price-desc') {
|
||||
result.sort((a, b) => b.price - a.price);
|
||||
} else if (sortBy === 'power-asc') {
|
||||
result.sort((a, b) => a.power - b.power);
|
||||
} else if (sortBy === 'power-desc') {
|
||||
result.sort((a, b) => b.power - a.power);
|
||||
}
|
||||
const COLOR_GROUPS = ['Biały', 'Czarny', 'Srebrny', 'Złoty', 'Grafitowy', 'Inne'];
|
||||
|
||||
return result;
|
||||
}, [products, selectedBrands, selectedCategories, sortBy]);
|
||||
// Buckety wielkości pomieszczenia → zakres mocy kW
|
||||
const ROOM_BUCKETS = [
|
||||
{ label: 'do 20 m²', min: 0, max: 2.5 },
|
||||
{ label: '21–35 m²', min: 2.5, max: 3.5 },
|
||||
{ label: '36–50 m²', min: 3.5, max: 5.0 },
|
||||
{ label: '51–70 m²', min: 5.0, max: 7.0 },
|
||||
{ label: 'powyżej 70 m²', min: 7.0, max: 99 },
|
||||
];
|
||||
|
||||
const toggleBrand = (brand: string) => {
|
||||
setSelectedBrands(prev =>
|
||||
prev.includes(brand) ? prev.filter(b => b !== brand) : [...prev, brand]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(category) ? prev.filter(c => c !== category) : [...prev, category]
|
||||
);
|
||||
};
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-8 py-12">
|
||||
{/* Mobile Filter Toggle */}
|
||||
<div className="md:hidden flex justify-between items-center mb-4">
|
||||
<span className="font-semibold">{filteredProducts.length} wyników</span>
|
||||
<button className="btn btn-outline btn-primary bg-white" onClick={() => setShowFiltersMobile(!showFiltersMobile)}>
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Filtry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar (Filtry) */}
|
||||
<aside className={`w-full md:w-64 shrink-0 ${showFiltersMobile ? 'block' : 'hidden md:block'}`}>
|
||||
<div className="bg-white p-6 rounded-2xl border border-border sticky top-24">
|
||||
<h3 className="font-bold text-lg font-heading text-primary mb-6">Filtrowanie</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Marki */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Marka urządzenia</h4>
|
||||
<div className="space-y-2">
|
||||
{brands.map(brand => (
|
||||
<label key={brand} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(brand)}
|
||||
onChange={() => toggleBrand(brand)}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-muted">{brand}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorie */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Typ urządzenia</h4>
|
||||
<div className="space-y-2">
|
||||
{categories.map(category => (
|
||||
<label key={category} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-muted">{categoryLabels[category] || category}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Główna sekcja (Narzędzia + Siatka) */}
|
||||
<div className="flex-grow">
|
||||
{/* Pasek narzędzi */}
|
||||
<div className="hidden md:flex justify-between items-center bg-white p-4 rounded-xl border border-border mb-8">
|
||||
<span className="text-text-muted font-medium">{filteredProducts.length} wyników</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="sort" className="text-sm font-semibold text-primary">Sortuj według:</label>
|
||||
<select
|
||||
id="sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 bg-slate-50"
|
||||
>
|
||||
<option value="recommended">Rekomendowane</option>
|
||||
<option value="price-asc">Cena rosnąco</option>
|
||||
<option value="price-desc">Cena malejąco</option>
|
||||
<option value="power-asc">Moc rosnąco</option>
|
||||
<option value="power-desc">Moc malejąco</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Siatka */}
|
||||
{filteredProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredProducts.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-slate-50 rounded-2xl border border-border">
|
||||
<h3 className="text-xl font-bold text-primary mb-2">Brak wyników</h3>
|
||||
<p className="text-text-muted">Spróbuj zmienić kryteria filtrowania.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-b border-border pb-4 mb-4">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<span className="text-sm font-bold text-primary font-heading">{title}</span>
|
||||
{open ? <ChevronUp className="size-4 text-text-muted" /> : <ChevronDown className="size-4 text-text-muted" />}
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit'>('all');
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
const [halasMin, setHalasMin] = useState(15);
|
||||
const [halasMax, setHalasMax] = useState(50);
|
||||
const [page, setPage] = useState(1);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
const toggleBrand = (b: string) => {
|
||||
setSelectedBrands(prev => prev.includes(b) ? prev.filter(x => x !== b) : [...prev, b]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleRoom = (r: string) => {
|
||||
setSelectedRooms(prev => prev.includes(r) ? prev.filter(x => x !== r) : [...prev, r]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleColor = (c: string) => {
|
||||
setSelectedColors(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return products.filter(p => {
|
||||
// Kategoria
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
|
||||
// Marka
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
|
||||
|
||||
// Cena
|
||||
const pMin = priceMin ? parseInt(priceMin) : null;
|
||||
const pMax = priceMax ? parseInt(priceMax) : null;
|
||||
if (p.price !== null) {
|
||||
if (pMin && p.price < pMin) return false;
|
||||
if (pMax && p.price > pMax) return false;
|
||||
}
|
||||
|
||||
// Wielkość pomieszczenia → moc
|
||||
if (selectedRooms.length > 0) {
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return false;
|
||||
const matches = selectedRooms.some(r => {
|
||||
const bucket = ROOM_BUCKETS.find(b => b.label === r);
|
||||
return bucket ? moc >= bucket.min && moc < bucket.max : false;
|
||||
});
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
// Hałas
|
||||
if (halasMin > 15 || halasMax < 50) {
|
||||
const h = parseHalasMin(p.specs['Hałas wewnątrz (dB)'] || '');
|
||||
if (h !== null && (h < halasMin || h > halasMax)) return false;
|
||||
}
|
||||
|
||||
// Kolor
|
||||
if (selectedColors.length > 0) {
|
||||
const wyk = p.specs['Wykończenie'] || '';
|
||||
const group = mapWykonczenie(wyk);
|
||||
if (!selectedColors.includes(group)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, halasMin, halasMax, selectedColors]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
// Liczniki per brand (dla aktualnych filtrów bez filtra marki)
|
||||
const brandCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
counts[p.brand] = (counts[p.brand] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per room bucket
|
||||
const roomCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return;
|
||||
ROOM_BUCKETS.forEach(b => {
|
||||
if (moc >= b.min && moc < b.max) {
|
||||
counts[b.label] = (counts[b.label] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
const resetAll = () => {
|
||||
setCategory('all');
|
||||
setSelectedBrands([]);
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
setSelectedRooms([]);
|
||||
setSelectedColors([]);
|
||||
setHalasMin(15);
|
||||
setHalasMax(50);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || halasMin > 15 || halasMax < 50;
|
||||
|
||||
const Sidebar = () => (
|
||||
<aside className="w-full">
|
||||
{/* Klimatyzatory - typ */}
|
||||
<Accordion title="Klimatyzatory">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
{ val: 'all', label: 'Wszystkie' },
|
||||
{ val: 'scienne', label: 'Klimatyzacja ścienna' },
|
||||
{ val: 'multisplit', label: 'Klimatyzacja Multisplit' },
|
||||
].map(opt => (
|
||||
<li key={opt.val}>
|
||||
<button
|
||||
onClick={() => { setCategory(opt.val as typeof category); setPage(1); }}
|
||||
className={`flex items-center gap-2.5 text-sm w-full text-left py-0.5 transition-colors ${
|
||||
category === opt.val ? 'text-primary font-semibold' : 'text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<span className={`size-3 rounded-full border-2 shrink-0 ${
|
||||
category === opt.val ? 'border-accent bg-accent' : 'border-border'
|
||||
}`} />
|
||||
{opt.label}
|
||||
<span className="ml-auto text-xs bg-slate-100 rounded-full px-1.5 py-0.5 text-text-muted">
|
||||
{opt.val === 'all' ? products.length : products.filter(p => p.category === opt.val).length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Marka */}
|
||||
<Accordion title="Marka">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{brands.map(b => (
|
||||
<li key={b}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(b)}
|
||||
onChange={() => toggleBrand(b)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedBrands.includes(b)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedBrands.includes(b) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedBrands.includes(b) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{brandCounts[b] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Cena */}
|
||||
<Accordion title="Cena (zł)">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="od"
|
||||
value={priceMin}
|
||||
onChange={e => { setPriceMin(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
<span className="text-text-muted shrink-0">–</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="do"
|
||||
value={priceMax}
|
||||
onChange={e => { setPriceMax(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Wielkość pomieszczenia */}
|
||||
<Accordion title="Wielkość pomieszczenia">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ROOM_BUCKETS.map(b => (
|
||||
<li key={b.label}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRooms.includes(b.label)}
|
||||
onChange={() => toggleRoom(b.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedRooms.includes(b.label)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedRooms.includes(b.label) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedRooms.includes(b.label) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{roomCounts[b.label] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">Szacunkowo: 10 m² = 1 kW mocy chłodniczej</p>
|
||||
</Accordion>
|
||||
|
||||
{/* Głośność */}
|
||||
<Accordion title={`Głośność wewnętrzna (dB) — ${halasMin}–${halasMax} dB`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted w-8">od</span>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={50}
|
||||
value={halasMin}
|
||||
onChange={e => {
|
||||
const val = Number(e.target.value);
|
||||
setHalasMin(Math.min(val, halasMax - 1));
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-primary font-medium w-10 text-right">{halasMin} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted w-8">do</span>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={50}
|
||||
value={halasMax}
|
||||
onChange={e => {
|
||||
const val = Number(e.target.value);
|
||||
setHalasMax(Math.max(val, halasMin + 1));
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-primary font-medium w-10 text-right">{halasMax} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Kolor/Wykończenie */}
|
||||
<Accordion title="Kolor dominujący">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{COLOR_GROUPS.map(c => (
|
||||
<li key={c}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.includes(c)}
|
||||
onChange={() => toggleColor(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedColors.includes(c)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedColors.includes(c) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedColors.includes(c) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{c}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Potrzebujesz pomocy */}
|
||||
<div className="rounded-xl bg-slate-50 border border-border p-4 text-center mt-2">
|
||||
<p className="text-sm font-bold text-primary font-heading mb-3">Potrzebujesz pomocy?</p>
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center justify-center gap-2 text-primary font-bold text-base hover:text-accent transition-colors"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1">{companyDetails.hours}</p>
|
||||
<Link href="/kontakt">
|
||||
<button className="mt-3 w-full btn btn-primary text-white font-bold text-sm py-2">
|
||||
Zadaj pytanie
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-8 md:py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{/* Mobile: przycisk filtrów */}
|
||||
<div className="lg:hidden mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-primary hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
Filtry {hasFilters && <span className="size-2 rounded-full bg-accent inline-block" />}
|
||||
</button>
|
||||
<span className="text-sm text-text-muted">{filtered.length} produktów</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileSidebarOpen(false)} />
|
||||
<div className="absolute top-0 left-0 bottom-0 w-80 bg-white overflow-y-auto p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-bold text-primary font-heading">Filtry</span>
|
||||
<button onClick={() => setMobileSidebarOpen(false)}>
|
||||
<X className="size-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block w-64 shrink-0">
|
||||
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-2">
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={resetAll}
|
||||
className="flex items-center gap-1.5 text-xs text-accent hover:underline mb-4"
|
||||
>
|
||||
<X className="size-3" /> Wyczyść filtry
|
||||
</button>
|
||||
)}
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Produkty */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Nagłówek z licznikiem */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-sm text-text-muted hidden lg:block">{filtered.length} produktów</span>
|
||||
{hasFilters && (
|
||||
<button onClick={resetAll} className="lg:hidden text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<X className="size-3" /> Wyczyść
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{paginated.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{paginated.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-text-muted mb-3">Brak produktów dla wybranych filtrów.</p>
|
||||
<button onClick={resetAll} className="text-accent hover:underline text-sm">
|
||||
Wyczyść filtry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginacja */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-12">
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.max(1, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Poprzednia
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const p = page <= 3 ? i + 1 : page - 2 + i;
|
||||
if (p < 1 || p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => { setPage(p); window.scrollTo(0, 0); }}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-accent text-white border border-accent'
|
||||
: 'border border-border text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.min(totalPages, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Następna →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user