Zastosowanie wszystkich poprawek UI i logiki kalkulatora
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
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 } from 'lucide-react';
|
||||
|
||||
export function CartItems() {
|
||||
const { items, removeItem, updateQuantity } = useCartStore();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-white p-12 rounded-2xl border border-border text-center">
|
||||
<div className="w-20 h-20 bg-slate-50 text-slate-300 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Trash2 className="w-10 h-10" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-4">Twój koszyk jest pusty</h2>
|
||||
<p className="text-text-muted mb-8">Nie dodałeś jeszcze żadnych produktów do koszyka.</p>
|
||||
<Link href="/sklep" className="inline-flex bg-cta hover:bg-cta-hover text-white font-bold py-3 px-8 rounded-xl transition-colors">
|
||||
Przejdź do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const itemPrice = item.price;
|
||||
const totalGross = itemPrice * item.quantity;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="bg-white p-6 rounded-2xl border border-border flex flex-col sm:flex-row gap-6 relative">
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="absolute top-4 right-4 p-2 text-text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
aria-label="Usuń z koszyka"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-32 shrink-0 bg-slate-50 rounded-xl p-2 border border-border">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-grow justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">
|
||||
{item.brand}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-primary font-heading leading-tight mb-2 pr-10">
|
||||
<Link href={`/sklep/${item.slug}`} className="hover:text-accent transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-4 mt-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center border border-border rounded-lg bg-slate-50">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={item.quantity <= 1}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-primary disabled:opacity-50"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-bold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-extrabold text-primary font-heading w-24 text-right">
|
||||
{formatCurrency(totalGross)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { ShieldCheck, ArrowRight, Tag } from 'lucide-react';
|
||||
|
||||
export function CartSummary() {
|
||||
const { items, getTotal } = useCartStore();
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
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">
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-6">Podsumowanie zamówienia</h3>
|
||||
|
||||
<div className="mb-8">
|
||||
<label htmlFor="promo" className="text-sm font-semibold text-text-muted mb-2 block">Posiadasz kod rabatowy?</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-grow">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
id="promo"
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
placeholder="Wprowadź kod"
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-border focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-primary bg-white shrink-0">Zastosuj</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6 border-b border-border/50 pb-6">
|
||||
<div className="flex justify-between text-sm text-text-muted">
|
||||
<span>Suma netto</span>
|
||||
<span className="font-semibold text-primary">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-text-muted">
|
||||
<span>Podatek VAT (23%)</span>
|
||||
<span className="font-semibold text-primary">{formatCurrency(vat)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<span className="text-lg font-bold text-primary">Do zapłaty brutto</span>
|
||||
<span className="text-3xl font-extrabold text-primary font-heading text-right">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
<Link href="/zamowienie" className="block w-full btn btn-primary font-bold group cursor-pointer text-white">
|
||||
|
||||
Przejdź do zamówienia
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted font-medium justify-center">
|
||||
<ShieldCheck className="w-4 h-4 text-green-600" />
|
||||
Bezpieczne zakupy z certyfikatem SSL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" className="btn btn-primary text-white font-bold text-sm py-2 px-4">
|
||||
|
||||
Porównaj
|
||||
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={clearAll} className="text-xs text-text-muted hover:text-primary transition-colors">
|
||||
Wyczyść
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { submitMultisplitForm } from '@/app/zapytanie-multisplit/actions';
|
||||
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
productSku: string;
|
||||
}
|
||||
|
||||
export default function MultisplitContactForm({ productName, productSku }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
rooms: '2',
|
||||
message: '',
|
||||
consent: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handle = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name || !form.phone || !form.consent) {
|
||||
setError('Wypełnij wymagane pola i zaznacz zgodę.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = await getRecaptchaToken('submit');
|
||||
const recaptchaToken = token || '';
|
||||
|
||||
const res = await submitMultisplitForm({
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
city: form.city,
|
||||
rooms: form.rooms,
|
||||
message: form.message,
|
||||
consent: form.consent,
|
||||
productName,
|
||||
productSku,
|
||||
recaptchaToken,
|
||||
});
|
||||
if (res.success) setSent(true);
|
||||
else setError(res.message || 'Błąd wysyłki. Zadzwoń do nas bezpośrednio.');
|
||||
} catch {
|
||||
setError('Błąd połączenia. Spróbuj ponownie.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="bg-green-50 text-green-800 p-8 rounded-2xl border border-green-200 flex flex-col items-center text-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-green-500 mb-4" />
|
||||
<h3 className="text-2xl font-bold font-heading mb-2">Zapytanie wysłane!</h3>
|
||||
<p className="text-green-700">Oddzwonimy w ciągu 24 godzin z bezpłatną wyceną.</p>
|
||||
<button
|
||||
onClick={() => setSent(false)}
|
||||
className="mt-6 px-6 py-2 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Wyślij kolejne zapytanie
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Produkt info */}
|
||||
<div className="bg-slate-50 rounded-xl px-4 py-3 text-xs text-text-muted">
|
||||
Zapytanie dotyczy: <span className="font-bold text-primary">{productName}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Imię i nazwisko *</label>
|
||||
<input name="name" value={form.name} onChange={handle}
|
||||
placeholder="Jan Kowalski"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Telefon *</label>
|
||||
<input name="phone" value={form.phone} onChange={handle}
|
||||
placeholder="+48 600 000 000"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Email</label>
|
||||
<input name="email" value={form.email} onChange={handle}
|
||||
placeholder="[email protected]"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Miasto *</label>
|
||||
<input name="city" value={form.city} onChange={handle}
|
||||
placeholder="Wrocław"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Ile pomieszczeń do klimatyzacji?</label>
|
||||
<select name="rooms" value={form.rooms} onChange={handle}
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none bg-white">
|
||||
<option value="2">2 pomieszczenia</option>
|
||||
<option value="3">3 pomieszczenia</option>
|
||||
<option value="4">4 pomieszczenia</option>
|
||||
<option value="5">5 pomieszczeń</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Dodatkowe informacje (opcjonalnie)</label>
|
||||
<textarea name="message" value={form.message} onChange={handle}
|
||||
rows={3}
|
||||
placeholder="Np. metraż pomieszczeń, typ budynku, piętro..."
|
||||
className="px-3 py-2.5 text-sm border border-border rounded-xl focus:border-accent outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<div className="relative mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
checked={form.consent}
|
||||
onChange={handle}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`size-4 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
form.consent ? 'bg-accent border-accent' : 'bg-white border-slate-300'
|
||||
}`}>
|
||||
{form.consent && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted leading-relaxed">
|
||||
Wyrażam zgodę na przetwarzanie moich danych osobowych w celu obsługi zapytania przez ThermCool. *
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="flex items-center justify-center gap-2 h-11 bg-accent text-white font-bold rounded-xl text-sm hover:bg-accent/90 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{loading ? 'Wysyłanie...' : 'Wyślij zapytanie o wycenę'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ShoppingCart, GitCompare, Phone } from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
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.webp',
|
||||
'Toshiba': '/brands/toshiba.webp',
|
||||
'Daikin': '/brands/daikin.webp',
|
||||
'CHV': '/brands/chv.webp',
|
||||
'Haier': '/brands/haier.webp',
|
||||
'LG': '/brands/lg.webp',
|
||||
'Samsung': '/brands/samsung.webp',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi.webp',
|
||||
'Fuji Electric': '/brands/fuji.webp',
|
||||
'Aux': '/brands/aux-brand.webp',
|
||||
'AUX': '/brands/aux-brand.webp',
|
||||
};
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: ProductCardProps) {
|
||||
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);
|
||||
};
|
||||
|
||||
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 relative bg-white rounded-xl border border-border hover:border-accent hover:shadow-md transition-all duration-200 overflow-hidden flex flex-col">
|
||||
<Link href={`/sklep/${product.slug}`} className="block relative aspect-[4/3] bg-slate-50 overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
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')}
|
||||
/>
|
||||
|
||||
<div className="absolute top-3 left-3 z-10 flex flex-col gap-1.5">
|
||||
{brandLogos[product.brand] ? (
|
||||
<div className="bg-white rounded px-1.5 py-1 inline-block shadow-sm">
|
||||
<Image
|
||||
src={brandLogos[product.brand]}
|
||||
alt={product.brand}
|
||||
width={40}
|
||||
height={14}
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-primary bg-white/95 px-2 py-1 rounded-md border border-border shadow-sm">
|
||||
{product.brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
|
||||
<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}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Cena + CTA */}
|
||||
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
|
||||
{product.category === 'multisplit' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-accent">
|
||||
<Phone className="size-4" />
|
||||
<span className="text-sm font-semibold">Wycena indywidualna</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/zapytanie-multisplit/${product.slug}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-accent text-white hover:bg-accent/90 transition-colors shrink-0">
|
||||
Zapytaj o wycenę
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
) : 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>
|
||||
{added ? (
|
||||
<Link
|
||||
href="/koszyk"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-green-500 text-white hover:bg-green-600 transition-colors shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ShoppingCart className="size-3.5" /> Zobacz koszyk
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-accent text-white hover:bg-accent/90 transition-colors shrink-0"
|
||||
>
|
||||
<ShoppingCart className="size-3.5" /> Do koszyka
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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="/kontakt" onClick={e => e.stopPropagation()}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
image: string;
|
||||
name: string;
|
||||
images?: string[]; // opcjonalna dodatkowa galeria z contentu, u nas na razie w mocku mamy 1 image
|
||||
}
|
||||
|
||||
export function ProductGallery({ image, name, images }: ProductGalleryProps) {
|
||||
const [mainImage, setMainImage] = useState(image || '/images/hero/shop-section-image.webp');
|
||||
|
||||
const gallery = images && images.length > 0
|
||||
? images
|
||||
: [image, '/images/hero/shop-section-image.webp'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative aspect-square w-full bg-slate-50 rounded-2xl border border-border overflow-hidden flex items-center justify-center p-8">
|
||||
<Image
|
||||
src={mainImage}
|
||||
alt={name}
|
||||
width={800}
|
||||
height={800}
|
||||
className="object-contain w-full h-full"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gallery.length > 1 && (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{gallery.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setMainImage(img)}
|
||||
className={`relative w-24 h-24 shrink-0 rounded-xl border-2 overflow-hidden bg-slate-50 p-2 transition-all ${mainImage === img ? 'border-primary' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt={`${name} - miniatura ${idx + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Phone, ShoppingCart, GitCompare, Check,
|
||||
Thermometer, Volume2, Zap, Wind, Star, Info, Eye, X, type LucideIcon
|
||||
} 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 { useRecentlyViewedStore } from '@/lib/recently-viewed-store';
|
||||
import { RecentlyViewedProducts } from './RecentlyViewedProducts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const brandLogos: Record<string, string> = {
|
||||
'Gree': '/brands/gree.webp',
|
||||
'Rotenso': '/brands/rotenso.webp',
|
||||
'Toshiba': '/brands/toshiba.webp',
|
||||
'Daikin': '/brands/daikin.webp',
|
||||
'CHV': '/brands/chv.webp',
|
||||
'Haier': '/brands/haier.webp',
|
||||
'LG': '/brands/lg.webp',
|
||||
'Samsung': '/brands/samsung.webp',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi.webp',
|
||||
'Fuji Electric': '/brands/fuji.webp',
|
||||
'Aux': '/brands/aux-brand.webp',
|
||||
'AUX': '/brands/aux-brand.webp',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
seriesProducts: Product[];
|
||||
companyPhone: string;
|
||||
companyPhoneRaw: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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ż', 'Opinie'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
function seededViews(slug: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < slug.length; i++) {
|
||||
hash = (hash * 31 + slug.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return 18 + (hash % 50);
|
||||
}
|
||||
|
||||
export function ProductPageClient({ product, seriesProducts, companyPhone, companyPhoneRaw }: Props) {
|
||||
const [currentProduct, setCurrentProduct] = useState(product);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Opis');
|
||||
const [godziny, setGodziny] = useState(8);
|
||||
const [cartState, setCartState] = useState<'idle' | 'added' | 'cart'>('idle');
|
||||
const [showViewsBanner, setShowViewsBanner] = useState(true);
|
||||
const viewsCount = seededViews(currentProduct.slug);
|
||||
|
||||
const variants = getProductVariants(currentProduct.slug);
|
||||
|
||||
const addToCart = useCartStore(s => s.addItem);
|
||||
const { addItem: addCompare, removeItem: removeCompare, isAdded } = useCompareStore();
|
||||
const inCompare = isAdded(currentProduct.slug);
|
||||
|
||||
const addRecentlyViewed = useRecentlyViewedStore((s) => s.addItem);
|
||||
useEffect(() => {
|
||||
addRecentlyViewed(currentProduct);
|
||||
}, [currentProduct, addRecentlyViewed]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentProduct.price) return;
|
||||
addToCart({
|
||||
slug: currentProduct.slug,
|
||||
name: currentProduct.name,
|
||||
brand: currentProduct.brand,
|
||||
image: currentProduct.image,
|
||||
price: currentProduct.price,
|
||||
});
|
||||
setCartState('added');
|
||||
setTimeout(() => setCartState('cart'), 1500);
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
if (inCompare) removeCompare(currentProduct.slug);
|
||||
else addCompare({ slug: currentProduct.slug, name: currentProduct.name, brand: currentProduct.brand, price: currentProduct.price, image: currentProduct.image, specs: currentProduct.specs });
|
||||
};
|
||||
|
||||
const mocChlodnicza = parseMoc(currentProduct.specs['Moc chłodnicza'] || '');
|
||||
const pobórMocy = parseMoc(currentProduct.specs['Pobór mocy chłodzenie (kW)'] || '') || (mocChlodnicza / estimateSEER(currentProduct.specs['Klasa energetyczna'] || ''));
|
||||
const cenaKwh = 0.75;
|
||||
const kosztGodzina = pobórMocy * cenaKwh;
|
||||
const kosztMiesiac = kosztGodzina * godziny * 30;
|
||||
|
||||
const params = [
|
||||
currentProduct.specs['Klasa energetyczna'] && { icon: Star, label: 'Klasa energetyczna', value: currentProduct.specs['Klasa energetyczna'], color: 'text-green-600 bg-green-50' },
|
||||
currentProduct.specs['Moc chłodnicza'] && { icon: Thermometer, label: 'Moc chłodnicza', value: `${currentProduct.specs['Moc chłodnicza']} kW`, color: 'text-blue-600 bg-blue-50' },
|
||||
currentProduct.specs['Moc grzewcza (kW)'] && { icon: Thermometer, label: 'Moc grzewcza', value: `${currentProduct.specs['Moc grzewcza (kW)']} kW`, color: 'text-orange-600 bg-orange-50' },
|
||||
currentProduct.specs['Hałas wewnątrz (dB)'] && { icon: Volume2, label: 'Hałas wewnątrz', value: `${currentProduct.specs['Hałas wewnątrz (dB)']} dB`, color: 'text-purple-600 bg-purple-50' },
|
||||
currentProduct.specs['Czynnik chłodniczy'] && { icon: Wind, label: 'Czynnik', value: currentProduct.specs['Czynnik chłodniczy'], color: 'text-teal-600 bg-teal-50' },
|
||||
currentProduct.specs['Grzanie do'] && { icon: Zap, label: 'Grzanie do', value: `-${currentProduct.specs['Grzanie do']}°C`, color: 'text-accent bg-accent-light' },
|
||||
].filter(Boolean) as { icon: LucideIcon; 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">
|
||||
|
||||
<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=${currentProduct.brand}`} className="hover:text-accent transition-colors">{currentProduct.brand}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{currentProduct.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 xl:gap-12">
|
||||
|
||||
<div>
|
||||
<div className="relative aspect-[4/3] lg:aspect-auto lg:h-[420px] lg:max-h-[420px] bg-white rounded-2xl border border-border overflow-hidden">
|
||||
<Image
|
||||
src={currentProduct.image}
|
||||
alt={currentProduct.name}
|
||||
fill
|
||||
className="object-contain p-6"
|
||||
priority
|
||||
unoptimized={currentProduct.image.startsWith('http')}
|
||||
/>
|
||||
{currentProduct.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">
|
||||
{currentProduct.specs['Klasa energetyczna'].split('/')[0]}
|
||||
</div>
|
||||
)}
|
||||
{currentProduct.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>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3 flex-wrap">
|
||||
{brandLogos[currentProduct.brand] ? (
|
||||
<div className="bg-white rounded-lg p-1.5 inline-block border border-border">
|
||||
<Image
|
||||
src={brandLogos[currentProduct.brand]}
|
||||
alt={currentProduct.brand}
|
||||
width={80}
|
||||
height={28}
|
||||
className="object-contain object-left"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-accent uppercase tracking-widest">
|
||||
{currentProduct.brand}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-primary font-heading leading-tight">
|
||||
{currentProduct.name}
|
||||
</h1>
|
||||
<p className="text-xs text-text-muted mt-1.5 mb-4">SKU: {currentProduct.sku}</p>
|
||||
</div>
|
||||
|
||||
{variants && variants.moce.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<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={() => {
|
||||
const p = seriesProducts.find(sp => sp.slug === v.slug);
|
||||
if (p) {
|
||||
setCurrentProduct(p);
|
||||
window.history.pushState(null, '', `/sklep/${v.slug}`);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border-2 transition-all ${
|
||||
v.slug === currentProduct.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 !== currentProduct.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-70 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
{v.slug === currentProduct.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-80 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variants && variants.wykończenia.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<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={() => {
|
||||
const p = seriesProducts.find(sp => sp.slug === v.slug);
|
||||
if (p) {
|
||||
setCurrentProduct(p);
|
||||
window.history.pushState(null, '', `/sklep/${v.slug}`);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border-2 transition-all ${
|
||||
v.slug === currentProduct.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>
|
||||
)}
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{params.map((p, i) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg ${p.color} border border-current/10`}>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] font-medium opacity-70 leading-none mb-0.5">{p.label}</p>
|
||||
<p className="text-[11px] font-bold truncate">{p.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-border p-4">
|
||||
{currentProduct.price !== null ? (
|
||||
<>
|
||||
<div className="flex items-end gap-3 mb-1">
|
||||
<span className="text-2xl font-extrabold text-primary font-heading">
|
||||
{currentProduct.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">
|
||||
{cartState === 'idle' && (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-accent text-white hover:bg-accent/90 transition-all"
|
||||
>
|
||||
<ShoppingCart className="size-4" /> Do koszyka
|
||||
</button>
|
||||
)}
|
||||
{cartState === 'added' && (
|
||||
<button
|
||||
disabled
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-green-500 text-white"
|
||||
>
|
||||
<Check className="size-4" /> Dodano do koszyka
|
||||
</button>
|
||||
)}
|
||||
{cartState === 'cart' && (
|
||||
<Link href="/koszyk" className="flex-1 w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-green-500 text-white hover:bg-green-600 transition-colors">
|
||||
|
||||
<ShoppingCart className="size-4" /> Zobacz koszyk
|
||||
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/kontakt" className="flex-1 w-full flex items-center justify-center gap-2 py-2.5 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ż
|
||||
|
||||
</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>
|
||||
|
||||
<a
|
||||
href={`tel:${companyPhoneRaw}`}
|
||||
className="flex items-center justify-between px-5 py-2.5 bg-primary rounded-xl text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold">Profesjonalna porada</span>
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<Phone className="size-4" /> {companyPhone}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{mocChlodnicza > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="size-4 text-accent" />
|
||||
<h2 className="text-sm font-bold text-primary font-heading">Kalkulator kosztów energii</h2>
|
||||
<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="range range-xs range-accent flex-1"
|
||||
/>
|
||||
<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
|
||||
{!currentProduct.specs['Pobór mocy chłodzenie (kW)'] && ' · pobór szacowany z klasy energetycznej'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="flex gap-1 border-b border-border mb-6 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{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>
|
||||
|
||||
{activeTab === 'Opis' && (
|
||||
<div className="max-w-3xl">
|
||||
{currentProduct.shortDescription ? (
|
||||
<p className="text-text-muted leading-relaxed text-base">{currentProduct.shortDescription}</p>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak opisu produktu.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Specyfikacja' && (
|
||||
<div className="max-w-2xl">
|
||||
{Object.keys(currentProduct.specs).length > 0 ? (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{Object.entries(currentProduct.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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{activeTab === 'Opinie' && (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-border">
|
||||
<h3 className="font-bold text-primary font-heading mb-4">Dodaj opinię</h3>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); alert('Opinia została wysłana i oczekuje na moderację.'); e.currentTarget.reset(); }}>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{[1,2,3,4,5].map(star => (
|
||||
<Star key={star} className="size-6 text-amber-400 cursor-pointer fill-amber-400 hover:scale-110 transition-transform" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold text-primary">Imię</label>
|
||||
<input type="text" required className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" placeholder="Twoje imię" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold text-primary">Treść opinii</label>
|
||||
<textarea required rows={4} className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" placeholder="Podziel się swoimi wrażeniami..."></textarea>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary font-bold text-white w-full sm:w-auto">Wyślij opinię</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-primary font-heading">Opinie klientów (0)</h3>
|
||||
<p className="text-sm text-text-muted">Bądź pierwszą osobą, która oceni ten produkt!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecentlyViewedProducts currentSlug={currentProduct.slug} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showViewsBanner && (
|
||||
<div className="fixed bottom-24 left-6 z-50 flex items-center gap-3 bg-white border border-amber-200 shadow-lg rounded-xl px-4 py-3 max-w-xs animate-in slide-in-from-left-4 duration-300">
|
||||
<Eye className="size-4 text-amber-500 shrink-0" />
|
||||
<p className="text-sm text-gray-800">
|
||||
W ostatnich 30 dniach interesuje się tym produktem <span className="font-bold">{viewsCount}</span> osób.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowViewsBanner(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors shrink-0 ml-1"
|
||||
aria-label="Zamknij"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Phone, X } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/content/products';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
const PER_PAGE = 24;
|
||||
|
||||
// Parsuj moc chłodniczą — zwróć wartość MAX z zakresu
|
||||
function parseMocMax(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.replace(/[^\d,.-]/g, ' ').split(/[\s\/–-]+/).map(s => parseFloat(s.replace(',', '.'))).filter(n => !isNaN(n));
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
}
|
||||
|
||||
// Parsuj hałas — zwróć wartość MIN z zakresu
|
||||
function parseHalasMin(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.match(/\d+/g);
|
||||
return nums ? Math.min(...nums.map(Number)) : null;
|
||||
}
|
||||
|
||||
// Mapowanie wykończenia na grupy kolorystyczne
|
||||
function mapWykonczenie(val: string): string {
|
||||
const v = val.toLowerCase();
|
||||
if (v.includes('white') || v === 'biały') return 'Biały';
|
||||
if (v.includes('black') || v === 'czarny' || v === 'dark' || v.includes('satin')) return 'Czarny';
|
||||
if (v.includes('silver') || v === 'srebrny') return 'Srebrny';
|
||||
if (v.includes('gold') || v === 'złoty' || v === 'champagne') return 'Złoty';
|
||||
if (v.includes('graphite') || v.includes('grey') || v.includes('gray')) return 'Grafitowy';
|
||||
if (v.includes('wood') || v.includes('fabric') || v.includes('textile') || v.includes('leather') || v.includes('stone') || v.includes('mirror') || v.includes('caramel') || v.includes('navy') || v.includes('miedziany') || v === 'beige stone') return 'Inne';
|
||||
if (v === 'matt' || v === 'shiny') return 'Biały';
|
||||
return 'Inne';
|
||||
}
|
||||
|
||||
const COLOR_GROUPS = ['Biały', 'Czarny', 'Srebrny', 'Złoty', 'Grafitowy', 'Inne'];
|
||||
|
||||
// Buckety wielkości pomieszczenia → zakres mocy kW
|
||||
const ROOM_BUCKETS = [
|
||||
{ label: 'do 20 m²', min: 0, max: 2.5 },
|
||||
{ label: '21–35 m²', min: 2.5, max: 3.5 },
|
||||
{ label: '36–50 m²', min: 3.5, max: 5.0 },
|
||||
{ label: '51–70 m²', min: 5.0, max: 7.0 },
|
||||
{ label: 'powyżej 70 m²', min: 7.0, max: 99 },
|
||||
];
|
||||
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border pb-4 mb-4">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<span className="text-sm font-bold text-primary font-heading">{title}</span>
|
||||
{open ? <ChevronUp className="size-4 text-text-muted" /> : <ChevronDown className="size-4 text-text-muted" />}
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const urlSearch = searchParams.get('search') || '';
|
||||
const urlBrand = searchParams.get('brand') || '';
|
||||
const urlCategory = searchParams.get('category') || 'all';
|
||||
|
||||
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna'>(
|
||||
urlCategory as 'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna'
|
||||
);
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>(urlBrand ? [urlBrand] : []);
|
||||
const [searchQuery, setSearchQuery] = useState(urlSearch);
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
const [selectedUnitCounts, setSelectedUnitCounts] = useState<number[]>([]);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearchQuery(urlSearch);
|
||||
setSelectedBrands(urlBrand ? [urlBrand] : []);
|
||||
if (urlCategory !== 'all') {
|
||||
setCategory(urlCategory as 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna');
|
||||
} else {
|
||||
setCategory('all');
|
||||
}
|
||||
setPage(1);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [urlSearch, urlBrand, urlCategory]);
|
||||
|
||||
const toggleBrand = (b: string) => {
|
||||
setSelectedBrands(prev => prev.includes(b) ? prev.filter(x => x !== b) : [...prev, b]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleRoom = (r: string) => {
|
||||
setSelectedRooms(prev => prev.includes(r) ? prev.filter(x => x !== r) : [...prev, r]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleColor = (c: string) => {
|
||||
setSelectedColors(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleUnitCount = (uc: number) => {
|
||||
setSelectedUnitCounts(prev => prev.includes(uc) ? prev.filter(x => x !== uc) : [...prev, uc]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return products.filter(p => {
|
||||
// Wyszukiwanie tekstowe
|
||||
if (searchQuery) {
|
||||
const sq = searchQuery.toLowerCase();
|
||||
if (!p.name.toLowerCase().includes(sq) && !p.brand.toLowerCase().includes(sq)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
|
||||
|
||||
const pMin = priceMin ? parseInt(priceMin) : null;
|
||||
const pMax = priceMax ? parseInt(priceMax) : null;
|
||||
if (p.price !== null) {
|
||||
if (pMin && p.price < pMin) return false;
|
||||
if (pMax && p.price > pMax) return false;
|
||||
}
|
||||
|
||||
// Wielkość pomieszczenia → moc
|
||||
if (selectedRooms.length > 0) {
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return false;
|
||||
const matches = selectedRooms.some(r => {
|
||||
const bucket = ROOM_BUCKETS.find(b => b.label === r);
|
||||
return bucket ? moc >= bucket.min && moc < bucket.max : false;
|
||||
});
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Kolor
|
||||
if (selectedColors.length > 0) {
|
||||
const wyk = p.specs['Wykończenie'] || '';
|
||||
const group = mapWykonczenie(wyk);
|
||||
if (!selectedColors.includes(group)) return false;
|
||||
}
|
||||
|
||||
// Liczba jednostek wewn. (multisplit)
|
||||
if (selectedUnitCounts.length > 0) {
|
||||
const unitsStr = p.specs['Liczba jednostek wewn.'];
|
||||
if (!unitsStr) return false;
|
||||
const units = parseFloat(unitsStr);
|
||||
if (!selectedUnitCounts.includes(units)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, selectedColors, selectedUnitCounts, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
// Liczniki per brand (dla aktualnych filtrów bez filtra marki)
|
||||
const brandCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
counts[p.brand] = (counts[p.brand] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per room bucket
|
||||
const roomCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return;
|
||||
ROOM_BUCKETS.forEach(b => {
|
||||
if (moc >= b.min && moc < b.max) {
|
||||
counts[b.label] = (counts[b.label] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per liczba jednostek wewn.
|
||||
const unitCountsAgg = useMemo(() => {
|
||||
const counts: Record<number, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const unitsStr = p.specs['Liczba jednostek wewn.'];
|
||||
if (unitsStr) {
|
||||
const units = parseFloat(unitsStr);
|
||||
if (!isNaN(units)) {
|
||||
counts[units] = (counts[units] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
const resetAll = () => {
|
||||
setCategory('all');
|
||||
setSelectedBrands([]);
|
||||
setSearchQuery('');
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
setSelectedRooms([]);
|
||||
setSelectedColors([]);
|
||||
setSelectedUnitCounts([]);
|
||||
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || selectedUnitCounts.length > 0;
|
||||
|
||||
const sidebarContent = (
|
||||
<aside className="w-full">
|
||||
<Accordion title="Klimatyzatory">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
{ val: 'all', label: 'Wszystkie' },
|
||||
{ val: 'scienne', label: 'Ścienne split' },
|
||||
{ val: 'multisplit', label: 'Multisplit' },
|
||||
|
||||
].map(opt => (
|
||||
<li key={opt.val}>
|
||||
<button
|
||||
onClick={() => { setCategory(opt.val as typeof category); setPage(1); }}
|
||||
className={`flex items-center gap-2.5 text-sm w-full text-left py-0.5 transition-colors ${
|
||||
category === opt.val ? 'text-primary font-semibold' : 'text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<span className={`size-3 rounded-full border-2 shrink-0 ${
|
||||
category === opt.val ? 'border-accent bg-accent' : 'border-border'
|
||||
}`} />
|
||||
{opt.label}
|
||||
<span className="ml-auto text-xs bg-slate-100 rounded-full px-1.5 py-0.5 text-text-muted">
|
||||
{opt.val === 'all' ? products.length : products.filter(p => p.category === opt.val).length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Marka">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{brands.map(b => (
|
||||
<li key={b}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(b)}
|
||||
onChange={() => toggleBrand(b)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedBrands.includes(b)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedBrands.includes(b) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedBrands.includes(b) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{brandCounts[b] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Ilość jednostek wewnętrznych */}
|
||||
{(category === 'all' || category === 'multisplit') && (
|
||||
<Accordion title="Ilość jednostek wewn.">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(uc => {
|
||||
if (unitCountsAgg[uc] === undefined && !selectedUnitCounts.includes(uc)) return null;
|
||||
return (
|
||||
<li key={uc}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUnitCounts.includes(uc)}
|
||||
onChange={() => toggleUnitCount(uc)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedUnitCounts.includes(uc)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedUnitCounts.includes(uc) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedUnitCounts.includes(uc) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{uc} {uc === 1 ? 'jednostka' : uc >= 2 && uc <= 4 ? 'jednostki' : 'jednostek'}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{unitCountsAgg[uc] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion title="Cena (zł)">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="od"
|
||||
value={priceMin}
|
||||
onChange={e => { setPriceMin(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
<span className="text-text-muted shrink-0">–</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="do"
|
||||
value={priceMax}
|
||||
onChange={e => { setPriceMax(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Wielkość pomieszczenia */}
|
||||
<Accordion title="Wielkość pomieszczenia">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ROOM_BUCKETS.map(b => (
|
||||
<li key={b.label}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRooms.includes(b.label)}
|
||||
onChange={() => toggleRoom(b.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedRooms.includes(b.label)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedRooms.includes(b.label) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedRooms.includes(b.label) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{roomCounts[b.label] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">Szacunkowo: 10 m² = 1 kW mocy chłodniczej</p>
|
||||
</Accordion>
|
||||
|
||||
|
||||
|
||||
<Accordion title="Kolor dominujący">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{COLOR_GROUPS.map(c => (
|
||||
<li key={c}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.includes(c)}
|
||||
onChange={() => toggleColor(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedColors.includes(c)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedColors.includes(c) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedColors.includes(c) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{c}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
<div className="rounded-xl bg-slate-50 border border-border p-4 text-center mt-2">
|
||||
<p className="text-sm font-bold text-primary font-heading mb-3">Potrzebujesz pomocy?</p>
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center justify-center gap-2 text-primary font-bold text-base hover:text-accent transition-colors"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1">{companyDetails.hours}</p>
|
||||
<Link href="/kontakt" className="mt-3 w-full btn btn-primary text-white font-bold text-sm py-2">
|
||||
|
||||
Zadaj pytanie
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-8 md:py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<div className="lg:hidden mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-primary hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
Filtry {hasFilters && <span className="size-2 rounded-full bg-accent inline-block" />}
|
||||
</button>
|
||||
<span className="text-sm text-text-muted">{filtered.length} produktów</span>
|
||||
</div>
|
||||
|
||||
{mobileSidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileSidebarOpen(false)} />
|
||||
<div className="absolute top-0 left-0 bottom-0 w-80 bg-white overflow-y-auto p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-bold text-primary font-heading">Filtry</span>
|
||||
<button onClick={() => setMobileSidebarOpen(false)}>
|
||||
<X className="size-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="hidden lg:block w-64 shrink-0">
|
||||
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-2">
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={resetAll}
|
||||
className="flex items-center gap-1.5 text-xs text-accent hover:underline mb-4"
|
||||
>
|
||||
<X className="size-3" /> Wyczyść filtry
|
||||
</button>
|
||||
)}
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-sm text-text-muted hidden lg:block">{filtered.length} produktów</span>
|
||||
{hasFilters && (
|
||||
<button onClick={resetAll} className="lg:hidden text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<X className="size-3" /> Wyczyść
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paginated.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{paginated.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-text-muted mb-3">Brak produktów dla wybranych filtrów.</p>
|
||||
<button onClick={resetAll} className="text-accent hover:underline text-sm">
|
||||
Wyczyść filtry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-12">
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.max(1, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Poprzednia
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const p = page <= 3 ? i + 1 : page - 2 + i;
|
||||
if (p < 1 || p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => { setPage(p); window.scrollTo(0, 0); }}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-accent text-white border border-accent'
|
||||
: 'border border-border text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.min(totalPages, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Następna →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecentlyViewedStore } from '@/lib/recently-viewed-store';
|
||||
import { ProductCard } from './ProductCard';
|
||||
|
||||
export function RecentlyViewedProducts({ currentSlug }: { currentSlug: string }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const items = useRecentlyViewedStore((state) => state.items);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setMounted(true), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const displayItems = items.filter(item => item.slug !== currentSlug).slice(0, 4);
|
||||
|
||||
if (displayItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-16 border-t border-border pt-12">
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-6">
|
||||
Ostatnio oglądane przez Ciebie
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayItems.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user