Zastosowanie wszystkich poprawek UI i logiki kalkulatora

This commit is contained in:
AI Assistant
2026-08-10 23:26:52 +02:00
commit 7324a6f800
218 changed files with 33628 additions and 0 deletions
+567
View File
@@ -0,0 +1,567 @@
'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'>(
urlCategory as 'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna'
);
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 [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');
} 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;
}
// 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, 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([]);
setPage(1);
};
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || selectedUnitCounts.length > 0;
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' },
].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>
<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>
);
}