Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11808b471 | ||
|
|
c9e4283789 | ||
|
|
092cff1899 | ||
|
|
6808dee9be | ||
|
|
21baad79f7 | ||
|
|
47a9ac07fb | ||
|
|
f07f2c2407 | ||
|
|
88cb4c516d | ||
|
|
02cb49e180 | ||
|
|
4969318096 |
@@ -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=
|
||||
@@ -0,0 +1,2 @@
|
||||
*.js
|
||||
*.cjs
|
||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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 "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.
|
||||
</p>
|
||||
|
||||
<h2 id="platnosci">3. Metody płatności</h2>
|
||||
|
||||
@@ -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. "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.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3">Google Analytics 4 (GA4) i Google Tag Manager (GTM)</h3>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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,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];
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { CheckCircle2, Zap, ShieldCheck, Banknote } from "lucide-react";
|
||||
import { CheckCircle2, Zap, ShieldCheck } from "lucide-react";
|
||||
|
||||
export function AdvantagesBar() {
|
||||
const advantages = [
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
const brands = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { SectionHeading } from '../common/SectionHeading';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "Czyste Powietrze"</span>
|
||||
<span>- 20 500 zł</span>
|
||||
</div>
|
||||
<div className="flex justify-between pb-4 border-b border-gray-100 text-green-600 font-medium">
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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,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,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'];
|
||||
|
||||
|
||||
@@ -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">"{t.text}"</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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") }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 }>,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface FaqItem {
|
||||
}
|
||||
|
||||
export const faqData: FaqItem[] = [
|
||||
// Klimatyzacja
|
||||
{
|
||||
id: 'klimatyzacja-moc',
|
||||
question: 'Jak dobrać odpowiednią moc klimatyzatora?',
|
||||
|
||||
@@ -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,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,6 +1,5 @@
|
||||
import { ProcessStep } from "@/types/types";
|
||||
|
||||
// MOCK
|
||||
export const mainProcessSteps: ProcessStep[] = [
|
||||
{
|
||||
number: 1,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { Testimonial } from "@/types/types";
|
||||
|
||||
// MOCK
|
||||
|
||||
export const testimonials: Testimonial[] = [
|
||||
{
|
||||
name: "Mariusz Wójcik",
|
||||
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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"),
|
||||
});
|
||||
@@ -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' }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
@@ -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 +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 |
|
After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB |