feat: optymalizacja GlobalSearch, nawigacji oraz filtrów w sklepie
This commit is contained in:
@@ -1,20 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { getProducts } from "@/content/products";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const allProducts = getProducts();
|
||||
|
||||
export function GlobalSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const products = getProducts();
|
||||
const results = query.length > 1
|
||||
? products.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.brand.toLowerCase().includes(query.toLowerCase()) || p.category.toLowerCase().includes(query.toLowerCase()))
|
||||
: [];
|
||||
const results = useMemo(() => {
|
||||
if (query.length < 2) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allProducts
|
||||
.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.brand.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q) ||
|
||||
(p.sku && p.sku.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 20);
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && query.length > 1) {
|
||||
router.push(`/sklep?search=${encodeURIComponent(query)}`);
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,7 +49,15 @@ export function GlobalSearch() {
|
||||
<dialog className={`modal ${isOpen ? "modal-open" : ""}`}>
|
||||
<div className="modal-box p-0 max-w-2xl bg-white rounded-2xl overflow-hidden mt-[-20vh]">
|
||||
<div className="flex items-center border-b border-border px-4 py-3 bg-slate-50">
|
||||
<Search className="size-5 text-accent mr-3 shrink-0" />
|
||||
<button aria-label="Szukaj" onClick={() => {
|
||||
if (query.length > 1) {
|
||||
router.push(`/sklep?search=${encodeURIComponent(query)}`);
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
}}>
|
||||
<Search className="size-5 text-accent mr-3 shrink-0 hover:text-primary transition-colors cursor-pointer" />
|
||||
</button>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
@@ -37,8 +65,9 @@ export function GlobalSearch() {
|
||||
className="flex-1 outline-none text-base placeholder:text-text-muted/50 bg-transparent py-2 text-primary font-medium"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button onClick={() => setIsOpen(false)} className="text-xs font-semibold text-text-muted hover:text-primary transition-colors bg-white px-2 py-1 rounded border border-border shadow-sm">
|
||||
<button type="button" onClick={() => setIsOpen(false)} className="text-xs font-semibold text-text-muted hover:text-primary transition-colors bg-white px-2 py-1 rounded border border-border shadow-sm">
|
||||
ESC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -76,20 +76,33 @@ export function Header() {
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const miastaSections = [
|
||||
{
|
||||
title: "Montaż w Twoim mieście",
|
||||
title: "Dolny Śląsk — centrum",
|
||||
items: [
|
||||
{ label: "Klimatyzacja Wrocław", href: "/klimatyzacja/wroclaw", description: "Montaż i serwis we Wrocławiu" },
|
||||
{ label: "Klimatyzacja Legnica", href: "/klimatyzacja/legnica", description: "Obsługujemy Legnicę i okolice" },
|
||||
{ label: "Klimatyzacja Wałbrzych", href: "/klimatyzacja/walbrzych", description: "Montaż w Wałbrzychu" },
|
||||
{ label: "Klimatyzacja Jelenia Góra", href: "/klimatyzacja/jelenia-gora", description: "Dolny Śląsk — południe" },
|
||||
{ label: "→ Wszystkie miasta", href: "/klimatyzacja#miasta", description: "Cały Dolny Śląsk" },
|
||||
{ label: "Klimatyzacja Oława", href: "/klimatyzacja/olawa", description: "25 km od Wrocławia" },
|
||||
{ label: "Klimatyzacja Oleśnica", href: "/klimatyzacja/olesnica", description: "30 km od Wrocławia" },
|
||||
{ label: "Klimatyzacja Środa Śląska", href: "/klimatyzacja/sroda-slaska", description: "35 km od Wrocławia" },
|
||||
{ label: "Klimatyzacja Trzebnica", href: "/klimatyzacja/trzebnica", description: "25 km od Wrocławia" },
|
||||
{ label: "Klimatyzacja Siechnice", href: "/klimatyzacja/siechnice", description: "15 km od Wrocławia" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Dolny Śląsk — regiony",
|
||||
items: [
|
||||
{ label: "Klimatyzacja Legnica", href: "/klimatyzacja/legnica", description: "Legnica i okolice" },
|
||||
{ label: "Klimatyzacja Wałbrzych", href: "/klimatyzacja/walbrzych", description: "Wałbrzych i Kotlina" },
|
||||
{ label: "Klimatyzacja Jelenia Góra", href: "/klimatyzacja/jelenia-gora", description: "Karkonosze i okolice" },
|
||||
{ label: "Klimatyzacja Lubin", href: "/klimatyzacja/lubin", description: "Zagłębie Miedziowe" },
|
||||
{ label: "Klimatyzacja Głogów", href: "/klimatyzacja/glogow", description: "Głogów i powiat" },
|
||||
{ label: "Klimatyzacja Świdnica", href: "/klimatyzacja/swidnica", description: "Świdnica i okolice" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ----------------- DESKTOP: TOP BAR ----------------- */}
|
||||
@@ -156,6 +169,11 @@ export function Header() {
|
||||
sections={acSections}
|
||||
align="left"
|
||||
/>
|
||||
<NavDropdown
|
||||
label="Miasta"
|
||||
sections={miastaSections}
|
||||
align="left"
|
||||
/>
|
||||
<Link
|
||||
href="/pompy-ciepla"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
|
||||
@@ -100,6 +100,42 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Nowa sekcja: Miasta */}
|
||||
<div className="border-b border-border py-3">
|
||||
<details className="collapse collapse-arrow rounded-none">
|
||||
<summary className="collapse-title text-sm font-medium text-primary hover:text-accent py-0 px-0 min-h-0">
|
||||
Montaż w miastach
|
||||
</summary>
|
||||
<div className="collapse-content px-0 pb-2">
|
||||
<ul className="flex flex-col gap-2 pl-4 pb-2 mt-2">
|
||||
{[
|
||||
{ label: "Klimatyzacja Wrocław", href: "/klimatyzacja/wroclaw" },
|
||||
{ label: "Klimatyzacja Legnica", href: "/klimatyzacja/legnica" },
|
||||
{ label: "Klimatyzacja Wałbrzych", href: "/klimatyzacja/walbrzych" },
|
||||
{ label: "Klimatyzacja Jelenia Góra", href: "/klimatyzacja/jelenia-gora" },
|
||||
{ label: "Klimatyzacja Lubin", href: "/klimatyzacja/lubin" },
|
||||
{ label: "Klimatyzacja Głogów", href: "/klimatyzacja/glogow" },
|
||||
{ label: "Klimatyzacja Świdnica", href: "/klimatyzacja/swidnica" },
|
||||
{ label: "Klimatyzacja Oława", href: "/klimatyzacja/olawa" },
|
||||
{ label: "Klimatyzacja Oleśnica", href: "/klimatyzacja/olesnica" },
|
||||
{ label: "→ Wszystkie miasta", href: "/klimatyzacja#miasta" },
|
||||
].map((item, idx) => (
|
||||
<li key={idx}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className="text-sm text-text-muted hover:text-accent transition-colors flex items-center justify-between py-1"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<ChevronRight className="size-3.5 opacity-60" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* 2. Pompy Ciepła */}
|
||||
<div className="border-b border-border py-3">
|
||||
<Link
|
||||
|
||||
@@ -20,8 +20,6 @@ export function BlogPreview() {
|
||||
const labels: Record<string, string> = {
|
||||
klimatyzacja: "Klimatyzacja",
|
||||
"pompy-ciepla": "Pompy ciepła",
|
||||
remonty: "Remonty",
|
||||
hydraulika: "Hydraulika",
|
||||
porady: "Porady ogólne",
|
||||
};
|
||||
return labels[cat] || cat;
|
||||
|
||||
@@ -17,8 +17,6 @@ const categories = [
|
||||
{ id: 'wszystkie', label: 'Wszystkie' },
|
||||
{ id: 'klimatyzacja', label: 'Klimatyzacja' },
|
||||
{ id: 'pompy-ciepla', label: 'Pompy ciepła' },
|
||||
{ id: 'remonty', label: 'Remonty' },
|
||||
{ id: 'hydraulika', label: 'Hydraulika' },
|
||||
{ id: 'porady', label: 'Porady' },
|
||||
];
|
||||
|
||||
@@ -45,8 +43,6 @@ export function PostsGrid({ posts }: PostsGridProps) {
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'klimatyzacja': 'Klimatyzacja',
|
||||
'pompy-ciepla': 'Pompy Ciepła',
|
||||
'remonty': 'Remonty',
|
||||
'hydraulika': 'Hydraulika',
|
||||
'porady': 'Porady'
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@ import { useCartStore } from '@/lib/cart-store';
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { useState } from 'react';
|
||||
|
||||
const brandLogos: Record<string, string> = {
|
||||
'Gree': '/brands/gree.webp',
|
||||
'Rotenso': '/brands/rotenso.png',
|
||||
'Toshiba': '/brands/toshiba.jpg',
|
||||
'Daikin': '/brands/daikin.png',
|
||||
'CHV': '/brands/chv.jpeg',
|
||||
'Haier': '/brands/haier.jpg',
|
||||
'Aux': '/brands/aux.png',
|
||||
'LG': '/brands/lg.png',
|
||||
'Samsung': '/brands/samsung.png',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi-electric.png',
|
||||
'Fuji Electric': '/brands/fuji-electric.png',
|
||||
};
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
}
|
||||
@@ -64,9 +78,22 @@ export function ProductCard({ product }: ProductCardProps) {
|
||||
/>
|
||||
|
||||
{/* Badge marka */}
|
||||
<span className="absolute top-2 left-2 px-2 py-1 bg-white/95 rounded-md text-xs font-semibold text-primary border border-border shadow-sm">
|
||||
{product.brand}
|
||||
</span>
|
||||
<div className="absolute top-2 left-2 px-2 py-1 bg-white/95 rounded-md border border-border shadow-sm flex items-center justify-center min-h-[28px]">
|
||||
{brandLogos[product.brand] ? (
|
||||
<div className="bg-white rounded px-2 py-1 inline-block">
|
||||
<Image
|
||||
src={brandLogos[product.brand]}
|
||||
alt={product.brand}
|
||||
width={56}
|
||||
height={20}
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-primary">{product.brand}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Badge multisplit */}
|
||||
{product.category === 'multisplit' && (
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft, Phone, ShoppingCart, GitCompare, Check,
|
||||
Thermometer, Volume2, Zap, Wind, Star, Info
|
||||
} from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { getProductVariants } from '@/content/product-variants';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const brandLogos: Record<string, string> = {
|
||||
'Gree': '/brands/gree.webp',
|
||||
'Rotenso': '/brands/rotenso.png',
|
||||
'Toshiba': '/brands/toshiba.jpg',
|
||||
'Daikin': '/brands/daikin.png',
|
||||
'CHV': '/brands/chv.jpeg',
|
||||
'Haier': '/brands/haier.jpg',
|
||||
'Aux': '/brands/aux.png',
|
||||
'LG': '/brands/lg.png',
|
||||
'Samsung': '/brands/samsung.png',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi-electric.png',
|
||||
'Fuji Electric': '/brands/fuji-electric.png',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
companyPhone: string;
|
||||
companyPhoneRaw: string;
|
||||
}
|
||||
|
||||
// Szacowany SEER na podstawie klasy energetycznej
|
||||
function estimateSEER(klasa: string): number {
|
||||
if (!klasa) return 5.0;
|
||||
const k = klasa.toUpperCase();
|
||||
if (k.includes('A+++')) return 8.5;
|
||||
if (k.includes('A++')) return 6.1;
|
||||
if (k.includes('A+')) return 4.6;
|
||||
if (k.includes('A')) return 3.6;
|
||||
return 5.0;
|
||||
}
|
||||
|
||||
// Parsuj moc — bierz wartość nominalną (środkową lub pierwszą)
|
||||
function parseMoc(val: string): number {
|
||||
if (!val) return 0;
|
||||
const nums = val.replace(',', '.').match(/[\d.]+/g);
|
||||
if (!nums) return 0;
|
||||
if (nums.length === 1) return parseFloat(nums[0]);
|
||||
// zakres: bierz środkową
|
||||
const floats = nums.map(parseFloat);
|
||||
return floats[Math.floor(floats.length / 2)];
|
||||
}
|
||||
|
||||
const TABS = ['Opis', 'Specyfikacja', 'Montaż', 'Dostawa'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
export function ProductPageClient({ product, companyPhone, companyPhoneRaw }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Opis');
|
||||
const [godziny, setGodziny] = useState(8);
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const variants = getProductVariants(product.slug);
|
||||
|
||||
const addToCart = useCartStore(s => s.addItem);
|
||||
const { addItem: addCompare, removeItem: removeCompare, isAdded } = useCompareStore();
|
||||
const inCompare = isAdded(product.slug);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product.price) return;
|
||||
addToCart({ slug: product.slug, name: product.name, brand: product.brand, image: product.image, price: product.price });
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 1500);
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
if (inCompare) removeCompare(product.slug);
|
||||
else addCompare({ slug: product.slug, name: product.name, brand: product.brand, price: product.price, image: product.image, specs: product.specs });
|
||||
};
|
||||
|
||||
// Kalkulator
|
||||
const mocChlodnicza = parseMoc(product.specs['Moc chłodnicza'] || '');
|
||||
const pobórMocy = parseMoc(product.specs['Pobór mocy chłodzenie (kW)'] || '') || (mocChlodnicza / estimateSEER(product.specs['Klasa energetyczna'] || ''));
|
||||
const cenaKwh = 0.75;
|
||||
const kosztGodzina = pobórMocy * cenaKwh;
|
||||
const kosztMiesiac = kosztGodzina * godziny * 30;
|
||||
|
||||
// Kluczowe parametry do ikon
|
||||
const params = [
|
||||
product.specs['Klasa energetyczna'] && { icon: Star, label: 'Klasa energetyczna', value: product.specs['Klasa energetyczna'], color: 'text-green-600 bg-green-50' },
|
||||
product.specs['Moc chłodnicza'] && { icon: Thermometer, label: 'Moc chłodnicza', value: `${product.specs['Moc chłodnicza']} kW`, color: 'text-blue-600 bg-blue-50' },
|
||||
product.specs['Moc grzewcza (kW)'] && { icon: Thermometer, label: 'Moc grzewcza', value: `${product.specs['Moc grzewcza (kW)']} kW`, color: 'text-orange-600 bg-orange-50' },
|
||||
product.specs['Hałas wewnątrz (dB)'] && { icon: Volume2, label: 'Hałas wewnątrz', value: `${product.specs['Hałas wewnątrz (dB)']} dB`, color: 'text-purple-600 bg-purple-50' },
|
||||
product.specs['Czynnik chłodniczy'] && { icon: Wind, label: 'Czynnik', value: product.specs['Czynnik chłodniczy'], color: 'text-teal-600 bg-teal-50' },
|
||||
product.specs['Grzanie do'] && { icon: Zap, label: 'Grzanie do', value: `-${product.specs['Grzanie do']}°C`, color: 'text-accent bg-accent-light' },
|
||||
].filter(Boolean) as { icon: any; label: string; value: string; color: string }[];
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen pb-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-text-muted mb-6">
|
||||
<Link href="/" className="hover:text-accent transition-colors">Strona główna</Link>
|
||||
<span>/</span>
|
||||
<Link href="/sklep" className="hover:text-accent transition-colors">Sklep</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/sklep?brand=${product.brand}`} className="hover:text-accent transition-colors">{product.brand}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 xl:gap-12">
|
||||
|
||||
{/* LEWA: Zdjęcie */}
|
||||
<div>
|
||||
<div className="relative aspect-square bg-white rounded-2xl border border-border overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain p-8"
|
||||
priority
|
||||
unoptimized={product.image.startsWith('http')}
|
||||
/>
|
||||
{/* Badges */}
|
||||
{product.specs['Klasa energetyczna'] && (
|
||||
<div className="absolute top-4 left-4 px-2.5 py-1 bg-green-500 text-white text-xs font-bold rounded-lg shadow">
|
||||
{product.specs['Klasa energetyczna'].split('/')[0]}
|
||||
</div>
|
||||
)}
|
||||
{product.category === 'multisplit' && (
|
||||
<div className="absolute top-4 right-4 px-2.5 py-1 bg-accent text-white text-xs font-bold rounded-lg shadow">
|
||||
MULTISPLIT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PRAWA: Info */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Nagłówek */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
{brandLogos[product.brand] ? (
|
||||
<div className="bg-white rounded-lg p-2 inline-block border border-border">
|
||||
<Image
|
||||
src={brandLogos[product.brand]}
|
||||
alt={product.brand}
|
||||
width={120}
|
||||
height={36}
|
||||
className="object-contain object-left"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-accent uppercase tracking-widest">
|
||||
{product.brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-primary font-heading leading-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="text-xs text-text-muted mt-1.5 mb-4">SKU: {product.sku}</p>
|
||||
</div>
|
||||
|
||||
{/* Warianty mocy */}
|
||||
{variants && variants.moce.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Dostępne moce (kW):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.moce.map((v) => (
|
||||
<button
|
||||
key={v.slug}
|
||||
onClick={() => router.push(`/sklep/${v.slug}`)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-bold border-2 transition-all ${
|
||||
v.slug === product.slug
|
||||
? 'border-accent bg-accent text-white shadow-sm'
|
||||
: 'border-border bg-white text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{v.moc} kW
|
||||
{v.slug !== product.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-70 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
{v.slug === product.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-80 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warianty wykończenia */}
|
||||
{variants && variants.wykończenia.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Wykończenie:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.wykończenia.map((v) => (
|
||||
<button
|
||||
key={v.slug}
|
||||
onClick={() => router.push(`/sklep/${v.slug}`)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-bold border-2 transition-all ${
|
||||
v.slug === product.slug
|
||||
? 'border-accent bg-accent text-white shadow-sm'
|
||||
: 'border-border bg-white text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{v.wykończenie}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ikony parametrów */}
|
||||
{params.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
|
||||
{params.map((p, i) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-2 px-3 py-2 rounded-xl ${p.color} border border-current/10`}>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-medium opacity-70 leading-none mb-0.5">{p.label}</p>
|
||||
<p className="text-xs font-bold truncate">{p.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cena i CTA */}
|
||||
<div className="bg-white rounded-2xl border border-border p-5">
|
||||
{product.price !== null ? (
|
||||
<>
|
||||
<div className="flex items-end gap-3 mb-1">
|
||||
<span className="text-3xl font-extrabold text-primary font-heading">
|
||||
{product.price.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mb-1">cena urządzenia brutto (23% VAT)</p>
|
||||
<p className="text-xs text-text-muted mb-4">Montaż wyceniamy indywidualnie — zadzwoń lub napisz</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-bold text-sm transition-all ${
|
||||
added ? 'bg-green-500 text-white' : 'bg-accent text-white hover:bg-accent/90'
|
||||
}`}
|
||||
>
|
||||
{added ? <><Check className="size-4" /> Dodano do koszyka</> : <><ShoppingCart className="size-4" /> Do koszyka</>}
|
||||
</button>
|
||||
<Link href="/kontakt" className="flex-1">
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-bold text-sm border-2 border-primary text-primary hover:bg-primary hover:text-white transition-all">
|
||||
Zapytaj o montaż
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCompare}
|
||||
className={`w-full mt-2 flex items-center justify-center gap-2 py-2 px-4 rounded-xl text-xs font-medium transition-colors ${
|
||||
inCompare ? 'bg-accent/10 text-accent border border-accent' : 'bg-slate-50 text-text-muted border border-border hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<GitCompare className="size-3.5" />
|
||||
{inCompare ? 'Usuń z porównania' : 'Dodaj do porównania'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-primary font-heading mb-1">Cena na zapytanie</p>
|
||||
<p className="text-sm text-text-muted mb-4">Multisplit — cena zależy od konfiguracji jednostek wewnętrznych.</p>
|
||||
<a href={`tel:${companyPhoneRaw}`} className="flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-accent text-white font-bold text-sm hover:bg-accent/90 transition-colors">
|
||||
<Phone className="size-4" /> Zadzwoń: {companyPhone}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profesjonalna porada */}
|
||||
<a
|
||||
href={`tel:${companyPhoneRaw}`}
|
||||
className="flex items-center justify-between px-5 py-3.5 bg-primary rounded-xl text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-semibold">Profesjonalna porada</span>
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<Phone className="size-4" /> {companyPhone}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Kalkulator kosztów energii */}
|
||||
{mocChlodnicza > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-border p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="size-4 text-accent" />
|
||||
<h3 className="text-sm font-bold text-primary font-heading">Kalkulator kosztów energii</h3>
|
||||
<div className="group relative ml-auto">
|
||||
<Info className="size-4 text-text-muted cursor-help" />
|
||||
<div className="absolute right-0 bottom-6 w-56 bg-primary text-white text-xs rounded-lg p-2.5 hidden group-hover:block z-10">
|
||||
Szacunek przy cenie prądu {cenaKwh} zł/kWh. Rzeczywiste koszty mogą się różnić.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="text-xs text-text-muted shrink-0">Godziny użycia dziennie:</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={24}
|
||||
value={godziny}
|
||||
onChange={e => setGodziny(Number(e.target.value))}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="text-sm font-bold text-primary w-8 text-right">{godziny}h</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<p className="text-xs text-text-muted mb-1">Koszt / godzinę</p>
|
||||
<p className="text-lg font-bold text-primary font-heading">
|
||||
{kosztGodzina.toFixed(2)} zł
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-accent-light rounded-xl p-3 text-center border border-accent/20">
|
||||
<p className="text-xs text-accent mb-1">Koszt miesięczny</p>
|
||||
<p className="text-lg font-bold text-accent font-heading">
|
||||
{kosztMiesiac.toFixed(2)} zł
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-2 text-center">
|
||||
Szacunek przy {cenaKwh} zł/kWh · {godziny}h/dzień · 30 dni
|
||||
{!product.specs['Pobór mocy chłodzenie (kW)'] && ' · pobór szacowany z klasy energetycznej'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TABS */}
|
||||
<div className="mt-10">
|
||||
<div className="flex gap-1 border-b border-border mb-6 overflow-x-auto">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-5 py-3 text-sm font-semibold whitespace-nowrap transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab: Opis */}
|
||||
{activeTab === 'Opis' && (
|
||||
<div className="max-w-3xl">
|
||||
{product.shortDescription ? (
|
||||
<p className="text-text-muted leading-relaxed text-base">{product.shortDescription}</p>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak opisu produktu.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Specyfikacja */}
|
||||
{activeTab === 'Specyfikacja' && (
|
||||
<div className="max-w-2xl">
|
||||
{Object.keys(product.specs).length > 0 ? (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{Object.entries(product.specs).map(([key, val], idx) => (
|
||||
<tr key={key} className={idx % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
|
||||
<td className="px-5 py-3 text-text-muted font-medium w-1/2 border-r border-border">{key}</td>
|
||||
<td className="px-5 py-3 text-primary font-semibold">{val}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak specyfikacji.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Montaż */}
|
||||
{activeTab === 'Montaż' && (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<h3 className="font-bold text-primary font-heading mb-3">Montaż klimatyzacji</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed mb-4">
|
||||
Oferujemy profesjonalny montaż wszystkich klimatyzatorów z naszego sklepu na terenie Dolnego Śląska.
|
||||
Montaż wykonujemy z uprawnieniami F-Gaz, estetycznie i terminowo.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-text-muted">
|
||||
{['Bezpłatna wycena montażu przed realizacją', 'Uprawnienia F-Gaz — wymóg prawny', 'Próba szczelności i próżnia po montażu', 'Szkolenie z obsługi urządzenia', 'Gwarancja na wykonaną instalację'].map(item => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href={`tel:${companyPhoneRaw}`} className="mt-4 inline-flex items-center gap-2 btn btn-primary text-white font-bold text-sm">
|
||||
<Phone className="size-4" /> Zapytaj o montaż
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Dostawa */}
|
||||
{activeTab === 'Dostawa' && (
|
||||
<div className="max-w-2xl">
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<h3 className="font-bold text-primary font-heading mb-3">Informacje o dostawie</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">
|
||||
Szczegóły dostawy ustalamy indywidualnie po złożeniu zamówienia. Skontaktujemy się z Tobą w ciągu 24 godzin
|
||||
od otrzymania zamówienia, aby potwierdzić termin dostawy i montażu.
|
||||
</p>
|
||||
<Link href="/dostawa" className="mt-3 inline-block text-sm text-accent hover:underline font-medium">
|
||||
Sprawdź politykę dostawy →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { companyDetails } from '@/content/company';
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
brands: string[];
|
||||
initialSearch?: string;
|
||||
initialBrand?: string;
|
||||
}
|
||||
|
||||
const PER_PAGE = 24;
|
||||
@@ -74,9 +76,10 @@ function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
export function ProductsGrid({ products, brands, initialSearch, initialBrand }: ProductsGridProps) {
|
||||
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit'>('all');
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>(initialBrand ? [initialBrand] : []);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch || '');
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
|
||||
@@ -103,6 +106,14 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Kategoria
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
|
||||
@@ -177,6 +188,7 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
const resetAll = () => {
|
||||
setCategory('all');
|
||||
setSelectedBrands([]);
|
||||
setSearchQuery('');
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
setSelectedRooms([]);
|
||||
@@ -186,7 +198,7 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || halasMin > 15 || halasMax < 50;
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || halasMin > 15 || halasMax < 50;
|
||||
|
||||
const Sidebar = () => (
|
||||
<aside className="w-full">
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useConsentStore } from "@/lib/consent-store";
|
||||
import { GoogleAnalytics, GoogleTagManager } from "@next/third-parties/google";
|
||||
import Script from 'next/script';
|
||||
import { useConsentStore } from '@/lib/consent-store';
|
||||
|
||||
const GA_ID = 'G-VW3GD6SS7K';
|
||||
const CLARITY_ID = 'xdo6q3i06e';
|
||||
|
||||
export function AnalyticsWrapper() {
|
||||
const { analytics, marketing, isInitialized, initialize } = useConsentStore();
|
||||
const { analyticsConsent } = useConsentStore();
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, [initialize]);
|
||||
|
||||
if (!isInitialized) return null;
|
||||
if (!analyticsConsent) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ładujemy GTM tylko jeśli zgoda na marketing ATAK LUB analitykę,
|
||||
w zależności od rygorystyczności, zazwyczaj GTM potrzebuje analityki/marketingu */}
|
||||
{(analytics || marketing) && <GoogleTagManager gtmId="GTM-MBMRCD2N" />}
|
||||
|
||||
{/* Ładujemy GA4 tylko jeśli użytkownik zgodził się na analitykę */}
|
||||
{analytics && <GoogleAnalytics gaId="G-1NGTSR4TWD" />}
|
||||
{/* GA4 */}
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="ga4-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_ID}', {
|
||||
page_path: window.location.pathname,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{/* Microsoft Clarity */}
|
||||
<Script id="clarity-init" strategy="afterInteractive">
|
||||
{`
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "${CLARITY_ID}");
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function CookieBanner() {
|
||||
analytics,
|
||||
marketing,
|
||||
setConsent,
|
||||
setAnalyticsConsent,
|
||||
acceptAll,
|
||||
rejectOptional,
|
||||
closeBanner
|
||||
@@ -25,9 +26,20 @@ export function CookieBanner() {
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
setConsent(localAnalytics, localMarketing);
|
||||
setAnalyticsConsent(localAnalytics);
|
||||
setShowSettings(false);
|
||||
};
|
||||
|
||||
const handleAcceptAll = () => {
|
||||
acceptAll();
|
||||
setAnalyticsConsent(true);
|
||||
};
|
||||
|
||||
const handleRejectOptional = () => {
|
||||
rejectOptional();
|
||||
setAnalyticsConsent(false);
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
const hasConsent = localStorage.getItem('thermcool_cookie_consent_v1');
|
||||
setLocalAnalytics(hasConsent ? analytics : true);
|
||||
@@ -63,13 +75,13 @@ export function CookieBanner() {
|
||||
|
||||
<div className="flex flex-col sm:flex-row flex-wrap items-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
onClick={handleAcceptAll}
|
||||
className="btn btn-primary text-white flex-grow sm:flex-grow-0 border-none shadow-md"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1 text-white" /> Akceptuj wszystkie
|
||||
</button>
|
||||
<button
|
||||
onClick={rejectOptional}
|
||||
onClick={handleRejectOptional}
|
||||
className="btn btn-outline border-slate-600 text-slate-200 hover:bg-slate-800 hover:text-white hover:border-slate-500 flex-grow sm:flex-grow-0"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1 text-slate-300" /> Tylko niezbędne
|
||||
@@ -87,7 +99,7 @@ export function CookieBanner() {
|
||||
|
||||
{/* Przycisk zamknięcia wymusza "Tylko niezbędne" dla bezpieczeństwa */}
|
||||
<button
|
||||
onClick={rejectOptional}
|
||||
onClick={handleRejectOptional}
|
||||
className="absolute top-4 right-4 text-slate-500 hover:text-white p-1"
|
||||
aria-label="Zamknij"
|
||||
>
|
||||
@@ -167,13 +179,13 @@ export function CookieBanner() {
|
||||
|
||||
<div className="p-5 border-t border-slate-700 bg-slate-800/50 flex flex-col-reverse sm:flex-row justify-end gap-3 items-center">
|
||||
<button
|
||||
onClick={rejectOptional}
|
||||
onClick={handleRejectOptional}
|
||||
className="btn btn-ghost text-slate-400 hover:text-white w-full sm:w-auto"
|
||||
>
|
||||
Odrzuć wszystkie
|
||||
</button>
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
onClick={handleAcceptAll}
|
||||
className="btn btn-outline border-slate-600 text-white hover:bg-slate-700 w-full sm:w-auto"
|
||||
>
|
||||
Akceptuj wszystkie
|
||||
|
||||
Reference in New Issue
Block a user