Compare commits

...
10 Commits
Author SHA1 Message Date
MichalC d11808b471 fix: standaryzacja i bezpieczeństwo formularzy
- Ujednolicono kontakt/actions.ts (rate limit, escapeHtml, getMailClient, usunięto bezpośrednie Resend)
- Usunięto nieużywany plik lib/recaptcha.ts
- Naprawiono brak obsługi błędów API (Resend error) w zgloszenie-awarii, zamowienie i zapytanie-multisplit
- Dodano logowanie błędów w catch dla tych formularzy
- Posprzątano stare skrypty robocze z roota
2026-08-02 21:10:24 +02:00
MichalC c9e4283789 feat: filtr jednostek multisplit, ostatnio oglądane, sklep pompy ciepła, czyszczenie komentarzy, checkbox zgody
- Filtr 'Ilość jednostek wewn.' w sklepie (1–9 jednostek, checkboxy, liczniki)
- Komponent RecentlyViewedProducts + Zustand store (recently-viewed-store.ts)
- Pompy ciepła dodane do sklepu i nawigacji (Header + MobileMenu)
- Logotypy marek zaktualizowane (Daikin, Panasonic + odświeżone istniejące)
- Checkbox zgody RODO w /zamowienie powiększony (w-5 h-5)
- Usunięcie ~65 zbędnych komentarzy WHAT z 12 plików (build: 0 errors)
- Plik analyze_products.ts (pomocniczy skrypt diagnostyczny)
2026-08-02 20:31:55 +02:00
MichalC 092cff1899 chore: update content, sitemap and lib files 2026-08-01 14:01:54 +02:00
MichalC 6808dee9be chore: remove leftover patch files 2026-08-01 13:52:44 +02:00
MichalC 21baad79f7 fix: update hero image and disable HSTS on localhost 2026-08-01 13:52:33 +02:00
MichalC 47a9ac07fb feat: security audit, rate limiting, server-side pricing, html escaping and security headers 2026-08-01 13:24:41 +02:00
MichalC f07f2c2407 feat: SEO cleanup, A11y, Next.js 16 dynamic routes, hide unverified content 2026-08-01 13:01:08 +02:00
MichalC 88cb4c516d fix: linting cleanup, TS errors, mobile cookie banner and hero max-width 2026-08-01 12:27:14 +02:00
MichalC 02cb49e180 feat: restore heat pump pages, navigation links and blog posts 2026-08-01 11:37:22 +02:00
MichalC 4969318096 changes 2026-07-28 09:40:37 +02:00
139 changed files with 897 additions and 7422 deletions
+6
View File
@@ -0,0 +1,6 @@
RESEND_API_KEY=
RESEND_FROM_EMAIL=
RESEND_TO_EMAIL=
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=
NEXT_PUBLIC_BASE_URL=
+2
View File
@@ -0,0 +1,2 @@
*.js
*.cjs
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
+1 -1
View File
@@ -53,7 +53,7 @@ export default function DostawaPage() {
<h2 id="instalatorzy">2. Dostawa podczas montażu</h2>
<p>
Jeśli wybierzesz wariant zakupu "Z montażem", urządzenie nie jest wysyłane z wyprzedzeniem za pośrednictwem zewnętrznej firmy kurierskiej. Sprzęt w dniu umówionej instalacji przywozi nasza własna ekipa monterska, dzięki czemu minimalizujemy ryzyko uszkodzenia urządzenia w transporcie i zdejmujemy z Ciebie obowiązek składowania gabarytowych kartonów.
Jeśli wybierzesz wariant zakupu &quot;Z montażem&quot;, urządzenie nie jest wysyłane z wyprzedzeniem za pośrednictwem zewnętrznej firmy kurierskiej. Sprzęt w dniu umówionej instalacji przywozi nasza własna ekipa monterska, dzięki czemu minimalizujemy ryzyko uszkodzenia urządzenia w transporcie i zdejmujemy z Ciebie obowiązek składowania gabarytowych kartonów.
</p>
<h2 id="platnosci">3. Metody płatności</h2>
+1 -1
View File
@@ -47,7 +47,7 @@ export default function PolitykaPrywatnosciPage() {
<h2 id="cookies" className="text-2xl font-bold mt-8 mb-4">4. Pliki Cookies i Analityka (Google Analytics 4 / Tag Manager)</h2>
<p>
Nasz serwis wykorzystuje pliki cookies (tzw. "ciasteczka") – dane informatyczne, w szczególności pliki tekstowe, które przechowywane są w urządzeniu końcowym Użytkownika i przeznaczone są do korzystania z naszej strony internetowej.
Nasz serwis wykorzystuje pliki cookies (tzw. &quot;ciasteczka&quot;) – dane informatyczne, w szczególności pliki tekstowe, które przechowywane są w urządzeniu końcowym Użytkownika i przeznaczone są do korzystania z naszej strony internetowej.
</p>
<h3 className="text-xl font-semibold mt-6 mb-3">Google Analytics 4 (GA4) i Google Tag Manager (GTM)</h3>
+9 -12
View File
@@ -3,14 +3,15 @@ import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getPosts, getPostBySlug, getRelatedPosts } from '@/content/posts';
import { JsonLd } from '@/components/ui/JsonLd';
import { CtaBanner } from '@/components/sections/CtaBanner';
import { Calendar, User, ArrowLeft, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
interface BlogPostPageProps {
params: {
params: Promise<{
slug: string;
};
}>;
}
// Generowanie ścieżek statycznych dla wpisów
@@ -139,14 +140,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
return (
<main className="bg-slate-50 pt-24 pb-16">
{/* Skrypty JSON-LD dla SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdArticle) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdBreadcrumb) }}
/>
<JsonLd data={jsonLdArticle} />
<JsonLd data={jsonLdBreadcrumb} />
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Powrót */}
@@ -188,7 +183,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
{/* Główny obraz */}
<div className="relative w-full aspect-video rounded-2xl overflow-hidden mb-12 shadow-md">
<Image
src={post.image || 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1600&q=80'}
src={post.image || '/images/hero/hero-section-image.webp'}
alt={post.title}
fill
priority
@@ -203,6 +198,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
prose-img:rounded-xl prose-img:shadow-md
marker:text-accent"
>
{/* Dane blogowe są obecnie lokalnym, zaufanym contentem; nie dodajemy ciężkiej biblioteki DOMPurify bez potrzeby.
Przy wdrożeniu CMS lub wyświetlaniu treści od użytkowników wymagane będzie sanitizowanie HTML na serwerze. */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
@@ -231,7 +228,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
<Link key={related.slug} href={`/blog/${related.slug}`} className="group block">
<div className="relative aspect-[16/10] rounded-xl overflow-hidden mb-4">
<Image
src={related.image || 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&q=80'}
src={related.image || '/images/hero/hero-section-image.webp'}
alt={related.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
+1 -1
View File
@@ -31,7 +31,7 @@ export default function BlogPage() {
ctaLink="/kontakt"
secondaryCtaText="Nasze usługi"
secondaryCtaLink="/uslugi-domowe"
backgroundImage="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1600&q=80"
backgroundImage="/images/hero/hero-section-image.webp"
/>
<section className="py-16 md:py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+4 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, ArrowRight, Wrench, Zap, Wind } from 'lucide-react';
@@ -111,7 +112,7 @@ export default function CennikMontazuPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
@@ -249,11 +250,11 @@ export default function CennikMontazuPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Napisz do nas <ArrowRight className="size-5" />
+32 -6
View File
@@ -11,9 +11,9 @@
--color-foreground: var(--foreground);
--color-text: var(--foreground);
--color-text-muted: var(--muted-foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-heading);
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-heading: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -158,12 +158,26 @@
@apply border-border outline-ring/50;
}
*:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground overflow-x-hidden;
}
html {
@apply font-sans;
@apply font-sans overflow-x-hidden scroll-smooth;
}
h1,
@@ -195,4 +209,16 @@
color: #ffffff !important;
border-color: var(--accent) !important;
}
}
}
@layer utilities {
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(calc(-50% - 0.5rem)); }
}
.animate-marquee {
animation: marquee 25s linear infinite;
}
.animate-marquee:hover {
animation-play-state: paused;
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, Building2, Thermometer, Wind, Zap, Clock } from 'lucide-react';
@@ -108,7 +109,7 @@ export default function KlimatyzacjaDoBiuraPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
@@ -275,11 +276,11 @@ export default function KlimatyzacjaDoBiuraPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Napisz do nas <ArrowRight className="size-5" />
+5 -4
View File
@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, Home, Thermometer, Wind, Zap } from 'lucide-react';
import { Phone, Wrench, ArrowRight, Home, Thermometer, Wind, Zap } from 'lucide-react';
import { companyDetails } from '@/content/company';
import { buttonClasses, Button } from '@/components/ui/Button';
@@ -86,7 +87,7 @@ export default function KlimatyzacjaDoDomuPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
@@ -244,11 +245,11 @@ export default function KlimatyzacjaDoDomuPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/kalkulator" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/kalkulator" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Kalkulator doboru <ArrowRight className="size-5" />
+3 -5
View File
@@ -1,7 +1,8 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { MapPin, Clock, Phone, ArrowRight, ThermometerSun } from 'lucide-react';
import { Clock, Phone, ArrowRight } from 'lucide-react';
import { getCityBySlug, getAllCitySlugs, dolnySlaskCities } from '@/content/cities';
import { HeroSection } from '@/components/sections/HeroSection';
import { ProcessSteps } from '@/components/sections/ProcessSteps';
@@ -63,10 +64,7 @@ export default async function MiastoPage({ params }: Props) {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={jsonLd} />
{/* HERO */}
<HeroSection
+2 -1
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { HeroSection } from '@/components/sections/HeroSection';
import { InfoHighlights } from '@/components/sections/InfoHighlights';
import { AcTypesSection } from '@/components/sections/AcTypesSection';
@@ -71,7 +72,7 @@ export default function KlimatyzacjaPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
<JsonLd data={faqJsonLd} />
<main>
<HeroSection
eyebrow="15+ lat doświadczenia · Split i multisplit · Uprawnienia F-gaz"
@@ -1,7 +1,8 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { MapPin, Clock, Phone, ArrowRight, ThermometerSun } from 'lucide-react';
import { MapPin, Clock, Phone, ArrowRight } from 'lucide-react';
import { getDistrictBySlug, getAllDistrictSlugs, wroclawDistricts } from '@/content/wroclaw-districts';
import { HeroSection } from '@/components/sections/HeroSection';
import { ProcessSteps } from '@/components/sections/ProcessSteps';
@@ -28,6 +29,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return {
title: district.seoTitle,
description: district.seoDescription,
robots: {
index: false,
follow: false,
},
alternates: { canonical: `/klimatyzacja/wroclaw/${district.slug}` },
openGraph: {
title: district.seoTitle,
@@ -63,10 +68,7 @@ export default async function DzielnicaPage({ params }: Props) {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={jsonLd} />
<HeroSection
eyebrow={`Klimatyzacja ${district.name} · Wrocław · F-Gaz`}
+25 -16
View File
@@ -1,12 +1,10 @@
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { escapeHtml, rateLimit, verifyRecaptcha } from "@/lib/security";
import { getMailClient } from "@/lib/mail";
import { headers } from "next/headers";
const resend = new Resend(process.env.RESEND_API_KEY);
// Schemat walidacji zgodny z tym po stronie klienta
const contactFormSchema = z.object({
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
@@ -17,20 +15,24 @@ const contactFormSchema = z.object({
consent: z.boolean().refine((val) => val === true, "Zgoda RODO jest wymagana"),
});
export async function submitContactForm(prevState: any, formData: FormData) {
export async function submitContactForm(prevState: unknown, formData: FormData) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
const token = formData.get('recaptchaToken') as string;
if (!token) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(token);
const success = await verifyRecaptcha(token);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
// 1. Walidacja danych z formularza
const rawData = {
name: formData.get("name"),
phone: formData.get("phone"),
@@ -43,8 +45,16 @@ export async function submitContactForm(prevState: any, formData: FormData) {
const validatedData = contactFormSchema.parse(rawData);
const mailClient = getMailClient();
if (!mailClient) {
return {
success: false,
message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna.",
};
}
// 2. Wysłanie e-maila przez Resend
const { data, error } = await resend.emails.send({
const { data, error } = await mailClient.resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || "[email protected]",
to: process.env.RESEND_TO_EMAIL || "[email protected]", // Docelowy e-mail (odbiorca)
replyTo: validatedData.email,
@@ -60,29 +70,29 @@ export async function submitContactForm(prevState: any, formData: FormData) {
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9; width: 120px;"><strong>Imię:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.name}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Telefon:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="tel:${validatedData.phone}" style="color: #0F2A47; text-decoration: none;">${validatedData.phone}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="tel:${escapeHtml(validatedData.phone)}" style="color: #0F2A47; text-decoration: none;">${escapeHtml(validatedData.phone)}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>E-mail:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="mailto:${validatedData.email}" style="color: #0F2A47; text-decoration: none;">${validatedData.email}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="mailto:${escapeHtml(validatedData.email || '')}" style="color: #0F2A47; text-decoration: none;">${escapeHtml(validatedData.email || '')}</a></td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Miasto:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.city}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.city)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Temat:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.subject}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.subject || '')}</td>
</tr>
</table>
<h2 style="font-size: 18px; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Treść wiadomości:</h2>
<div style="background-color: #f8fafc; padding: 15px; border-radius: 6px; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">
${validatedData.message}
${escapeHtml(validatedData.message || '')}
</div>
<p style="margin-top: 24px; font-size: 12px; color: #64748b; text-align: center;">
@@ -106,7 +116,6 @@ export async function submitContactForm(prevState: any, formData: FormData) {
console.log("Wysłano e-mail (ID):", data?.id);
// 3. Zwrotka o sukcesie
return {
success: true,
message: "Dziękujemy za wiadomość! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
+1 -1
View File
@@ -23,7 +23,7 @@ export default function KontaktPage() {
ctaLink={`tel:${companyDetails.phoneRaw}`}
secondaryCtaText="Napisz maila"
secondaryCtaLink={`mailto:${companyDetails.email}`}
backgroundImage="https://images.unsplash.com/photo-1516387938699-a93567ec168e?w=1600&q=80"
backgroundImage="/images/hero/hero-section-image.webp"
/>
<ContactSection />
+6 -14
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Inter, Poppins } from "next/font/google";
import { Header } from "@/components/layout/Header";
import { TrustBar } from "@/components/layout/TrustBar";
import { Footer } from "@/components/layout/Footer";
@@ -10,16 +10,7 @@ import { AnalyticsWrapper } from "@/components/ui/AnalyticsWrapper";
import { CookieSettingsFloatingIcon } from "@/components/ui/CookieSettingsFloatingIcon";
import "./globals.css";
const fontSans = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
const fontHeading = Poppins({
subsets: ["latin"],
weight: ["600", "700", "800"],
variable: "--font-heading",
});
export const metadata: Metadata = {
title: {
@@ -61,13 +52,14 @@ export default function RootLayout({
return (
<html
lang="pl"
className={`${fontSans.variable} ${fontHeading.variable} antialiased`}
className="antialiased"
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-screen flex flex-col bg-background text-foreground">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-accent focus:text-white focus:rounded-md focus:font-bold">
Przejdź do głównej treści
</a>
{process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && (
<Script
src={`https://www.google.com/recaptcha/api.js?render=${process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}`}
@@ -75,7 +67,7 @@ export default function RootLayout({
/>
)}
<Header />
<main className="flex-grow">{children}</main>
<main id="main-content" tabIndex={-1} className="flex-grow focus:outline-none">{children}</main>
<TrustBar />
<Footer />
<AnalyticsWrapper />
+3 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, ShieldCheck, Clock, Star } from 'lucide-react';
@@ -87,7 +88,7 @@ export default function MontazKlimatyzacjiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
@@ -252,7 +253,7 @@ export default function MontazKlimatyzacjiPage() {
Zadzwoń po bezpłatną wycenę — odpowiadamy szybko i przyjeżdżamy w całym Dolnym Śląsku.
</p>
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg" className="mx-auto">
<Button variant="primary" size="md" className="mx-auto">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
+4 -7
View File
@@ -1,10 +1,7 @@
import { Metadata } from 'next';
import { HeroSection } from '@/components/sections/HeroSection';
import { AboutStory } from '@/components/sections/AboutStory';
import { StatsBar } from '@/components/sections/StatsBar';
import { ValuesGrid } from '@/components/sections/ValuesGrid';
import { CertificatesSection } from '@/components/sections/CertificatesSection';
import { Testimonials } from '@/components/sections/Testimonials';
import { CtaBanner } from '@/components/sections/CtaBanner';
import { companyDetails } from '@/content/company';
@@ -27,18 +24,18 @@ export default function ONasPage() {
ctaLink="/kontakt"
secondaryCtaText="Nasze realizacje"
secondaryCtaLink="/realizacje"
backgroundImage="https://images.unsplash.com/photo-1504307651254-35680f356f12?w=1600&q=80"
backgroundImage="/images/about/about-story.webp"
/>
<AboutStory />
<StatsBar />
{/* <StatsBar /> (Ukryte na czas zbierania autentycznych danych statystycznych) */}
<ValuesGrid />
<CertificatesSection />
{/* <CertificatesSection /> (Ukryte na czas uzyskania skanów dokumentów) */}
<Testimonials />
{/* <Testimonials /> (Ukryte na czas zbierania autentycznych opinii) */}
<CtaBanner
title="Szukasz sprawdzonej ekipy do swojego projektu?"
+4 -7
View File
@@ -3,13 +3,13 @@ import { AdvantagesBar } from "@/components/sections/AdvantagesBar";
import { ServicesGrid } from "@/components/sections/ServicesGrid";
import { WhyUs } from "@/components/sections/WhyUs";
import { FeaturedProducts } from "@/components/sections/FeaturedProducts";
import { Testimonials } from "@/components/sections/Testimonials";
import { BlogPreview } from "@/components/sections/BlogPreview";
import { CtaBanner } from "@/components/sections/CtaBanner";
import { companyDetails } from "@/content/company";
import { Wrench, Phone, ArrowRight, Thermometer, Wind, Zap, Calendar, Check } from "lucide-react";
import Link from "next/link";
import { Button, buttonClasses } from "@/components/ui/Button";
import { JsonLd } from "@/components/ui/JsonLd";
export default function Home() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://thermcool.pl';
@@ -104,7 +104,7 @@ export default function Home() {
companyDetails.socials.facebook,
companyDetails.socials.instagram,
companyDetails.socials.google,
],
].filter(Boolean),
},
{
"@type": "WebSite",
@@ -129,10 +129,7 @@ export default function Home() {
return (
<>
{/* LocalBusiness Schema for Google Search SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={jsonLd} />
<HeroSection />
<AdvantagesBar />
@@ -141,7 +138,7 @@ export default function Home() {
<FeaturedProducts />
<WhyUs />
{/* ProjectsSection will go here in Group B */}
<Testimonials />
{/* <Testimonials /> (Ukryte na czas zbierania autentycznych opinii) */}
{/* Serwis LG */}
<section className="py-16 bg-white">
+2 -3
View File
@@ -1,4 +1,5 @@
import { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import { HeroSection } from '@/components/sections/HeroSection';
import { BenefitsGrid } from '@/components/sections/BenefitsGrid';
import { HeatPumpTypes } from '@/components/sections/HeatPumpTypes';
@@ -18,10 +19,8 @@ export const metadata: Metadata = {
}
};
import { notFound } from 'next/navigation';
export default function PompyCieplaPage() {
notFound();
const faqPompyJsonLd = {
"@context": "https://schema.org",
@@ -72,7 +71,7 @@ export default function PompyCieplaPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPompyJsonLd) }} />
<JsonLd data={faqPompyJsonLd} />
<main>
<HeroSection
title="Pompy ciepła — niższe rachunki, niezależność, dofinansowanie"
+5 -1
View File
@@ -9,6 +9,10 @@ export const metadata: Metadata = {
description: 'Zobacz nasze wybrane realizacje z zakresu klimatyzacji, pomp ciepła, remontów i usług hydraulicznych.',
alternates: {
canonical: '/realizacje',
},
robots: {
index: false,
follow: false,
}
};
@@ -23,7 +27,7 @@ export default function RealizacjePage() {
ctaLink="/uslugi-domowe"
secondaryCtaText="Skontaktuj się"
secondaryCtaLink="/kontakt"
backgroundImage="https://images.unsplash.com/photo-1503387762-592deb58ef4e?w=1600&q=80"
backgroundImage="/images/hero/hero-section-image.webp"
/>
<ProjectsGrid />
+4 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisDaikinPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
@@ -195,11 +196,11 @@ export default function SerwisDaikinPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Zgłoś online <ArrowRight className="size-5" />
+35 -14
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import Image from 'next/image';
import { Check, Phone, Wrench, ArrowRight, Thermometer, Wind, Zap, ShieldCheck } from 'lucide-react';
@@ -82,7 +83,18 @@ const USLUGI = [
},
];
const MARKI = ['Daikin', 'Mitsubishi Electric', 'LG', 'Samsung', 'Gree', 'Rotenso', 'Haier', 'Aux', 'Fuji Electric', 'Toshiba', 'Fujitsu', 'Bosch'];
const BRANDS_WITH_LOGOS = [
{ name: 'Aux', src: '/brands/aux-brand.webp' },
{ name: 'CHV', src: '/brands/chv.webp' },
{ name: 'Fuji Electric', src: '/brands/fuji.webp' },
{ name: 'Gree', src: '/brands/gree.webp' },
{ name: 'Haier', src: '/brands/haier.webp' },
{ name: 'LG', src: '/brands/lg.webp' },
{ name: 'Mitsubishi Electric', src: '/brands/mitsubishi.webp' },
{ name: 'Rotenso', src: '/brands/rotenso.webp' },
{ name: 'Samsung', src: '/brands/samsung.webp' },
{ name: 'Toshiba', src: '/brands/toshiba.webp' },
];
export default function SerwisKlimatyzacjiPage() {
const jsonLd = {
@@ -120,7 +132,7 @@ export default function SerwisKlimatyzacjiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="relative text-white py-24 md:py-32 overflow-hidden">
@@ -188,16 +200,27 @@ export default function SerwisKlimatyzacjiPage() {
</section>
{/* Marki */}
<section className="py-16 bg-slate-50">
<section className="py-16 bg-slate-50 overflow-hidden">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl font-extrabold text-primary font-heading mb-6">Serwisujemy wszystkie popularne marki</h2>
<div className="flex flex-wrap gap-2 mb-4">
{MARKI.map(m => (
<span key={m} className="px-4 py-2 bg-white border border-border rounded-xl text-sm font-semibold text-primary">
{m}
</span>
))}
<div className="relative flex overflow-hidden w-full mb-4">
<div className="flex w-max animate-marquee gap-4 whitespace-nowrap items-center">
{/* Pierwszy zestaw */}
{BRANDS_WITH_LOGOS.map(m => (
<div key={m.name} className="flex-shrink-0 px-6 py-4 bg-white border border-border rounded-xl flex items-center justify-center w-40 h-20 hover:shadow-md transition-shadow">
<Image src={m.src} alt={m.name} width={120} height={60} className="object-contain w-full h-full" />
</div>
))}
{/* Drugi zestaw (klon) do płynnego przejścia */}
{BRANDS_WITH_LOGOS.map(m => (
<div key={`dup-${m.name}`} className="flex-shrink-0 px-6 py-4 bg-white border border-border rounded-xl flex items-center justify-center w-40 h-20 hover:shadow-md transition-shadow">
<Image src={m.src} alt={m.name} width={120} height={60} className="object-contain w-full h-full" />
</div>
))}
</div>
</div>
<p className="text-xs text-text-muted">Nie widzisz swojej marki? Zadzwoń — prawdopodobnie też ją obsługujemy.</p>
</div>
</section>
@@ -233,15 +256,13 @@ export default function SerwisKlimatyzacjiPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Zgłoś online <ArrowRight className="size-5" />
</Link>
</Link>
</div>
</div>
</section>
+3 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -68,7 +69,7 @@ export default function SerwisLGPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
@@ -190,7 +191,7 @@ export default function SerwisLGPage() {
Zadzwoń lub napisz — odpowiadamy szybko i przyjeżdżamy na Dolny Śląsk.
</p>
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg" className="mx-auto">
<Button variant="primary" size="md" className="mx-auto">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
+4 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisMitsubishiPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
@@ -199,11 +200,11 @@ export default function SerwisMitsubishiPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Zgłoś online <ArrowRight className="size-5" />
+4 -3
View File
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
import { JsonLd } from '@/components/ui/JsonLd';
import Link from 'next/link';
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
import { companyDetails } from '@/content/company';
@@ -72,7 +73,7 @@ export default function SerwisSamsungPage() {
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd data={jsonLd} />
<main>
{/* Hero */}
<section className="bg-primary text-white py-16 md:py-24">
@@ -195,11 +196,11 @@ export default function SerwisSamsungPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href={`tel:${companyDetails.phoneRaw}`}>
<Button variant="primary" size="lg">
<Button variant="primary" size="md">
<Phone className="size-5" /> {companyDetails.phone}
</Button>
</a>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'lg' })}>
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
Zgłoś online <ArrowRight className="size-5" />
+2 -16
View File
@@ -2,7 +2,6 @@ import { MetadataRoute } from 'next';
import { getProducts } from '@/content/products';
import { getPosts } from '@/content/posts';
import { dolnySlaskCities } from '@/content/cities';
import { wroclawDistricts } from '@/content/wroclaw-districts';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://thermcool.pl';
@@ -11,8 +10,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
'',
'/klimatyzacja',
'/pompy-ciepla',
'/realizacje',
'/blog',
'/o-nas',
'/kalkulator',
@@ -42,7 +39,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: route === '' ? 1 : 0.8,
}));
// Dynamiczne produkty
const products = getProducts();
const productRoutes = products.map((product) => ({
url: `${baseUrl}/sklep/${product.slug}`,
@@ -51,16 +47,14 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.8,
}));
// Dynamiczne artykuły na blogu
const posts = getPosts();
const postRoutes = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.date, // Używamy daty publikacji jako lastModified
lastModified: post.date,
changeFrequency: 'monthly' as const,
priority: 0.6,
}));
// Dynamiczne strony miast
const cityRoutes = dolnySlaskCities.map((city) => ({
url: `${baseUrl}/klimatyzacja/${city.slug}`,
lastModified: new Date().toISOString(),
@@ -68,13 +62,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.7,
}));
// Dynamiczne strony dzielnic
const districtUrls = wroclawDistricts.map((d) => ({
url: `${baseUrl}/klimatyzacja/wroclaw/${d.slug}`,
lastModified: new Date().toISOString(),
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
return [...staticRoutes, ...productRoutes, ...postRoutes, ...cityRoutes, ...districtUrls];
return [...staticRoutes, ...productRoutes, ...postRoutes, ...cityRoutes];
}
+2 -5
View File
@@ -4,6 +4,7 @@ import { getProductBySlug, getProducts } from '@/content/products';
import { companyDetails } from '@/content/company';
import { ProductPageClient } from '@/components/shop/ProductPageClient';
import { getProductVariants } from '@/content/product-variants';
import { JsonLd } from '@/components/ui/JsonLd';
interface Props {
params: Promise<{ slug: string }>;
@@ -51,17 +52,13 @@ export default async function ProductPage({ params }: Props) {
"url": `${baseUrl}/sklep/${product.slug}`,
"priceCurrency": "PLN",
"price": product.price,
"priceValidUntil": "2027-12-31",
"availability": "https://schema.org/InStock",
"seller": { "@type": "Organization", "name": "ThermCool" },
},
} : null;
return (
<>
{productJsonLd && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }} />
)}
{productJsonLd && <JsonLd data={productJsonLd} />}
<ProductPageClient
product={product}
seriesProducts={seriesProducts}
+72 -28
View File
@@ -1,35 +1,46 @@
'use server';
import { z } from 'zod';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { headers } from 'next/headers';
import { getProductBySlug } from '@/content/products';
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from '@/lib/security';
import { getMailClient } from '@/lib/mail';
const resend = new Resend(process.env.RESEND_API_KEY);
const orderSchema = z.object({
name: z.string().min(2),
phone: z.string().min(9),
email: z.string().email(),
const orderSchema = baseContactSchema.extend({
address: z.string().optional(),
notes: z.string().optional(),
consent: z.string(),
products: z.string(),
total: z.string(),
total: z.string(), // We don't trust this total, just checking if it exists
});
export async function submitOrderForm(prevState: any, formData: FormData) {
const productItemSchema = z.array(z.object({
slug: z.string(),
quantity: z.number().int().min(1).max(100),
}));
export async function submitOrderForm(prevState: unknown, formData: FormData) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
const token = formData.get('recaptchaToken') as string;
if (!token) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(token);
const success = await verifyRecaptcha(token);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
const data = orderSchema.parse({
name: formData.get('name'),
phone: formData.get('phone'),
@@ -41,18 +52,44 @@ export async function submitOrderForm(prevState: any, formData: FormData) {
total: formData.get('total'),
});
const products = JSON.parse(data.products);
const total = parseFloat(data.total);
let rawProducts;
try {
rawProducts = JSON.parse(data.products);
} catch {
return { success: false, message: "Błąd przetwarzania koszyka." };
}
const productRows = products.map((p: any) =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${p.name}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
const parsedProducts = productItemSchema.parse(rawProducts);
if (parsedProducts.length === 0) {
return { success: false, message: "Koszyk jest pusty." };
}
let calculatedTotal = 0;
const validatedProducts = [];
for (const p of parsedProducts) {
const dbProduct = getProductBySlug(p.slug);
if (!dbProduct || !dbProduct.price) {
return { success: false, message: "Jeden z produktów w koszyku jest niedostępny." };
}
validatedProducts.push({
name: dbProduct.name,
quantity: p.quantity,
price: dbProduct.price
});
calculatedTotal += dbProduct.price * p.quantity;
}
const productRows = validatedProducts.map(p =>
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${escapeHtml(p.name)}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
).join('');
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
const { data: emailData, error } = await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
replyTo: data.email,
subject: `Nowe zamówienie od ${data.name} — ${total.toLocaleString('pl-PL')} zł`,
subject: `Nowe zamówienie od ${escapeHtml(data.name)} — ${calculatedTotal.toLocaleString('pl-PL')} zł`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:#0F2A47;padding:20px;text-align:center;">
@@ -60,11 +97,11 @@ export async function submitOrderForm(prevState: any, formData: FormData) {
</div>
<div style="padding:24px;">
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
<p><strong>Imię:</strong> ${data.name}</p>
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
<p><strong>Email:</strong> ${data.email}</p>
${data.address ? `<p><strong>Adres:</strong> ${data.address}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${data.notes}</p>` : ''}
<p><strong>Imię:</strong> ${escapeHtml(data.name)}</p>
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(data.phone)}">${escapeHtml(data.phone)}</a></p>
<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>
${data.address ? `<p><strong>Adres:</strong> ${escapeHtml(data.address)}</p>` : ''}
${data.notes ? `<p><strong>Uwagi:</strong> ${escapeHtml(data.notes)}</p>` : ''}
<h2 style="font-size:16px;">Zamówione produkty</h2>
<table style="width:100%;border-collapse:collapse;">
<thead><tr style="background:#f8fafc;">
@@ -75,7 +112,7 @@ export async function submitOrderForm(prevState: any, formData: FormData) {
<tbody>${productRows}</tbody>
<tfoot><tr>
<td colspan="2" style="padding:8px;font-weight:bold;">Łącznie (urządzenia)</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${total.toLocaleString('pl-PL')} zł</td>
<td style="padding:8px;font-weight:bold;text-align:right;">${calculatedTotal.toLocaleString('pl-PL')} zł</td>
</tr></tfoot>
</table>
<p style="margin-top:16px;font-size:12px;color:#64748b;">+ koszt montażu do ustalenia telefonicznie</p>
@@ -83,9 +120,16 @@ export async function submitOrderForm(prevState: any, formData: FormData) {
</div>
`,
});
if (error) {
console.error("Resend Error:", error);
return { success: false, message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego." };
}
console.log("Wysłano e-mail (ID):", emailData?.id);
return { success: true, message: 'Dziękujemy! Skontaktujemy się z Tobą w ciągu 24 godzin, aby potwierdzić zamówienie i ustalić termin montażu.' };
} catch (e) {
return { success: false, message: 'Wystąpił błąd. Spróbuj ponownie lub zadzwoń do nas bezpośrednio.' };
} catch (error) {
console.error("Błąd formularza:", error);
return { success: false, message: 'Wystąpił błąd podczas wysyłania zamówienia. Spróbuj ponownie.' };
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ export default function ZamowieniePage() {
useEffect(() => {
if (state.success) clearCart();
}, [state.success]);
}, [state.success, clearCart]);
if (items.length === 0 && !state.success) {
return (
@@ -122,7 +122,7 @@ Wróć do sklepu
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="consent" required className="mt-0.5 accent-accent" />
<input type="checkbox" name="consent" required className="mt-0.5 w-5 h-5 accent-accent shrink-0 cursor-pointer" />
<span className="text-xs text-text-muted">Wyrażam zgodę na przetwarzanie danych osobowych w celu realizacji zamówienia zgodnie z Polityką Prywatności. *</span>
</label>
+4 -4
View File
@@ -8,7 +8,7 @@ import { ArrowLeft, Phone, Zap, Wind, Volume2, Thermometer } from 'lucide-react'
import MultisplitContactForm from '@/components/shop/MultisplitContactForm';
interface Props {
params: { slug: string };
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
@@ -18,7 +18,7 @@ export async function generateStaticParams() {
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = params;
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return { title: 'Produkt nie znaleziony' };
return {
@@ -28,11 +28,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export default async function MultisplitInquiryPage({ params }: Props) {
const { slug } = params;
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product || product.category !== 'multisplit') notFound();
const specIcons: Record<string, any> = {
const specIcons: Record<string, React.ElementType> = {
'Moc chłodnicza': Zap,
'Moc grzewcza': Thermometer,
'Głośność wewnętrzna (dB)': Volume2,
+41 -53
View File
@@ -1,88 +1,91 @@
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { verifyRecaptcha } from "@/lib/recaptcha";
import { headers } from "next/headers";
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
import { getMailClient } from "@/lib/mail";
const resend = new Resend(process.env.RESEND_API_KEY);
const multisplitFormSchema = z.object({
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
email: z.union([z.literal(''), z.string().email("Niepoprawny adres e-mail")]).optional(),
const multisplitFormSchema = baseContactSchema.extend({
city: z.string().min(1, "Wybierz miasto"),
rooms: z.string().optional(),
message: z.string().optional(),
consent: z.boolean().refine((val) => val === true, "Zgoda RODO jest wymagana"),
productName: z.string(),
productSku: z.string(),
recaptchaToken: z.string().optional(),
});
export async function submitMultisplitForm(data: any) {
export async function submitMultisplitForm(data: Record<string, unknown>) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(data.recaptchaToken);
const success = await verifyRecaptcha(data.recaptchaToken);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
const validatedData = multisplitFormSchema.parse(data);
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || "[email protected]",
to: process.env.RESEND_TO_EMAIL || "[email protected]",
// Convert boolean consent to string for base schema compatibility if it comes as boolean
const processData = { ...data };
if (typeof processData.consent === 'boolean') {
processData.consent = processData.consent ? "true" : "false";
}
const validatedData = multisplitFormSchema.parse(processData);
const { data: emailData, error } = await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
replyTo: validatedData.email || undefined,
subject: `Zapytanie o multisplit: ${validatedData.productName} (${validatedData.productSku})`,
subject: `Zapytanie o multisplit: ${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
<div style="background-color: #0F2A47; padding: 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Zapytanie o multisplit</h1>
<p style="color: #2BA8E0; margin: 5px 0 0 0; font-size: 14px;">Domena: thermcool.pl</p>
</div>
<div style="padding: 24px; color: #1e293b;">
<h2 style="font-size: 18px; margin-top: 0; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dane kontaktowe:</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9; width: 120px;"><strong>Imię:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.name}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.name)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Telefon:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="tel:${validatedData.phone}" style="color: #0F2A47; text-decoration: none;">${validatedData.phone}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.phone)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>E-mail:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="mailto:${validatedData.email}" style="color: #0F2A47; text-decoration: none;">${validatedData.email || 'Brak'}</a></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.email || 'Brak')}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Miasto:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.city}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.city)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Produkt:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.productName} (${validatedData.productSku})</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Pokoje:</strong></td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.rooms}</td>
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.rooms || '')}</td>
</tr>
</table>
<h2 style="font-size: 18px; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dodatkowe informacje:</h2>
<div style="background-color: #f8fafc; padding: 15px; border-radius: 6px; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">
${validatedData.message || 'Brak'}
${escapeHtml(validatedData.message || 'Brak')}
</div>
<p style="margin-top: 24px; font-size: 12px; color: #64748b; text-align: center;">
Zgoda RODO została zaakceptowana: <strong>${validatedData.consent ? "Tak" : "Nie"}</strong>
</p>
</div>
<div style="background-color: #f1f5f9; padding: 15px; text-align: center; font-size: 12px; color: #64748b;">
Wiadomość wygenerowana automatycznie z formularza multisplit na stronie thermcool.pl
</div>
</div>
`,
@@ -90,28 +93,13 @@ export async function submitMultisplitForm(data: any) {
if (error) {
console.error("Resend Error:", error);
return {
success: false,
message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego.",
};
return { success: false, message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego." };
}
console.log("Wysłano e-mail (ID):", emailData?.id);
return {
success: true,
message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
};
return { success: true, message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe." };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
message: "Wystąpił błąd walidacji. Sprawdź poprawność danych.",
errors: error.flatten().fieldErrors,
};
}
return {
success: false,
message: "Wystąpił nieoczekiwany błąd serwera. Spróbuj ponownie później.",
};
console.error("Błąd formularza:", error);
return { success: false, message: "Błąd walidacji lub błąd serwera. Spróbuj ponownie później." };
}
}
+63 -40
View File
@@ -1,71 +1,94 @@
'use server';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { z } from 'zod';
import { headers } from "next/headers";
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
import { getMailClient } from "@/lib/mail";
const resend = new Resend(process.env.RESEND_API_KEY);
const awariaFormSchema = baseContactSchema.omit({ consent: true }).extend({
isUrgent: z.boolean().optional(),
brand: z.string().min(1, "Wymagana nazwa urządzenia"),
model: z.string().optional(),
errorCode: z.string().optional(),
problemType: z.string().optional(),
description: z.string().optional(),
warranty: z.string().optional(),
lastService: z.string().optional(),
city: z.string().min(1, "Wymagane miasto"),
preferredDate: z.string().optional(),
preferredTime: z.string().optional(),
recaptchaToken: z.string().optional(),
});
export async function submitAwariaForm(data: {
isUrgent: boolean;
brand: string;
model: string;
errorCode: string;
problemType: string;
description: string;
warranty: string;
lastService: string;
name: string;
phone: string;
city: string;
preferredDate: string;
preferredTime: string;
recaptchaToken?: string;
}) {
export async function submitAwariaForm(data: Record<string, unknown>) {
try {
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
if (!rateLimit(ip)) {
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
}
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
const { success } = await verifyRecaptcha(data.recaptchaToken);
const success = await verifyRecaptcha(data.recaptchaToken);
if (!success) {
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
}
}
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
subject: `${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${data.brand} — ${data.city}`,
const mailClient = getMailClient();
if (!mailClient) {
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
}
// Default missing fields for zod validation
const parsedData = awariaFormSchema.parse({
...data,
email: data.email || '[email protected]',
});
const { data: emailData, error } = await mailClient.resend.emails.send({
from: mailClient.fromEmail,
to: mailClient.toEmail,
subject: `${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${escapeHtml(parsedData.brand)} — ${escapeHtml(parsedData.city)}`,
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="background:${data.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
<div style="background:${parsedData.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
<h1 style="color:#fff;margin:0;font-size:20px;">
${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
</h1>
</div>
<div style="padding:24px;">
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
<p><strong>Imię:</strong> ${data.name}</p>
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
<p><strong>Miasto:</strong> ${data.city}</p>
${data.preferredDate ? `<p><strong>Preferowany termin:</strong> ${data.preferredDate} ${data.preferredTime || ''}</p>` : ''}
<p><strong>Imię:</strong> ${escapeHtml(parsedData.name)}</p>
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(parsedData.phone)}">${escapeHtml(parsedData.phone)}</a></p>
<p><strong>Miasto:</strong> ${escapeHtml(parsedData.city)}</p>
${parsedData.preferredDate ? `<p><strong>Preferowany termin:</strong> ${escapeHtml(parsedData.preferredDate)} ${escapeHtml(parsedData.preferredTime || '')}</p>` : ''}
<h2 style="font-size:16px;">Urządzenie</h2>
<p><strong>Marka:</strong> ${data.brand}</p>
${data.model ? `<p><strong>Model:</strong> ${data.model}</p>` : ''}
${data.warranty ? `<p><strong>Gwarancja:</strong> ${data.warranty}</p>` : ''}
${data.lastService ? `<p><strong>Ostatni serwis:</strong> ${data.lastService}</p>` : ''}
<p><strong>Marka:</strong> ${escapeHtml(parsedData.brand)}</p>
${parsedData.model ? `<p><strong>Model:</strong> ${escapeHtml(parsedData.model)}</p>` : ''}
${parsedData.warranty ? `<p><strong>Gwarancja:</strong> ${escapeHtml(parsedData.warranty)}</p>` : ''}
${parsedData.lastService ? `<p><strong>Ostatni serwis:</strong> ${escapeHtml(parsedData.lastService)}</p>` : ''}
<h2 style="font-size:16px;">Problem</h2>
<p><strong>Typ problemu:</strong> ${data.problemType}</p>
${data.errorCode ? `<p><strong>Kod błędu:</strong> ${data.errorCode}</p>` : ''}
${data.description ? `<p><strong>Opis:</strong> ${data.description}</p>` : ''}
<p><strong>Typ problemu:</strong> ${escapeHtml(parsedData.problemType || '')}</p>
${parsedData.errorCode ? `<p><strong>Kod błędu:</strong> ${escapeHtml(parsedData.errorCode)}</p>` : ''}
${parsedData.description ? `<p><strong>Opis:</strong> ${escapeHtml(parsedData.description)}</p>` : ''}
</div>
</div>
`,
});
if (error) {
console.error("Resend Error:", error);
return { success: false, message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego." };
}
console.log("Wysłano e-mail (ID):", emailData?.id);
return { success: true };
} catch {
return { success: false };
} catch (error) {
console.error("Błąd formularza:", error);
return { success: false, message: 'Wystąpił błąd podczas wysyłania.' };
}
}
-109
View File
@@ -1,109 +0,0 @@
const fs = require('fs');
const path = require('path');
function walkDir(dir, callback) {
if (!fs.existsSync(dir)) return;
fs.readdirSync(dir).forEach(f => {
const dirPath = path.join(dir, f);
const isDirectory = fs.statSync(dirPath).isDirectory();
if (isDirectory) {
walkDir(dirPath, callback);
} else if (dirPath.endsWith('.tsx')) {
callback(dirPath);
}
});
}
const appFiles = [];
const componentsFiles = [];
walkDir('app', f => appFiles.push(f));
walkDir('components', f => componentsFiles.push(f));
const allFiles = [...appFiles, ...componentsFiles];
const results = {};
allFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
const lines = content.split('\n');
const headings = [];
lines.forEach((line, index) => {
const regex = /<h([1-6])[\s>]/g;
let match;
while ((match = regex.exec(line)) !== null) {
headings.push({ level: parseInt(match[1]), line: index + 1, content: line.trim() });
}
});
if (headings.length > 0 || file.endsWith('page.tsx')) {
results[file] = headings;
}
});
// Analysis
console.log("=== HEADINGS AUDIT ===");
// 1. Pages with >1 H1
console.log("\n1. Strony z więcej niż jednym <h1>:");
let foundMultipleH1 = false;
for (const [file, headings] of Object.entries(results)) {
if (file.includes('page.tsx')) {
const h1s = headings.filter(h => h.level === 1);
if (h1s.length > 1) {
console.log(`- ${file} (Liczba <h1>: ${h1s.length})`);
foundMultipleH1 = true;
}
}
}
if (!foundMultipleH1) console.log("Brak.");
// 2. Pages with 0 H1
console.log("\n2. Strony (page.tsx) bez <h1>:");
let foundZeroH1 = false;
for (const [file, headings] of Object.entries(results)) {
if (file.includes('page.tsx') && !file.includes('api/')) { // ignoring api if any
const h1s = headings.filter(h => h.level === 1);
if (h1s.length === 0) {
console.log(`- ${file}`);
foundZeroH1 = true;
}
}
}
if (!foundZeroH1) console.log("Brak.");
// 3. Components rendering H1
console.log("\n3. Komponenty współdzielone (w components/) z <h1>:");
let foundComponentH1 = false;
for (const [file, headings] of Object.entries(results)) {
if (file.startsWith('components/')) {
const h1s = headings.filter(h => h.level === 1);
if (h1s.length > 0) {
console.log(`- ${file} (${h1s.length} wystąpień <h1>)`);
foundComponentH1 = true;
}
}
}
if (!foundComponentH1) console.log("Brak.");
// 4. Heading Skips
console.log("\n4. Skoki poziomów nagłówków w plikach:");
let foundSkips = false;
for (const [file, headings] of Object.entries(results)) {
if (headings.length === 0) continue;
let currentLevel = null;
for (const h of headings) {
if (currentLevel !== null) {
// A skip happens if the new level is strictly greater than currentLevel + 1
if (h.level > currentLevel + 1) {
console.log(`- ${file}: Skok z <h${currentLevel}> na <h${h.level}> w linii ${h.line}`);
foundSkips = true;
}
}
currentLevel = h.level;
}
}
if (!foundSkips) console.log("Brak oczywistych skoków.");
+25 -29
View File
@@ -37,13 +37,16 @@ export default function CookieBanner() {
useEffect(() => {
if (isOpen) {
setPrefs({
necessary: true,
analytics: analytics,
marketing: marketing,
advertising: advertising,
});
setShowDetails(false);
const timer = setTimeout(() => {
setPrefs({
necessary: true,
analytics: analytics,
marketing: marketing,
advertising: advertising,
});
setShowDetails(false);
}, 0);
return () => clearTimeout(timer);
}
}, [isOpen, analytics, marketing, advertising]);
@@ -54,17 +57,16 @@ export default function CookieBanner() {
if (!isInitialized || !isOpen) return null;
return (
<div className="fixed bottom-0 left-0 right-0 z-[100] p-4 sm:p-6">
<div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl border border-border overflow-hidden">
{/* Główny pasek */}
<div className="p-5 sm:p-6">
<div className="flex items-start gap-3 mb-4">
<span className="text-2xl shrink-0">🍪</span>
<div className="fixed bottom-0 left-0 right-0 z-[100] p-0 sm:p-6">
<div className="max-w-4xl mx-auto bg-white sm:rounded-2xl shadow-[0_-4px_20px_rgba(0,0,0,0.1)] sm:shadow-2xl border-t sm:border border-border overflow-hidden pb-safe sm:pb-0">
<div className="p-4 sm:p-6">
<div className="flex items-start gap-3 mb-3 sm:mb-4">
<span className="hidden sm:block text-2xl shrink-0">🍪</span>
<div>
<h2 className="text-base font-extrabold text-primary font-heading mb-1">
<h2 className="text-sm sm:text-base font-extrabold text-primary font-heading mb-1">
Ta strona używa plików cookie
</h2>
<p className="text-sm text-text-muted leading-relaxed">
<p className="text-xs sm:text-sm text-text-muted leading-relaxed">
Używamy plików cookie, aby zapewnić prawidłowe działanie strony, analizować ruch
i dostosowywać treści. Możesz wybrać które kategorie akceptujesz.{' '}
<Link href="/polityka-prywatnosci" className="text-accent underline hover:no-underline">
@@ -74,10 +76,8 @@ export default function CookieBanner() {
</div>
</div>
{/* Szczegóły */}
{showDetails && (
<div className="flex flex-col gap-3 mb-5 p-4 bg-slate-50 rounded-xl">
{/* Niezbędne */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-primary">Niezbędne</p>
@@ -92,7 +92,6 @@ export default function CookieBanner() {
</div>
</div>
{/* Analityczne */}
<div className="flex items-start justify-between gap-4 border-t border-border pt-3">
<div>
<p className="text-sm font-semibold text-primary">Analityczne</p>
@@ -110,7 +109,6 @@ export default function CookieBanner() {
</button>
</div>
{/* Marketingowe */}
<div className="flex items-start justify-between gap-4 border-t border-border pt-3">
<div>
<p className="text-sm font-semibold text-primary">Marketingowe</p>
@@ -128,7 +126,6 @@ export default function CookieBanner() {
</button>
</div>
{/* Reklamowe */}
<div className="flex items-start justify-between gap-4 border-t border-border pt-3">
<div>
<p className="text-sm font-semibold text-primary">Reklamowe</p>
@@ -148,33 +145,32 @@ export default function CookieBanner() {
</div>
)}
{/* Przyciski */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<div className="grid grid-cols-2 sm:flex sm:flex-row items-stretch sm:items-center gap-2">
<button
onClick={storeAcceptAll}
className="flex-1 sm:flex-none px-5 py-2.5 bg-accent text-white font-bold rounded-xl text-sm hover:bg-accent/90 transition-colors"
className="col-span-2 sm:col-span-1 flex-1 sm:flex-none px-4 py-2 sm:px-5 sm:py-2.5 bg-accent text-white font-bold rounded-xl text-xs sm:text-sm hover:bg-accent/90 transition-colors min-h-[44px]"
>
Akceptuj wszystkie
</button>
<button
onClick={storeRejectOptional}
className="flex-1 sm:flex-none px-5 py-2.5 bg-slate-100 text-primary font-bold rounded-xl text-sm hover:bg-slate-200 transition-colors"
className="col-span-1 flex-1 sm:flex-none px-4 py-2 sm:px-5 sm:py-2.5 bg-slate-100 text-primary font-bold rounded-xl text-xs sm:text-sm hover:bg-slate-200 transition-colors min-h-[44px]"
>
Odrzuć opcjonalne
Odrzuć
</button>
{showDetails ? (
<button
onClick={saveSelected}
className="flex-1 sm:flex-none px-5 py-2.5 bg-white border border-border text-primary font-bold rounded-xl text-sm hover:border-accent transition-colors"
className="col-span-1 flex-1 sm:flex-none px-4 py-2 sm:px-5 sm:py-2.5 bg-white border border-border text-primary font-bold rounded-xl text-xs sm:text-sm hover:border-accent transition-colors min-h-[44px]"
>
Zapisz wybór
Zapisz
</button>
) : (
<button
onClick={() => setShowDetails(true)}
className="flex-1 sm:flex-none px-5 py-2.5 bg-white border border-border text-primary font-bold rounded-xl text-sm hover:border-accent transition-colors"
className="col-span-1 flex-1 sm:flex-none px-4 py-2 sm:px-5 sm:py-2.5 bg-white border border-border text-primary font-bold rounded-xl text-xs sm:text-sm hover:border-accent transition-colors min-h-[44px]"
>
Dostosuj ustawienia
Dostosuj
</button>
)}
</div>
+2 -11
View File
@@ -1,8 +1,7 @@
'use client';
import { useState } from 'react';
import { Wrench, Zap, Info, Clock, Check, AlertTriangle, Phone, CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { Wrench, Zap, Check, AlertTriangle, Phone, CheckCircle2 } from 'lucide-react';
import { companyDetails } from '@/content/company';
import { submitAwariaForm } from '@/app/zgloszenie-awarii/actions';
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
@@ -115,7 +114,6 @@ export function AwariaClient() {
const [consent, setConsent] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Podpowiedź kodu błędu
const errorHint = brand && errorCode && ERROR_CODES[brand]?.[errorCode.toUpperCase()];
const handleSubmit = async (e: React.FormEvent) => {
@@ -127,6 +125,7 @@ export function AwariaClient() {
setIsSubmitting(true);
const recaptchaToken = await getRecaptchaToken('submit');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formDataObj: any = {
isUrgent,
brand,
@@ -180,7 +179,6 @@ export function AwariaClient() {
return (
<main className="bg-slate-50 min-h-screen py-12">
<div className="max-w-2xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="text-center mb-8">
<div className="size-14 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Wrench className="size-7 text-red-600" />
@@ -191,7 +189,6 @@ export function AwariaClient() {
<p className="text-text-muted">Wypełnij formularz — oddzwonimy i ustalimy termin wizyty</p>
</div>
{/* Pilne zgłoszenie toggle */}
<div
onClick={() => setIsUrgent(!isUrgent)}
className={`flex items-center gap-4 p-4 rounded-2xl border-2 cursor-pointer mb-6 transition-all ${
@@ -214,7 +211,6 @@ export function AwariaClient() {
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
{/* Sekcja 1: Urządzenie */}
<div className="bg-white rounded-2xl border border-border p-6">
<h2 className="text-base font-bold text-primary font-heading mb-4 flex items-center gap-2">
<Zap className="size-4 text-accent" /> Urządzenie
@@ -270,7 +266,6 @@ export function AwariaClient() {
</div>
</div>
{/* Sekcja 2: Problem */}
<div className="bg-white rounded-2xl border border-border p-6">
<h2 className="text-base font-bold text-primary font-heading mb-4 flex items-center gap-2">
<AlertTriangle className="size-4 text-accent" /> Opis problemu
@@ -293,7 +288,6 @@ export function AwariaClient() {
</div>
</div>
{/* Kod błędu z podpowiedzią */}
<div>
<label className="text-xs font-medium text-primary mb-1 block">
Kod błędu z wyświetlacza (opcjonalnie)
@@ -328,7 +322,6 @@ export function AwariaClient() {
{/* Sekcja 4: Dane kontaktowe */}
<div className="bg-white rounded-2xl border border-border p-6">
<h2 className="text-base font-bold text-primary font-heading mb-4 flex items-center gap-2">
<Phone className="size-4 text-accent" /> Dane kontaktowe
@@ -367,7 +360,6 @@ export function AwariaClient() {
</div>
</div>
{/* Zgoda RODO */}
<div
className="flex items-start gap-3 cursor-pointer"
onClick={() => setConsent(!consent)}
@@ -383,7 +375,6 @@ export function AwariaClient() {
</span>
</div>
{/* Submit */}
<button
type="submit"
disabled={isSubmitting || !brand || !problemType || !name || !phone || !city || !consent}
+48 -1
View File
@@ -15,6 +15,8 @@ const contactFormSchema = z.object({
city: z.string().min(1, "Wybierz miasto"),
subject: z.string().optional(),
message: z.string().optional(),
brand: z.string().optional(),
age: z.string().optional(),
});
type ContactFormValues = z.infer<typeof contactFormSchema>;
@@ -28,7 +30,8 @@ export function ContactForm() {
register,
handleSubmit: hookFormSubmit,
formState: { errors, isSubmitting },
reset
reset,
watch
} = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
@@ -38,6 +41,8 @@ export function ContactForm() {
city: '',
subject: '',
message: '',
brand: '',
age: '',
}
});
@@ -185,11 +190,53 @@ export function ContactForm() {
<option value="">Wybierz temat...</option>
<option value="klimatyzacja">Klimatyzacja</option>
<option value="pompy-ciepla">Pompy ciepła</option>
<option value="serwis">Serwis</option>
<option value="inne">Inne zapytanie</option>
</select>
{errors.subject && <p className="text-red-500 text-xs font-medium">{errors.subject.message}</p>}
</div>
{/* Warunkowe pola dla serwisu */}
{watch('subject') === 'serwis' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-2">
<label htmlFor="brand" className="text-sm font-semibold text-primary">Marka klimatyzatora</label>
<select
{...register("brand")}
id="brand"
className={`w-full px-4 py-3 rounded-xl border ${errors.brand ? 'border-red-500 focus:ring-red-500' : 'border-border focus:border-primary focus:ring-primary/20'} outline-none focus:ring-2 transition-all bg-slate-50 appearance-none`}
>
<option value="">Wybierz markę...</option>
<option value="Daikin">Daikin</option>
<option value="Mitsubishi">Mitsubishi</option>
<option value="LG">LG</option>
<option value="Samsung">Samsung</option>
<option value="Gree">Gree</option>
<option value="Rotenso">Rotenso</option>
<option value="Haier">Haier</option>
<option value="Aux">Aux</option>
<option value="Fuji Electric">Fuji Electric</option>
<option value="Toshiba">Toshiba</option>
<option value="Inna">Inna / Nie wiem</option>
</select>
</div>
<div className="space-y-2">
<label htmlFor="age" className="text-sm font-semibold text-primary">Wiek urządzenia</label>
<select
{...register("age")}
id="age"
className={`w-full px-4 py-3 rounded-xl border ${errors.age ? 'border-red-500 focus:ring-red-500' : 'border-border focus:border-primary focus:ring-primary/20'} outline-none focus:ring-2 transition-all bg-slate-50 appearance-none`}
>
<option value="">Jak długo masz klimatyzator?</option>
<option value="Mniej niż rok">Mniej niż rok (na gwarancji)</option>
<option value="1-3 lata">1 - 3 lata</option>
<option value="Ponad 3 lata">Ponad 3 lata</option>
<option value="Nie wiem">Nie wiem</option>
</select>
</div>
</div>
)}
{/* Wiadomość */}
<div className="space-y-2">
<label htmlFor="message" className="text-sm font-semibold text-primary">Wiadomość</label>
+8 -36
View File
@@ -2,8 +2,8 @@
import { useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ArrowRight, ArrowLeft, Check, Thermometer, Home, Zap, ShoppingCart, Phone, RotateCcw } from 'lucide-react';
import { ArrowRight, ArrowLeft, Check, Home, Zap, ShoppingCart, Phone } from 'lucide-react';
import { Product } from '@/content/products';
import { companyDetails } from '@/content/company';
import { ProductCard } from '@/components/shop/ProductCard';
@@ -20,9 +20,10 @@ function useSessionState<T>(key: string, initialValue: T) {
try {
const item = window.sessionStorage.getItem(key);
if (item) {
setState(JSON.parse(item));
const timer = setTimeout(() => setState(JSON.parse(item)), 0);
return () => clearTimeout(timer);
}
} catch (error) {}
} catch {}
}, [key]);
const setValue = (value: T | ((val: T) => T)) => {
@@ -32,7 +33,7 @@ function useSessionState<T>(key: string, initialValue: T) {
if (typeof window !== 'undefined') {
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {}
} catch {}
};
return [state, setValue] as const;
@@ -203,35 +204,6 @@ export function KalkulatorClient({ products }: Props) {
return pages;
};
const handleFinish = () => {
setStep(4);
setPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleReset = () => {
setStep(0);
setPage(1);
Object.keys(window.sessionStorage).forEach(key => {
if (key.startsWith('calc_')) window.sessionStorage.removeItem(key);
});
setDeviceType('klimatyzacja');
setBuilding('apartment');
setPomieszczenia([{ nazwa: 'Pokój 1', m2: 20 }]);
setFloor('middle');
setSun('south');
setPipes('medium');
setInstallMethod('korytko');
setOutdoorUnitLocation('balcony');
setHasSocket('yes');
setBudget(8000);
setPreferredBrand('');
setEnergyClass('');
setMaxNoise(null);
setMaxNoiseOut(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNext = () => {
setStep(s => s + 1);
setPage(1);
@@ -621,7 +593,7 @@ export function KalkulatorClient({ products }: Props) {
{ value: 'facade', label: 'Elewacja / balkon francuski', sub: 'Wymaga użycia podnośnika koszowego (ok. 600 zł/h) i pompki skroplin (+ ok. 400 zł)' },
{ value: 'roof', label: 'Dach budynku', sub: 'Wymaga odpowiedniego stelaża lub konstrukcji dachowej' },
].map(o => (
<button key={o.value} onClick={() => setOutdoorUnitLocation(o.value as any)}
<button key={o.value} onClick={() => setOutdoorUnitLocation(o.value as 'balcony' | 'facade' | 'roof')}
className={`p-4 rounded-xl border-2 text-left transition-all ${outdoorUnitLocation === o.value ? 'border-accent bg-accent/5' : 'border-border hover:border-accent'}`}>
<p className="text-sm font-bold text-primary">{o.label}</p>
<p className="text-xs text-text-muted">{o.sub}</p>
@@ -643,7 +615,7 @@ export function KalkulatorClient({ products }: Props) {
{ value: 'no', label: 'Nie', sub: 'Instalator musi dociągnąć prąd z wewnątrz (+ ok. 200 zł)' },
{ value: 'dont_know', label: 'Nie wiem / Do ustalenia', sub: 'Do ustalenia w trakcie wizji lokalnej' },
].map(o => (
<button key={o.value} onClick={() => setHasSocket(o.value as any)}
<button key={o.value} onClick={() => setHasSocket(o.value as 'yes' | 'no' | 'dont_know')}
className={`p-4 rounded-xl border-2 text-left transition-all ${hasSocket === o.value ? 'border-accent bg-accent/5' : 'border-border hover:border-accent'}`}>
<p className="text-sm font-bold text-primary">{o.label}</p>
<p className="text-xs text-text-muted">{o.sub}</p>
+2 -2
View File
@@ -65,11 +65,11 @@ export function Footer() {
Klimatyzacja i Montaż
</Link>
</li>
{/* <li>
<li>
<Link href="/pompy-ciepla" className="hover:text-accent transition-colors">
Pompy Ciepła
</Link>
</li> */}
</li>
<li>
<Link href="/sklep" className="hover:text-accent transition-colors">
+3 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import { Search } from "lucide-react";
import { getProducts } from "@/content/products";
import Link from "next/link";
@@ -85,7 +85,7 @@ export function GlobalSearch() {
>
<div className="w-16 h-16 bg-white border border-border rounded-md overflow-hidden relative shrink-0">
<Image
src={product.image || 'https://images.unsplash.com/photo-1621905251189-08b45d6a269e?w=800&q=80'}
src={product.image || '/images/about/about-story.webp'}
alt={product.name}
fill
className="object-contain p-1 mix-blend-multiply"
@@ -104,7 +104,7 @@ export function GlobalSearch() {
</div>
) : (
<div className="p-8 text-center text-text-muted">
Brak wyników dla <span className="font-semibold">"{query}"</span>
Brak wyników dla <span className="font-semibold">&quot;{query}&quot;</span>
</div>
)}
</div>
+23 -5
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { User, ShoppingBag, Menu, Phone, Wrench } from "lucide-react";
import { ShoppingBag, Menu, Phone, Wrench } from "lucide-react";
import { NavDropdown } from "./NavDropdown";
import { GlobalSearch } from "./GlobalSearch";
import { MobileMenu } from "./MobileMenu";
@@ -20,7 +20,7 @@ export function Header() {
const cartItems = useCartStore((state) => state.items);
useEffect(() => {
setMounted(true);
const t = setTimeout(() => setMounted(true), 0);
const handleScroll = () => {
if (window.scrollY > 40) {
setIsScrolled(true);
@@ -30,7 +30,10 @@ export function Header() {
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
clearTimeout(t);
};
}, []);
const cartCount = mounted
@@ -202,12 +205,27 @@ export function Header() {
sections={acSections}
align="left"
/>
{/* <Link
<Link
href="/pompy-ciepla"
className="text-sm font-medium text-primary hover:text-accent transition-colors"
>
Pompy ciepła
</Link> */}
</Link>
<NavDropdown
label="Sklep"
triggerHref="/sklep"
sections={[
{
title: "Kategorie produktów",
items: [
{ label: "Klimatyzatory ścienne", href: "/sklep?category=scienne", description: "Jednostki do domu i biura" },
{ label: "Klimatyzatory multisplit", href: "/sklep?category=multisplit", description: "Systemy na kilka pomieszczeń" },
{ label: "Pompy ciepła", href: "/sklep?category=pompy-ciepla", description: "Ekologiczne ogrzewanie" },
]
}
]}
align="left"
/>
<NavDropdown
label="Miasta"
sections={miastaSections}
+29 -6
View File
@@ -53,7 +53,6 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
isOpen ? "pointer-events-auto" : "pointer-events-none"
)}
>
{/* Dark Overlay */}
<div
className={cn(
"absolute inset-0 bg-black/60 transition-opacity duration-300",
@@ -62,14 +61,12 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
onClick={onClose}
/>
{/* Drawer Panel */}
<div
className={cn(
"absolute top-0 right-0 bottom-0 w-full max-w-sm bg-white shadow-xl flex flex-col transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "translate-x-full"
)}
>
{/* Drawer Header */}
<div className="flex justify-between items-center px-6 py-4 border-b border-border">
<span className="text-xl font-bold font-heading text-primary">
Menu<span className="text-accent">.</span>
@@ -83,7 +80,6 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
</button>
</div>
{/* Drawer Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="w-full">
{/* 1. Klimatyzacja */}
@@ -133,7 +129,7 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
</details>
{/* 2. Pompy Ciepła */}
{/* <div className="border-b border-border py-3">
<div className="border-b border-border py-3">
<Link
href="/pompy-ciepla"
onClick={onClose}
@@ -141,7 +137,34 @@ export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
>
Pompy ciepła
</Link>
</div> */}
</div>
{/* Sklep */}
<details className="collapse collapse-arrow border-b border-border rounded-none">
<summary className="collapse-title text-sm font-medium text-primary hover:text-accent py-3 px-0 min-h-0">
Sklep
</summary>
<div className="collapse-content px-0 pb-2">
<ul className="flex flex-col gap-2.5 pl-4 pb-2">
{[
{ label: "Klimatyzatory ścienne", href: "/sklep?category=scienne" },
{ label: "Klimatyzatory multisplit", href: "/sklep?category=multisplit" },
{ label: "Pompy ciepła", href: "/sklep?category=pompy-ciepla" },
].map((item, idx) => (
<li key={idx}>
<Link
href={item.href}
onClick={onClose}
className="text-sm text-text-muted hover:text-accent transition-colors flex items-center justify-between py-1"
>
<span>{item.label}</span>
<ChevronRight className="size-3.5 opacity-60" />
</Link>
</li>
))}
</ul>
</div>
</details>
{/* Nowa sekcja: Miasta */}
<div className="border-b border-border py-3">
+1 -1
View File
@@ -12,7 +12,7 @@ export function AboutStory() {
<div className="relative">
<div className="relative aspect-[4/5] rounded-3xl overflow-hidden shadow-2xl">
<Image
src="https://images.unsplash.com/photo-1621905251189-08b45d6a269e?w=800&q=80"
src="/images/about/about-story.webp"
alt="Zespół firmy w trakcie pracy"
fill
className="object-cover"
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Wind, LayoutGrid, Sparkles, ArrowRight } from 'lucide-react';
import { Wind, LayoutGrid, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
+1 -1
View File
@@ -1,4 +1,4 @@
import { CheckCircle2, Zap, ShieldCheck, Banknote } from "lucide-react";
import { CheckCircle2, Zap, ShieldCheck } from "lucide-react";
export function AdvantagesBar() {
const advantages = [
+2 -2
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import Image from "next/image";
import { ArrowRight, Calendar, User } from "lucide-react";
import { ArrowRight, Calendar } from "lucide-react";
import { SectionHeading } from "../common/SectionHeading";
import { getLatestPosts } from "@/content/posts";
@@ -19,7 +19,7 @@ export function BlogPreview() {
const getCategoryLabel = (cat: string) => {
const labels: Record<string, string> = {
klimatyzacja: "Klimatyzacja",
// "pompy-ciepla": "Pompy ciepła",
"pompy-ciepla": "Pompy ciepła",
porady: "Porady ogólne",
};
return labels[cat] || cat;
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
const brands = [
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import Image from 'next/image';
import { SectionHeading } from '../common/SectionHeading';
import { companyDetails } from '@/content/company';
+3 -3
View File
@@ -24,7 +24,7 @@ export function ContactSection() {
</div>
<div>
<p className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-1">Zadzwoń do nas</p>
<a href={`tel:${companyDetails.phoneRaw}`} className="text-2xl font-bold text-primary hover:text-accent transition-colors font-heading">
<a href={`tel:${companyDetails.phoneRaw}`} className="text-base font-bold text-primary hover:text-accent transition-colors font-heading">
{companyDetails.phone}
</a>
</div>
@@ -37,7 +37,7 @@ export function ContactSection() {
</div>
<div>
<p className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-1">Napisz e-mail</p>
<a href={`mailto:${companyDetails.email}`} className="text-xl font-bold text-primary hover:text-accent transition-colors font-heading">
<a href={`mailto:${companyDetails.email}`} className="text-base font-bold text-primary hover:text-accent transition-colors font-heading">
{companyDetails.email}
</a>
</div>
@@ -50,7 +50,7 @@ export function ContactSection() {
</div>
<div>
<p className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-1">Nasze biuro</p>
<div className="text-lg font-medium text-primary">
<div className="text-sm font-medium text-primary">
{companyDetails.name}<br />
{companyDetails.address.street}<br />
{companyDetails.address.zipCode} {companyDetails.address.city}
+1 -1
View File
@@ -32,7 +32,7 @@ export function FeaturedPost({ post }: FeaturedPostProps) {
{/* Obraz */}
<div className="relative w-full md:w-3/5 aspect-video md:aspect-auto md:min-h-[400px]">
<Image
src={post.image || 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200&q=80'}
src={post.image || '/images/hero/hero-section-image.webp'}
alt={post.title}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
+1 -1
View File
@@ -68,7 +68,7 @@ export const FundingSection = () => {
<span className="font-semibold text-gray-900">45 000 zł</span>
</div>
<div className="flex justify-between pb-4 border-b border-gray-100 text-green-600 font-medium">
<span>Dotacja "Czyste Powietrze"</span>
<span>Dotacja &quot;Czyste Powietrze&quot;</span>
<span>- 20 500 zł</span>
</div>
<div className="flex justify-between pb-4 border-b border-gray-100 text-green-600 font-medium">
+9 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import Image from "next/image";
import { ArrowRight, Star } from "lucide-react";
import { buttonClasses } from "@/components/ui/Button";
export interface HeroSectionProps {
eyebrow?: string;
@@ -27,11 +28,11 @@ export function HeroSection({
ctaLink = "/kontakt",
secondaryCtaText = "Zobacz ofertę",
secondaryCtaLink = "/sklep",
backgroundImage = "/images/hero/hero-section-image.webp",
backgroundImage = "/images/hero/home_page_hero.webp",
features
}: HeroSectionProps = {}) {
return (
<section className="relative bg-primary min-h-[480px] md:min-h-[600px] flex items-center overflow-hidden">
<section className="relative bg-primary min-h-[480px] md:min-h-[600px] flex items-center overflow-hidden w-full">
{/* TODO ZDJĘCIE HERO: realne zdjęcie firmy/montażu, format poziomy min. 1920×1080, plik public/hero.jpg */}
<Image
src={backgroundImage}
@@ -80,11 +81,16 @@ export function HeroSection({
{/* CTA Buttons */}
<div className="mt-10 flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto">
{ctaText && ctaLink && (
<Link href={ctaLink} className="w-fit sm:w-auto group btn btn-primary text-white font-bold px-8">
<Link href={ctaLink} className={buttonClasses({ variant: 'primary', size: 'md', className: 'w-fit sm:w-auto group' })}>
<span>{ctaText}</span>
<ArrowRight className="size-5 group-hover:translate-x-1 transition-transform" />
</Link>
)}
{secondaryCtaText && secondaryCtaLink && (
<Link href={secondaryCtaLink} className={buttonClasses({ variant: 'outline', size: 'md', className: 'w-fit sm:w-auto' })}>
<span>{secondaryCtaText}</span>
</Link>
)}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import { ThermometerSun, Home, ChevronRight, Wrench, ShieldCheck, ArrowRight } from "lucide-react";
import { ThermometerSun, Home, ChevronRight, ArrowRight } from "lucide-react";
export function PillarsSection() {
return (
+3 -3
View File
@@ -18,7 +18,7 @@ const POSTS_PER_PAGE = 6;
const categories = [
{ id: 'wszystkie', label: 'Wszystkie' },
{ id: 'klimatyzacja', label: 'Klimatyzacja' },
// { id: 'pompy-ciepla', label: 'Pompy ciepła' },
{ id: 'pompy-ciepla', label: 'Pompy ciepła' },
{ id: 'porady', label: 'Porady' },
];
@@ -41,7 +41,7 @@ export function PostsGrid({ posts, featuredPost }: PostsGridProps) {
const categoryLabels: Record<string, string> = {
'klimatyzacja': 'Klimatyzacja',
// 'pompy-ciepla': 'Pompy Ciepła',
'pompy-ciepla': 'Pompy Ciepła',
'porady': 'Porady'
};
@@ -88,7 +88,7 @@ export function PostsGrid({ posts, featuredPost }: PostsGridProps) {
>
<Link href={`/blog/${post.slug}`} className="relative aspect-[16/10] overflow-hidden">
<Image
src={post.image || 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&q=80'}
src={post.image || '/images/hero/hero-section-image.webp'}
alt={post.title}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { pricingData, PricingItem } from '@/content/pricing';
import { Check, Info, ArrowRight, Download } from 'lucide-react';
import { Check, Download } from 'lucide-react';
export const PricingTable = () => {
const splitItems = pricingData.filter((item) => item.category === 'split');
+2 -2
View File
@@ -2,10 +2,10 @@
import React, { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { projectsData, Project } from '@/content/projects';
import { cn } from '@/lib/utils';
import { MapPin, ArrowRight } from 'lucide-react';
import { MapPin } from 'lucide-react';
type Category = 'wszystkie' | Project['category'];
+1 -1
View File
@@ -63,7 +63,7 @@ export function Testimonials() {
<Star key={j} className="size-4 fill-yellow-400 text-yellow-400" />
))}
</div>
<p className="text-sm text-text-muted leading-relaxed flex-1">"{t.text}"</p>
<p className="text-sm text-text-muted leading-relaxed flex-1">&quot;{t.text}&quot;</p>
<div className="pt-3 border-t border-border">
<p className="text-sm font-bold text-primary">{t.name}</p>
<p className="text-xs text-text-muted">{t.city}</p>
-5
View File
@@ -33,7 +33,6 @@ export function CartItems() {
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"
@@ -42,7 +41,6 @@ export function CartItems() {
<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}
@@ -52,7 +50,6 @@ export function CartItems() {
/>
</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">
@@ -65,9 +62,7 @@ export function CartItems() {
</h3>
</div>
{/* Opcja Montażu i Cena jednostkowa */}
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-4 mt-4">
{/* Ilość i Cena */}
<div className="flex items-center gap-6">
<div className="flex items-center border border-border rounded-lg bg-slate-50">
<button
-5
View File
@@ -20,7 +20,6 @@ export function CartSummary() {
<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">
@@ -39,7 +38,6 @@ export function CartSummary() {
</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>
@@ -51,13 +49,11 @@ export function CartSummary() {
</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 */}
<Link href="/zamowienie" className="block w-full btn btn-primary font-bold group cursor-pointer text-white">
Przejdź do zamówienia
@@ -65,7 +61,6 @@ export function CartSummary() {
</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" />
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { Check, Loader2, CheckCircle2 } from 'lucide-react';
import { Loader2, CheckCircle2 } from 'lucide-react';
import { submitMultisplitForm } from '@/app/zapytanie-multisplit/actions';
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
+1 -9
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import Image from 'next/image';
import { ShoppingCart, GitCompare, Phone, Check } from 'lucide-react';
import { ShoppingCart, GitCompare, Phone } from 'lucide-react';
import { Product } from '@/content/products';
import { useCartStore } from '@/lib/cart-store';
import { useCompareStore } from '@/lib/compare-store';
@@ -66,7 +66,6 @@ export function ProductCard({ product }: ProductCardProps) {
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">
{/* Zdjęcie */}
<Link href={`/sklep/${product.slug}`} className="block relative aspect-[4/3] bg-slate-50 overflow-hidden">
<Image
src={product.image}
@@ -77,7 +76,6 @@ export function ProductCard({ product }: ProductCardProps) {
unoptimized={product.image.startsWith('http')}
/>
{/* Brand Logo or Name */}
<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">
@@ -97,7 +95,6 @@ export function ProductCard({ product }: ProductCardProps) {
)}
</div>
{/* Badge multisplit */}
{product.category === 'multisplit' && (
<span className="absolute top-2 right-2 px-2 py-1 bg-accent/10 rounded-md text-xs font-semibold text-accent border border-accent/20">
Multisplit
@@ -119,7 +116,6 @@ export function ProductCard({ product }: ProductCardProps) {
</button>
</Link>
{/* Treść */}
<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">
@@ -127,7 +123,6 @@ export function ProductCard({ product }: ProductCardProps) {
</h3>
</Link>
{/* Kluczowe specs */}
<div className="flex flex-wrap gap-1.5">
{product.specs['Moc chłodnicza'] && (
<span className="px-2 py-0.5 bg-slate-50 rounded text-xs text-text-muted border border-border">
@@ -149,7 +144,6 @@ export function ProductCard({ product }: ProductCardProps) {
{/* Cena + CTA */}
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
{product.category === 'multisplit' ? (
// Multisplit — zawsze formularz zapytania
<>
<div className="flex items-center gap-1 text-accent">
<Phone className="size-4" />
@@ -165,7 +159,6 @@ export function ProductCard({ product }: ProductCardProps) {
</Link>
</>
) : product.price !== null ? (
// Split z ceną — koszyk
<>
<div>
<p className="text-lg font-bold text-primary font-heading leading-none">
@@ -191,7 +184,6 @@ export function ProductCard({ product }: ProductCardProps) {
)}
</>
) : (
// Split bez ceny — zapytaj
<>
<div className="flex items-center gap-1 text-accent">
<Phone className="size-3.5" />
+2 -5
View File
@@ -10,16 +10,14 @@ interface ProductGalleryProps {
}
export function ProductGallery({ image, name, images }: ProductGalleryProps) {
const [mainImage, setMainImage] = useState(image || 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=800&q=80');
const [mainImage, setMainImage] = useState(image || '/images/hero/shop-section-image.webp');
// 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'];
: [image, '/images/hero/shop-section-image.webp'];
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}
@@ -31,7 +29,6 @@ export function ProductGallery({ image, name, images }: ProductGalleryProps) {
/>
</div>
{/* Miniatury */}
{gallery.length > 1 && (
<div className="flex gap-4 overflow-x-auto pb-2">
{gallery.map((img, idx) => (
+12 -24
View File
@@ -4,13 +4,16 @@ import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import {
ArrowLeft, Phone, ShoppingCart, GitCompare, Check,
Thermometer, Volume2, Zap, Wind, Star, Info, Eye, X
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',
@@ -34,7 +37,6 @@ interface Props {
companyPhoneRaw: string;
}
// Szacowany SEER na podstawie klasy energetycznej
function estimateSEER(klasa: string): number {
if (!klasa) return 5.0;
const k = klasa.toUpperCase();
@@ -45,7 +47,6 @@ function estimateSEER(klasa: string): number {
return 5.0;
}
// Parsuj moc — bierz wartość nominalną (środkową lub pierwszą)
function parseMoc(val: string): number {
if (!val) return 0;
const nums = val.replace(',', '.').match(/[\d.]+/g);
@@ -81,6 +82,11 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
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({
@@ -99,14 +105,12 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
else addCompare({ slug: currentProduct.slug, name: currentProduct.name, brand: currentProduct.brand, price: currentProduct.price, image: currentProduct.image, specs: currentProduct.specs });
};
// Kalkulator
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;
// Kluczowe parametry do ikon
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' },
@@ -114,13 +118,12 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
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: any; label: string; value: string; color: string }[];
].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">
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-xs text-text-muted mb-6">
<Link href="/" className="hover:text-accent transition-colors">Strona główna</Link>
<span>/</span>
@@ -133,7 +136,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 xl:gap-12">
{/* LEWA: Zdjęcie */}
<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
@@ -144,7 +146,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
priority
unoptimized={currentProduct.image.startsWith('http')}
/>
{/* Badges */}
{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]}
@@ -158,9 +159,7 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
</div>
{/* PRAWA: Info */}
<div className="flex flex-col gap-3">
{/* Nagłówek */}
<div>
<div className="mb-2 flex items-center gap-3 flex-wrap">
{brandLogos[currentProduct.brand] ? (
@@ -195,7 +194,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
<p className="text-xs text-text-muted mt-1.5 mb-4">SKU: {currentProduct.sku}</p>
</div>
{/* Warianty mocy */}
{variants && variants.moce.length > 0 && (
<div className="mb-3">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
@@ -235,7 +233,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Warianty wykończenia */}
{variants && variants.wykończenia.length > 0 && (
<div className="mb-3">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
@@ -265,7 +262,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Ikony parametrów */}
{params.length > 0 && (
<div className="flex flex-wrap gap-2">
{params.map((p, i) => {
@@ -283,7 +279,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Cena i CTA */}
<div className="bg-white rounded-2xl border border-border p-4">
{currentProduct.price !== null ? (
<>
@@ -347,7 +342,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
)}
</div>
{/* Profesjonalna porada */}
<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"
@@ -358,7 +352,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</span>
</a>
{/* Kalkulator kosztów energii */}
{mocChlodnicza > 0 && (
<div className="bg-white rounded-2xl border border-border p-4">
<div className="flex items-center gap-2 mb-4">
@@ -408,7 +401,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
</div>
{/* TABS */}
<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 => (
@@ -426,7 +418,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
))}
</div>
{/* Tab: Opis */}
{activeTab === 'Opis' && (
<div className="max-w-3xl">
{currentProduct.shortDescription ? (
@@ -437,7 +428,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Tab: Specyfikacja */}
{activeTab === 'Specyfikacja' && (
<div className="max-w-2xl">
{Object.keys(currentProduct.specs).length > 0 ? (
@@ -459,7 +449,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Tab: Montaż */}
{activeTab === 'Montaż' && (
<div className="max-w-2xl space-y-4">
<div className="bg-white rounded-xl border border-border p-5">
@@ -483,7 +472,6 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
{/* Tab: Opinie */}
{activeTab === 'Opinie' && (
<div className="max-w-3xl space-y-8">
<div className="bg-slate-50 p-6 rounded-2xl border border-border">
@@ -512,7 +500,7 @@ export function ProductPageClient({ product, seriesProducts, companyPhone, compa
</div>
)}
<RecentlyViewedProducts currentSlug={currentProduct.slug} />
</div>
</div>
+90 -32
View File
@@ -81,8 +81,8 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
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 [category, setCategory] = useState<'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla'>(
urlCategory as 'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla'
);
const [selectedBrands, setSelectedBrands] = useState<string[]>(urlBrand ? [urlBrand] : []);
const [searchQuery, setSearchQuery] = useState(urlSearch);
@@ -90,20 +90,24 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
const [priceMax, setPriceMax] = useState('');
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const [selectedUnitCounts, setSelectedUnitCounts] = useState<number[]>([]);
const [halasMin, setHalasMin] = useState(15);
const [halasMax, setHalasMax] = useState(50);
const [page, setPage] = useState(1);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
setSearchQuery(urlSearch);
setSelectedBrands(urlBrand ? [urlBrand] : []);
if (urlCategory !== 'all') {
setCategory(urlCategory as 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna');
} else {
setCategory('all');
}
setPage(1);
const timer = setTimeout(() => {
setSearchQuery(urlSearch);
setSelectedBrands(urlBrand ? [urlBrand] : []);
if (urlCategory !== 'all') {
setCategory(urlCategory as 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla');
} else {
setCategory('all');
}
setPage(1);
}, 0);
return () => clearTimeout(timer);
}, [urlSearch, urlBrand, urlCategory]);
const toggleBrand = (b: string) => {
@@ -121,6 +125,11 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
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
@@ -131,13 +140,10 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
}
}
// Kategoria
if (category !== 'all' && p.category !== category) return false;
// Marka
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
// Cena
const pMin = priceMin ? parseInt(priceMin) : null;
const pMax = priceMax ? parseInt(priceMax) : null;
if (p.price !== null) {
@@ -169,9 +175,17 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
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, halasMin, halasMax, selectedColors]);
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, halasMin, halasMax, selectedColors, selectedUnitCounts, searchQuery]);
const totalPages = Math.ceil(filtered.length / PER_PAGE);
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
@@ -202,6 +216,22 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
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([]);
@@ -210,22 +240,23 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
setPriceMax('');
setSelectedRooms([]);
setSelectedColors([]);
setSelectedUnitCounts([]);
setHalasMin(15);
setHalasMax(50);
setPage(1);
};
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || halasMin > 15 || halasMax < 50;
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || selectedUnitCounts.length > 0 || halasMin > 15 || halasMax < 50;
const Sidebar = () => (
const sidebarContent = (
<aside className="w-full">
{/* Klimatyzatory - typ */}
<Accordion title="Klimatyzatory">
<ul className="flex flex-col gap-2">
{[
{ val: 'all', label: 'Wszystkie' },
{ val: 'scienne', label: 'Ścienne split' },
{ val: 'multisplit', label: 'Multisplit' },
{ val: 'pompy-ciepla', label: 'Pompy ciepła' },
].map(opt => (
<li key={opt.val}>
<button
@@ -247,7 +278,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
</ul>
</Accordion>
{/* Marka */}
<Accordion title="Marka">
<ul className="flex flex-col gap-2">
{brands.map(b => (
@@ -282,7 +312,46 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
</ul>
</Accordion>
{/* Cena */}
{/* 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
@@ -342,7 +411,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
{/* Głośność */}
<Accordion title={`Głośność wewnętrzna (dB) — ${halasMin}–${halasMax} dB`}>
<div className="flex flex-col gap-4 px-1">
{/* od */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">od</span>
@@ -356,7 +424,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
className="range range-xs range-accent w-full"
/>
</div>
{/* do */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-text-muted">do</span>
@@ -373,7 +440,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
</div>
</Accordion>
{/* Kolor/Wykończenie */}
<Accordion title="Kolor dominujący">
<ul className="flex flex-col gap-2">
{COLOR_GROUPS.map(c => (
@@ -405,7 +471,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
</ul>
</Accordion>
{/* Potrzebujesz pomocy */}
<div className="rounded-xl bg-slate-50 border border-border p-4 text-center mt-2">
<p className="text-sm font-bold text-primary font-heading mb-3">Potrzebujesz pomocy?</p>
<a
@@ -429,7 +494,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
<section className="py-8 md:py-12 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Mobile: przycisk filtrów */}
<div className="lg:hidden mb-4 flex items-center justify-between">
<button
onClick={() => setMobileSidebarOpen(true)}
@@ -441,7 +505,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
<span className="text-sm text-text-muted">{filtered.length} produktów</span>
</div>
{/* Mobile sidebar overlay */}
{mobileSidebarOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileSidebarOpen(false)} />
@@ -452,13 +515,12 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
<X className="size-5 text-text-muted" />
</button>
</div>
<Sidebar />
{sidebarContent}
</div>
</div>
)}
<div className="flex gap-8">
{/* Desktop sidebar */}
<div className="hidden lg:block w-64 shrink-0">
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-2">
{hasFilters && (
@@ -469,13 +531,11 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
<X className="size-3" /> Wyczyść filtry
</button>
)}
<Sidebar />
{sidebarContent}
</div>
</div>
{/* Produkty */}
<div className="flex-1 min-w-0">
{/* Nagłówek z licznikiem */}
<div className="flex items-center justify-between mb-6">
<span className="text-sm text-text-muted hidden lg:block">{filtered.length} produktów</span>
{hasFilters && (
@@ -485,7 +545,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
)}
</div>
{/* Grid */}
{paginated.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
{paginated.map(product => (
@@ -501,7 +560,6 @@ export function ProductsGrid({ products, brands }: ProductsGridProps) {
</div>
)}
{/* Paginacja */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-12">
<button
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
/>
);
}
+5 -15
View File
@@ -15,20 +15,10 @@ export const companyDetails = {
},
hours: "Pon - Pt: 8:00 - 18:00, Sob: 9:00 - 14:00",
socials: {
facebook: "https://facebook.com",
instagram: "https://instagram.com",
google: "https://google.com/maps",
facebook: "",
instagram: "",
google: "",
},
stats: [
{ label: "Lat doświadczenia", value: "15+", number: 15 },
{ label: "Ukończonych realizacji", value: "3500+", number: 3500 },
{ label: "Zadowolonych klientów", value: "99%", number: 99 },
{ label: "Obsługiwanych miast", value: "12+", number: 12 },
],
certificates: [
{ name: "Certyfikat F-Gaz dla przedsiębiorcy", image: "/placeholder-fgaz.png" },
{ name: "Autoryzowany Instalator Daikin", image: "/placeholder-daikin.png" },
{ name: "Autoryzowany Instalator Mitsubishi Electric", image: "/placeholder-mitsubishi.png" },
{ name: "Certyfikat Rzetelna Firma", image: "/placeholder-rzetelna.png" },
],
stats: [] as Array<{ label: string; value: string; number: number }>,
certificates: [] as Array<{ name: string; image: string }>,
};
-1
View File
@@ -6,7 +6,6 @@ export interface FaqItem {
}
export const faqData: FaqItem[] = [
// Klimatyzacja
{
id: 'klimatyzacja-moc',
question: 'Jak dobrać odpowiednią moc klimatyzatora?',
+52 -9
View File
@@ -13,6 +13,51 @@ export interface Post {
}
export const posts: Post[] = [
{
slug: 'dofinansowanie-czyste-powietrze-2026',
title: 'Czyste Powietrze 2026 — ile możesz dostać na pompę ciepła?',
category: 'pompy-ciepla',
excerpt: 'Program Czyste Powietrze w 2026 roku oferuje rekordowe dopłaty. Dowiedz się, kto może skorzystać, jakie są progi dochodowe i jak złożyć wniosek.',
date: '2026-02-02T10:00:00Z',
author: 'Tomasz Kowalski',
image: '',
featured: true,
content: `
<h2>Rekordowe dopłaty w programie Czyste Powietrze 2026</h2>
<p>Program Czyste Powietrze na rok 2026 przeszedł kilka ważnych modyfikacji, które ułatwiają inwestycję w odnawialne źródła energii. Jeśli planujesz wymianę "kopciucha" na nowoczesną pompę ciepła, to idealny moment na działanie.</p>
<h3>Progi dofinansowania</h3>
<p>Kwota, jaką możesz otrzymać, zależy od Twoich dochodów:</p>
<ul>
<li><strong>Poziom podstawowy:</strong> dla osób z rocznym dochodem do 135 000 zł.</li>
<li><strong>Poziom podwyższony:</strong> wyższa dotacja dla osób z niższym dochodem na członka rodziny.</li>
<li><strong>Poziom najwyższy:</strong> nawet do 100% kosztów (do 135 000 zł) dla najuboższych.</li>
</ul>
<h3>Jak uniknąć błędów?</h3>
<p>Najczęstszym błędem jest zakup urządzenia, które nie figuruje na liście ZUM (Zielonych Urządzeń i Materiałów). Przed opłaceniem faktury zawsze sprawdzaj certyfikaty. Nasi specjaliści pomagają nie tylko w doborze i montażu urządzeń, ale również w kompleksowym przygotowaniu wniosków o dotację. Chcesz wiedzieć więcej? Odezwij się do nas!</p>
`
},
{
slug: 'pompy-ciepla-dolny-slask-dofinansowanie',
title: 'Pompy ciepła na Dolnym Śląsku — dofinansowanie i realizacje',
category: 'pompy-ciepla',
excerpt: 'Sprawdź, jak działają pompy ciepła na terenie Dolnego Śląska, jakie dofinansowania lokalne możesz uzyskać i jak dbamy o profesjonalny montaż.',
date: '2026-04-25T10:00:00Z',
author: 'Tomasz Kowalski',
image: '',
content: `
<h2>Dolny Śląsk to idealny region dla pomp ciepła</h2>
<p>Dzięki stosunkowo łagodnym zimom na nizinach Dolnego Śląska (Wrocław, Legnica, Lubin), powietrzne pompy ciepła osiągają u nas fenomenalne wyniki wydajnościowe (SCOP) przez cały sezon grzewczy. Nawet w rejonach przedgórskich (Wałbrzych, Jelenia Góra) nowoczesne instalacje świetnie radzą sobie bez wspomagania grzałkami.</p>
<h3>Wsparcie finansowe dla mieszkańców</h3>
<p>Poza ogólnopolskim programem "Czyste Powietrze", wiele dolnośląskich gmin i miast (m.in. Wrocław z programem KAWKA Plus) oferuje dodatkowe dotacje na likwidację pieców węglowych. Połączenie lokalnych funduszy z rządowymi potrafi sfinansować nawet do 80-90% całej inwestycji.</p>
<h3>Zaufaj lokalnym ekspertom</h3>
<ul>
<li>Znamy specyfikę dolnośląskiego budownictwa.</li>
<li>Zapewniamy szybki serwis gwarancyjny (nasza baza to okolice Wrocławia/Legnicy).</li>
<li>Pomagamy z biurokracją we wrocławskim i lokalnych urzędach.</li>
</ul>
<p>Chcesz dołączyć do grona ekologicznych domów w naszym województwie? Zostaw kontakt, a przeanalizujemy Twoje możliwości dotacyjne!</p>
`
},
{
slug: 'jaki-klimatyzator-wybrac-do-mieszkania',
title: 'Jaki klimatyzator wybrać do mieszkania? Kompletny poradnik 2026',
@@ -1658,20 +1703,18 @@ export const posts: Post[] = [
}
];
const activePosts = posts.filter(p => p.category !== 'pompy-ciepla');
export const getPosts = () => activePosts;
export const getPostBySlug = (slug: string) => activePosts.find((p) => p.slug === slug);
export const getFeaturedPosts = (limit = 3) => activePosts.filter((p) => p.featured).slice(0, limit);
export const getPostsByCategory = (category: string) => activePosts.filter((p) => p.category === category);
export const getRecentPosts = (limit = 6) => [...activePosts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, limit);
export const getPosts = () => posts;
export const getPostBySlug = (slug: string) => posts.find((p) => p.slug === slug);
export const getFeaturedPosts = (limit = 3) => posts.filter((p) => p.featured).slice(0, limit);
export const getPostsByCategory = (category: string) => posts.filter((p) => p.category === category);
export const getRecentPosts = (limit = 6) => [...posts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, limit);
// Dodane z powrotem funkcje wywoływane w komponentach, które nie zostały uwzględnione w prompcie:
export const getFeaturedPost = () => {
const dayIndex = Math.floor(Date.now() / (1000 * 60 * 60 * 24));
return activePosts[dayIndex % activePosts.length] || activePosts[0];
return posts[dayIndex % posts.length] || posts[0];
};
export const getLatestPosts = (limit = 3) => getRecentPosts(limit);
export const getRelatedPosts = (slug: string, category: string, limit = 2) => {
return activePosts.filter(p => p.slug !== slug && p.category === category).slice(0, limit);
return posts.filter(p => p.slug !== slug && p.category === category).slice(0, limit);
};
+2 -2
View File
@@ -2,8 +2,8 @@ export interface PricingItem {
id: string;
name: string;
priceNet: number;
priceGrossPrivate: number; // 8% VAT
priceGrossCompany: number; // 23% VAT
priceGrossPrivate: number;
priceGrossCompany: number;
category: 'split' | 'multisplit' | 'additional';
}
-1
View File
@@ -1,6 +1,5 @@
import { ProcessStep } from "@/types/types";
// MOCK
export const mainProcessSteps: ProcessStep[] = [
{
number: 1,
+1 -4
View File
@@ -21,7 +21,6 @@ export function getProductVariants(slug: string): ProductVariants | null {
const currentProduct = products.find(p => p.slug === slug);
if (!currentProduct) return null;
// Extract series from SKU (e.g., GREE-PULAR-PU09S -> GREE-PULAR)
const skuParts = currentProduct.sku.split('-');
if (skuParts.length < 2) return null;
const series = skuParts.slice(0, -1).join('-');
@@ -31,7 +30,6 @@ export function getProductVariants(slug: string): ProductVariants | null {
const currentMoc = currentProduct.specs['Moc chłodnicza'] || '';
const currentWykończenie = currentProduct.specs['Wykończenie'] || '';
// Moce: same series, same wykończenie
const moceProducts = seriesProducts.filter(p => (p.specs['Wykończenie'] || '') === currentWykończenie);
const moce = moceProducts
.filter(p => p.specs['Moc chłodnicza'] && p.price)
@@ -46,7 +44,6 @@ export function getProductVariants(slug: string): ProductVariants | null {
})
.sort((a, b) => (a.moc_float || 0) - (b.moc_float || 0));
// Wykończenia: same series, same moc
const wykończeniaProducts = seriesProducts.filter(p => (p.specs['Moc chłodnicza'] || '') === currentMoc);
let wykończenia = wykończeniaProducts
.filter(p => p.specs['Wykończenie'] && p.price)
@@ -56,7 +53,7 @@ export function getProductVariants(slug: string): ProductVariants | null {
cena: p.price as number,
}));
// If there's only 1 finish available in the entire series, don't show the variants selector
const allFinishesInSeries = new Set(seriesProducts.map(p => p.specs['Wykończenie'] || '').filter(Boolean));
if (allFinishesInSeries.size <= 1) {
wykończenia = [];
+12 -1
View File
@@ -3,7 +3,7 @@ export interface Product {
sku: string;
name: string;
brand: string;
category: 'scienne' | 'multisplit' | 'kanalowe' | 'konsolowe' | 'podlogowe' | 'jednostka-scienna';
category: 'scienne' | 'multisplit' | 'kanalowe' | 'konsolowe' | 'podlogowe' | 'jednostka-scienna' | 'pompy-ciepla';
price: number | null;
image: string;
shortDescription: string;
@@ -8889,6 +8889,17 @@ export const products: Product[] = [
shortDescription: 'Mitsubishi Electric MXZ – R32, inwerterowy agregat Multi Split, Wi-Fi MELCloud, czynnik R32, WiFi, grzanie do -15°C, 6 jednostek wewnętrzne. Do domów i biur.',
specs: {"Moc chłodnicza": "12.2", "Moc grzewcza (kW)": "14.0", "Czynnik chłodniczy": "R32", "Grzanie do": "'-15", "Moc chłodnicza min (kW)": "3.5", "Moc chłodnicza max (kW)": "13.5", "Moc grzewcza min (kW)": "3.5", "Liczba jednostek wewn.": "6.0"},
},
{
slug: 'panasonic-aquarea-high-performance',
sku: 'PANASONIC-AQUAREA-HP',
name: 'Pompa ciepła Panasonic Aquarea High Performance 9kW',
brand: 'Panasonic',
category: 'pompy-ciepla',
price: 24500,
image: '/images/products/placeholder.jpg',
shortDescription: 'Nowoczesna pompa ciepła typu split, idealna do nowych domów energooszczędnych. Wysoka wydajność, niskie koszty eksploatacji i sterowanie przez WiFi.',
specs: {"Moc grzewcza (kW)": "9", "Zasilanie": "1-fazowe", "Czynnik chłodniczy": "R32", "Klasa energetyczna": "A+++"},
}
];
export const getProducts = () => products;
+2 -2
View File
@@ -6,13 +6,13 @@ export const services: ServiceItem[] = [
title: "Montaż klimatyzacji",
description: "Profesjonalny dobór, montaż klimatyzacji split i multisplit dla domów, mieszkań i firm.",
icon: "Wind",
href: "/klimatyzacja",
href: "/montaz-klimatyzacji",
},
{
title: "Serwis i przeglądy",
description: "Regularne przeglądy, czyszczenie, odgrzybianie oraz serwis naprawczy urządzeń klimatyzacyjnych.",
icon: "Wrench",
href: "/klimatyzacja#serwis",
href: "/serwis-klimatyzacji",
},
{
title: "Pompy ciepła",
+1 -1
View File
@@ -1,6 +1,6 @@
import { Testimonial } from "@/types/types";
// MOCK
export const testimonials: Testimonial[] = [
{
name: "Mariusz Wójcik",
-43
View File
@@ -1,43 +0,0 @@
import os
import re
from collections import Counter
dirs_to_search = ['app', 'components']
button_classes = []
class_regex = re.compile(r'className=["\']([^"\']+)["\']')
dynamic_class_regex = re.compile(r'className=\{`([^`]+)`\}')
for d in dirs_to_search:
for root, _, files in os.walk(d):
for file in files:
if file.endswith(('.tsx', '.jsx', '.ts', '.js')):
filepath = os.path.join(root, file)
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Find all classNames
matches = class_regex.findall(content) + dynamic_class_regex.findall(content)
for m in matches:
# Check if it looks like a button
if 'btn' in m or ('px-' in m and 'py-' in m and 'rounded' in m):
# Normalize spacing
normalized = ' '.join(m.split())
button_classes.append((normalized, filepath))
# Count frequencies
counts = Counter([c[0] for c in button_classes])
most_common = counts.most_common(10)
print("Top variants:")
for variant, count in most_common:
print(f"\nVariant ({count} times): {variant}")
# Find up to 3 files where it appears
files_seen = set()
for c, fpath in button_classes:
if c == variant and fpath not in files_seen:
files_seen.add(fpath)
print(f" - {fpath}")
if len(files_seen) >= 3:
break
+3 -6
View File
@@ -31,7 +31,7 @@ export const useConsentStore = create<ConsentState>((set) => ({
if (typeof window !== 'undefined') {
let stored = localStorage.getItem(CONSENT_KEY);
// Migration logic
if (!stored) {
const oldStored = localStorage.getItem(OLD_CONSENT_KEY);
if (oldStored) {
@@ -46,8 +46,7 @@ export const useConsentStore = create<ConsentState>((set) => ({
stored = JSON.stringify(newData);
localStorage.setItem(CONSENT_KEY, stored);
localStorage.removeItem(OLD_CONSENT_KEY);
} catch (e) {
// Ignore if parse fails
} catch {
}
}
}
@@ -63,12 +62,10 @@ export const useConsentStore = create<ConsentState>((set) => ({
marketing: !!parsed.marketing,
advertising: !!parsed.advertising,
});
} catch (e) {
// If parsing fails, show banner
} catch {
set({ isInitialized: true, isOpen: true });
}
} else {
// No consent found, show banner
set({ isInitialized: true, isOpen: true });
}
}
+12 -3
View File
@@ -1,15 +1,24 @@
'use client';
declare global {
interface Window {
grecaptcha: {
ready: (cb: () => void) => void;
execute: (siteKey: string, options: { action: string }) => Promise<string>;
};
}
}
export function getRecaptchaToken(action: string = 'submit'): Promise<string | null> {
return new Promise((resolve) => {
const siteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
if (typeof window === 'undefined' || !siteKey || !(window as any).grecaptcha) {
if (typeof window === 'undefined' || !siteKey || !window.grecaptcha) {
resolve(null);
return;
}
try {
(window as any).grecaptcha.ready(() => {
(window as any).grecaptcha
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action })
.then((token: string) => resolve(token))
.catch(() => resolve(null));
+17
View File
@@ -0,0 +1,17 @@
import { Resend } from 'resend';
export function getMailClient() {
const apiKey = process.env.RESEND_API_KEY;
const fromEmail = process.env.RESEND_FROM_EMAIL;
const toEmail = process.env.RESEND_TO_EMAIL;
if (!apiKey || !fromEmail || !toEmail) {
return null;
}
return {
resend: new Resend(apiKey),
fromEmail,
toEmail,
};
}
-9
View File
@@ -1,9 +0,0 @@
export async function verifyRecaptcha(token: string): Promise<{ success: boolean; score?: number }> {
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
});
const data = await res.json();
return { success: data.success && (data.score === undefined || data.score >= 0.5), score: data.score };
}
+29
View File
@@ -0,0 +1,29 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Product } from '@/content/products';
interface RecentlyViewedState {
items: Product[];
addItem: (item: Product) => void;
clear: () => void;
}
export const useRecentlyViewedStore = create<RecentlyViewedState>()(
persist(
(set) => ({
items: [],
addItem: (newItem) => {
set((state) => {
// Usuń, jeśli już jest na liście (aby przenieść na początek)
const filtered = state.items.filter((i) => i.slug !== newItem.slug);
// Dodaj na początek, zachowaj tylko ostatnie 4
return { items: [newItem, ...filtered].slice(0, 4) };
});
},
clear: () => set({ items: [] }),
}),
{ name: 'thermcool-recently-viewed' }
)
);
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod';
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const rateLimitMap = new Map<string, { count: number; expiresAt: number }>();
const MAX_REQUESTS = 5;
const WINDOW_MS = 10 * 60 * 1000;
export function rateLimit(ip: string | undefined | null): boolean {
if (!ip) return true;
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || entry.expiresAt < now) {
rateLimitMap.set(ip, { count: 1, expiresAt: now + WINDOW_MS });
return true;
}
if (entry.count >= MAX_REQUESTS) {
return false;
}
entry.count++;
return true;
}
export async function verifyRecaptcha(token: string): Promise<boolean> {
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
if (!secretKey) return false;
try {
const res = await fetch(`https://www.google.com/recaptcha/api/siteverify`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `secret=${secretKey}&response=${token}`,
});
const data = await res.json();
return data.success;
} catch (err) {
return false;
}
}
export const baseContactSchema = z.object({
name: z.string().min(2, "Imię musi mieć co najmniej 2 znaki").max(100, "Imię jest zbyt długie"),
phone: z.string().regex(/^[+\d\s-]{9,20}$/, "Niepoprawny format telefonu"),
email: z.string().email("Niepoprawny adres e-mail").max(150, "E-mail jest zbyt długi"),
consent: z.string().refine(val => val === "on" || val === "true", "Wymagana zgoda"),
});
+20
View File
@@ -5,6 +5,21 @@ const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.googletagmanager.com https://www.clarity.ms;
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https://images.unsplash.com https://placehold.co https://c.bing.com https://www.google-analytics.com;
font-src 'self';
connect-src 'self' ws: wss: https://www.google-analytics.com https://*.clarity.ms;
frame-src 'self' https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
${process.env.NODE_ENV === 'production' ? 'upgrade-insecure-requests;' : ''}
`.replace(/\s{2,}/g, ' ').trim();
const nextConfig: NextConfig = {
experimental: {
optimizeCss: true,
@@ -25,6 +40,11 @@ const nextConfig: NextConfig = {
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Content-Security-Policy', value: cspHeader },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
...(process.env.NODE_ENV === 'production'
? [{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' }]
: []),
],
},
];
-6426
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
import json
transcript_path = '/Users/michalcukrowski/.gemini/antigravity-ide/brain/1d7cf0f7-087b-4c82-94a5-07c51084a559/.system_generated/logs/transcript_full.jsonl'
with open(transcript_path, 'r', encoding='utf-8') as f:
for line in f:
data = json.loads(line)
if data.get('type') == 'USER_INPUT':
content = data.get('content', '')
if 'export interface Product {' in content and len(content) > 100000:
print(f"Found large user input of length {len(content)}")
# Extract the file content. It probably starts with "export interface Product {"
# or there is some text before it.
idx = content.find('export interface Product {')
if idx != -1:
file_content = content[idx:]
with open('content/products.ts', 'w', encoding='utf-8') as out:
out.write(file_content)
print("Wrote to content/products.ts successfully!")
break
-14
View File
@@ -1,14 +0,0 @@
import json
transcript_path = '/Users/michalcukrowski/.gemini/antigravity-ide/brain/1d7cf0f7-087b-4c82-94a5-07c51084a559/.system_generated/logs/transcript_full.jsonl'
with open(transcript_path, 'r', encoding='utf-8') as f:
for line in f:
data = json.loads(line)
if data.get('type') == 'USER_INPUT':
content = data.get('content', '')
print(f"USER_INPUT len: {len(content)}")
if len(content) > 50000:
with open('large_input.txt', 'w', encoding='utf-8') as out:
out.write(content)
print("Saved large input to large_input.txt")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Some files were not shown because too many files have changed in this diff Show More