feat: complete website implementation
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"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, Wrench } from 'lucide-react';
|
||||
|
||||
export function CartItems() {
|
||||
const { items, removeItem, updateQuantity, toggleInstall } = 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.withInstall ? item.priceWithInstall : 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">
|
||||
{/* Usuń pozycję */}
|
||||
<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>
|
||||
|
||||
{/* Obraz */}
|
||||
<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>
|
||||
|
||||
{/* Treść */}
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* Ilość i Cena */}
|
||||
<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,83 @@
|
||||
"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, getTotals } = 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;
|
||||
|
||||
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>
|
||||
|
||||
{/* Kod rabatowy */}
|
||||
<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>
|
||||
|
||||
{/* Kwoty */}
|
||||
<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 (mieszany)</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 */}
|
||||
<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>
|
||||
|
||||
{/* Przycisk (Placeholder - brak właściwego checkoutu) */}
|
||||
<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
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* Zaufanie */}
|
||||
<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,82 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
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;
|
||||
|
||||
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>
|
||||
|
||||
<Image
|
||||
src={product.image || 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=800&q=80'}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={400}
|
||||
className="object-contain w-full h-full transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</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}`}>
|
||||
{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>
|
||||
</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>
|
||||
|
||||
<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'}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"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 || 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=800&q=80');
|
||||
|
||||
// Tworzymy tablicę miniatur, używamy placeholdera jako drugiej by pokazać funkcjonalność
|
||||
const gallery = images && images.length > 0
|
||||
? images
|
||||
: [image, 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=800&q=80'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Główne zdjęcie */}
|
||||
<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>
|
||||
|
||||
{/* Miniatury */}
|
||||
{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,276 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Product } from '@/types/types';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { SlidersHorizontal, X, Search } from 'lucide-react';
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Logika filtrowania i sortowania
|
||||
const filteredProducts = useMemo(() => {
|
||||
let result = [...products];
|
||||
|
||||
if (selectedBrands.length > 0) {
|
||||
result = result.filter(p => selectedBrands.includes(p.brand));
|
||||
}
|
||||
if (selectedCategories.length > 0) {
|
||||
result = result.filter(p => selectedCategories.includes(p.category));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [products, selectedBrands, selectedCategories, sortBy]);
|
||||
|
||||
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]
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user