Files
thermcool/components/shop/ProductsGrid.tsx
T
MichalC c9e4283789 feat: filtr jednostek multisplit, ostatnio oglądane, sklep pompy ciepła, czyszczenie komentarzy, checkbox zgody
- Filtr 'Ilość jednostek wewn.' w sklepie (1–9 jednostek, checkboxy, liczniki)
- Komponent RecentlyViewedProducts + Zustand store (recently-viewed-store.ts)
- Pompy ciepła dodane do sklepu i nawigacji (Header + MobileMenu)
- Logotypy marek zaktualizowane (Daikin, Panasonic + odświeżone istniejące)
- Checkbox zgody RODO w /zamowienie powiększony (w-5 h-5)
- Usunięcie ~65 zbędnych komentarzy WHAT z 12 plików (build: 0 errors)
- Plik analyze_products.ts (pomocniczy skrypt diagnostyczny)
2026-08-02 20:31:55 +02:00

604 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useMemo, useEffect } from 'react';
import { ChevronDown, ChevronUp, Phone, X } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
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 searchParams = useSearchParams();
const urlSearch = searchParams.get('search') || '';
const urlBrand = searchParams.get('brand') || '';
const urlCategory = searchParams.get('category') || 'all';
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla'>(
urlCategory as 'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla'
);
const [selectedBrands, setSelectedBrands] = useState<string[]>(urlBrand ? [urlBrand] : []);
const [searchQuery, setSearchQuery] = useState(urlSearch);
const [priceMin, setPriceMin] = useState('');
const [priceMax, setPriceMax] = useState('');
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const [selectedUnitCounts, setSelectedUnitCounts] = useState<number[]>([]);
const [halasMin, setHalasMin] = useState(15);
const [halasMax, setHalasMax] = useState(50);
const [page, setPage] = useState(1);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setSearchQuery(urlSearch);
setSelectedBrands(urlBrand ? [urlBrand] : []);
if (urlCategory !== 'all') {
setCategory(urlCategory as 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla');
} else {
setCategory('all');
}
setPage(1);
}, 0);
return () => clearTimeout(timer);
}, [urlSearch, urlBrand, urlCategory]);
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 toggleUnitCount = (uc: number) => {
setSelectedUnitCounts(prev => prev.includes(uc) ? prev.filter(x => x !== uc) : [...prev, uc]);
setPage(1);
};
const filtered = useMemo(() => {
return products.filter(p => {
// Wyszukiwanie tekstowe
if (searchQuery) {
const sq = searchQuery.toLowerCase();
if (!p.name.toLowerCase().includes(sq) && !p.brand.toLowerCase().includes(sq)) {
return false;
}
}
if (category !== 'all' && p.category !== category) return false;
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
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;
}
// Liczba jednostek wewn. (multisplit)
if (selectedUnitCounts.length > 0) {
const unitsStr = p.specs['Liczba jednostek wewn.'];
if (!unitsStr) return false;
const units = parseFloat(unitsStr);
if (!selectedUnitCounts.includes(units)) return false;
}
return true;
});
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, halasMin, halasMax, selectedColors, selectedUnitCounts, searchQuery]);
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]);
// Liczniki per liczba jednostek wewn.
const unitCountsAgg = useMemo(() => {
const counts: Record<number, number> = {};
products.forEach(p => {
if (category !== 'all' && p.category !== category) return;
const unitsStr = p.specs['Liczba jednostek wewn.'];
if (unitsStr) {
const units = parseFloat(unitsStr);
if (!isNaN(units)) {
counts[units] = (counts[units] || 0) + 1;
}
}
});
return counts;
}, [products, category]);
const resetAll = () => {
setCategory('all');
setSelectedBrands([]);
setSearchQuery('');
setPriceMin('');
setPriceMax('');
setSelectedRooms([]);
setSelectedColors([]);
setSelectedUnitCounts([]);
setHalasMin(15);
setHalasMax(50);
setPage(1);
};
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || selectedUnitCounts.length > 0 || halasMin > 15 || halasMax < 50;
const sidebarContent = (
<aside className="w-full">
<Accordion title="Klimatyzatory">
<ul className="flex flex-col gap-2">
{[
{ val: 'all', label: 'Wszystkie' },
{ val: 'scienne', label: 'Ścienne split' },
{ val: 'multisplit', label: 'Multisplit' },
{ val: 'pompy-ciepla', label: 'Pompy ciepła' },
].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>
<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>
{/* Ilość jednostek wewnętrznych */}
{(category === 'all' || category === 'multisplit') && (
<Accordion title="Ilość jednostek wewn.">
<ul className="flex flex-col gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(uc => {
if (unitCountsAgg[uc] === undefined && !selectedUnitCounts.includes(uc)) return null;
return (
<li key={uc}>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={selectedUnitCounts.includes(uc)}
onChange={() => toggleUnitCount(uc)}
className="sr-only"
/>
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
selectedUnitCounts.includes(uc)
? 'bg-accent border-accent'
: 'bg-white border-slate-300 group-hover:border-accent'
}`}>
{selectedUnitCounts.includes(uc) && (
<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 ${selectedUnitCounts.includes(uc) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
{uc} {uc === 1 ? 'jednostka' : uc >= 2 && uc <= 4 ? 'jednostki' : 'jednostek'}
</span>
<span className="ml-auto text-xs text-text-muted">
{unitCountsAgg[uc] || 0}
</span>
</label>
</li>
);
})}
</ul>
</Accordion>
)}
<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-4 px-1">
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">od</span>
<span className="text-xs font-bold text-primary bg-accent/10 px-2 py-0.5 rounded-md">{halasMin} dB</span>
</div>
<input
type="range"
min={15} max={50}
value={halasMin}
onChange={e => { setHalasMin(Math.min(Number(e.target.value), halasMax - 1)); setPage(1); }}
className="range range-xs range-accent w-full"
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">do</span>
<span className="text-xs font-bold text-primary bg-accent/10 px-2 py-0.5 rounded-md">{halasMax} dB</span>
</div>
<input
type="range"
min={15} max={50}
value={halasMax}
onChange={e => { setHalasMax(Math.max(Number(e.target.value), halasMin + 1)); setPage(1); }}
className="range range-xs range-accent w-full"
/>
</div>
</div>
</Accordion>
<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>
<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" className="mt-3 w-full btn btn-primary text-white font-bold text-sm py-2">
Zadaj pytanie
</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">
<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>
{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>
{sidebarContent}
</div>
</div>
)}
<div className="flex gap-8">
<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>
)}
{sidebarContent}
</div>
</div>
<div className="flex-1 min-w-0">
<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>
{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>
)}
{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>
);
}