feat: implement compare functionality, checkout flow, and add 30 blog posts
This commit is contained in:
@@ -29,7 +29,7 @@ export function SectionHeading({
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs md:text-sm font-semibold uppercase tracking-wider font-heading",
|
||||
dark ? "text-accent" : "text-cta"
|
||||
"text-accent"
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { submitContactForm } from '@/app/kontakt/actions';
|
||||
import { companyDetails } from "@/content/company";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
|
||||
@@ -170,7 +171,7 @@ export function ContactForm() {
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted leading-relaxed select-none">
|
||||
Wyrażam zgodę na przetwarzanie moich danych osobowych podanych w formularzu w celu obsługi zapytania przez firmę ThermCool. Zapoznałem/am się z Polityką Prywatności.
|
||||
Wyrażam zgodę na przetwarzanie moich danych osobowych podanych w formularzu w celu obsługi zapytania przez firmę {companyDetails.shortName}. Zapoznałem/am się z Polityką Prywatności.
|
||||
</span>
|
||||
</label>
|
||||
{errors.consent && <p className="text-red-500 text-xs font-medium">{errors.consent.message}</p>}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Footer() {
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-slate-300 max-w-xs leading-relaxed">
|
||||
Kompleksowe usługi HVAC, klimatyzacji, pomp ciepła oraz prace wykończeniowe i serwisowe dla Twojego domu.
|
||||
Profesjonalne usługi w zakresie klimatyzacji i pomp ciepła dla domów, mieszkań i firm.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 mt-2 text-sm text-slate-300">
|
||||
<a
|
||||
@@ -70,21 +70,7 @@ export function Footer() {
|
||||
Pompy Ciepła
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/uslugi-domowe/remonty" className="hover:text-accent transition-colors">
|
||||
Remonty i Wykończenia
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/uslugi-domowe/hydraulika" className="hover:text-accent transition-colors">
|
||||
Usługi Hydrauliczne
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/uslugi-domowe/serwis-agd" className="hover:text-accent transition-colors">
|
||||
Serwis AGD i Lodówek
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<Link href="/sklep" className="hover:text-accent transition-colors">
|
||||
Sklep online
|
||||
@@ -143,14 +129,9 @@ export function Footer() {
|
||||
Obsługa Klienta
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3 text-sm text-slate-300">
|
||||
<li>
|
||||
<Link href="/koszyk" className="hover:text-accent transition-colors">
|
||||
Twój Koszyk
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/sklep" className="hover:text-accent transition-colors">
|
||||
Zamów urządzenia
|
||||
Sklep online
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -65,10 +65,10 @@ export function GlobalSearch() {
|
||||
<div className="flex-1 flex flex-col">
|
||||
<span className="text-[10px] font-bold text-text-muted uppercase tracking-wider">{product.brand}</span>
|
||||
<span className="text-sm font-semibold text-primary group-hover:text-accent transition-colors">{product.name}</span>
|
||||
<span className="text-xs text-text-muted mt-0.5">{product.power} kW • {product.category}</span>
|
||||
<span className="text-xs text-text-muted mt-0.5">{product.specs?.['Moc chłodnicza'] ? `${product.specs['Moc chłodnicza']} kW • ` : ''}{product.category}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-primary block">{formatCurrency(product.price)}</span>
|
||||
<span className="text-sm font-bold text-primary block">{product.price !== null ? formatCurrency(product.price) : 'Zapytaj o cenę'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -71,45 +71,24 @@ export function Header() {
|
||||
},
|
||||
{
|
||||
label: "Serwis i czyszczenie",
|
||||
href: "/klimatyzacja",
|
||||
href: "/klimatyzacja#serwis",
|
||||
description: "Odgrzybianie, naprawy i przeglądy",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Montaż w Twoim mieście",
|
||||
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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const homeSections = [
|
||||
{
|
||||
title: "Prace wykończeniowe",
|
||||
items: [
|
||||
{
|
||||
label: "Remonty i wykończenia",
|
||||
href: "/uslugi-domowe/remonty",
|
||||
description: "Kompleksowe remonty mieszkań i łazienek",
|
||||
},
|
||||
{
|
||||
label: "Drobne naprawy",
|
||||
href: "/uslugi-domowe",
|
||||
description: "Pomoc „złotej rączki” w drobnych usterkach",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Instalacje i AGD",
|
||||
items: [
|
||||
{
|
||||
label: "Hydraulika",
|
||||
href: "/uslugi-domowe/hydraulika",
|
||||
description: "Montaż armatury, naprawy i instalacje wod-kan",
|
||||
},
|
||||
{
|
||||
label: "Serwis AGD / Lodówki",
|
||||
href: "/uslugi-domowe/serwis-agd",
|
||||
description: "Szybka naprawa lodówek i urządzeń chłodniczych",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -123,7 +102,7 @@ export function Header() {
|
||||
<span className="text-accent">.</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-medium border-l border-border pl-3">
|
||||
Klimatyzacja i usługi domowe
|
||||
Klimatyzacja i pompy ciepła
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -139,15 +118,10 @@ export function Header() {
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<GlobalSearch />
|
||||
|
||||
<Link
|
||||
href="/koszyk"
|
||||
className="p-2 text-primary hover:text-accent hover:bg-background rounded-lg transition-colors relative outline-none"
|
||||
aria-label={`Koszyk z ${cartCount} przedmiotami`}
|
||||
>
|
||||
<Link href="/koszyk" className="p-2 text-primary hover:text-accent hover:bg-background rounded-lg transition-colors relative outline-none" aria-label="Koszyk">
|
||||
<ShoppingBag className="size-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-cta text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
<span className="absolute top-0 right-0 bg-accent text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -188,11 +162,7 @@ export function Header() {
|
||||
>
|
||||
Pompy ciepła
|
||||
</Link>
|
||||
<NavDropdown
|
||||
label="Usługi domowe"
|
||||
triggerHref="/uslugi-domowe"
|
||||
sections={homeSections}
|
||||
/>
|
||||
|
||||
<Link
|
||||
href="/realizacje"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
@@ -246,18 +216,15 @@ export function Header() {
|
||||
|
||||
{/* Mobile Icons (Cart + Hamburger) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/koszyk"
|
||||
className="p-2 text-primary hover:text-accent transition-colors relative outline-none"
|
||||
aria-label={`Koszyk z ${cartCount} przedmiotami`}
|
||||
>
|
||||
<ShoppingBag className="size-6" />
|
||||
<Link href="/koszyk" className="p-2 text-primary hover:text-accent hover:bg-muted transition-colors rounded-lg relative outline-none" aria-label="Koszyk">
|
||||
<ShoppingBag className="size-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-cta text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
<span className="absolute top-0 right-0 bg-accent text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="p-2 text-primary hover:text-accent hover:bg-muted transition-colors rounded-lg outline-none"
|
||||
|
||||
@@ -35,12 +35,7 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
|
||||
{ label: "Serwis i przeglądy", href: "/klimatyzacja" },
|
||||
];
|
||||
|
||||
const homeServices = [
|
||||
{ label: "Wszystkie usługi domowe", href: "/uslugi-domowe" },
|
||||
{ label: "Remonty i wykończenia", href: "/uslugi-domowe/remonty" },
|
||||
{ label: "Hydraulika i instalacje", href: "/uslugi-domowe/hydraulika" },
|
||||
{ label: "Serwis AGD / Lodówki", href: "/uslugi-domowe/serwis-agd" },
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -116,28 +111,7 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 3. Usługi Domowe */}
|
||||
<details className="collapse collapse-arrow border-b border-border rounded-none">
|
||||
<summary className="collapse-title text-sm font-medium text-primary hover:text-accent py-3 px-0 min-h-0">
|
||||
Usługi domowe
|
||||
</summary>
|
||||
<div className="collapse-content px-0 pb-2">
|
||||
<ul className="flex flex-col gap-2.5 pl-4 pb-2">
|
||||
{homeServices.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>
|
||||
|
||||
|
||||
{/* 4. Realizacje */}
|
||||
<div className="border-b border-border py-3">
|
||||
|
||||
@@ -55,7 +55,7 @@ export function NavDropdown({ label, triggerHref, sections, align = "center" }:
|
||||
{/* Dropdown Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full pt-2 z-50 w-[480px] transition-all duration-200 origin-top",
|
||||
"absolute top-full pt-2 z-50 w-[640px] transition-all duration-200 origin-top",
|
||||
align === "left" && "left-0",
|
||||
align === "center" && "left-1/2 -translate-x-1/2",
|
||||
align === "right" && "right-0",
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, MouseEvent, TouchEvent } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { projectsData } from '@/content/projects';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
export const BeforeAfterSlider = () => {
|
||||
const [sliderPosition, setSliderPosition] = useState(50);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pobierz pierwszy projekt z kategorii remonty, który ma beforeAfter
|
||||
const project = projectsData.find(p => p.category === 'remonty' && p.beforeAfter);
|
||||
|
||||
if (!project || !project.beforeAfter) return null;
|
||||
|
||||
const handleMove = (clientX: number) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
setSliderPosition(percentage);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: globalThis.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
handleMove(e.clientX);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: globalThis.TouchEvent) => {
|
||||
if (!isDragging) return;
|
||||
handleMove(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', stopDragging);
|
||||
window.addEventListener('touchmove', onTouchMove);
|
||||
window.addEventListener('touchend', stopDragging);
|
||||
} else {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', stopDragging);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', stopDragging);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', stopDragging);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', stopDragging);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-5xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Przed i Po</h2>
|
||||
<p className="text-gray-600">Zobacz metamorfozę na jednym z naszych ostatnich projektów.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full aspect-[4/3] md:aspect-[16/9] rounded-2xl overflow-hidden select-none cursor-ew-resize bg-gray-100"
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
onTouchStart={() => setIsDragging(true)}
|
||||
>
|
||||
{/* Obrazek PO (na spodzie) */}
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={project.beforeAfter.afterUrl}
|
||||
alt="Wygląd po remoncie"
|
||||
fill
|
||||
className="object-cover pointer-events-none"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 bg-white/80 backdrop-blur text-primary font-semibold px-4 py-1.5 rounded-full text-sm">
|
||||
Po
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Obrazek PRZED (na wierzchu, obcięty) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: `${sliderPosition}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 w-[100vw] min-w-full">
|
||||
{/* Używamy native img dla prostszego manipulowania wymiarami wewnątrz overflow */}
|
||||
<img
|
||||
src={project.beforeAfter.beforeUrl}
|
||||
alt="Wygląd przed remontem"
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
style={{ width: containerRef.current?.getBoundingClientRect().width || '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4 bg-white/80 backdrop-blur text-gray-700 font-semibold px-4 py-1.5 rounded-full text-sm">
|
||||
Przed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suwak (linia i uchwyt) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white cursor-ew-resize flex items-center justify-center shadow-[0_0_10px_rgba(0,0,0,0.3)]"
|
||||
style={{ left: `calc(${sliderPosition}% - 2px)` }}
|
||||
>
|
||||
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center shadow-lg border border-gray-100 text-primary hover:scale-110 transition-transform">
|
||||
<GripVertical className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -15,18 +15,18 @@ export interface HeroSectionProps {
|
||||
}
|
||||
|
||||
export function HeroSection({
|
||||
eyebrow = "HVAC & Usługi Wykończeniowe",
|
||||
eyebrow = "Klimatyzacja i Pompy Ciepła",
|
||||
title = (
|
||||
<>
|
||||
Klimatyzacja, pompy ciepła<br className="hidden sm:inline" />
|
||||
<span className="text-accent"> i usługi domowe</span>
|
||||
</>
|
||||
),
|
||||
subtitle = "Oferujemy profesjonalny dobór, czysty montaż i sprawny serwis. Wykończymy Twoje wnętrza i naprawimy instalacje. Solidnie i z polecenia.",
|
||||
subtitle = "Oferujemy profesjonalny dobór, czysty montaż i sprawny serwis klimatyzacji oraz pomp ciepła. Solidnie, estetycznie i z pełną gwarancją.",
|
||||
ctaText = "Darmowa wycena",
|
||||
ctaLink = "/kontakt",
|
||||
secondaryCtaText = "Zobacz ofertę",
|
||||
secondaryCtaLink = "/uslugi-domowe",
|
||||
secondaryCtaLink = "/sklep",
|
||||
backgroundImage = "/hero.jpg",
|
||||
features
|
||||
}: HeroSectionProps = {}) {
|
||||
|
||||
@@ -47,11 +47,8 @@ export function ProcessSteps({
|
||||
{/* Process Steps Timeline */}
|
||||
<div className="relative mt-12">
|
||||
{/* Desktop connecting line (only if few steps, otherwise wrap) */}
|
||||
{steps.length <= 4 && (
|
||||
<div className="hidden lg:block absolute top-10 left-[12%] right-[12%] h-0.5 bg-slate-200 z-0" />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-y-12 gap-x-8 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-y-12 gap-x-8 relative z-10">
|
||||
{steps.map((step, idx) => {
|
||||
const Icon = iconMap[step.icon || ""] || HelpCircle;
|
||||
return (
|
||||
@@ -59,10 +56,15 @@ export function ProcessSteps({
|
||||
{/* Circle Badge with Icon & Number */}
|
||||
<div className="relative size-20 rounded-full bg-slate-50 border-2 border-slate-200 flex items-center justify-center text-primary group-hover:border-accent group-hover:bg-accent-light group-hover:text-accent transition-all duration-300 shadow-sm z-10">
|
||||
{/* Step Number */}
|
||||
<span className="absolute -top-1.5 -right-1.5 size-6 rounded-full bg-cta text-white text-xs font-bold flex items-center justify-center font-heading border-2 border-white shadow-sm">
|
||||
{step.number}
|
||||
</span>
|
||||
<Icon className="size-8 group-hover:scale-110 transition-transform duration-300" />
|
||||
{step.icon && iconMap[step.icon] && (
|
||||
<span className="absolute -top-1.5 -right-1.5 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center font-heading border-2 border-white shadow-sm">
|
||||
{step.number}
|
||||
</span>
|
||||
)}
|
||||
{step.icon && iconMap[step.icon]
|
||||
? <Icon className="size-8 group-hover:scale-110 transition-transform duration-300" />
|
||||
: <span className="text-2xl font-extrabold font-heading text-accent">{step.number}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
|
||||
@@ -13,8 +13,6 @@ const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'wszystkie', label: 'Wszystkie' },
|
||||
{ id: 'klimatyzacja', label: 'Klimatyzacja' },
|
||||
{ id: 'pompy-ciepla', label: 'Pompy ciepła' },
|
||||
{ id: 'remonty', label: 'Remonty' },
|
||||
{ id: 'hydraulika', label: 'Hydraulika' },
|
||||
];
|
||||
|
||||
export function ProjectsGrid() {
|
||||
|
||||
@@ -9,7 +9,7 @@ export function Testimonials() {
|
||||
<SectionHeading
|
||||
eyebrow="Opinie Klientów"
|
||||
title="Co mówią o nas nasi klienci?"
|
||||
subtitle="Zadowolenie klientów to nasz najwyższy cel. Zobacz opinie osób, które zaufały naszym instalatorom i ekipom budowlanym."
|
||||
subtitle="Zadowolenie klientów to nasz najwyższy cel. Zobacz opinie osób, które zaufały naszym montażystom i serwisantom."
|
||||
align="center"
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export function WhyUs() {
|
||||
const advantages = [
|
||||
{
|
||||
icon: Award,
|
||||
title: "Uprawnienia F-Gaz i certyfikaty",
|
||||
description: "Posiadamy wszystkie wymagane certyfikaty UDT oraz autoryzacje wiodących producentów klimatyzacji.",
|
||||
title: "Doświadczenie i certyfikaty",
|
||||
description: "Posiadamy wszystkie wymagane certyfikaty i autoryzacje. Działamy na rynku od ponad 15 lat.",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
@@ -32,8 +32,8 @@ export function WhyUs() {
|
||||
{/* Left Column: Title, Intro and Statistics */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-6">
|
||||
<div>
|
||||
<span className="text-xs md:text-sm font-semibold uppercase tracking-wider text-cta font-heading">
|
||||
Dlaczego ThermCool
|
||||
<span className="text-xs md:text-sm font-semibold uppercase tracking-wider text-accent font-heading">
|
||||
{`Dlaczego ${companyDetails.shortName}`}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary font-heading leading-tight mt-2">
|
||||
Zaufany partner w instalacjach domowych
|
||||
|
||||
@@ -5,10 +5,10 @@ import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Minus, Plus, Trash2, Wrench } from 'lucide-react';
|
||||
import { Minus, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export function CartItems() {
|
||||
const { items, removeItem, updateQuantity, toggleInstall } = useCartStore();
|
||||
const { items, removeItem, updateQuantity } = useCartStore();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
@@ -28,7 +28,7 @@ export function CartItems() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const itemPrice = item.withInstall ? item.priceWithInstall : item.price;
|
||||
const itemPrice = item.price;
|
||||
const totalGross = itemPrice * item.quantity;
|
||||
|
||||
return (
|
||||
@@ -63,31 +63,10 @@ export function CartItems() {
|
||||
{item.name}
|
||||
</Link>
|
||||
</h3>
|
||||
{/* Wariant mocowy */}
|
||||
{(item.label || item.power) && (
|
||||
<div className="text-sm text-text-muted mb-3">
|
||||
Wariant: <span className="font-semibold text-primary">{item.label || `${item.power} kW`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Opcja Montażu i Cena jednostkowa */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.withInstall}
|
||||
onChange={() => toggleInstall(item.id)}
|
||||
className="w-5 h-5 rounded border-border text-green-600 focus:ring-green-600"
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold flex items-center gap-1.5 ${item.withInstall ? 'text-green-700' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
<Wrench className="w-4 h-4" />
|
||||
Z montażem
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-4 mt-4">
|
||||
{/* Ilość i Cena */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center border border-border rounded-lg bg-slate-50">
|
||||
|
||||
@@ -7,15 +7,14 @@ import Link from 'next/link';
|
||||
import { ShieldCheck, ArrowRight, Tag } from 'lucide-react';
|
||||
|
||||
export function CartSummary() {
|
||||
const { items, getTotals } = useCartStore();
|
||||
const { items, getTotal } = useCartStore();
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const { subtotal, vat, total } = getTotals();
|
||||
|
||||
// Obliczamy ile pozycji ma montaż by wyświetlić odpowiednią informację
|
||||
const itemsWithInstall = items.filter(i => i.withInstall).length;
|
||||
const total = getTotal();
|
||||
const subtotal = total / 1.23;
|
||||
const vat = total - subtotal;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 p-6 md:p-8 rounded-2xl border border-border sticky top-24">
|
||||
@@ -47,14 +46,9 @@ export function CartSummary() {
|
||||
<span className="font-semibold text-primary">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-text-muted">
|
||||
<span>Podatek VAT (mieszany)</span>
|
||||
<span>Podatek VAT (23%)</span>
|
||||
<span className="font-semibold text-primary">{formatCurrency(vat)}</span>
|
||||
</div>
|
||||
{itemsWithInstall > 0 && (
|
||||
<div className="text-xs text-green-700 bg-green-50 p-2 rounded border border-green-100 mt-2">
|
||||
Zastosowano obniżoną stawkę VAT 8% dla {itemsWithInstall} poz. z montażem (dla os. fizycznych).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total */}
|
||||
@@ -63,7 +57,7 @@ export function CartSummary() {
|
||||
<span className="text-3xl font-extrabold text-primary font-heading text-right">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
{/* Przycisk (Placeholder - brak właściwego checkoutu) */}
|
||||
{/* Przycisk */}
|
||||
<Link href="/zamowienie" className="block w-full">
|
||||
<button className="btn btn-primary w-full font-bold group cursor-pointer text-white">
|
||||
Przejdź do zamówienia
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { X, GitCompare } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CompareBar() {
|
||||
const { items, removeItem, clearAll } = useCompareStore();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-border shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<GitCompare className="size-4 text-accent" />
|
||||
<span className="text-sm font-semibold text-primary hidden sm:block">
|
||||
Porównaj ({items.length}/3)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-1 overflow-x-auto">
|
||||
{items.map((item) => (
|
||||
<div key={item.slug} className="flex items-center gap-2 bg-slate-50 rounded-lg px-3 py-1.5 border border-border shrink-0">
|
||||
<div className="relative size-8 bg-white rounded">
|
||||
<Image src={item.image} alt={item.name} fill className="object-contain p-0.5" unoptimized={item.image.startsWith('http')} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-xs font-medium text-primary line-clamp-1 max-w-[140px]">{item.name}</p>
|
||||
{item.price && <p className="text-xs text-accent font-bold">{item.price.toLocaleString('pl-PL')} zł</p>}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.slug)} className="text-text-muted hover:text-primary transition-colors ml-1">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Puste sloty */}
|
||||
{Array.from({ length: 3 - items.length }).map((_, i) => (
|
||||
<div key={i} className="size-11 rounded-lg border-2 border-dashed border-border flex items-center justify-center text-text-muted shrink-0">
|
||||
<span className="text-lg">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{items.length >= 2 && (
|
||||
<Link href="/porownaj">
|
||||
<button className="btn btn-primary text-white font-bold text-sm py-2 px-4">
|
||||
Porównaj
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={clearAll} className="text-xs text-text-muted hover:text-primary transition-colors">
|
||||
Wyczyść
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+137
-57
@@ -1,80 +1,160 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Product } from '@/types/types';
|
||||
import { Heart, Wrench } from 'lucide-react';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { ShoppingCart, GitCompare, Phone, Check } from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: ProductCardProps) {
|
||||
// Jeśli ma warianty, przycisk prowadzi do karty z wariantami
|
||||
const hasVariants = product.variants && product.variants.length > 0;
|
||||
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
const { addItem: addCompare, removeItem: removeCompare, isAdded, items: compareItems } = useCompareStore();
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!product.price) return;
|
||||
addItem({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
image: product.image,
|
||||
price: product.price,
|
||||
});
|
||||
setAdded(true);
|
||||
setTimeout(() => setAdded(false), 1500);
|
||||
};
|
||||
|
||||
const handleCompare = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (isAdded(product.slug)) {
|
||||
removeCompare(product.slug);
|
||||
} else {
|
||||
addCompare({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
price: product.price,
|
||||
image: product.image,
|
||||
specs: product.specs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const inCompare = isAdded(product.slug);
|
||||
const compareDisabled = compareItems.length >= 3 && !inCompare;
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col bg-white rounded-2xl border border-border overflow-hidden hover:shadow-xl transition-all duration-300">
|
||||
{/* Obraz */}
|
||||
<Link href={`/sklep/${product.slug}`} className="relative aspect-square p-6 bg-slate-50 flex items-center justify-center overflow-hidden">
|
||||
{/* Placeholder tagów */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2">
|
||||
{product.features?.slice(0, 2).map((feat, idx) => (
|
||||
<span key={idx} className="bg-white/90 backdrop-blur-sm text-primary text-[10px] font-bold px-2 py-1 rounded shadow-sm border border-border/50 uppercase">
|
||||
{feat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Serce */}
|
||||
<button className="absolute top-4 right-4 z-10 p-2 bg-white/90 backdrop-blur-sm rounded-full text-text-muted hover:text-red-500 hover:bg-white transition-colors shadow-sm">
|
||||
<Heart className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="group relative bg-white rounded-xl border border-border hover:border-accent hover:shadow-md transition-all duration-200 overflow-hidden flex flex-col">
|
||||
{/* Zdjęcie */}
|
||||
<Link href={`/sklep/${product.slug}`} className="block relative aspect-[4/3] bg-slate-50 overflow-hidden">
|
||||
<Image
|
||||
src={product.image || 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=800&q=80'}
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={400}
|
||||
className="object-contain w-full h-full transition-transform duration-500 group-hover:scale-105"
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-contain p-6 group-hover:scale-105 transition-transform duration-300"
|
||||
unoptimized={product.image.startsWith('http')}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Badge multisplit */}
|
||||
{product.category === 'multisplit' && (
|
||||
<span className="absolute top-2 right-2 px-2 py-1 bg-accent/10 rounded-md text-xs font-semibold text-accent border border-accent/20">
|
||||
Multisplit
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Porównywarka - pojawia się na hover */}
|
||||
<button
|
||||
onClick={handleCompare}
|
||||
disabled={compareDisabled}
|
||||
title={inCompare ? 'Usuń z porównywarki' : compareDisabled ? 'Max 3 produkty' : 'Dodaj do porównania'}
|
||||
className={`absolute bottom-2 right-2 p-1.5 rounded-lg border transition-all ${
|
||||
inCompare
|
||||
? 'bg-accent text-white border-accent opacity-100'
|
||||
: 'bg-white/90 text-text-muted border-border opacity-0 group-hover:opacity-100 hover:border-accent hover:text-accent'
|
||||
} ${compareDisabled ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<GitCompare className="size-3.5" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* Treść */}
|
||||
<div className="flex flex-col flex-grow p-6">
|
||||
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">
|
||||
{product.brand}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading leading-tight mb-2 group-hover:text-accent transition-colors">
|
||||
<Link href={`/sklep/${product.slug}`}>
|
||||
<div className="p-4 flex flex-col flex-1 gap-2">
|
||||
<Link href={`/sklep/${product.slug}`}>
|
||||
<h3 className="text-sm font-semibold text-primary font-heading leading-snug line-clamp-2 hover:text-accent transition-colors">
|
||||
{product.name}
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
{/* Informacja o mocy */}
|
||||
<div className="text-sm font-medium text-text-muted mb-4">
|
||||
Moc chłodnicza: <span className="text-primary">{product.power} kW</span>
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Kluczowe specs */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{product.specs['Moc chłodnicza'] && (
|
||||
<span className="px-2 py-0.5 bg-slate-50 rounded text-xs text-text-muted border border-border">
|
||||
{product.specs['Moc chłodnicza']} kW
|
||||
</span>
|
||||
)}
|
||||
{product.specs['Klasa energetyczna'] && (
|
||||
<span className="px-2 py-0.5 bg-green-50 rounded text-xs text-green-700 border border-green-100 font-medium">
|
||||
{product.specs['Klasa energetyczna']}
|
||||
</span>
|
||||
)}
|
||||
{product.specs['Czynnik chłodniczy'] && (
|
||||
<span className="px-2 py-0.5 bg-slate-50 rounded text-xs text-text-muted border border-border">
|
||||
{product.specs['Czynnik chłodniczy']}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ceny */}
|
||||
<div className="mt-auto space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-extrabold text-primary font-heading">
|
||||
{formatCurrency(product.price)} <span className="text-sm text-text-muted font-normal">samo urządzenie</span>
|
||||
</div>
|
||||
{product.priceWithInstall && (
|
||||
<div className="flex items-center gap-1.5 text-sm font-bold text-green-600 bg-green-50 px-2 py-1 rounded w-fit">
|
||||
<Wrench className="w-3.5 h-3.5" />
|
||||
od {formatCurrency(product.priceWithInstall)} z montażem
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Cena + CTA */}
|
||||
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
|
||||
{product.price !== null ? (
|
||||
<div>
|
||||
<p className="text-lg font-bold text-primary font-heading leading-none">
|
||||
{product.price.toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">brutto</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-accent">
|
||||
<Phone className="size-3.5" />
|
||||
<span className="text-sm font-semibold">Zapytaj o cenę</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href={`/sklep/${product.slug}`} className="block mt-4">
|
||||
<button className="btn btn-outline btn-primary bg-white w-full font-semibold">
|
||||
{hasVariants ? 'Wybierz wariant' : 'Zobacz szczegóły'}
|
||||
{product.price !== null ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold transition-all shrink-0 ${
|
||||
added
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-accent text-white hover:bg-accent/90'
|
||||
}`}
|
||||
>
|
||||
{added ? (
|
||||
<><Check className="size-3.5" /> Dodano</>
|
||||
) : (
|
||||
<><ShoppingCart className="size-3.5" /> Do koszyka</>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/kontakt">
|
||||
<button className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-primary text-white hover:bg-primary/90 transition-colors shrink-0">
|
||||
Zapytaj
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '@/types/types';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { ShoppingCart, Heart, Scale, ShieldCheck, Truck, Clock, AlertCircle, Wrench, ChevronDown, Check, Zap, Volume2, ThermometerSun, Settings, ArrowRightLeft, Sun, Mail, Info, Snowflake, CreditCard } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface ProductInfoProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function ProductInfo({ product }: ProductInfoProps) {
|
||||
const router = useRouter();
|
||||
const addItem = useCartStore((state) => state.addItem);
|
||||
|
||||
// Wariant
|
||||
const hasVariants = product.variants && product.variants.length > 0;
|
||||
const defaultVariant = hasVariants ? product.variants![0] : null;
|
||||
const [selectedVariant, setSelectedVariant] = useState(defaultVariant);
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number>(1);
|
||||
|
||||
// Aktualnie wybrana cena
|
||||
const currentPrice = selectedVariant ? selectedVariant.price : product.price;
|
||||
const currentPriceWithInstall = selectedVariant ? selectedVariant.priceWithInstall : product.priceWithInstall;
|
||||
const currentPower = selectedVariant ? selectedVariant.power : product.power;
|
||||
const currentLabel = selectedVariant ? selectedVariant.label : undefined;
|
||||
|
||||
const [addedMessage, setAddedMessage] = useState('');
|
||||
|
||||
// Kalkulator energii
|
||||
const costPerKwh = 1.15; // Przykładowy koszt 1 kWh w PLN
|
||||
const consumptionCooling = currentPower / 4;
|
||||
const consumptionHeating = currentPower / 3.8;
|
||||
const costHourCooling = consumptionCooling * costPerKwh;
|
||||
const costHourHeating = consumptionHeating * costPerKwh;
|
||||
|
||||
const monthlyCostCooling = costHourCooling * hoursPerDay * 30;
|
||||
const monthlyCostHeating = costHourHeating * hoursPerDay * 30;
|
||||
|
||||
const handleAddToCart = (withInstall: boolean) => {
|
||||
addItem({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
image: product.image || '/hero.jpg',
|
||||
power: currentPower,
|
||||
label: currentLabel,
|
||||
price: currentPrice,
|
||||
priceWithInstall: currentPriceWithInstall || currentPrice,
|
||||
withInstall
|
||||
});
|
||||
|
||||
setAddedMessage(withInstall ? 'Dodano z montażem' : 'Dodano do koszyka');
|
||||
setTimeout(() => setAddedMessage(''), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-sm font-bold text-text-muted uppercase tracking-wider">{product.brand}</span>
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<button className="btn btn-ghost btn-circle btn-sm hover:text-accent" aria-label="Dodaj do listy życzeń">
|
||||
<Heart className="w-5 h-5" />
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-circle btn-sm hover:text-accent" aria-label="Porównaj">
|
||||
<ArrowRightLeft className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold text-primary font-heading leading-tight mb-4">
|
||||
{product.name}
|
||||
</h1>
|
||||
|
||||
{/* Cechy skrócone */}
|
||||
{product.features && (
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{product.features.map((feat, idx) => (
|
||||
<span key={idx} className="bg-slate-100 text-text-muted text-xs font-semibold px-2.5 py-1 rounded-md border border-border">
|
||||
{feat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wybór Wariantu */}
|
||||
{hasVariants && (
|
||||
<div className="mb-6">
|
||||
<span className="block text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">Wybierz wariant mocy:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{product.variants!.map((variant, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelectedVariant(variant)}
|
||||
className={`px-4 py-2 rounded-lg border text-sm font-bold transition-all ${
|
||||
selectedVariant?.label === variant.label
|
||||
? 'border-primary bg-primary text-white shadow-md'
|
||||
: 'border-border bg-white text-text-muted hover:border-primary/50 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{variant.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Szybka specyfikacja z ikonami */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6 text-sm">
|
||||
<div className="flex items-center gap-2 text-primary font-semibold">
|
||||
<Snowflake className="w-4 h-4 text-accent" />
|
||||
<span>{product.specs?.["Klasa energetyczna chłodzenie/grzanie"]?.split("/")[0]?.trim() || "A++"}</span>
|
||||
</div>
|
||||
{product.heating !== false && (
|
||||
<div className="flex items-center gap-2 text-primary font-semibold">
|
||||
<Sun className="w-4 h-4 text-orange-500" />
|
||||
<span>{product.specs?.["Klasa energetyczna chłodzenie/grzanie"]?.split("/")[1]?.trim() || "A+"}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-primary font-semibold">
|
||||
<Zap className="w-4 h-4 text-yellow-500" />
|
||||
<span>{(currentPower / 4).toFixed(2)} kW/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-primary font-semibold">
|
||||
<Zap className="w-4 h-4 text-yellow-500" />
|
||||
<span>{currentPower} kW</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Szczegółowa specyfikacja skrócona (lista) */}
|
||||
<ul className="space-y-2.5 mb-8 text-sm text-text-muted">
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Wydajność chłodnicza:</strong> {(currentPower).toFixed(2)} kW</span>
|
||||
</li>
|
||||
{product.heating !== false && (
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Wydajność grzewcza:</strong> {(currentPower * 1.1).toFixed(2)} kW</span>
|
||||
</li>
|
||||
)}
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Średnie zużycie energii (chłodzenie/grzanie):</strong> {(consumptionCooling).toFixed(2)} / {(consumptionHeating).toFixed(2)} kW</span>
|
||||
</li>
|
||||
<li className="flex gap-2 flex-col">
|
||||
<div className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Szacunkowy koszt energii – 1 h (chłodzenie/grzanie):</strong> {costHourCooling.toFixed(2)} / {costHourHeating.toFixed(2)} zł</span>
|
||||
</div>
|
||||
<div className="ml-3.5 mt-2 p-4 bg-white rounded-xl border border-border shadow-sm inline-block w-full max-w-sm">
|
||||
<div className="flex items-center justify-between gap-4 mb-3">
|
||||
<span className="text-sm font-medium text-text-muted">Ile godzin dziennie używasz?</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={hoursPerDay}
|
||||
onChange={(e) => setHoursPerDay(Number(e.target.value) || 1)}
|
||||
className="w-16 px-2 py-1.5 text-sm font-bold text-center border border-border rounded-lg outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted leading-relaxed">
|
||||
Twój miesięczny koszt wyniesie: <br />
|
||||
<strong className="text-primary font-bold">Chłodzenie {monthlyCostCooling.toFixed(2)} zł</strong> / <strong className="text-primary font-bold">Grzanie {monthlyCostHeating.toFixed(2)} zł</strong>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">SEER / SCOP:</strong> {product.specs?.["SEER / SCOP"] || "6.10 / 4.00"}</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Klasa energetyczna (chłodzenie/grzanie):</strong> {product.specs?.["Klasa energetyczna chłodzenie/grzanie"] || "A++ / A+"}</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Poziom głośności jednostki wewnętrznej:</strong> {product.specs?.["Poziom hałasu min/max (dB)"] || "24–38 dB"}</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Zakres pracy - chłodzenie:</strong> {product.specs?.["Zakres temperatur zewnętrznych - chłodzenie"] || "-15~43 °C"}</span>
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Czas gwarancji:</strong> 5 lat</span>
|
||||
</li>
|
||||
{product.features && (
|
||||
<li className="flex gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent mt-2 shrink-0"></span>
|
||||
<span><strong className="text-primary font-semibold">Funkcje:</strong> {product.features.join(", ")}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Dostępność i Kontakt */}
|
||||
<div className="flex items-center gap-6 py-4 border-y border-border mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-green-500 shadow-sm shadow-green-200"></div>
|
||||
<span className="text-sm font-medium text-primary">Dostępny od ręki</span>
|
||||
</div>
|
||||
<Link href="/kontakt" className="flex items-center gap-2 text-text-muted hover:text-primary transition-colors">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span className="text-sm">Zapytaj o produkt</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Ceny i CTA */}
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-border mb-8 space-y-6">
|
||||
|
||||
{/* Cena z montażem (Promowana) */}
|
||||
{currentPriceWithInstall && (
|
||||
<div className="flex flex-col gap-3 pb-6 border-b border-border/50">
|
||||
<div className="flex justify-between items-end flex-wrap gap-4">
|
||||
<div>
|
||||
<span className="flex items-center gap-1 text-text-muted text-xs font-semibold mb-1 uppercase tracking-wider">
|
||||
Cena z montażem dla konsumenta (8% VAT) <Info className="w-3 h-3" />
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-extrabold text-primary font-heading">{formatCurrency(currentPriceWithInstall)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-accent font-medium mt-1">Darmowa wycena</div>
|
||||
</div>
|
||||
<button onClick={() => handleAddToCart(true)} className="btn btn-primary text-white">
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
Kup z montażem
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cena bez montażu */}
|
||||
<div className="flex justify-between items-end flex-wrap gap-4">
|
||||
<div>
|
||||
<span className="block text-text-muted text-xs font-semibold mb-1 uppercase tracking-wider">Cena urządzenia bez montażu (23% VAT)</span>
|
||||
<div className="text-xl font-bold text-primary font-heading">{formatCurrency(currentPrice)}</div>
|
||||
</div>
|
||||
<button onClick={() => handleAddToCart(false)} className="btn btn-outline btn-primary bg-white">
|
||||
Do koszyka
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feedback o dodaniu */}
|
||||
{addedMessage && (
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-green-600 bg-green-50 p-3 rounded-lg border border-green-200 animate-in fade-in slide-in-from-top-2">
|
||||
<Check className="w-4 h-4" />
|
||||
{addedMessage}. <button onClick={() => router.push('/koszyk')} className="underline hover:text-green-800">Przejdź do koszyka</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Atuty */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-center text-center p-4 bg-white rounded-xl border border-border">
|
||||
<Truck className="w-6 h-6 text-accent mb-2" />
|
||||
<span className="text-xs font-bold text-primary">Darmowa dostawa</span>
|
||||
<span className="text-[10px] text-text-muted">na terenie Polski</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center p-4 bg-white rounded-xl border border-border">
|
||||
<ShieldCheck className="w-6 h-6 text-accent mb-2" />
|
||||
<span className="text-xs font-bold text-primary">5 lat gwarancji</span>
|
||||
<span className="text-[10px] text-text-muted">na urządzenie i montaż</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center p-4 bg-white rounded-xl border border-border">
|
||||
<Settings className="w-6 h-6 text-accent mb-2" />
|
||||
<span className="text-xs font-bold text-primary">Pełne wsparcie</span>
|
||||
<span className="text-[10px] text-text-muted">na każdym etapie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+502
-142
@@ -1,160 +1,520 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Product } from '@/types/types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronUp, Phone, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/content/products';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { SlidersHorizontal, X, Search } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products }: ProductsGridProps) {
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<string>('recommended');
|
||||
const [showFiltersMobile, setShowFiltersMobile] = useState(false);
|
||||
const PER_PAGE = 24;
|
||||
|
||||
// Pobranie unikalnych wartości do filtrów
|
||||
const brands = useMemo(() => Array.from(new Set(products.map(p => p.brand))), [products]);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'scienne': 'Ścienne',
|
||||
'multisplit': 'Multisplit',
|
||||
'kasetonowe': 'Kasetonowe',
|
||||
'przenosne': 'Przenośne',
|
||||
};
|
||||
const categories = useMemo(() => Array.from(new Set(products.map(p => p.category))), [products]);
|
||||
// Parsuj moc chłodniczą — zwróć wartość MAX z zakresu
|
||||
function parseMocMax(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.replace(/[^\d,.-]/g, ' ').split(/[\s\/–-]+/).map(s => parseFloat(s.replace(',', '.'))).filter(n => !isNaN(n));
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
}
|
||||
|
||||
// Logika filtrowania i sortowania
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = [...products];
|
||||
// Parsuj hałas — zwróć wartość MIN z zakresu
|
||||
function parseHalasMin(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.match(/\d+/g);
|
||||
return nums ? Math.min(...nums.map(Number)) : null;
|
||||
}
|
||||
|
||||
if (selectedBrands.length > 0) {
|
||||
result = result.filter(p => selectedBrands.includes(p.brand));
|
||||
}
|
||||
if (selectedCategories.length > 0) {
|
||||
result = result.filter(p => selectedCategories.includes(p.category));
|
||||
}
|
||||
// Mapowanie wykończenia na grupy kolorystyczne
|
||||
function mapWykonczenie(val: string): string {
|
||||
const v = val.toLowerCase();
|
||||
if (v.includes('white') || v === 'biały') return 'Biały';
|
||||
if (v.includes('black') || v === 'czarny' || v === 'dark' || v.includes('satin')) return 'Czarny';
|
||||
if (v.includes('silver') || v === 'srebrny') return 'Srebrny';
|
||||
if (v.includes('gold') || v === 'złoty' || v === 'champagne') return 'Złoty';
|
||||
if (v.includes('graphite') || v.includes('grey') || v.includes('gray')) return 'Grafitowy';
|
||||
if (v.includes('wood') || v.includes('fabric') || v.includes('textile') || v.includes('leather') || v.includes('stone') || v.includes('mirror') || v.includes('caramel') || v.includes('navy') || v.includes('miedziany') || v === 'beige stone') return 'Inne';
|
||||
if (v === 'matt' || v === 'shiny') return 'Biały';
|
||||
return 'Inne';
|
||||
}
|
||||
|
||||
if (sortBy === 'price-asc') {
|
||||
result.sort((a, b) => a.price - b.price);
|
||||
} else if (sortBy === 'price-desc') {
|
||||
result.sort((a, b) => b.price - a.price);
|
||||
} else if (sortBy === 'power-asc') {
|
||||
result.sort((a, b) => a.power - b.power);
|
||||
} else if (sortBy === 'power-desc') {
|
||||
result.sort((a, b) => b.power - a.power);
|
||||
}
|
||||
const COLOR_GROUPS = ['Biały', 'Czarny', 'Srebrny', 'Złoty', 'Grafitowy', 'Inne'];
|
||||
|
||||
return result;
|
||||
}, [products, selectedBrands, selectedCategories, sortBy]);
|
||||
// Buckety wielkości pomieszczenia → zakres mocy kW
|
||||
const ROOM_BUCKETS = [
|
||||
{ label: 'do 20 m²', min: 0, max: 2.5 },
|
||||
{ label: '21–35 m²', min: 2.5, max: 3.5 },
|
||||
{ label: '36–50 m²', min: 3.5, max: 5.0 },
|
||||
{ label: '51–70 m²', min: 5.0, max: 7.0 },
|
||||
{ label: 'powyżej 70 m²', min: 7.0, max: 99 },
|
||||
];
|
||||
|
||||
const toggleBrand = (brand: string) => {
|
||||
setSelectedBrands(prev =>
|
||||
prev.includes(brand) ? prev.filter(b => b !== brand) : [...prev, brand]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories(prev =>
|
||||
prev.includes(category) ? prev.filter(c => c !== category) : [...prev, category]
|
||||
);
|
||||
};
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-8 py-12">
|
||||
{/* Mobile Filter Toggle */}
|
||||
<div className="md:hidden flex justify-between items-center mb-4">
|
||||
<span className="font-semibold">{filteredProducts.length} wyników</span>
|
||||
<button className="btn btn-outline btn-primary bg-white" onClick={() => setShowFiltersMobile(!showFiltersMobile)}>
|
||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||
Filtry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar (Filtry) */}
|
||||
<aside className={`w-full md:w-64 shrink-0 ${showFiltersMobile ? 'block' : 'hidden md:block'}`}>
|
||||
<div className="bg-white p-6 rounded-2xl border border-border sticky top-24">
|
||||
<h3 className="font-bold text-lg font-heading text-primary mb-6">Filtrowanie</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Marki */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Marka urządzenia</h4>
|
||||
<div className="space-y-2">
|
||||
{brands.map(brand => (
|
||||
<label key={brand} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(brand)}
|
||||
onChange={() => toggleBrand(brand)}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-muted">{brand}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorie */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm mb-3">Typ urządzenia</h4>
|
||||
<div className="space-y-2">
|
||||
{categories.map(category => (
|
||||
<label key={category} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-muted">{categoryLabels[category] || category}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Główna sekcja (Narzędzia + Siatka) */}
|
||||
<div className="flex-grow">
|
||||
{/* Pasek narzędzi */}
|
||||
<div className="hidden md:flex justify-between items-center bg-white p-4 rounded-xl border border-border mb-8">
|
||||
<span className="text-text-muted font-medium">{filteredProducts.length} wyników</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor="sort" className="text-sm font-semibold text-primary">Sortuj według:</label>
|
||||
<select
|
||||
id="sort"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 bg-slate-50"
|
||||
>
|
||||
<option value="recommended">Rekomendowane</option>
|
||||
<option value="price-asc">Cena rosnąco</option>
|
||||
<option value="price-desc">Cena malejąco</option>
|
||||
<option value="power-asc">Moc rosnąco</option>
|
||||
<option value="power-desc">Moc malejąco</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Siatka */}
|
||||
{filteredProducts.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredProducts.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-slate-50 rounded-2xl border border-border">
|
||||
<h3 className="text-xl font-bold text-primary mb-2">Brak wyników</h3>
|
||||
<p className="text-text-muted">Spróbuj zmienić kryteria filtrowania.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-b border-border pb-4 mb-4">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<span className="text-sm font-bold text-primary font-heading">{title}</span>
|
||||
{open ? <ChevronUp className="size-4 text-text-muted" /> : <ChevronDown className="size-4 text-text-muted" />}
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit'>('all');
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>([]);
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
const [halasMin, setHalasMin] = useState(15);
|
||||
const [halasMax, setHalasMax] = useState(50);
|
||||
const [page, setPage] = useState(1);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
const toggleBrand = (b: string) => {
|
||||
setSelectedBrands(prev => prev.includes(b) ? prev.filter(x => x !== b) : [...prev, b]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleRoom = (r: string) => {
|
||||
setSelectedRooms(prev => prev.includes(r) ? prev.filter(x => x !== r) : [...prev, r]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleColor = (c: string) => {
|
||||
setSelectedColors(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return products.filter(p => {
|
||||
// Kategoria
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
|
||||
// Marka
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
|
||||
|
||||
// Cena
|
||||
const pMin = priceMin ? parseInt(priceMin) : null;
|
||||
const pMax = priceMax ? parseInt(priceMax) : null;
|
||||
if (p.price !== null) {
|
||||
if (pMin && p.price < pMin) return false;
|
||||
if (pMax && p.price > pMax) return false;
|
||||
}
|
||||
|
||||
// Wielkość pomieszczenia → moc
|
||||
if (selectedRooms.length > 0) {
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return false;
|
||||
const matches = selectedRooms.some(r => {
|
||||
const bucket = ROOM_BUCKETS.find(b => b.label === r);
|
||||
return bucket ? moc >= bucket.min && moc < bucket.max : false;
|
||||
});
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
// Hałas
|
||||
if (halasMin > 15 || halasMax < 50) {
|
||||
const h = parseHalasMin(p.specs['Hałas wewnątrz (dB)'] || '');
|
||||
if (h !== null && (h < halasMin || h > halasMax)) return false;
|
||||
}
|
||||
|
||||
// Kolor
|
||||
if (selectedColors.length > 0) {
|
||||
const wyk = p.specs['Wykończenie'] || '';
|
||||
const group = mapWykonczenie(wyk);
|
||||
if (!selectedColors.includes(group)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, halasMin, halasMax, selectedColors]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
// Liczniki per brand (dla aktualnych filtrów bez filtra marki)
|
||||
const brandCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
counts[p.brand] = (counts[p.brand] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per room bucket
|
||||
const roomCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return;
|
||||
ROOM_BUCKETS.forEach(b => {
|
||||
if (moc >= b.min && moc < b.max) {
|
||||
counts[b.label] = (counts[b.label] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
const resetAll = () => {
|
||||
setCategory('all');
|
||||
setSelectedBrands([]);
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
setSelectedRooms([]);
|
||||
setSelectedColors([]);
|
||||
setHalasMin(15);
|
||||
setHalasMax(50);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || halasMin > 15 || halasMax < 50;
|
||||
|
||||
const Sidebar = () => (
|
||||
<aside className="w-full">
|
||||
{/* Klimatyzatory - typ */}
|
||||
<Accordion title="Klimatyzatory">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
{ val: 'all', label: 'Wszystkie' },
|
||||
{ val: 'scienne', label: 'Klimatyzacja ścienna' },
|
||||
{ val: 'multisplit', label: 'Klimatyzacja Multisplit' },
|
||||
].map(opt => (
|
||||
<li key={opt.val}>
|
||||
<button
|
||||
onClick={() => { setCategory(opt.val as typeof category); setPage(1); }}
|
||||
className={`flex items-center gap-2.5 text-sm w-full text-left py-0.5 transition-colors ${
|
||||
category === opt.val ? 'text-primary font-semibold' : 'text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<span className={`size-3 rounded-full border-2 shrink-0 ${
|
||||
category === opt.val ? 'border-accent bg-accent' : 'border-border'
|
||||
}`} />
|
||||
{opt.label}
|
||||
<span className="ml-auto text-xs bg-slate-100 rounded-full px-1.5 py-0.5 text-text-muted">
|
||||
{opt.val === 'all' ? products.length : products.filter(p => p.category === opt.val).length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Marka */}
|
||||
<Accordion title="Marka">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{brands.map(b => (
|
||||
<li key={b}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(b)}
|
||||
onChange={() => toggleBrand(b)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedBrands.includes(b)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedBrands.includes(b) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedBrands.includes(b) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{brandCounts[b] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Cena */}
|
||||
<Accordion title="Cena (zł)">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="od"
|
||||
value={priceMin}
|
||||
onChange={e => { setPriceMin(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
<span className="text-text-muted shrink-0">–</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="do"
|
||||
value={priceMax}
|
||||
onChange={e => { setPriceMax(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Wielkość pomieszczenia */}
|
||||
<Accordion title="Wielkość pomieszczenia">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ROOM_BUCKETS.map(b => (
|
||||
<li key={b.label}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRooms.includes(b.label)}
|
||||
onChange={() => toggleRoom(b.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedRooms.includes(b.label)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedRooms.includes(b.label) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedRooms.includes(b.label) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{roomCounts[b.label] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">Szacunkowo: 10 m² = 1 kW mocy chłodniczej</p>
|
||||
</Accordion>
|
||||
|
||||
{/* Głośność */}
|
||||
<Accordion title={`Głośność wewnętrzna (dB) — ${halasMin}–${halasMax} dB`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted w-8">od</span>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={50}
|
||||
value={halasMin}
|
||||
onChange={e => {
|
||||
const val = Number(e.target.value);
|
||||
setHalasMin(Math.min(val, halasMax - 1));
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-primary font-medium w-10 text-right">{halasMin} dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted w-8">do</span>
|
||||
<input
|
||||
type="range"
|
||||
min={15}
|
||||
max={50}
|
||||
value={halasMax}
|
||||
onChange={e => {
|
||||
const val = Number(e.target.value);
|
||||
setHalasMax(Math.max(val, halasMin + 1));
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex-1 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-primary font-medium w-10 text-right">{halasMax} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Kolor/Wykończenie */}
|
||||
<Accordion title="Kolor dominujący">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{COLOR_GROUPS.map(c => (
|
||||
<li key={c}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.includes(c)}
|
||||
onChange={() => toggleColor(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedColors.includes(c)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedColors.includes(c) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedColors.includes(c) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{c}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Potrzebujesz pomocy */}
|
||||
<div className="rounded-xl bg-slate-50 border border-border p-4 text-center mt-2">
|
||||
<p className="text-sm font-bold text-primary font-heading mb-3">Potrzebujesz pomocy?</p>
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center justify-center gap-2 text-primary font-bold text-base hover:text-accent transition-colors"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1">{companyDetails.hours}</p>
|
||||
<Link href="/kontakt">
|
||||
<button className="mt-3 w-full btn btn-primary text-white font-bold text-sm py-2">
|
||||
Zadaj pytanie
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-8 md:py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{/* Mobile: przycisk filtrów */}
|
||||
<div className="lg:hidden mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-primary hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
Filtry {hasFilters && <span className="size-2 rounded-full bg-accent inline-block" />}
|
||||
</button>
|
||||
<span className="text-sm text-text-muted">{filtered.length} produktów</span>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileSidebarOpen(false)} />
|
||||
<div className="absolute top-0 left-0 bottom-0 w-80 bg-white overflow-y-auto p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-bold text-primary font-heading">Filtry</span>
|
||||
<button onClick={() => setMobileSidebarOpen(false)}>
|
||||
<X className="size-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block w-64 shrink-0">
|
||||
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-2">
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={resetAll}
|
||||
className="flex items-center gap-1.5 text-xs text-accent hover:underline mb-4"
|
||||
>
|
||||
<X className="size-3" /> Wyczyść filtry
|
||||
</button>
|
||||
)}
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Produkty */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Nagłówek z licznikiem */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-sm text-text-muted hidden lg:block">{filtered.length} produktów</span>
|
||||
{hasFilters && (
|
||||
<button onClick={resetAll} className="lg:hidden text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<X className="size-3" /> Wyczyść
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{paginated.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{paginated.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-text-muted mb-3">Brak produktów dla wybranych filtrów.</p>
|
||||
<button onClick={resetAll} className="text-accent hover:underline text-sm">
|
||||
Wyczyść filtry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginacja */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-12">
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.max(1, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Poprzednia
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const p = page <= 3 ? i + 1 : page - 2 + i;
|
||||
if (p < 1 || p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => { setPage(p); window.scrollTo(0, 0); }}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-accent text-white border border-accent'
|
||||
: 'border border-border text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.min(totalPages, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Następna →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Product } from '@/types/types';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
interface TabsProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function Tabs({ product }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'opis' | 'dane' | 'opinie'>('opis');
|
||||
|
||||
return (
|
||||
<div className="mt-16 bg-white rounded-3xl border border-border overflow-hidden">
|
||||
{/* Nawigacja */}
|
||||
<div className="flex border-b border-border">
|
||||
{(['opis', 'dane', 'opinie'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-4 text-sm md:text-base font-bold uppercase tracking-wider transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-slate-50 text-primary border-b-2 border-primary'
|
||||
: 'bg-white text-text-muted hover:bg-slate-50 hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab === 'opis' && 'Opis produktu'}
|
||||
{tab === 'dane' && 'Dane techniczne'}
|
||||
{tab === 'opinie' && 'Opinie'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zawartość */}
|
||||
<div className="p-8 md:p-12">
|
||||
{/* Opis */}
|
||||
{activeTab === 'opis' && (
|
||||
<div className="prose prose-slate max-w-none">
|
||||
<h3 className="text-2xl font-bold text-primary font-heading mb-4">Szczegółowe informacje</h3>
|
||||
<p className="text-lg text-text-muted leading-relaxed">
|
||||
{product.description}
|
||||
</p>
|
||||
{product.heating && (
|
||||
<p className="mt-4 font-semibold text-accent">
|
||||
Ten model posiada funkcję efektywnego ogrzewania, dzięki czemu sprawdzi się doskonale jako dodatkowe źródło ciepła w okresach przejściowych lub główny system grzewczy dla wybranych pomieszczeń.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dane techniczne */}
|
||||
{activeTab === 'dane' && (
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-primary font-heading mb-6">Specyfikacja techniczna</h3>
|
||||
{product.specs ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody>
|
||||
{Object.entries(product.specs).map(([key, value], idx) => (
|
||||
<tr key={idx} className="border-b border-border hover:bg-slate-50 transition-colors">
|
||||
<th className="py-4 px-4 font-semibold text-primary w-1/2">{key}</th>
|
||||
<td className="py-4 px-4 text-text-muted w-1/2">{value as React.ReactNode}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak szczegółowych danych technicznych dla tego modelu.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opinie */}
|
||||
{activeTab === 'opinie' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="text-4xl font-extrabold text-primary font-heading">4.9</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex text-yellow-400">
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
<Star className="w-5 h-5 fill-current" />
|
||||
</div>
|
||||
<span className="text-sm text-text-muted font-medium mt-1">Na podstawie 12 opinii</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Przykładowa opinia */}
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-border">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span className="font-bold text-primary">Janusz z Wrocławia</span>
|
||||
<span className="text-xs text-text-muted block mt-0.5">Potwierdzony zakup z montażem</span>
|
||||
</div>
|
||||
<div className="flex text-yellow-400">
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
<Star className="w-4 h-4 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-muted text-sm leading-relaxed">
|
||||
Sprzęt najwyższej klasy. Bardzo cichy, w ogóle go nie słychać w sypialni. Montaż wykonany przez panów z AirProject przebiegł super szybko i czyściutko. Polecam!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function CookieBanner() {
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
const hasConsent = localStorage.getItem('airproject_cookie_consent_v2');
|
||||
const hasConsent = localStorage.getItem('thermcool_cookie_consent_v1');
|
||||
setLocalAnalytics(hasConsent ? analytics : true);
|
||||
setLocalMarketing(hasConsent ? marketing : true);
|
||||
setShowSettings(true);
|
||||
|
||||
Reference in New Issue
Block a user