521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
'use client';
|
||
|
||
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 { companyDetails } from '@/content/company';
|
||
|
||
interface ProductsGridProps {
|
||
products: Product[];
|
||
brands: string[];
|
||
}
|
||
|
||
const PER_PAGE = 24;
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
const COLOR_GROUPS = ['Biały', 'Czarny', 'Srebrny', 'Złoty', 'Grafitowy', 'Inne'];
|
||
|
||
// 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 },
|
||
];
|
||
|
||
interface AccordionProps {
|
||
title: string;
|
||
children: React.ReactNode;
|
||
defaultOpen?: boolean;
|
||
}
|
||
|
||
function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<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>
|
||
);
|
||
}
|