Zastosowanie wszystkich poprawek UI i logiki kalkulatora
This commit is contained in:
@@ -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
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# temp files
|
||||
skipped.json
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# AGENTS.md — Reguły projektu
|
||||
|
||||
> Ten plik definiuje stałe zasady dla każdego agenta pracującego nad projektem.
|
||||
> Czytaj go na początku każdej sesji i stosuj się do niego bez wyjątku.
|
||||
> Szczegółowe specyfikacje znajdują się w `docs/specs/` i `docs/pages/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Czym jest ten projekt
|
||||
|
||||
Strona firmy z branży HVAC i usług domowych. Firma oferuje:
|
||||
- **Klimatyzacja** — montaż, serwis, sprzedaż (sklep z koszykiem).
|
||||
- **Pompy ciepła** — dobór, montaż, dofinansowania.
|
||||
- **Usługi domowe** — remonty i wykończenia, hydraulika, serwis lodówek/AGD, drobne naprawy.
|
||||
|
||||
Dwa cele biznesowe: (1) generowanie leadów (kontakt/wycena), (2) sprzedaż klimatyzatorów online.
|
||||
Język interfejsu: **polski**. Grupa docelowa: klienci indywidualni i małe firmy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rola agenta
|
||||
|
||||
Działaj jak **Senior Next.js Developer** z dbałością o jakość produkcyjną.
|
||||
- Przed kodowaniem zadania złożonego — przygotuj plan i listę zadań (tryb Planning).
|
||||
- Po implementacji UI — uruchom dev server i zweryfikuj w przeglądarce (desktop + mobile).
|
||||
- Na końcu każdego etapu — krótkie podsumowanie + zrzut ekranu, zanim przejdziesz dalej.
|
||||
- Nie mieszaj refaktoru z nowymi funkcjami w jednym zadaniu.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stack technologiczny (obowiązkowy)
|
||||
|
||||
- **Next.js** (najnowsza stabilna) — App Router + Server Components.
|
||||
- **TypeScript** — tryb strict, bez `any` bez uzasadnienia.
|
||||
- **Tailwind CSS** — stylowanie, tokeny w configu (patrz `docs/specs/02-design-system.md`).
|
||||
- **shadcn/ui** — biblioteka komponentów bazowych.
|
||||
- **lucide-react** — ikony.
|
||||
- **React Hook Form + Zod** — formularze i walidacja.
|
||||
- **Zustand** — stan koszyka (lekki, bez Reduxa).
|
||||
- **next/image**, **next/font** — obrazy i fonty.
|
||||
- Brak zewnętrznego backendu na tym etapie. Dane = pliki w `content/`. Formularze = Server Actions
|
||||
(przygotuj miejsce na integrację e-mail, na razie bez realnej wysyłki).
|
||||
|
||||
---
|
||||
|
||||
## 4. Twarde zasady jakości (nie do pominięcia)
|
||||
|
||||
1. **Architektura** — trzymaj się struktury z `docs/specs/01-architecture.md`. Żadnych plików „na skróty".
|
||||
2. **Komponenty** — reużywalne, typowane, jedna odpowiedzialność. Header/Footer/sekcje współdzielone,
|
||||
nigdy kopiowane między stronami.
|
||||
3. **Server vs Client** — domyślnie Server Components. `"use client"` tylko tam, gdzie potrzebna
|
||||
interaktywność (formularze, koszyk, dropdowny, slidery).
|
||||
4. **Responsywność** — mobile-first. Każda strona działa od 360px do 1920px.
|
||||
5. **Dostępność** — semantyczny HTML, ARIA gdzie trzeba, widoczny focus, kontrast min. WCAG AA.
|
||||
6. **SEO** — patrz `docs/specs/03-seo.md`. Metadata per strona, jeden H1, JSON-LD gdzie wskazano.
|
||||
7. **Wydajność** — obrazy zoptymalizowane, lazy loading, minimum JS po stronie klienta.
|
||||
8. **Build** — `npm run build` musi przechodzić bez błędów i ostrzeżeń TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## 5. Konwencje kodu
|
||||
|
||||
- Nazwy plików komponentów: PascalCase (`HeroSection.tsx`). Pozostałe: kebab-case.
|
||||
- Jeden komponent = jeden plik. Eksport nazwany dla komponentów współdzielonych.
|
||||
- Importy absolutne przez alias `@/` (skonfiguruj w tsconfig).
|
||||
- Teksty UI po polsku, trzymane blisko komponentu lub w `content/` (nie hardcode w wielu miejscach).
|
||||
- Komentarze tylko tam, gdzie wyjaśniają „dlaczego", nie „co".
|
||||
- Każda sekcja strony = osobny komponent w `components/sections/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. System projektowy (skrót — pełnia w docs/specs/02)
|
||||
|
||||
- Kolory: granat `#0F2A47` (primary), cyjan `#2BA8E0` (akcent), pomarańcz `#F39320` (CTA),
|
||||
biały + jasnoszary `#F5F7FA` (tła), ciemnoszary tekst.
|
||||
- Font: Inter lub Poppins (next/font). Duże, wyraźne nagłówki.
|
||||
- Karty: `rounded-xl`, subtelny cień. Przyciski CTA: pomarańcz z wyraźnym hover.
|
||||
- Estetyka: czysta, profesjonalna, dużo wolnej przestrzeni. NIE zatłaczaj sekcji.
|
||||
- Spójność: identyczny header, footer i styl na wszystkich stronach.
|
||||
|
||||
---
|
||||
|
||||
## 7. Kolejność realizacji (etapy)
|
||||
|
||||
Realizuj projekt etapami. Nie przeskakuj — każdy etap bazuje na poprzednim.
|
||||
|
||||
| Etap | Zakres | Dokumentacja |
|
||||
|------|--------|--------------|
|
||||
| 1 | Setup: Next.js+TS+Tailwind+shadcn/ui+fonty+tokeny | `docs/specs/01`, `02` |
|
||||
| 2 | Layout wspólny: header (dropdowny, mobile menu), footer, pasek zaufania | `docs/specs/04-layouts.md` |
|
||||
| 3 | Strona główna (wszystkie sekcje jako komponenty) | `docs/pages/01-home.md` |
|
||||
| 4 | Strony usługowe: klimatyzacja, pompy ciepła, usługi domowe + 3 podstrony | `docs/pages/02–07` |
|
||||
| 5 | Realizacje, blog (+ [slug]), o nas, kontakt (formularz) | `docs/pages/08–11` |
|
||||
| 6 | E-commerce: sklep, produkt, koszyk, raty | `docs/pages/12–15` |
|
||||
| 7 | Strony formalne + finalne SEO/JSON-LD | `docs/pages/16`, `docs/specs/03` |
|
||||
| 8 | Build produkcyjny, naprawa błędów, weryfikacja w przeglądarce | — |
|
||||
|
||||
---
|
||||
|
||||
## 8. Jak czytać dokumentację
|
||||
|
||||
- `docs/specs/01-architecture.md` — struktura katalogów, routing, wzorce.
|
||||
- `docs/specs/02-design-system.md` — pełne tokeny, typografia, komponenty UI.
|
||||
- `docs/specs/03-seo.md` — metadata, JSON-LD, sitemap.
|
||||
- `docs/specs/04-layouts.md` — header, footer, nawigacja, wspólne elementy.
|
||||
- `docs/specs/05-data-model.md` — modele danych i mock content.
|
||||
- `docs/pages/NN-*.md` — szczegółowy brief każdej strony (sekcje, komponenty, treść).
|
||||
|
||||
Gdy budujesz daną stronę — najpierw przeczytaj jej plik w `docs/pages/`, potem odpowiednie specs.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Paczka promptów — strona HVAC w Next.js (dla Google Antigravity)
|
||||
|
||||
Kompletna dokumentacja projektowa rozbita na warstwy. Antigravity czyta `AGENTS.md`
|
||||
automatycznie i traktuje go jako stałe reguły; reszta to szczegółowe specyfikacje, do których
|
||||
agent sięga podczas pracy nad konkretnym etapem/stroną.
|
||||
|
||||
## Jak użyć
|
||||
|
||||
1. Utwórz folder nowego projektu i skopiuj do niego całą tę zawartość
|
||||
(`AGENTS.md` w katalogu głównym, folder `docs/` obok).
|
||||
2. Otwórz folder w Antigravity.
|
||||
3. Agent Manager → New Task → tryb **Planning** (projekt jest wielo-plikowy).
|
||||
4. Pierwszy prompt:
|
||||
> Przeczytaj `AGENTS.md` i całą dokumentację w `docs/`. Przygotuj plan implementacji
|
||||
> i listę zadań zgodnie z etapami z sekcji 7 AGENTS.md. Zacznij od Etapu 1 (setup).
|
||||
> Po każdym etapie pokaż podsumowanie i zrzut ekranu, zanim przejdziesz dalej.
|
||||
5. Przejrzyj wygenerowany plan/Task List PRZED akceptacją. Potem prowadź etap po etapie.
|
||||
|
||||
## Struktura paczki
|
||||
|
||||
```
|
||||
AGENTS.md # reguły projektu (czytane automatycznie)
|
||||
docs/
|
||||
├── specs/
|
||||
│ ├── 01-architecture.md # struktura katalogów, routing, wzorce
|
||||
│ ├── 02-design-system.md # kolory, typografia, komponenty UI
|
||||
│ ├── 03-seo.md # metadata, JSON-LD, sitemap
|
||||
│ ├── 04-layouts.md # header, footer, nawigacja, CTA
|
||||
│ └── 05-data-model.md # typy i mock content
|
||||
└── pages/
|
||||
├── 01-home.md
|
||||
├── 02-klimatyzacja.md
|
||||
├── 03-pompy-ciepla.md
|
||||
├── 04-uslugi-domowe.md
|
||||
├── 05-07-podstrony-uslug.md # remonty, hydraulika, serwis AGD
|
||||
├── 08-11-realizacje-blog-onas-kontakt.md
|
||||
├── 12-15-ecommerce.md # sklep, produkt, koszyk, raty
|
||||
└── 16-strony-formalne.md
|
||||
```
|
||||
|
||||
## Kolejność pracy
|
||||
Trzymaj się etapów z `AGENTS.md` (sekcja 7). Nie buduj wszystkiego naraz —
|
||||
każdy etap bazuje na poprzednim, a weryfikacja w przeglądarce po etapie łapie błędy wcześnie.
|
||||
|
||||
## Co uzupełnić po wygenerowaniu (właściciel)
|
||||
- Dane firmy (adres, NIP, telefon, godziny) — `content/company.ts`.
|
||||
- Zdjęcia (hero, realizacje, produkty) — w miejscach oznaczonych `TODO`.
|
||||
- Treści prawne — strony formalne.
|
||||
- Realne ceny i produkty — `content/products.ts`, `content/pricing.ts`.
|
||||
- Integracja wysyłki formularza (np. Resend) i ewentualny realny checkout.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Realizacja zamówień',
|
||||
description: 'Informacje o realizacji zamówień na klimatyzatory i pompy ciepła. Montaż na terenie Dolnego Śląska, wycena indywidualna.',
|
||||
alternates: {
|
||||
canonical: '/dostawa',
|
||||
}
|
||||
};
|
||||
|
||||
export default function DostawaPage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-extrabold mb-8">Dostawa i Płatności</h1>
|
||||
|
||||
<p className="lead">
|
||||
Sprawdź dostępne metody wysyłki urządzeń oraz formy płatności akceptowane w naszym sklepie.
|
||||
</p>
|
||||
|
||||
{/* TODO: treść prawna do uzupełnienia przez właściciela */}
|
||||
|
||||
<h2 id="dostawa">1. Metody i koszty dostawy</h2>
|
||||
<p>Dla wygody naszych klientów przygotowaliśmy kilka opcji odbioru towaru:</p>
|
||||
|
||||
<div className="not-prose overflow-x-auto my-8 border border-border rounded-xl">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 border-b border-border text-primary font-semibold">
|
||||
<tr>
|
||||
<th className="p-4">Opcja dostawy</th>
|
||||
<th className="p-4">Czas realizacji</th>
|
||||
<th className="p-4">Koszt brutto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
<tr className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4 font-medium">Kurier Paletowy (Klimatyzatory)</td>
|
||||
<td className="p-4 text-text-muted">2-3 dni robocze</td>
|
||||
<td className="p-4 font-bold text-primary">0 zł (Darmowa)</td>
|
||||
</tr>
|
||||
<tr className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4 font-medium">Kurier Standard (Mniejsze elementy)</td>
|
||||
<td className="p-4 text-text-muted">1-2 dni robocze</td>
|
||||
<td className="p-4 font-bold text-primary">19 zł</td>
|
||||
</tr>
|
||||
<tr className="hover:bg-slate-50 transition-colors">
|
||||
<td className="p-4 font-medium">Dostawa przez naszych Instalatorów</td>
|
||||
<td className="p-4 text-text-muted">W dniu montażu</td>
|
||||
<td className="p-4 font-bold text-primary">0 zł</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h2 id="platnosci">3. Metody płatności</h2>
|
||||
<ul>
|
||||
<li><strong>Przelew tradycyjny</strong> – po złożeniu zamówienia otrzymasz fakturę pro forma do opłacenia.</li>
|
||||
<li><strong>Szybkie płatności (Przelewy24/PayU)</strong> – natychmiastowe księgowanie wpłaty.</li>
|
||||
<li><strong>Płatność u instalatora</strong> – gotówką po zakończonym montażu i uruchomieniu sprzętu (opcja dostępna po wpłaceniu zaliczki 20%).</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Gwarancja na Klimatyzatory i Montaż',
|
||||
description: 'Szczegółowe informacje dotyczące warunków gwarancji na instalowane urządzenia oraz usługi montażowe.',
|
||||
alternates: {
|
||||
canonical: '/gwarancja',
|
||||
}
|
||||
};
|
||||
|
||||
export default function GwarancjaPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-6 text-green-600">
|
||||
<ShieldCheck className="w-12 h-12" />
|
||||
<h1 className="text-4xl font-extrabold m-0 text-primary">Gwarancja i Serwis</h1>
|
||||
</div>
|
||||
|
||||
<p className="lead">
|
||||
Jesteśmy autoryzowanym partnerem wszystkich sprzedawanych przez nas urządzeń, co oznacza pełne bezpieczeństwo Twojej inwestycji i bezproblemową realizację świadczeń gwarancyjnych.
|
||||
</p>
|
||||
|
||||
{/* TODO: treść prawna do uzupełnienia przez właściciela */}
|
||||
|
||||
<h2 id="gwarancja-urzadzenia">1. Gwarancja na urządzenia</h2>
|
||||
<p>Czas trwania gwarancji zależy od wybranego producenta:</p>
|
||||
<ul>
|
||||
<li><strong>Daikin</strong> – 3 lata gwarancji</li>
|
||||
<li><strong>Rotenso</strong> – 5 lat gwarancji</li>
|
||||
<li><strong>Mitsubishi Electric</strong> – 5 lat gwarancji</li>
|
||||
<li><strong>Gree</strong> – 5 lat gwarancji</li>
|
||||
<li><strong>LG</strong> – do 10 lat gwarancji na sprężarkę, 3 lata na urządzenie</li>
|
||||
</ul>
|
||||
|
||||
<div className="bg-primary-light/10 border-l-4 border-primary p-4 my-8 rounded-r-lg">
|
||||
<strong>Ważne:</strong> Warunkiem zachowania gwarancji jest wykonywanie regularnych (płatnych) przeglądów technicznych zgodnie z zaleceniami producenta – zazwyczaj dwa razy do roku.
|
||||
</div>
|
||||
|
||||
<h2 id="gwarancja-montaz">2. Gwarancja na usługę montażu</h2>
|
||||
<p>
|
||||
Bierzemy pełną odpowiedzialność za wykonaną przez nas pracę. Udzielamy <strong>5-letniej rękojmi</strong> na poprawność działania całej instalacji chłodniczej i odpływu skroplin. Jeśli jakikolwiek problem wynika z błędu montażowego, naprawimy go na własny koszt.
|
||||
</p>
|
||||
|
||||
<h2 id="serwis">3. Autoryzowany Serwis</h2>
|
||||
<p>
|
||||
Posiadamy pełne zaplecze techniczne, własnych wykwalifikowanych serwisantów i uprawnienia UDT oraz certyfikaty F-GAZ. Co to oznacza dla Ciebie?
|
||||
W przypadku awarii, nie odsyłamy Cię do producenta. Przyjeżdżamy na miejsce diagnozując usterkę, a w razie konieczności zamawiamy części i realizujemy naprawę, oszczędzając Twój czas i nerwy.
|
||||
</p>
|
||||
|
||||
<h2 id="przeglady">4. Zleć nam przegląd gwarancyjny</h2>
|
||||
<p>
|
||||
Oferujemy atrakcyjne pakiety stałej opieki serwisowej. Przy stałej współpracy koszty serwisu są niższe, a my sami pilnujemy za Ciebie terminów obowiązkowych przeglądów narzucanych przez producentów sprzętu.
|
||||
<br/><br/>
|
||||
<Link href="/kontakt" className="font-bold text-accent hover:underline">Skontaktuj się z nami</Link>, aby zarezerwować termin wizyty serwisowej.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function FormalneLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen py-24 md:py-32">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white p-8 md:p-16 rounded-3xl border border-border shadow-sm prose prose-slate max-w-none prose-headings:font-heading prose-headings:text-primary prose-a:text-accent hover:prose-a:text-accent-hover prose-a:font-semibold">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Metadata } from 'next';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Polityka Prywatności',
|
||||
description: 'Zasady przetwarzania danych osobowych oraz wykorzystania plików cookies w serwisie ThermCool.',
|
||||
alternates: {
|
||||
canonical: '/polityka-prywatnosci',
|
||||
}
|
||||
};
|
||||
|
||||
export default function PolitykaPrywatnosciPage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-extrabold mb-8">Polityka Prywatności i Cookies</h1>
|
||||
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none space-y-6">
|
||||
<p className="lead text-lg text-text-muted">
|
||||
Poniższa Polityka Prywatności określa zasady zapisywania i uzyskiwania dostępu do danych na Urządzeniach Użytkowników korzystających z Serwisu do celów świadczenia usług drogą elektroniczną oraz zasady gromadzenia i przetwarzania danych osobowych Użytkowników.
|
||||
</p>
|
||||
|
||||
<h2 id="administrator" className="text-2xl font-bold mt-8 mb-4">1. Administrator danych</h2>
|
||||
<p>
|
||||
Administratorem Twoich danych osobowych jest <strong>{companyDetails.legalName}</strong> z siedzibą we Wrocławiu (adres: {companyDetails.address.street}, {companyDetails.address.zipCode} {companyDetails.address.city}), NIP: {companyDetails.nip}, REGON: {companyDetails.regon}.
|
||||
</p>
|
||||
<p>Z administratorem można skontaktować się za pośrednictwem adresu e-mail: <strong>{companyDetails.email}</strong>.</p>
|
||||
|
||||
<h2 id="zakres-cel" className="text-2xl font-bold mt-8 mb-4">2. Zakres i cele przetwarzania</h2>
|
||||
<p>Przetwarzamy dane osobowe podane przez Ciebie dobrowolnie w celu:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Nawiązania kontaktu i odpowiedzi na przesłane zapytania (podstawa prawna: art. 6 ust. 1 lit. f RODO).</li>
|
||||
<li>Realizacji usług, m.in. montażu, serwisu i dostarczenia urządzeń HVAC pod wskazany adres (podstawa prawna: art. 6 ust. 1 lit. b RODO).</li>
|
||||
<li>Rozpatrywania potencjalnych reklamacji i roszczeń (podstawa prawna: art. 6 ust. 1 lit. c oraz f RODO).</li>
|
||||
<li>Prowadzenia ksiąg rachunkowych i dokumentacji podatkowej.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="prawa" className="text-2xl font-bold mt-8 mb-4">3. Prawa Użytkownika</h2>
|
||||
<p>Zgodnie z przepisami RODO masz prawo do:</p>
|
||||
<ul className="list-disc pl-6 space-y-2">
|
||||
<li>Dostępu do swoich danych oraz otrzymania ich kopii.</li>
|
||||
<li>Sprostowania (poprawienia) swoich danych.</li>
|
||||
<li>Usunięcia danych (prawo do bycia zapomnianym) lub ograniczenia przetwarzania.</li>
|
||||
<li>Przenoszenia danych do innego administratora.</li>
|
||||
<li>Wniesienia sprzeciwu wobec przetwarzania danych w celach marketingowych lub wynikających z prawnie uzasadnionego interesu administratora.</li>
|
||||
<li>Cofnięcia zgody w dowolnym momencie (bez wpływu na zgodność z prawem przetwarzania przed jej wycofaniem).</li>
|
||||
</ul>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<h3 className="text-xl font-semibold mt-6 mb-3">Google Analytics 4 (GA4) i Google Tag Manager (GTM)</h3>
|
||||
<p>
|
||||
W celu analizy statystyk ruchu, zrozumienia zachowań Użytkowników oraz ciągłej optymalizacji naszego serwisu korzystamy z narzędzi dostarczanych przez firmę Google LLC: <strong>Google Analytics 4</strong> oraz <strong>Google Tag Manager</strong>.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 mt-4">
|
||||
<li>
|
||||
<strong>Zasada działania:</strong> Narzędzia te używają plików cookies do generowania anonimowych statystyk dotyczących sposobu korzystania z witryny. Generowane informacje są następnie przekazywane i przechowywane na serwerach Google.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anonimizacja IP:</strong> Wdrożyliśmy funkcje ochrony prywatności, a informacje zbierane przez analitykę Google są pseudonimizowane, uniemożliwiając bezpośrednią identyfikację konkretnej osoby.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zarządzanie:</strong> Użytkownik może w każdej chwili wyłączyć śledzenie analityczne za pomocą ustawień przeglądarki, instalując oficjalny dodatek do przeglądarki blokujący Google Analytics, lub korzystając z baneru zgody na naszej stronie.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="okres-przechowywania" className="text-2xl font-bold mt-8 mb-4">5. Okres przechowywania danych</h2>
|
||||
<p>
|
||||
Dane osobowe przetwarzane w celu zawarcia lub wykonania umowy będą przechowywane przez okres obowiązywania umowy, a po jego upływie przez okres niezbędny do posprzedażowej obsługi klientów (np. obsługi roszczeń z tytułu gwarancji/rękojmi) oraz wypełnienia obowiązków prawnych (np. rozliczeń podatkowych przez 5 lat).
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-text-muted mt-12 pt-8 border-t border-border">
|
||||
Ostatnia aktualizacja dokumentu: {new Date().toLocaleDateString('pl-PL')} r.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Regulamin Sklepu i Usług',
|
||||
description: 'Regulamin świadczenia usług, zasady korzystania ze sklepu, prawa i obowiązki klienta.',
|
||||
alternates: {
|
||||
canonical: '/regulamin',
|
||||
}
|
||||
};
|
||||
|
||||
export default function RegulaminPage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-extrabold mb-8">Regulamin Serwisu i Sklepu Internetowego</h1>
|
||||
|
||||
<p className="lead">
|
||||
Niniejszy dokument określa zasady korzystania z serwisu AirProject oraz dokonywania w nim zakupów urządzeń i usług montażu.
|
||||
</p>
|
||||
|
||||
{/* TODO: treść prawna do uzupełnienia przez właściciela i weryfikacji przez radcę prawnego */}
|
||||
|
||||
<h2 id="postanowienia-ogolne">§ 1. Postanowienia ogólne</h2>
|
||||
<p>
|
||||
1. Sklep internetowy działający pod adresem internetowym airproject.pl prowadzony jest przez:
|
||||
<strong> [TUTAJ WPISZ DANE FIRMY: Nazwa, Adres, NIP, REGON]</strong>.
|
||||
</p>
|
||||
<p>
|
||||
2. Niniejszy Regulamin określa zasady zawierania umów sprzedaży i świadczenia usług za pośrednictwem serwisu.
|
||||
</p>
|
||||
|
||||
<h2 id="definicje">§ 2. Definicje</h2>
|
||||
<ul>
|
||||
<li><strong>Klient</strong> – osoba fizyczna, osoba prawna lub jednostka organizacyjna nie posiadająca osobowości prawnej.</li>
|
||||
<li><strong>Konsument</strong> – Klient będący osobą fizyczną, dokonujący zakupów w celu niezwiązanym bezpośrednio z jego działalnością gospodarczą lub zawodową.</li>
|
||||
<li><strong>Sklep</strong> – platforma e-commerce pod adresem airproject.pl.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="zamowienia">§ 3. Zasady składania i realizacji zamówień</h2>
|
||||
<p>
|
||||
1. Informacje zawarte na Stronie Internetowej Sklepu nie stanowią oferty w rozumieniu Kodeksu Cywilnego, a jedynie zaproszenie do zawarcia umowy.
|
||||
<br />
|
||||
2. Klient może składać zamówienia 24 godziny na dobę przez 7 dni w tygodniu.
|
||||
<br />
|
||||
3. Realizacja usług montażowych odbywa się po uprzednim kontakcie i ustaleniu terminu.
|
||||
</p>
|
||||
|
||||
<h2 id="platnosci">§ 4. Płatności i ceny</h2>
|
||||
<p>
|
||||
1. Wszystkie ceny na stronie są cenami brutto (zawierają podatek VAT).
|
||||
</p>
|
||||
|
||||
<h2 id="odstapienie">§ 5. Odstąpienie od umowy</h2>
|
||||
<p>
|
||||
1. Konsument ma prawo odstąpić od umowy zawartej na odległość bez podania przyczyny w terminie 14 dni od dnia dostarczenia towaru.
|
||||
<br />
|
||||
2. Prawo odstąpienia nie przysługuje w odniesieniu do usług, które zostały w pełni wykonane za wyraźną zgodą Konsumenta.
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-text-muted mt-12 pt-8 border-t border-border">
|
||||
Ostatnia aktualizacja regulaminu: 1 stycznia 2026 r.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reklamacje i Zwroty',
|
||||
description: 'Zasady i procedury składania reklamacji oraz zwrotów produktów i usług w sklepie ThermCool.',
|
||||
alternates: {
|
||||
canonical: '/reklamacje',
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReklamacjePage() {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-4xl font-extrabold mb-8">Reklamacje i Zwroty</h1>
|
||||
|
||||
<p className="lead">
|
||||
Dokładamy wszelkich starań, aby nasze produkty i usługi były najwyższej jakości. Jeśli jednak cokolwiek nie spełnia Twoich oczekiwań, masz pełne prawo złożyć reklamację lub dokonać zwrotu.
|
||||
</p>
|
||||
|
||||
{/* TODO: treść prawna do uzupełnienia przez właściciela */}
|
||||
|
||||
<h2 id="zwroty">1. Zwroty (Odstąpienie od umowy)</h2>
|
||||
<p>
|
||||
Zgodnie z polskim prawem konsument ma prawo zwrócić zakupiony produkt bez podania przyczyny w ciągu 14 dni od dnia jego otrzymania. Pamiętaj jednak, że:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Zwracany towar nie może nosić śladów montażu i używania.</li>
|
||||
<li>Koszty odesłania urządzenia ponosi kupujący.</li>
|
||||
<li>W przypadku wykonania usługi montażu urządzenia za uprzednią zgodą konsumenta, prawo do odstąpienia od umowy może zostać wyłączone.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="jak-zwrocic">Jak dokonać zwrotu?</h2>
|
||||
<ol>
|
||||
<li>Powiadom nas drogą mailową na adres: <strong>kontakt@twoj-sklep.pl</strong>.</li>
|
||||
<li>Zapakuj bezpiecznie produkt w oryginalne opakowanie.</li>
|
||||
<li>Odeślij go na wskazany przez nas adres magazynu.</li>
|
||||
<li>Po weryfikacji sprzętu, środki zwrócimy Ci w ciągu 14 dni roboczych.</li>
|
||||
</ol>
|
||||
|
||||
<h2 id="reklamacje">2. Procedura Reklamacyjna (Rękojmia i Gwarancja)</h2>
|
||||
<p>
|
||||
Wszystkie wady urządzeń mogą być zgłaszane z tytułu gwarancji producenta lub rękojmi.
|
||||
Gwarancje na poszczególne urządzenia mogą wynosić od 2 do 5 lat (szczegóły w karcie produktu i zakładce <Link href="/gwarancja">Gwarancja</Link>).
|
||||
</p>
|
||||
|
||||
<h2 id="jak-reklamowac">Jak zgłosić usterkę?</h2>
|
||||
<p>
|
||||
W razie awarii sprzętu zamontowanego przez naszą firmę, zalecamy kontakt telefoniczny lub poprzez e-mail:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Opisz rodzaj usterki i kod błędu wyświetlający się na urządzeniu (jeśli występuje).</li>
|
||||
<li>Dołącz zdjęcia instalacji lub uszkodzonego elementu.</li>
|
||||
<li>Nasz serwisant skontaktuje się z Tobą w celu ustalenia terminu darmowej diagnozy i naprawy (w okresie gwarancyjnym).</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 p-6 bg-slate-50 border border-border rounded-xl">
|
||||
<p className="mb-0 font-semibold text-primary">Masz dodatkowe pytania?</p>
|
||||
<p className="mb-0 mt-2 text-sm">Zadzwoń do nas pod numer {companyDetails.phone} lub skorzystaj z <Link href="/kontakt">formularza kontaktowego</Link>.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Metadata } from 'next';
|
||||
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: Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Generowanie ścieżek statycznych dla wpisów
|
||||
export async function generateStaticParams() {
|
||||
const posts = getPosts();
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
// Generowanie metadanych dla danego wpisu
|
||||
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: 'Nie znaleziono wpisu',
|
||||
};
|
||||
}
|
||||
|
||||
const pageTitle = post.metaTitle || post.title;
|
||||
const pageDescription = post.metaDescription || post.excerpt;
|
||||
|
||||
return {
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
alternates: {
|
||||
canonical: `/blog/${post.slug}`,
|
||||
},
|
||||
openGraph: {
|
||||
title: pageTitle,
|
||||
description: pageDescription,
|
||||
type: 'article',
|
||||
publishedTime: post.date,
|
||||
authors: [post.author || companyDetails.shortName],
|
||||
images: [
|
||||
{
|
||||
url: post.image || '/hero.jpg',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const relatedPosts = getRelatedPosts(post.slug, post.category, 3);
|
||||
|
||||
const dateObj = new Date(post.date);
|
||||
const formattedDate = dateObj.toLocaleDateString('pl-PL', {
|
||||
day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'klimatyzacja': 'Klimatyzacja',
|
||||
|
||||
'remonty': 'Remonty',
|
||||
'hydraulika': 'Hydraulika',
|
||||
'porady': 'Porady'
|
||||
};
|
||||
|
||||
// Ustawienie CTA dopasowanego do kategorii wpisu
|
||||
const getCtaLink = () => {
|
||||
switch(post.category) {
|
||||
case 'klimatyzacja': return '/klimatyzacja';
|
||||
|
||||
default: return '/kontakt';
|
||||
}
|
||||
};
|
||||
|
||||
// --- JSON-LD ---
|
||||
const jsonLdArticle = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
image: post.image,
|
||||
datePublished: post.date,
|
||||
dateModified: post.date,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.author || companyDetails.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: companyDetails.name,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: 'https://thermcool.pl/logo.png', // MOCK URL
|
||||
},
|
||||
},
|
||||
description: post.excerpt,
|
||||
};
|
||||
|
||||
const jsonLdBreadcrumb = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Strona Główna',
|
||||
item: 'https://thermcool.pl',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Blog',
|
||||
item: 'https://thermcool.pl/blog',
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: post.title,
|
||||
item: `https://thermcool.pl/blog/${post.slug}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 pt-24 pb-16">
|
||||
{/* Skrypty JSON-LD dla SEO */}
|
||||
<JsonLd data={jsonLdArticle} />
|
||||
<JsonLd data={jsonLdBreadcrumb} />
|
||||
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Powrót */}
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-primary/70 hover:text-primary transition-colors mb-8"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Wróć do listy artykułów
|
||||
</Link>
|
||||
|
||||
{/* Header artykułu */}
|
||||
<header className="mb-10 text-center md:text-left">
|
||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4 mb-6 text-sm font-medium text-text-muted">
|
||||
<span className="px-3 py-1 bg-accent/10 text-accent font-bold rounded-full uppercase tracking-wider">
|
||||
{categoryLabels[post.category] || post.category}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<time dateTime={post.date}>{formattedDate}</time>
|
||||
</div>
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{post.author}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-primary font-heading leading-tight mb-6">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-text-muted leading-relaxed max-w-3xl">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Główny obraz */}
|
||||
<div className="relative w-full aspect-video rounded-2xl overflow-hidden mb-12 shadow-md">
|
||||
<Image
|
||||
src={post.image || '/images/hero/hero-section-image.webp'}
|
||||
alt={post.title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Treść */}
|
||||
<div className="prose prose-lg md:prose-xl prose-slate max-w-none mb-16
|
||||
prose-headings:font-heading prose-headings:text-primary prose-headings:font-bold
|
||||
prose-a:text-accent hover:prose-a:text-accent-hover prose-a:font-semibold
|
||||
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>
|
||||
|
||||
</article>
|
||||
|
||||
{/* CTA do usługi */}
|
||||
<CtaBanner
|
||||
title="Podobał Ci się ten artykuł?"
|
||||
subtitle="Jeśli potrzebujesz pomocy specjalistów w podobnej sprawie, sprawdź naszą ofertę lub skontaktuj się z nami bezpośrednio."
|
||||
ctaText="Zobacz naszą ofertę"
|
||||
ctaLink={getCtaLink()}
|
||||
/>
|
||||
|
||||
{/* Powiązane artykuły */}
|
||||
{relatedPosts.length > 0 && (
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-16 md:mt-24">
|
||||
<div className="flex items-center justify-between border-b border-border pb-4 mb-8">
|
||||
<h2 className="text-2xl font-bold text-primary font-heading">Powiązane artykuły</h2>
|
||||
<Link href="/blog" className="text-sm font-semibold text-accent hover:text-accent-hover flex items-center gap-1">
|
||||
Więcej wpisów <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{relatedPosts.map(related => (
|
||||
<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 || '/images/hero/hero-section-image.webp'}
|
||||
alt={related.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-primary font-heading group-hover:text-accent transition-colors mb-2 line-clamp-2">
|
||||
{related.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted line-clamp-2">
|
||||
{related.excerpt}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Metadata } from 'next';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { PostsGrid } from '@/components/sections/PostsGrid';
|
||||
import { CtaBanner } from '@/components/sections/CtaBanner';
|
||||
import { getPosts, getFeaturedPost } from '@/content/posts';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog i Porady',
|
||||
description: 'Baza wiedzy na temat klimatyzacji i pomp ciepła na Dolnym Śląsku. Poradniki, porównania urządzeń i aktualności branżowe.',
|
||||
alternates: {
|
||||
canonical: '/blog',
|
||||
}
|
||||
};
|
||||
|
||||
export const revalidate = 86400; // Regenerate every 24 hours
|
||||
|
||||
export default function BlogPage() {
|
||||
const allPosts = getPosts();
|
||||
const featuredPost = getFeaturedPost();
|
||||
|
||||
// Usuwamy z głównej siatki artykuł wyróżniony, żeby się nie powielał (chyba że chcemy, ale zazwyczaj się usuwa)
|
||||
const remainingPosts = allPosts.filter(post => post.slug !== featuredPost.slug);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="Baza Wiedzy"
|
||||
title="Blog i poradniki"
|
||||
subtitle="Dzielimy się naszą wiedzą i doświadczeniem. Zobacz, jak dbać o domowe instalacje, na co uważać podczas remontu i jak uzyskać dofinansowanie."
|
||||
ctaText="Skontaktuj się z nami"
|
||||
ctaLink="/kontakt"
|
||||
secondaryCtaText="Nasze usługi"
|
||||
secondaryCtaLink="/uslugi-domowe"
|
||||
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">
|
||||
<PostsGrid posts={remainingPosts} featuredPost={featuredPost} />
|
||||
</section>
|
||||
|
||||
<CtaBanner
|
||||
title="Potrzebujesz indywidualnej porady?"
|
||||
subtitle="Zadaj nam pytanie, a z przyjemnością udzielimy darmowej konsultacji i doradzimy najlepsze rozwiązanie."
|
||||
ctaText="Zadaj pytanie"
|
||||
ctaLink="/kontakt"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
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';
|
||||
import { companyDetails } from '@/content/company';
|
||||
import { buttonClasses, Button } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cennik montażu klimatyzacji Wrocław 2026 — ile kosztuje montaż?',
|
||||
description: 'Sprawdź aktualne ceny montażu klimatyzacji we Wrocławiu. Montaż split od 1200 zł, bruzda 90 zł/mb, korytko 80 zł/mb. Bezpłatna wycena przed realizacją.',
|
||||
alternates: { canonical: '/cennik-montazu' },
|
||||
openGraph: {
|
||||
title: 'Cennik montażu klimatyzacji Wrocław 2026',
|
||||
description: 'Aktualne ceny montażu klimatyzacji. Split od 1200 zł, multisplit od 2800 zł. Wrocław i Dolny Śląsk.',
|
||||
},
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
q: 'Ile kosztuje montaż klimatyzacji we Wrocławiu?',
|
||||
a: 'Montaż klimatyzatora split w jednym pomieszczeniu kosztuje od 1200 zł netto. Cena obejmuje montaż jednostki wewnętrznej i zewnętrznej, wykonanie trasy freonowej do 3 mb, podłączenie elektryczne i uruchomienie. Każdy metr trasy powyżej 3 mb to dodatkowe 80 zł (korytko) lub 90 zł (bruzda w ścianie).',
|
||||
},
|
||||
{
|
||||
q: 'Co jest droższe — bruzda czy korytko?',
|
||||
a: 'Bruzda w ścianie kosztuje 90 zł/mb i daje estetyczniejszy efekt — rury są niewidoczne. Korytko PCV to 80 zł/mb, jest tańsze i szybsze w montażu, ale rury są widoczne wzdłuż ściany. Przy nowym budownictwie lub remoncie zdecydowanie polecamy bruzdę. W blokach z gotowym wykończeniem korytko jest najczęstszym wyborem.',
|
||||
},
|
||||
{
|
||||
q: 'Czy cena montażu zawiera urządzenie?',
|
||||
a: 'Nie — ceny w cenniku to wyłącznie koszt robocizny i montażu. Klimatyzator możesz kupić u nas w sklepie lub dostarczyć własny. Jeśli kupujesz u nas, możemy zaproponować pakiet urządzenie + montaż w atrakcyjnej cenie.',
|
||||
},
|
||||
{
|
||||
q: 'Czy do ceny doliczacie VAT?',
|
||||
a: 'Tak, wszystkie ceny w cenniku są cenami netto. Do podanych kwot doliczamy VAT 23%. Wystawiamy faktury VAT — klimatyzacja w firmie to koszt uzyskania przychodu.',
|
||||
},
|
||||
{
|
||||
q: 'Dlaczego montaż na dachu lub poddaszu jest droższy?',
|
||||
a: 'Montaż w trudno dostępnych miejscach wymaga dodatkowego sprzętu (drabiny, rusztowania), więcej czasu pracy ekipy i często specjalistycznych uchwytów montażowych. Dlatego takie realizacje wyceniamy indywidualnie po oględzinach.',
|
||||
},
|
||||
{
|
||||
q: 'Jak uzyskać dokładną wycenę montażu?',
|
||||
a: 'Zadzwoń do nas lub wypełnij formularz kontaktowy. Przyjedziemy na bezpłatne oględziny, ocenimy warunki montażu i w ciągu 24 godzin wystawimy szczegółową wycenę. Cena w wycenie jest ceną ostateczną — bez ukrytych kosztów.',
|
||||
},
|
||||
];
|
||||
|
||||
const CENNIK_MONTAZ = [
|
||||
{ label: 'Montaż klimatyzatora split — podstawowy (do 4,5 kW, trasa do 3 mb)', cena: '1400 zł netto', highlight: true },
|
||||
{ label: 'Montaż klimatyzatora split przy istniejącej instalacji', cena: '800 zł netto', highlight: false },
|
||||
{ label: 'Montaż multisplit 2+1 — podstawowy (do 6 mb instalacji)', cena: '2400 zł netto', highlight: false },
|
||||
{ label: 'Montaż multisplit 3+1 — podstawowy (do 9 mb instalacji)', cena: '3200 zł netto', highlight: false },
|
||||
{ label: 'Montaż multisplit 4+1 — podstawowy (do 12 mb instalacji)', cena: '3800 zł netto', highlight: false },
|
||||
{ label: 'Montaż multisplit powyżej 4 jednostek wewnętrznych', cena: 'wycena indywidualna', highlight: false },
|
||||
{ label: 'Przegląd gwarancyjny klimatyzatora split', cena: '230 zł netto', highlight: false },
|
||||
{ label: 'Przegląd gwarancyjny multisplit 2+1', cena: '250 zł netto', highlight: false },
|
||||
{ label: 'Przegląd gwarancyjny multisplit 3+1 / 4+1 / 5+1', cena: '700 zł netto', highlight: false },
|
||||
];
|
||||
|
||||
const CENNIK_TRASA = [
|
||||
{ label: 'Dodatkowy metr instalacji freonowej (korytko)', cena: '80 zł/mb' },
|
||||
{ label: 'Dodatkowy metr instalacji freonowej (bruzda)', cena: '90 zł/mb' },
|
||||
{ label: 'Dodatkowy metr instalacji elektrycznej', cena: '20 zł/mb' },
|
||||
{ label: 'Dopłata za przewiert otworu instalacyjnego w betonie', cena: '100 zł' },
|
||||
{ label: 'Dopłata za kucie bruzdy instalacyjnej w żelbecie', cena: '150 zł' },
|
||||
{ label: 'Instalacja pompy skroplin', cena: '400 zł' },
|
||||
{ label: 'Instalacja jednostki zewnętrznej z rusztowania', cena: 'wycena indywidualna' },
|
||||
{ label: 'Instalacja jednostki zewnętrznej ze zwyżki', cena: 'wycena indywidualna' },
|
||||
{ label: 'Dopłata do montażu poza sezonem (1 sobota/dzień)', cena: '240 zł' },
|
||||
{ label: 'Demontaż klimatyzacji', cena: '300 zł netto' },
|
||||
];
|
||||
|
||||
const CENNIK_SERWIS = [
|
||||
{ label: 'Czyszczenie i odgrzybianie klimatyzatora', cena: 'od 250 zł' },
|
||||
{ label: 'Przegląd techniczny (F-Gaz)', cena: 'od 150 zł' },
|
||||
{ label: 'Uzupełnienie czynnika R32', cena: 'od 180 zł' },
|
||||
{ label: 'Uzupełnienie czynnika R410A', cena: 'od 220 zł' },
|
||||
{ label: 'Diagnostyka i naprawa', cena: 'wycena po diagnostyce' },
|
||||
{ label: 'Serwis sezonowy / abonament', cena: 'od 199 zł/rok' },
|
||||
];
|
||||
|
||||
export default function CennikMontazuPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: 'Montaż klimatyzacji Wrocław — cennik',
|
||||
provider: {
|
||||
'@type': 'HVACBusiness',
|
||||
name: companyDetails.name,
|
||||
telephone: companyDetails.phone,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: companyDetails.address.city,
|
||||
addressRegion: companyDetails.address.region,
|
||||
addressCountry: 'PL',
|
||||
},
|
||||
},
|
||||
areaServed: { '@type': 'AdministrativeArea', name: 'Dolny Śląsk' },
|
||||
description: 'Cennik montażu klimatyzacji we Wrocławiu i na Dolnym Śląsku. Montaż split od 1200 zł, bruzda 90 zł/mb, korytko 80 zł/mb.',
|
||||
offers: [
|
||||
{ '@type': 'Offer', name: 'Montaż klimatyzatora split', priceCurrency: 'PLN', price: '1200' },
|
||||
{ '@type': 'Offer', name: 'Czyszczenie klimatyzatora', priceCurrency: 'PLN', price: '250' },
|
||||
],
|
||||
mainEntityOfPage: {
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map(f => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.a },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
<Image
|
||||
src="/images/services/wycena.webp"
|
||||
alt="Cennik montażu klimatyzacji Wrocław"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/50" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-white/10 rounded-full text-sm font-medium mb-6">
|
||||
<Wrench className="size-4" />
|
||||
Aktualne ceny — 2026
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold font-heading leading-tight mb-6">
|
||||
Cennik montażu klimatyzacji<br />
|
||||
<span className="text-accent">Wrocław — bez ukrytych kosztów</span>
|
||||
</h1>
|
||||
<p className="text-lg text-white/80 mb-8 max-w-2xl">
|
||||
Przejrzyste ceny montażu klimatyzacji. Przed każdą realizacją wystawiamy
|
||||
bezpłatną wycenę — płacisz dokładnie tyle ile uzgodniono. Zero niespodzianek.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<Button variant="primary" size="md">
|
||||
<Phone className="size-5" /> Zadzwoń po wycenę
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'md' })}>
|
||||
|
||||
Formularz kontaktowy <ArrowRight className="size-5" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cennik główny */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col gap-10">
|
||||
|
||||
{/* Montaż */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Wrench className="size-5 text-accent" />
|
||||
<h2 className="text-xl font-extrabold text-primary font-heading">Montaż klimatyzacji</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{CENNIK_MONTAZ.map((r, i) => (
|
||||
<div key={i} className={`flex items-center justify-between px-5 py-4 gap-4 ${i > 0 ? 'border-t border-border' : ''} ${r.highlight ? 'bg-accent/5' : 'bg-white'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.highlight && <Check className="size-4 text-accent shrink-0" />}
|
||||
<span className={`text-sm ${r.highlight ? 'font-semibold text-primary' : 'text-primary'}`}>{r.label}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-bold whitespace-nowrap ${r.highlight ? 'text-accent' : 'text-primary'}`}>{r.cena}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trasa freonowa */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="size-5 text-accent" />
|
||||
<h2 className="text-xl font-extrabold text-primary font-heading">Trasa freonowa i dodatkowe prace</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border overflow-hidden bg-white">
|
||||
{CENNIK_TRASA.map((r, i) => (
|
||||
<div key={i} className={`flex items-center justify-between px-5 py-4 gap-4 ${i > 0 ? 'border-t border-border' : ''}`}>
|
||||
<span className="text-sm text-primary">{r.label}</span>
|
||||
<span className="text-sm font-bold text-primary whitespace-nowrap">{r.cena}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<p className="text-xs text-amber-900 leading-relaxed">
|
||||
<span className="font-bold">Bruzda vs korytko:</span> Bruzda w ścianie (90 zł/mb) daje niewidoczną instalację — polecana przy nowym budownictwie i remontach. Korytko PCV (80 zł/mb) jest szybsze i tańsze — popularne w gotowych mieszkaniach. Doradzimy najlepsze rozwiązanie po oględzinach.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Serwis */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Wind className="size-5 text-accent" />
|
||||
<h2 className="text-xl font-extrabold text-primary font-heading">Serwis i przeglądy</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border overflow-hidden bg-white">
|
||||
{CENNIK_SERWIS.map((r, i) => (
|
||||
<div key={i} className={`flex items-center justify-between px-5 py-4 gap-4 ${i > 0 ? 'border-t border-border' : ''}`}>
|
||||
<span className="text-sm text-primary">{r.label}</span>
|
||||
<span className="text-sm font-bold text-primary whitespace-nowrap">{r.cena}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
* Wszystkie ceny są cenami netto. Do podanych kwot doliczany jest VAT 23%.
|
||||
Ostateczna wycena po oględzinach miejsca montażu. Cena w zatwierdzonej wycenie jest ceną ostateczną.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="py-16 bg-slate-50">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-extrabold text-primary font-heading mb-8">Często zadawane pytania o cennik</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{faqItems.map((item, i) => (
|
||||
<details key={i} className="group bg-white rounded-xl border border-border overflow-hidden">
|
||||
<summary className="flex items-center justify-between p-5 cursor-pointer font-semibold text-primary text-sm list-none">
|
||||
{item.q}
|
||||
<ArrowRight className="size-4 text-text-muted group-open:rotate-90 transition-transform shrink-0 ml-3" />
|
||||
</summary>
|
||||
<div className="px-5 pb-5 text-sm text-text-muted leading-relaxed border-t border-border pt-4">
|
||||
{item.a}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 bg-primary text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-extrabold font-heading mb-4">
|
||||
Chcesz poznać dokładną cenę montażu?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-8">
|
||||
Zadzwoń lub napisz — bezpłatna wycena w ciągu 24 godzin. Obsługujemy Wrocław i cały Dolny Śląsk.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<Button variant="primary" size="md">
|
||||
<Phone className="size-5" /> {companyDetails.phone}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/kontakt" className={buttonClasses({ variant: 'outline', size: 'md' })}>
|
||||
|
||||
Napisz do nas <ArrowRight className="size-5" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
+224
@@ -0,0 +1,224 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-text: var(--foreground);
|
||||
--color-text-muted: var(--muted-foreground);
|
||||
--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);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-border: var(--theme-border);
|
||||
--color-input: var(--input);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-light: var(--accent-light);
|
||||
--color-cta: var(--cta);
|
||||
--color-cta-foreground: var(--cta-foreground);
|
||||
--color-cta-hover: var(--cta-hover);
|
||||
--color-primary-light: var(--primary-light);
|
||||
--color-success: var(--success);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #1a2230;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1a2230;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1a2230;
|
||||
--primary: #0f2a47;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-light: #1b3d63;
|
||||
--secondary: #f5f7fa;
|
||||
--secondary-foreground: #0f2a47;
|
||||
--muted: #f5f7fa;
|
||||
--muted-foreground: #5a6678;
|
||||
--accent: #1030D0;
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-light: #e3f4fc;
|
||||
--cta: #F39320;
|
||||
--cta-foreground: #ffffff;
|
||||
--cta-hover: #D97D0D;
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--theme-border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--ring: #1030D0;
|
||||
--success: #16a34a;
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #1a2230;
|
||||
--sidebar-primary: #0f2a47;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #f5f7fa;
|
||||
--sidebar-accent-foreground: #0f2a47;
|
||||
--sidebar-border: #e2e8f0;
|
||||
--sidebar-ring: #1030D0;
|
||||
|
||||
/* DaisyUI Overrides */
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-content: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-content: var(--accent-foreground);
|
||||
--color-neutral: #1a2230;
|
||||
--color-neutral-content: #ffffff;
|
||||
--color-base-100: var(--background);
|
||||
--color-base-200: var(--muted);
|
||||
--color-base-300: var(--theme-border);
|
||||
--color-base-content: var(--foreground);
|
||||
--color-success: var(--success);
|
||||
--color-error: var(--destructive);
|
||||
--radius-selector: var(--radius);
|
||||
--radius-field: var(--radius);
|
||||
--radius-box: var(--radius);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0f2a47;
|
||||
--foreground: #f5f7fa;
|
||||
--card: #1b3d63;
|
||||
--card-foreground: #f5f7fa;
|
||||
--popover: #1b3d63;
|
||||
--popover-foreground: #f5f7fa;
|
||||
--primary: #2ba8e0;
|
||||
--primary-foreground: #0f2a47;
|
||||
--secondary: #1b3d63;
|
||||
--secondary-foreground: #f5f7fa;
|
||||
--muted: #1b3d63;
|
||||
--muted-foreground: #e3f4fc;
|
||||
--accent: #2ba8e0;
|
||||
--accent-foreground: #0f2a47;
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--theme-border: rgba(226, 232, 240, 0.1);
|
||||
--input: rgba(226, 232, 240, 0.15);
|
||||
--ring: #2ba8e0;
|
||||
|
||||
/* DaisyUI Overrides */
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-content: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-content: var(--accent-foreground);
|
||||
--color-neutral: #1a2230;
|
||||
--color-neutral-content: #ffffff;
|
||||
--color-base-100: var(--background);
|
||||
--color-base-200: var(--muted);
|
||||
--color-base-300: var(--theme-border);
|
||||
--color-base-content: var(--foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@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 overflow-x-hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans overflow-x-hidden scroll-smooth;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-heading;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.btn:hover {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08), 0 10px 15px rgba(0, 0, 0, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn.btn-outline.bg-white {
|
||||
background-color: #ffffff !important;
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
.btn.btn-outline.bg-white:hover {
|
||||
background-color: var(--accent) !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { KalkulatorClient } from '@/components/kalkulator/KalkulatorClient';
|
||||
import { getProducts } from '@/content/products';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Kalkulator doboru klimatyzacji",
|
||||
description: 'Dobierz klimatyzator do swojego pomieszczenia w 4 krokach. Kalkulator mocy, dobór kategorii i szacunkowy koszt montażu.',
|
||||
};
|
||||
|
||||
export default function KalkulatorPage() {
|
||||
const products = getProducts();
|
||||
return <KalkulatorClient products={products} />;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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';
|
||||
import { ProcessSteps } from '@/components/sections/ProcessSteps';
|
||||
import { ConfiguratorTeaser } from '@/components/sections/ConfiguratorTeaser';
|
||||
import { PricingTable } from '@/components/sections/PricingTable';
|
||||
import { BrandsGrid } from '@/components/sections/BrandsGrid';
|
||||
import { FeaturedProducts } from '@/components/sections/FeaturedProducts';
|
||||
import { WhyUs } from '@/components/sections/WhyUs';
|
||||
import { FaqAccordion } from '@/components/sections/FaqAccordion';
|
||||
import { CtaBanner } from '@/components/sections/CtaBanner';
|
||||
import { acProcessSteps } from '@/content/process';
|
||||
import { dolnySlaskCities } from '@/content/cities';
|
||||
import Link from 'next/link';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Montaż Klimatyzacji Wrocław i Dolny Śląsk',
|
||||
description: 'Profesjonalny montaż klimatyzacji split i multisplit we Wrocławiu i na Dolnym Śląsku. Uprawnienia F-Gaz, bezpłatna wycena, estetyczny montaż.',
|
||||
alternates: {
|
||||
canonical: '/klimatyzacja',
|
||||
}
|
||||
};
|
||||
|
||||
export default function KlimatyzacjaPage() {
|
||||
const faqJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Ile kosztuje montaż klimatyzacji?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Koszt montażu klimatyzacji split wynosi od 1200 zł netto. Cena zależy od długości instalacji, trudności montażu i wybranego urządzenia. Oferujemy bezpłatną wycenę przed realizacją.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Jak długo trwa montaż klimatyzacji?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Standardowy montaż klimatyzatora split trwa od 4 do 8 godzin. Systemy multisplit wymagają 1-2 dni roboczych w zależności od liczby jednostek.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Czy potrzebuję uprawnień F-Gaz do montażu klimatyzacji?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Tak, montaż klimatyzacji zawierającej czynniki F-Gaz (R32, R410A) wymaga certyfikatu F-Gaz. Posiadamy wszystkie wymagane uprawnienia UDT.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Jak często serwisować klimatyzację?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Zalecamy serwis klimatyzacji 2 razy w roku: przed sezonem letnim (kwiecień-maj) i przed zimowym (wrzesień-październik). Regularny serwis zachowuje gwarancję producenta.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Czy klimatyzacja może ogrzewać pomieszczenia zimą?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Tak, nowoczesne klimatyzatory inwerterowe to pompy ciepła powietrze-powietrze, które efektywnie ogrzewają nawet przy -20°C na zewnątrz. Koszt ogrzewania klimatyzacją jest 3-4 razy niższy niż ogrzewaniem elektrycznym.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={faqJsonLd} />
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="15+ lat doświadczenia · Split i multisplit · Uprawnienia F-gaz"
|
||||
title="Montaż klimatyzacji — dobór, instalacja, serwis"
|
||||
subtitle="Zapewnij sobie idealny klimat w domu i biurze. Oferujemy kompleksowe usługi od bezpłatnego audytu po montaż i opiekę serwisową."
|
||||
ctaText="Dobierz klimatyzator"
|
||||
ctaLink="/sklep"
|
||||
secondaryCtaText="Sprawdź cennik"
|
||||
secondaryCtaLink="#cennik"
|
||||
backgroundImage="/images/services/montaz.webp"
|
||||
features={[
|
||||
'Bezpłatny audyt',
|
||||
'Estetyczny montaż',
|
||||
'Zgodność z F-gaz',
|
||||
'Serwis gwarancyjny'
|
||||
]}
|
||||
/>
|
||||
|
||||
<InfoHighlights
|
||||
description="Montaż klimatyzacji to inwestycja w komfort na lata. Dlatego kładziemy nacisk na precyzję, estetykę wykonania i czystość w miejscu pracy. Nasza ekipa używa profesjonalnych narzędzi, a każdy montaż kończymy szkoleniem z obsługi."
|
||||
highlights={[
|
||||
{
|
||||
icon: 'clock',
|
||||
title: 'Czas montażu',
|
||||
description: 'Standardowy montaż jednostki typu split trwa zazwyczaj od 4 do 8 godzin.'
|
||||
},
|
||||
{
|
||||
icon: 'price',
|
||||
title: 'Ceny od 1200 zł',
|
||||
description: 'Jasny cennik usług bez ukrytych opłat. Ostateczna wycena zawsze przed rozpoczęciem prac.'
|
||||
},
|
||||
{
|
||||
icon: 'map',
|
||||
title: 'Zasięg działania',
|
||||
description: 'Świadczymy usługi na terenie miasta i w promieniu 50 km z bezpłatnym dojazdem.'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<AcTypesSection />
|
||||
|
||||
<ProcessSteps
|
||||
steps={acProcessSteps}
|
||||
eyebrow="Krok po Kroku"
|
||||
title="Jak wygląda proces montażu?"
|
||||
subtitle="Od pierwszej wizyty do pełnego uruchomienia — zobacz, jak działamy u naszych klientów."
|
||||
/>
|
||||
|
||||
<ConfiguratorTeaser />
|
||||
|
||||
<PricingTable />
|
||||
|
||||
<BrandsGrid />
|
||||
|
||||
<FeaturedProducts />
|
||||
|
||||
<WhyUs />
|
||||
|
||||
<FaqAccordion category="klimatyzacja" />
|
||||
|
||||
{/* Sekcja miast dla SEO */}
|
||||
<section id="miasta" className="py-16 bg-slate-50 border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-10">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-accent font-heading">
|
||||
Zasięg działania
|
||||
</span>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-primary font-heading mt-2">
|
||||
Montaż klimatyzacji na Dolnym Śląsku
|
||||
</h2>
|
||||
<p className="text-text-muted mt-3 max-w-xl mx-auto">
|
||||
Działamy na terenie całego Dolnego Śląska. Wybierz swoje miasto i dowiedz się więcej:
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{dolnySlaskCities.map((city) => (
|
||||
<Link
|
||||
key={city.slug}
|
||||
href={`/klimatyzacja/${city.slug}`}
|
||||
className="px-4 py-2 rounded-full bg-white border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent transition-colors shadow-sm"
|
||||
>
|
||||
Klimatyzacja {city.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CtaBanner
|
||||
title="Niestandardowa instalacja?"
|
||||
subtitle="Potrzebujesz klimatyzacji w zabytkowym budynku lub masz specyficzne wymagania architektoniczne? Skontaktuj się z nami po indywidualną wycenę."
|
||||
ctaText="Zamów wycenę"
|
||||
ctaLink="/kontakt"
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha } from "@/lib/security";
|
||||
import { getMailClient } from "@/lib/mail";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
const contactFormSchema = 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(),
|
||||
city: z.string().min(1, "Wybierz miasto"),
|
||||
subject: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
consent: z.boolean().refine((val) => val === true, "Zgoda RODO jest wymagana"),
|
||||
});
|
||||
|
||||
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);
|
||||
if (!success) {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const rawData = {
|
||||
name: formData.get("name"),
|
||||
phone: formData.get("phone"),
|
||||
email: formData.get("email"),
|
||||
city: formData.get("city"),
|
||||
subject: formData.get("subject"),
|
||||
message: formData.get("message"),
|
||||
consent: formData.get("consent") === "true" || formData.get("consent") === "on",
|
||||
};
|
||||
|
||||
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 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,
|
||||
subject: `Nowa wiadomość ze strony: ${validatedData.subject}`,
|
||||
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;">Nowe zapytanie z formularza</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;">${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:${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:${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;">${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;">${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;">
|
||||
${escapeHtml(validatedData.message || '')}
|
||||
</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 kontaktowego na stronie thermcool.pl
|
||||
</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):", data?.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Dziękujemy za wiadomość! 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Metadata } from 'next';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { ContactSection } from '@/components/sections/ContactSection';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Kontakt — Klimatyzacja Wrocław',
|
||||
description: 'Skontaktuj się z nami! Odpowiemy na Twoje pytania, doradzimy w wyborze urządzeń i przygotujemy bezpłatną wycenę. Jesteśmy do Twojej dyspozycji.',
|
||||
alternates: {
|
||||
canonical: '/kontakt',
|
||||
}
|
||||
};
|
||||
|
||||
export default function KontaktPage() {
|
||||
return (
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="Kontakt"
|
||||
title="Jesteśmy do Twojej dyspozycji"
|
||||
subtitle="Zastanawiasz się nad montażem klimatyzacji ? Potrzebujesz serwisu? Napisz lub zadzwoń — odpowiemy szybko i bezpłatnie."
|
||||
ctaText="Zadzwoń do nas"
|
||||
ctaLink={`tel:${companyDetails.phoneRaw}`}
|
||||
secondaryCtaText="Napisz maila"
|
||||
secondaryCtaLink={`mailto:${companyDetails.email}`}
|
||||
backgroundImage="/images/hero/hero-section-image.webp"
|
||||
/>
|
||||
|
||||
<ContactSection />
|
||||
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Koszyk',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function KoszykLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowRight } from 'lucide-react';
|
||||
import { buttonClasses } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export default function KoszykPage() {
|
||||
const { items, removeItem, updateQuantity, getTotal, clearCart } = useCartStore();
|
||||
const total = getTotal();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ShoppingBag className="size-16 text-border mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-2">Koszyk jest pusty</h2>
|
||||
<p className="text-text-muted mb-6">Dodaj produkty ze sklepu aby złożyć zamówienie.</p>
|
||||
<Link href="/sklep" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Przejdź do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-bold text-primary font-heading mb-8">Twój koszyk</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Lista produktów */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl border border-border p-4 flex gap-4 items-center">
|
||||
<div className="relative size-20 bg-slate-50 rounded-lg shrink-0">
|
||||
<Image src={item.image} alt={item.name} fill className="object-contain p-2" unoptimized={item.image.startsWith('http')} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-accent font-semibold">{item.brand}</p>
|
||||
<p className="text-sm font-semibold text-primary font-heading leading-snug line-clamp-2">{item.name}</p>
|
||||
<p className="text-base font-bold text-primary mt-1">{item.price.toLocaleString('pl-PL')} zł</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="size-7 rounded-lg border border-border flex items-center justify-center text-primary hover:border-accent hover:text-accent transition-colors">
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-primary w-6 text-center">{item.quantity}</span>
|
||||
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="size-7 rounded-lg border border-border flex items-center justify-center text-primary hover:border-accent hover:text-accent transition-colors">
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-base font-bold text-primary shrink-0 w-24 text-right">
|
||||
{(item.price * item.quantity).toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
<button onClick={() => removeItem(item.id)} className="text-text-muted hover:text-red-500 transition-colors shrink-0">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Podsumowanie */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl border border-border p-6 sticky top-24">
|
||||
<h2 className="text-base font-bold text-primary font-heading mb-4">Podsumowanie</h2>
|
||||
<div className="flex justify-between text-sm text-text-muted mb-2">
|
||||
<span>Produkty ({items.reduce((s, i) => s + i.quantity, 0)} szt.)</span>
|
||||
<span>{total.toLocaleString('pl-PL')} zł</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-text-muted mb-4">
|
||||
<span>Montaż</span>
|
||||
<span className="text-accent font-medium">wycena telefoniczna</span>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4 mb-6">
|
||||
<div className="flex justify-between font-bold text-primary">
|
||||
<span>Razem (urządzenia)</span>
|
||||
<span>{total.toLocaleString('pl-PL')} zł</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">+ koszt montażu ustalany indywidualnie</p>
|
||||
</div>
|
||||
<Link href="/zamowienie" className="block btn btn-primary text-white font-bold w-full flex items-center justify-center gap-2">
|
||||
|
||||
Złóż zamówienie <ArrowRight className="size-4" />
|
||||
|
||||
</Link>
|
||||
<button onClick={clearCart} className="w-full text-xs text-text-muted hover:text-primary transition-colors mt-3">
|
||||
Wyczyść koszyk
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { TrustBar } from "@/components/layout/TrustBar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import CookieBanner from '@/components/CookieBanner';
|
||||
import Script from 'next/script';
|
||||
import { CompareBar } from '@/components/shop/CompareBar';
|
||||
import { AnalyticsWrapper } from "@/components/ui/AnalyticsWrapper";
|
||||
import { CookieSettingsFloatingIcon } from "@/components/ui/CookieSettingsFloatingIcon";
|
||||
import "./globals.css";
|
||||
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "ThermCool | Klimatyzacja Wrocław — Dolny Śląsk",
|
||||
template: "%s | ThermCool",
|
||||
},
|
||||
description: "Profesjonalny montaż i serwis klimatyzacji na Dolnym Śląsku. Uprawnienia F-Gaz, bezpłatny audyt, estetyczny montaż. Zadzwoń po wycenę!",
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'https://thermcool.pl'),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pl_PL',
|
||||
siteName: 'ThermCool',
|
||||
title: 'ThermCool | Klimatyzacja Wrocław — Dolny Śląsk',
|
||||
description: 'Profesjonalny montaż i serwis klimatyzacji oraz pomp ciepła na Dolnym Śląsku.',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'ThermCool | Klimatyzacja ',
|
||||
description: 'Profesjonalny montaż i serwis klimatyzacji oraz pomp ciepła na Dolnym Śląsku.',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="pl"
|
||||
className="antialiased"
|
||||
>
|
||||
<head>
|
||||
</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}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<Header />
|
||||
<main id="main-content" tabIndex={-1} className="flex-grow focus:outline-none">{children}</main>
|
||||
<TrustBar />
|
||||
<Footer />
|
||||
<AnalyticsWrapper />
|
||||
<CompareBar />
|
||||
<CookieBanner />
|
||||
<CookieSettingsFloatingIcon />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-muted">
|
||||
<div className="w-16 h-16 bg-primary rounded-full animate-pulse shadow-md"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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';
|
||||
import { companyDetails } from '@/content/company';
|
||||
import { buttonClasses, Button } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Montaż klimatyzacji Wrocław — split, multisplit',
|
||||
description: 'Profesjonalny montaż klimatyzacji we Wrocławiu i na Dolnym Śląsku. Uprawnienia F-Gaz, estetyczna instalacja, gwarancja 2 lata. Bezpłatna wycena — zadzwoń!',
|
||||
alternates: { canonical: '/montaz-klimatyzacji' },
|
||||
openGraph: {
|
||||
title: 'Montaż klimatyzacji Wrocław',
|
||||
description: 'Montaż klimatyzatorów split i multisplit z uprawnieniami F-Gaz. Wrocław i Dolny Śląsk. Bezpłatna wycena.',
|
||||
},
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
q: 'Ile kosztuje montaż klimatyzacji we Wrocławiu?',
|
||||
a: 'Koszt montażu klimatyzatora split zaczyna się od 1200 zł i obejmuje montaż jednostki wewnętrznej i zewnętrznej, wykonanie trasy freonowej do 3 mb, podłączenie elektryczne i uruchomienie. Montaż multisplit (kilka pomieszczeń) wyceniamy indywidualnie. Przed każdą realizacją wystawiamy bezpłatną wycenę.',
|
||||
},
|
||||
{
|
||||
q: 'Jak długo trwa montaż klimatyzacji?',
|
||||
a: 'Standardowy montaż jednostki split w jednym pomieszczeniu zajmuje 3-5 godzin. Montaż systemu multisplit (2-4 jednostki) to zazwyczaj pełny dzień roboczy. Po zakończeniu prac sprzątamy i zostawiamy pomieszczenie w idealnym porządku.',
|
||||
},
|
||||
{
|
||||
q: 'Czy montaż wymaga pozwolenia na budowę?',
|
||||
a: 'W zdecydowanej większości przypadków montaż klimatyzatora split nie wymaga pozwolenia na budowę — wystarczy zgłoszenie. Wyjątkiem mogą być budynki zabytkowe lub objęte ochroną konserwatora. W razie wątpliwości doradzimy co zrobić.',
|
||||
},
|
||||
{
|
||||
q: 'Trasa freonowa w bruździe czy w korytku?',
|
||||
a: 'Oferujemy oba rozwiązania. Bruzda w ścianie (90 zł/mb) daje estetyczniejszy efekt — rury są niewidoczne. Korytko PCV (80 zł/mb) jest tańsze i szybsze w montażu. Doradzamy najlepsze rozwiązanie po oględzinach miejsca montażu.',
|
||||
},
|
||||
{
|
||||
q: 'Gdzie montujecie jednostkę zewnętrzną?',
|
||||
a: 'Jednostkę zewnętrzną montujemy na konsoli ściennej, dachu, balkonie lub gruncie — w zależności od warunków budynku. Zawsze dbamy o stabilność mocowania, minimalizację hałasu i estetykę instalacji.',
|
||||
},
|
||||
{
|
||||
q: 'Czy przyjeżdżacie poza Wrocław?',
|
||||
a: 'Tak, obsługujemy cały Dolny Śląsk — Legnicę, Wałbrzych, Jelenią Górę, Lubin, Świdnicę, Dzierżoniów i okoliczne miejscowości. Koszt dojazdu ustalamy indywidualnie.',
|
||||
},
|
||||
];
|
||||
|
||||
const ETAPY = [
|
||||
{ n: '01', title: 'Bezpłatna wycena', desc: 'Przyjeżdżamy lub rozmawiamy zdalnie. Oceniamy warunki montażu i doradzamy dobór urządzenia.' },
|
||||
{ n: '02', title: 'Zakup urządzenia', desc: 'Możesz kupić klimatyzator u nas lub dostarczyć własny. Pracujemy z wszystkimi markami.' },
|
||||
{ n: '03', title: 'Montaż', desc: 'Ekipa montażowa wykonuje instalację w uzgodnionym terminie. Sprzątamy po sobie.' },
|
||||
{ n: '04', title: 'Uruchomienie i szkolenie', desc: 'Uruchamiamy urządzenie, sprawdzamy szczelność i pokazujemy jak obsługiwać klimatyzator.' },
|
||||
{ n: '05', title: 'Gwarancja', desc: '2 lata gwarancji na montaż. Jesteśmy do dyspozycji w razie pytań i serwisu.' },
|
||||
];
|
||||
|
||||
export default function MontazKlimatyzacjiPage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: 'Montaż klimatyzacji Wrocław',
|
||||
provider: {
|
||||
'@type': 'HVACBusiness',
|
||||
name: companyDetails.name,
|
||||
telephone: companyDetails.phone,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: companyDetails.address.city,
|
||||
addressRegion: companyDetails.address.region,
|
||||
addressCountry: 'PL',
|
||||
},
|
||||
},
|
||||
areaServed: { '@type': 'AdministrativeArea', name: 'Dolny Śląsk' },
|
||||
description: 'Profesjonalny montaż klimatyzacji split, multisplit i kasetonowych na Dolnym Śląsku z uprawnieniami F-Gaz.',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
priceCurrency: 'PLN',
|
||||
price: '1200',
|
||||
priceSpecification: { '@type': 'UnitPriceSpecification', price: '1200', priceCurrency: 'PLN', name: 'Montaż klimatyzatora split od' },
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map(f => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.a },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
<Image
|
||||
src="/images/services/instalacja_klimatyzacji.webp"
|
||||
alt="Montaż klimatyzacji Wrocław"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/50" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-white/10 rounded-full text-sm font-medium mb-6">
|
||||
<Wrench className="size-4" />
|
||||
Montaż z uprawnieniami F-Gaz
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold font-heading leading-tight mb-6">
|
||||
Montaż klimatyzacji Wrocław<br />
|
||||
<span className="text-accent">estetycznie i z gwarancją</span>
|
||||
</h1>
|
||||
<p className="text-lg text-white/80 mb-8 max-w-2xl">
|
||||
Montujemy klimatyzatory split, multisplit i kanałowe na terenie Wrocławia i całego Dolnego Śląska.
|
||||
Uprawnienia F-Gaz, estetyczna instalacja, sprzątanie po montażu — i 2 lata gwarancji.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<Button variant="primary" size="md">
|
||||
<Phone className="size-5" /> Zadzwoń: {companyDetails.phone}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/kalkulator" className={buttonClasses({ variant: 'outline', size: 'md' })}>
|
||||
|
||||
Dobierz urządzenie <ArrowRight className="size-5" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Wyróżniki */}
|
||||
<section className="py-16 bg-white">
|
||||
<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-8">Dlaczego warto wybrać ThermCool?</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-12">
|
||||
{[
|
||||
{ icon: ShieldCheck, title: 'Uprawnienia F-Gaz', desc: 'Posiadamy wymagane prawem certyfikaty F-Gaz uprawniające do pracy z czynnikami chłodniczymi R32 i R410A.' },
|
||||
{ icon: Star, title: 'Estetyczna instalacja', desc: 'Ukryte korytka, schludne przewerty, brak widocznych rur — dbamy o wygląd pomieszczenia po montażu.' },
|
||||
{ icon: Clock, title: 'Terminowość', desc: 'Realizujemy montaże w ustalonym terminie. Przyjeżdżamy punktualnie i kończymy w deklarowanym czasie.' },
|
||||
{ icon: ShieldCheck, title: '2 lata gwarancji na montaż', desc: 'Gwarantujemy jakość wykonanej instalacji przez 2 lata. Jakikolwiek problem z montażem — naprawiamy bezpłatnie.' },
|
||||
{ icon: Wrench, title: 'Wszystkie marki', desc: 'Montujemy klimatyzatory Daikin, Mitsubishi Electric, Gree, Rotenso, LG, Samsung, Haier i innych producentów.' },
|
||||
{ icon: Check, title: 'Sprzątanie po montażu', desc: 'Po zakończeniu prac zbieramy gruz, kurz i opakowania. Zostawiamy pomieszczenie w takim stanie jak zastaliśmy.' },
|
||||
].map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={i} className="flex gap-3 p-4 bg-slate-50 rounded-xl">
|
||||
<Icon className="size-5 text-accent shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-bold text-primary text-sm">{item.title}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Typy montażu */}
|
||||
<h2 className="text-2xl font-extrabold text-primary font-heading mb-6">Rodzaje montażu</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ title: 'Klimatyzator split', desc: 'Jedna jednostka zewnętrzna + jedna wewnętrzna. Idealne do jednego pokoju lub małego mieszkania. Od 1200 zł.' },
|
||||
{ title: 'System multisplit', desc: 'Jedna jednostka zewnętrzna obsługuje 2-5 pomieszczeń. Ekonomiczne rozwiązanie dla domów i biur. Wycena indywidualna.' },
|
||||
{ title: 'Klimatyzator kasetonowy', desc: 'Montowany w suficie podwieszanym. Równomierne chłodzenie dużych przestrzeni biurowych i handlowych.' },
|
||||
].map((t, i) => (
|
||||
<div key={i} className="bg-white border border-border rounded-2xl p-5">
|
||||
<h3 className="font-bold text-primary text-sm mb-2">{t.title}</h3>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{t.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Etapy montażu */}
|
||||
<section className="py-16 bg-slate-50">
|
||||
<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-8">Jak wygląda montaż krok po kroku?</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{ETAPY.map((e, i) => (
|
||||
<div key={i} className="flex gap-4 p-5 bg-white rounded-2xl border border-border">
|
||||
<div className="size-10 rounded-xl bg-accent text-white flex items-center justify-center font-extrabold text-sm shrink-0">
|
||||
{e.n}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-primary mb-1">{e.title}</p>
|
||||
<p className="text-sm text-text-muted leading-relaxed">{e.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cennik */}
|
||||
<section className="py-16 bg-white">
|
||||
<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-2">Cennik montażu</h2>
|
||||
<p className="text-text-muted text-sm mb-8">Ceny netto. Do cen doliczany jest VAT 23%. Ostateczna wycena po oględzinach miejsca montażu.</p>
|
||||
<div className="bg-slate-50 rounded-2xl border border-border overflow-hidden">
|
||||
{[
|
||||
{ label: 'Montaż klimatyzatora split (1 pomieszczenie)', cena: 'od 1200 zł' },
|
||||
{ label: 'Montaż systemu multisplit (2 jednostki)', cena: 'od 2800 zł' },
|
||||
{ label: 'Montaż systemu multisplit (3 jednostki)', cena: 'od 3800 zł' },
|
||||
{ label: 'Trasa freonowa — korytko PCV', cena: '80 zł/mb' },
|
||||
{ label: 'Trasa freonowa — bruzda w ścianie', cena: '90 zł/mb' },
|
||||
{ label: 'Konsola ścienna jednostki zewnętrznej', cena: 'od 120 zł' },
|
||||
{ label: 'Montaż kasetonowy', cena: 'wycena indywidualna' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className={`flex items-center justify-between px-5 py-4 gap-4 ${i > 0 ? 'border-t border-border' : ''}`}>
|
||||
<span className="text-sm text-primary">{r.label}</span>
|
||||
<span className="text-sm font-bold text-primary whitespace-nowrap">{r.cena}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/kontakt" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white font-bold rounded-xl text-sm hover:bg-accent/90 transition-colors">
|
||||
|
||||
Zapytaj o bezpłatną wycenę <ArrowRight className="size-4" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="py-16 bg-slate-50">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-extrabold text-primary font-heading mb-8">Często zadawane pytania</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{faqItems.map((item, i) => (
|
||||
<details key={i} className="group bg-white rounded-xl border border-border overflow-hidden">
|
||||
<summary className="flex items-center justify-between p-5 cursor-pointer font-semibold text-primary text-sm list-none">
|
||||
{item.q}
|
||||
<ArrowRight className="size-4 text-text-muted group-open:rotate-90 transition-transform shrink-0 ml-3" />
|
||||
</summary>
|
||||
<div className="px-5 pb-5 text-sm text-text-muted leading-relaxed border-t border-border pt-4">
|
||||
{item.a}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 bg-primary text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-extrabold font-heading mb-4">
|
||||
Chcesz zamontować klimatyzację we Wrocławiu?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-8">
|
||||
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="md" className="mx-auto">
|
||||
<Phone className="size-5" /> {companyDetails.phone}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link';
|
||||
import { buttonClasses } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] bg-muted px-4 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold font-heading text-primary mb-4">
|
||||
Strona nie istnieje
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-slate-500 mb-8 max-w-md mx-auto">
|
||||
Szukana strona została usunięta lub nigdy nie istniała.
|
||||
</p>
|
||||
<Link href="/" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Wróć na stronę główną
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Metadata } from 'next';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { AboutStory } from '@/components/sections/AboutStory';
|
||||
import { ValuesGrid } from '@/components/sections/ValuesGrid';
|
||||
import { CtaBanner } from '@/components/sections/CtaBanner';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'O nas — ThermCool Klimatyzacja Wrocław',
|
||||
description: 'Poznaj zespół specjalistów ThermCool. Certyfikat F-Gaz, profesjonalny montaż i serwis klimatyzacji oraz pomp ciepła na Dolnym Śląsku.',
|
||||
alternates: {
|
||||
canonical: '/o-nas',
|
||||
}
|
||||
};
|
||||
|
||||
export default function ONasPage() {
|
||||
return (
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="Poznaj Nas"
|
||||
title={`O firmie ${companyDetails.shortName}`}
|
||||
subtitle="Jesteśmy zgranym zespołem inżynierów, instalatorów i serwisantów. Od lat dostarczamy sprawdzone rozwiązania z zakresu klimatyzacji, ogrzewania i wykończeń."
|
||||
ctaText="Skontaktuj się z nami"
|
||||
ctaLink="/kontakt"
|
||||
secondaryCtaText="Nasze realizacje"
|
||||
secondaryCtaLink="/realizacje"
|
||||
backgroundImage="/images/about/about-story.webp"
|
||||
/>
|
||||
|
||||
<AboutStory />
|
||||
|
||||
{/* <StatsBar /> (Ukryte na czas zbierania autentycznych danych statystycznych) */}
|
||||
|
||||
<ValuesGrid />
|
||||
|
||||
{/* <CertificatesSection /> (Ukryte na czas uzyskania skanów dokumentów) */}
|
||||
|
||||
{/* <Testimonials /> (Ukryte na czas zbierania autentycznych opinii) */}
|
||||
|
||||
<CtaBanner
|
||||
title="Szukasz sprawdzonej ekipy do swojego projektu?"
|
||||
subtitle="Dołącz do grona naszych zadowolonych klientów. Gwarantujemy rzetelność, czystość prowadzonych prac i profesjonalne doradztwo."
|
||||
ctaText="Napisz do nas"
|
||||
ctaLink="/kontakt"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export const alt = 'ThermCool - Klimatyzacja '
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: '#0f2a47',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 130,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
letterSpacing: '-0.05em',
|
||||
}}
|
||||
>
|
||||
ThermCool
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 48,
|
||||
color: '#2ba8e0',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Klimatyzacja — Wrocław, Dolny Śląsk
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
import { HeroSection } from "@/components/sections/HeroSection";
|
||||
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 { 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';
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "HVACBusiness",
|
||||
"@id": `${baseUrl}/#business`,
|
||||
"name": companyDetails.name,
|
||||
"legalName": companyDetails.legalName,
|
||||
"url": baseUrl,
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": `${baseUrl}/logo.png`,
|
||||
"width": 200,
|
||||
"height": 60,
|
||||
},
|
||||
"image": `${baseUrl}/opengraph-image`,
|
||||
"telephone": companyDetails.phone,
|
||||
"email": companyDetails.email,
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": companyDetails.address.street,
|
||||
"addressLocality": companyDetails.address.city,
|
||||
"postalCode": companyDetails.address.zipCode,
|
||||
"addressRegion": companyDetails.address.region,
|
||||
"addressCountry": "PL",
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 51.1079,
|
||||
"longitude": 17.0385,
|
||||
},
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "PLN",
|
||||
"paymentAccepted": "Gotówka, Przelew bankowy, Karta",
|
||||
"openingHoursSpecification": [
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
|
||||
"opens": "08:00",
|
||||
"closes": "18:00",
|
||||
},
|
||||
{
|
||||
"@type": "OpeningHoursSpecification",
|
||||
"dayOfWeek": ["Saturday"],
|
||||
"opens": "09:00",
|
||||
"closes": "14:00",
|
||||
},
|
||||
],
|
||||
"areaServed": [
|
||||
{ "@type": "City", "name": "Wrocław" },
|
||||
{ "@type": "City", "name": "Legnica" },
|
||||
{ "@type": "City", "name": "Wałbrzych" },
|
||||
{ "@type": "City", "name": "Jelenia Góra" },
|
||||
{ "@type": "City", "name": "Lubin" },
|
||||
{ "@type": "AdministrativeArea", "name": "Dolny Śląsk" },
|
||||
],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "Usługi HVAC",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Montaż klimatyzacji",
|
||||
"description": "Profesjonalny montaż klimatyzatorów split i multisplit z uprawnieniami F-Gaz. Ceny od 1200 zł.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Montaż pomp ciepła",
|
||||
"description": "Dobór i montaż pomp ciepła powietrze-woda z pomocą w uzyskaniu dofinansowania z Czystego Powietrza.",
|
||||
},
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"itemOffered": {
|
||||
"@type": "Service",
|
||||
"name": "Serwis klimatyzacji",
|
||||
"description": "Przeglądy, czyszczenie, odgrzybianie i naprawy klimatyzatorów. Ceny od 250 zł.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"sameAs": [
|
||||
companyDetails.socials.facebook,
|
||||
companyDetails.socials.instagram,
|
||||
companyDetails.socials.google,
|
||||
].filter(Boolean),
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${baseUrl}/#website`,
|
||||
"url": baseUrl,
|
||||
"name": "ThermCool",
|
||||
"description": "Klimatyzacja na Dolnym Śląsku",
|
||||
"inLanguage": "pl-PL",
|
||||
"publisher": { "@id": `${baseUrl}/#business` },
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": `${baseUrl}/sklep?search={search_term_string}`,
|
||||
},
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* LocalBusiness Schema for Google Search SEO */}
|
||||
<JsonLd data={jsonLd} />
|
||||
|
||||
<HeroSection />
|
||||
<AdvantagesBar />
|
||||
<ServicesGrid />
|
||||
{/* BrandsBar will go here in Group B */}
|
||||
<FeaturedProducts />
|
||||
<WhyUs />
|
||||
{/* ProjectsSection will go here in Group B */}
|
||||
{/* <Testimonials /> (Ukryte na czas zbierania autentycznych opinii) */}
|
||||
|
||||
|
||||
|
||||
<BlogPreview />
|
||||
|
||||
{/* Cennik usług */}
|
||||
{/* Cennik usług */}
|
||||
<section className="bg-white py-16 px-4">
|
||||
<div className="bg-slate-50 max-w-7xl mx-auto rounded-[2.5rem] p-6 sm:p-10 lg:p-14">
|
||||
<div className="max-w-7xl mx-auto flex flex-col gap-8">
|
||||
|
||||
{/* Header (Sekcja hero nad kartami) */}
|
||||
<div className="mb-2">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold text-accent uppercase tracking-widest mb-6 bg-accent/5 border border-accent/20 px-3 py-1.5 rounded-full">
|
||||
<Check className="size-3.5" />
|
||||
Cennik usług
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-5xl font-extrabold text-primary font-heading leading-tight mb-4">
|
||||
Montaż, serwis i czyszczenie klimatyzacji –<br />już od <span className="underline decoration-accent underline-offset-4">150 zł</span>
|
||||
</h2>
|
||||
<p className="text-text-muted text-sm sm:text-base max-w-2xl leading-relaxed">
|
||||
Profesjonalna obsługa, certyfikowani specjaliści i szybki czas reakcji na terenie całego Wrocławia. Zadbaj o czyste powietrze i wydajność swoich urządzeń.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Montaż klimatyzacji */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wrench className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Montaż klimatyzacji</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Profesjonalny montaż urządzeń split i multisplit. Zapewniamy estetykę wykonania i pełne uruchomienie.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 1400 zł</p>
|
||||
<p className="text-[10px] text-text-muted mt-1">* cena standardowego montażu do 5m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Czyszczenie */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Thermometer className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Czyszczenie i odgrzybianie</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Kompleksowe czyszczenie, dezynfekcja chemiczna, odgrzybianie parowe, czyszczenie filtrów.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 250 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Przegląd techniczny */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wind className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Przegląd techniczny</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Sprawdzenie parametrów pracy, kontrola szczelności układu, pomiar ciśnień roboczych.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 150 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Uzupełnienie czynnika */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Zap className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Uzupełnienie czynnika</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Lokalizacja nieszczelności, próba ciśnieniowa azotem, dobicie czynnika chłodniczego.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 180 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Naprawa usterek */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wrench className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Naprawa usterek</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Diagnoza awarii, wymiana uszkodzonych podzespołów elektroniki, sprężarek, wentylatorów.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-sm font-extrabold text-primary">Wycena indywidualna</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Serwis sezonowy (Dark card) */}
|
||||
<div className="bg-[#1a2332] p-8 flex flex-col border border-[#1a2332] shadow-sm rounded-2xl">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-800 flex items-center justify-center shrink-0 border border-slate-700 rounded-lg">
|
||||
<Calendar className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white font-heading mb-3">Serwis sezonowy / Abonament</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed">Regularna opieka nad instalacją przed sezonem letnim i zimowym. Priorytetowy czas reakcji w przypadku awarii.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-white/60 mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-white">od 199 zł / rok</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Gotowy na świeże powietrze? (Full width) */}
|
||||
<div className="bg-white md:col-span-3 p-8 md:p-10 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-8 border-l-[6px] border-accent rounded-2xl shadow-sm">
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="text-2xl font-bold text-primary font-heading mb-3">Gotowy na świeże powietrze?</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Nasi certyfikowani technicy są dostępni od poniedziałku do soboty. Zarezerwuj termin już dziś, aby uniknąć kolejek w sezonie.</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 shrink-0 w-full lg:w-auto justify-center lg:justify-end">
|
||||
<Link href="/kontakt" className="w-fit sm:w-auto flex justify-center items-center gap-2 px-6 py-3.5 bg-accent text-white font-bold text-sm hover:bg-accent/90 transition-colors rounded-xl">
|
||||
Umów serwis <Calendar className="size-4" />
|
||||
</Link>
|
||||
<a href={`tel:${companyDetails.phoneRaw}`} className="w-fit sm:w-auto">
|
||||
<button className="w-fit sm:w-auto flex justify-center items-center gap-2 px-6 py-3.5 bg-white text-primary border border-border font-bold text-sm hover:bg-slate-50 transition-colors rounded-xl">
|
||||
<Phone className="size-4" /> Zadzwoń do nas
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CtaBanner bgVariant="accent-light" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { X, ShoppingCart } from 'lucide-react';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { buttonClasses } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export default function PorownajPage() {
|
||||
const { items, removeItem } = useCompareStore();
|
||||
const addToCart = useCartStore((s) => s.addItem);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-text-muted mb-4">Nie masz żadnych produktów do porównania.</p>
|
||||
<Link href="/sklep" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Wróć do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const allSpecKeys = [...new Set(items.flatMap((i) => Object.keys(i.specs)))];
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold text-primary font-heading">Porównanie produktów</h1>
|
||||
<Link href="/sklep" className="text-sm text-accent hover:underline">← Wróć do sklepu</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full bg-white rounded-xl border border-border overflow-hidden">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<td className="p-4 w-36 bg-slate-50" />
|
||||
{items.map((item) => (
|
||||
<td key={item.slug} className="p-4 text-center align-top border-l border-border">
|
||||
<div className="relative mx-auto size-24 mb-3">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
unoptimized={item.image.startsWith('http')}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-accent font-semibold uppercase mb-1">{item.brand}</p>
|
||||
<p className="text-sm font-bold text-primary font-heading leading-snug">{item.name}</p>
|
||||
{item.price && (
|
||||
<p className="text-lg font-bold text-primary mt-2">
|
||||
{item.price.toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3 justify-center">
|
||||
{item.price && (
|
||||
<button
|
||||
onClick={() => addToCart({
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
brand: item.brand,
|
||||
image: item.image,
|
||||
price: item.price!,
|
||||
})}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-accent text-white rounded-lg text-xs font-bold hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<ShoppingCart className="size-3" /> Do koszyka
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeItem(item.slug)}
|
||||
className="p-1.5 text-text-muted hover:text-primary border border-border rounded-lg transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allSpecKeys.map((key, idx) => (
|
||||
<tr key={key} className={`border-b border-border ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50'}`}>
|
||||
<td className="p-3 text-xs font-medium text-text-muted bg-slate-50 border-r border-border">
|
||||
{key}
|
||||
</td>
|
||||
{items.map((item) => (
|
||||
<td key={item.slug} className="p-3 text-sm text-primary text-center border-l border-border">
|
||||
{item.specs[key] || <span className="text-border">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Metadata } from 'next';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { ProjectsGrid } from '@/components/sections/ProjectsGrid';
|
||||
import { StatsBar } from '@/components/sections/StatsBar';
|
||||
import { CtaBanner } from '@/components/sections/CtaBanner';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Realizacje',
|
||||
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,
|
||||
}
|
||||
};
|
||||
|
||||
export default function RealizacjePage() {
|
||||
return (
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="Portfolio"
|
||||
title="Nasze realizacje"
|
||||
subtitle="Zobacz wybrane projekty, które zrealizowaliśmy dla naszych klientów. Każde zlecenie traktujemy indywidualnie, dbając o najwyższą jakość wykonania."
|
||||
ctaText="Zobacz ofertę"
|
||||
ctaLink="/uslugi-domowe"
|
||||
secondaryCtaText="Skontaktuj się"
|
||||
secondaryCtaLink="/kontakt"
|
||||
backgroundImage="/images/hero/hero-section-image.webp"
|
||||
/>
|
||||
|
||||
<ProjectsGrid />
|
||||
|
||||
<StatsBar />
|
||||
|
||||
<CtaBanner
|
||||
title="Chcesz uzyskać podobny efekt?"
|
||||
subtitle="Umów się na bezpłatną konsultację. Doradzimy, przygotujemy projekt i wycenimy prace bez żadnych zobowiązań."
|
||||
ctaText="Zamów darmową wycenę"
|
||||
ctaLink="/kontakt"
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
// Możemy tu dodać disallow np. do panelu admina, jeśli by powstał w przyszłości
|
||||
},
|
||||
sitemap: 'https://thermcool.pl/sitemap.xml',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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';
|
||||
import { companyDetails } from '@/content/company';
|
||||
import { buttonClasses, Button } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Serwis klimatyzacji Wrocław — przegląd, czyszczenie, naprawa',
|
||||
description: 'Serwis klimatyzacji we Wrocławiu i na Dolnym Śląsku. Czyszczenie, odgrzybianie, przegląd, naprawa — wszystkie marki. Szybki termin, uczciwa cena. Zadzwoń!',
|
||||
alternates: { canonical: '/serwis-klimatyzacji' },
|
||||
openGraph: {
|
||||
title: 'Serwis klimatyzacji Wrocław',
|
||||
description: 'Czyszczenie, przegląd i naprawa klimatyzatorów wszystkich marek. Wrocław i Dolny Śląsk.',
|
||||
},
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
q: 'Jak często należy serwisować klimatyzację?',
|
||||
a: 'Zalecamy przegląd i czyszczenie klimatyzatora co najmniej raz w roku — najlepiej przed sezonem letnim (kwiecień–maj). Urządzenia pracujące intensywnie lub w zapylonych pomieszczeniach warto serwisować dwa razy do roku. Regularne przeglądy wydłużają żywotność urządzenia i obniżają rachunki za prąd.',
|
||||
},
|
||||
{
|
||||
q: 'Ile kosztuje czyszczenie klimatyzatora?',
|
||||
a: 'Czyszczenie i odgrzybianie klimatyzatora split zaczyna się od 250 zł. Cena obejmuje czyszczenie filtrów, lameli, tacy ociekowej, wentylatora i dezynfekcję układu nawiewu. Dokładna cena zależy od stanu urządzenia i jego modelu.',
|
||||
},
|
||||
{
|
||||
q: 'Jakie marki klimatyzatorów serwisujecie?',
|
||||
a: 'Serwisujemy wszystkie popularne marki: Daikin, Mitsubishi Electric, LG, Samsung, Gree, Rotenso, Haier, Aux, Fuji Electric, Toshiba, Fujitsu i inne. Jeśli masz mniej popularny model — zadzwoń, prawdopodobnie też go obsługujemy.',
|
||||
},
|
||||
{
|
||||
q: 'Czy przyjeżdżacie w ten sam dzień?',
|
||||
a: 'Staramy się zapewnić wizytę serwisową w ciągu 24–48 godzin od zgłoszenia. W przypadku awarii krytycznych (brak chłodzenia podczas upałów) priorytetyzujemy pilne wyjazdy. Zadzwoń i opisz sytuację — dopasujemy termin.',
|
||||
},
|
||||
{
|
||||
q: 'Co obejmuje przegląd techniczny klimatyzatora?',
|
||||
a: 'Przegląd obejmuje: kontrolę szczelności układu chłodniczego (wymóg F-Gaz), pomiar ciśnień roboczych i temperatur, sprawdzenie układu elektrycznego, kontrolę wentylatora i silnika, czyszczenie filtrów oraz raport z przeglądu.',
|
||||
},
|
||||
{
|
||||
q: 'Czy serwisujecie klimatyzatory na gwarancji?',
|
||||
a: 'Tak, wykonujemy przeglądy gwarancyjne które są wymagane przez producentów do zachowania gwarancji. Wystawiamy dokumentację potwierdzającą wykonanie przeglądu. W przypadku usterek gwarancyjnych kontaktujemy się z importerem w imieniu klienta.',
|
||||
},
|
||||
];
|
||||
|
||||
const USLUGI = [
|
||||
{
|
||||
icon: Thermometer,
|
||||
title: 'Czyszczenie i odgrzybianie',
|
||||
desc: 'Kompleksowe czyszczenie jednostki wewnętrznej i zewnętrznej. Dezynfekcja chemiczna, odgrzybianie parowe, czyszczenie filtrów i tacy ociekowej.',
|
||||
cena: 'od 250 zł',
|
||||
},
|
||||
{
|
||||
icon: Wind,
|
||||
title: 'Przegląd techniczny (F-Gaz)',
|
||||
desc: 'Kontrola szczelności układu chłodniczego, pomiar ciśnień i temperatur, sprawdzenie układu elektrycznego. Wymagany prawem co 1–2 lata.',
|
||||
cena: 'od 150 zł',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Uzupełnienie czynnika chłodniczego',
|
||||
desc: 'Lokalizacja nieszczelności próbą azotem, próżniowanie układu, dobicie czynnika R32 lub R410A do właściwego ciśnienia.',
|
||||
cena: 'od 180 zł',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: 'Naprawa i diagnostyka',
|
||||
desc: 'Odczyt kodów błędów, wymiana uszkodzonych podzespołów elektroniki, sprężarki, wentylatora, czujników. Oryginalne części zamienne.',
|
||||
cena: 'wycena po diagnostyce',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Serwis gwarancyjny',
|
||||
desc: 'Przeglądy gwarancyjne wymagane przez producenta do zachowania gwarancji. Pełna dokumentacja i raport z przeglądu.',
|
||||
cena: 'od 150 zł',
|
||||
},
|
||||
{
|
||||
icon: Check,
|
||||
title: 'Serwis sezonowy / abonament',
|
||||
desc: '2 przeglądy rocznie (przed latem i zimą), priorytetowy czas reakcji i 15% zniżki na naprawy. Idealne dla biur i restauracji.',
|
||||
cena: 'od 199 zł/rok',
|
||||
},
|
||||
];
|
||||
|
||||
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 = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: 'Serwis klimatyzacji Wrocław',
|
||||
provider: {
|
||||
'@type': 'HVACBusiness',
|
||||
name: companyDetails.name,
|
||||
telephone: companyDetails.phone,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: companyDetails.address.city,
|
||||
addressRegion: companyDetails.address.region,
|
||||
addressCountry: 'PL',
|
||||
},
|
||||
},
|
||||
areaServed: { '@type': 'AdministrativeArea', name: 'Dolny Śląsk' },
|
||||
description: 'Serwis, czyszczenie, przegląd i naprawa klimatyzatorów wszystkich marek na Dolnym Śląsku.',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
priceCurrency: 'PLN',
|
||||
price: '250',
|
||||
priceSpecification: { '@type': 'UnitPriceSpecification', price: '250', priceCurrency: 'PLN', name: 'Czyszczenie klimatyzatora od' },
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map(f => ({
|
||||
'@type': 'Question',
|
||||
name: f.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.a },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
<Image
|
||||
src="/images/services/czyszczenie.webp"
|
||||
alt="Serwis klimatyzacji Wrocław"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-primary/50" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-white/10 rounded-full text-sm font-medium mb-6">
|
||||
<Wrench className="size-4" />
|
||||
Wszystkie marki — Dolny Śląsk
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold font-heading leading-tight mb-6">
|
||||
Serwis klimatyzacji Wrocław<br />
|
||||
<span className="text-accent">szybko i z gwarancją jakości</span>
|
||||
</h1>
|
||||
<p className="text-lg text-white/80 mb-8 max-w-2xl">
|
||||
Czyszczenie, odgrzybianie, przeglądy techniczne i naprawy klimatyzatorów wszystkich marek.
|
||||
Działamy na terenie Wrocławia i całego Dolnego Śląska — szybki termin, uczciwa cena.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<Button variant="primary" size="md">
|
||||
<Phone className="size-5" /> Zadzwoń: {companyDetails.phone}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
|
||||
|
||||
Zgłoś awarię online <ArrowRight className="size-5" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Usługi serwisowe */}
|
||||
<section className="py-16 bg-white">
|
||||
<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-8">Co obejmuje nasz serwis?</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{USLUGI.map((u, i) => {
|
||||
const Icon = u.icon;
|
||||
return (
|
||||
<div key={i} className="flex gap-4 p-5 bg-slate-50 rounded-2xl border border-border">
|
||||
<div className="size-10 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="size-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<p className="font-bold text-primary text-sm">{u.title}</p>
|
||||
<span className="text-xs font-bold text-accent whitespace-nowrap">{u.cena}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{u.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Marki */}
|
||||
<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="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>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-extrabold text-primary font-heading mb-8">Często zadawane pytania</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{faqItems.map((item, i) => (
|
||||
<details key={i} className="group bg-slate-50 rounded-xl border border-border overflow-hidden">
|
||||
<summary className="flex items-center justify-between p-5 cursor-pointer font-semibold text-primary text-sm list-none">
|
||||
{item.q}
|
||||
<ArrowRight className="size-4 text-text-muted group-open:rotate-90 transition-transform shrink-0 ml-3" />
|
||||
</summary>
|
||||
<div className="px-5 pb-5 text-sm text-text-muted leading-relaxed border-t border-border pt-4">
|
||||
{item.a}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 bg-primary text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 text-center">
|
||||
<h2 className="text-2xl font-extrabold font-heading mb-4">
|
||||
Twój klimatyzator wymaga serwisu?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-8">
|
||||
Zadzwoń lub zgłoś awarię online — przyjeżdżamy na terenie Wrocławia i Dolnego Śląska.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<Button variant="primary" size="md">
|
||||
<Phone className="size-5" /> {companyDetails.phone}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/zgloszenie-awarii" className={buttonClasses({ variant: 'outline', size: 'md' })}>
|
||||
Zgłoś online <ArrowRight className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { getProducts } from '@/content/products';
|
||||
import { getPosts } from '@/content/posts';
|
||||
import { dolnySlaskCities } from '@/content/cities';
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = 'https://thermcool.pl';
|
||||
|
||||
// Statyczne trasy (Główne)
|
||||
const staticRoutes = [
|
||||
'',
|
||||
'/klimatyzacja',
|
||||
'/blog',
|
||||
'/o-nas',
|
||||
'/kalkulator',
|
||||
'/kontakt',
|
||||
'/sklep',
|
||||
'/koszyk',
|
||||
'/porownaj',
|
||||
'/regulamin',
|
||||
'/polityka-prywatnosci',
|
||||
'/reklamacje',
|
||||
'/montaz-klimatyzacji',
|
||||
'/serwis-klimatyzacji',
|
||||
'/cennik-montazu',
|
||||
'/dostawa',
|
||||
'/gwarancja',
|
||||
].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: route === '' ? 1 : 0.8,
|
||||
}));
|
||||
|
||||
const products = getProducts();
|
||||
const productRoutes = products.map((product) => ({
|
||||
url: `${baseUrl}/sklep/${product.slug}`,
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
const posts = getPosts();
|
||||
const postRoutes = posts.map((post) => ({
|
||||
url: `${baseUrl}/blog/${post.slug}`,
|
||||
lastModified: post.date,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.6,
|
||||
}));
|
||||
|
||||
const cityRoutes = dolnySlaskCities.map((city) => ({
|
||||
url: `${baseUrl}/klimatyzacja/${city.slug}`,
|
||||
lastModified: new Date().toISOString(),
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticRoutes, ...productRoutes, ...postRoutes, ...cityRoutes];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
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 }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getProducts().map(p => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = getProductBySlug(slug);
|
||||
if (!product) return { title: 'Produkt nie znaleziony' };
|
||||
return {
|
||||
title: `${product.brand} ${product.name}`,
|
||||
description: product.shortDescription || `Klimatyzator ${product.name} — sprawdź specyfikację i cenę. Profesjonalny montaż na Dolnym Śląsku.`,
|
||||
alternates: { canonical: `/sklep/${product.slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const product = getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
// Pobierz wszystkie produkty serii dla wariantów
|
||||
const variants = getProductVariants(slug);
|
||||
const allProducts = getProducts();
|
||||
const seriesProducts = variants
|
||||
? allProducts.filter(p => getProductVariants(p.slug)?.series === variants.series)
|
||||
: [product];
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://thermcool.pl';
|
||||
|
||||
const productJsonLd = product.price ? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": product.name,
|
||||
"description": product.shortDescription,
|
||||
"sku": product.sku,
|
||||
"brand": { "@type": "Brand", "name": product.brand },
|
||||
"image": product.image.startsWith('http') ? product.image : `${baseUrl}${product.image}`,
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"url": `${baseUrl}/sklep/${product.slug}`,
|
||||
"priceCurrency": "PLN",
|
||||
"price": product.price,
|
||||
"seller": { "@type": "Organization", "name": "ThermCool" },
|
||||
},
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productJsonLd && <JsonLd data={productJsonLd} />}
|
||||
<ProductPageClient
|
||||
product={product}
|
||||
seriesProducts={seriesProducts}
|
||||
companyPhone={companyDetails.phone}
|
||||
companyPhoneRaw={companyDetails.phoneRaw}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Metadata } from 'next';
|
||||
import { getProducts, getBrands } from '@/content/products';
|
||||
import { ProductsGrid } from '@/components/shop/ProductsGrid';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sklep z klimatyzatorami',
|
||||
description: 'Klimatyzatory split i multisplit — Gree, Rotenso, Haier, Daikin, LG, Samsung i inne. Szeroki wybór, konkurencyjne ceny, profesjonalny montaż.',
|
||||
alternates: { canonical: '/sklep' },
|
||||
};
|
||||
|
||||
export default function ShopPage() {
|
||||
const products = getProducts();
|
||||
const brands = getBrands();
|
||||
const splitCount = products.filter(p => p.category === 'scienne').length;
|
||||
const multiCount = products.filter(p => p.category === 'multisplit').length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="Sklep ThermCool"
|
||||
title="Klimatyzatory split i multisplit"
|
||||
subtitle={`${splitCount} klimatyzatorów split i ${multiCount} jednostek multisplit. Gree, Rotenso, Haier, Daikin, LG, Samsung i inne marki. Z montażem lub bez.`}
|
||||
ctaText="Zapytaj o montaż"
|
||||
ctaLink="/kontakt"
|
||||
secondaryCtaText=""
|
||||
secondaryCtaLink=""
|
||||
backgroundImage="/images/hero/shop-section-image.webp"
|
||||
/>
|
||||
<ProductsGrid products={products} brands={brands} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const runtime = 'edge'
|
||||
|
||||
export const alt = 'ThermCool - Klimatyzacja '
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
}
|
||||
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default async function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
background: '#0f2a47',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 130,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
letterSpacing: '-0.05em',
|
||||
}}
|
||||
>
|
||||
ThermCool
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 48,
|
||||
color: '#2ba8e0',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Klimatyzacja — Wrocław, Dolny Śląsk
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{
|
||||
...size,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { headers } from 'next/headers';
|
||||
import { getProductBySlug } from '@/content/products';
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from '@/lib/security';
|
||||
import { getMailClient } from '@/lib/mail';
|
||||
|
||||
const orderSchema = baseContactSchema.extend({
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
products: z.string(),
|
||||
total: z.string(), // We don't trust this total, just checking if it exists
|
||||
});
|
||||
|
||||
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);
|
||||
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'),
|
||||
email: formData.get('email'),
|
||||
address: formData.get('address') || '',
|
||||
notes: formData.get('notes') || '',
|
||||
consent: formData.get('consent'),
|
||||
products: formData.get('products'),
|
||||
total: formData.get('total'),
|
||||
});
|
||||
|
||||
let rawProducts;
|
||||
try {
|
||||
rawProducts = JSON.parse(data.products);
|
||||
} catch {
|
||||
return { success: false, message: "Błąd przetwarzania koszyka." };
|
||||
}
|
||||
|
||||
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('');
|
||||
|
||||
const { data: emailData, error } = await mailClient.resend.emails.send({
|
||||
from: mailClient.fromEmail,
|
||||
to: mailClient.toEmail,
|
||||
replyTo: data.email,
|
||||
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;">
|
||||
<h1 style="color:#fff;margin:0;font-size:20px;">Nowe zamówienie — ThermCool</h1>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
|
||||
<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;">
|
||||
<th style="padding:8px;text-align:left;">Produkt</th>
|
||||
<th style="padding:8px;text-align:center;">Ilość</th>
|
||||
<th style="padding:8px;text-align:right;">Wartość</th>
|
||||
</tr></thead>
|
||||
<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;">${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>
|
||||
</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, message: 'Dziękujemy! Skontaktujemy się z Tobą w ciągu 24 godzin, aby potwierdzić zamówienie i ustalić termin montażu.' };
|
||||
} catch (error) {
|
||||
console.error("Błąd formularza:", error);
|
||||
return { success: false, message: 'Wystąpił błąd podczas wysyłania zamówienia. Spróbuj ponownie.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Zamówienie',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function ZamowienieLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { useActionState, useEffect, startTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { submitOrderForm } from './actions';
|
||||
import { ArrowLeft, CheckCircle2 } from 'lucide-react';
|
||||
import { buttonClasses } from '@/components/ui/Button';
|
||||
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
|
||||
|
||||
|
||||
const initialState = { success: false, message: '' };
|
||||
|
||||
export default function ZamowieniePage() {
|
||||
const { items, getTotal, clearCart } = useCartStore();
|
||||
const [state, formAction, pending] = useActionState(submitOrderForm, initialState);
|
||||
const total = getTotal();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) clearCart();
|
||||
}, [state.success, clearCart]);
|
||||
|
||||
if (items.length === 0 && !state.success) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-text-muted mb-4">Koszyk jest pusty.</p>
|
||||
<Link href="/sklep" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Wróć do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.success) {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 flex items-center justify-center py-16">
|
||||
<div className="max-w-md mx-auto px-4 w-full">
|
||||
<div className="bg-green-50 text-green-800 p-8 rounded-2xl border border-green-200 flex flex-col items-center text-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-green-500 mb-4" />
|
||||
<h3 className="text-2xl font-bold font-heading mb-2">Zamówienie przyjęte!</h3>
|
||||
<p className="text-green-700">{state.message}</p>
|
||||
<Link href="/sklep" className="mt-6 px-6 py-2 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 transition-colors">
|
||||
Wróć do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen py-8">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/koszyk" className="inline-flex items-center gap-2 text-sm text-text-muted hover:text-accent mb-6">
|
||||
<ArrowLeft className="size-4" /> Wróć do koszyka
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary font-heading mb-8">Złóż zamówienie</h1>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Podsumowanie produktów */}
|
||||
<div className="bg-white rounded-xl border border-border p-6">
|
||||
<h2 className="text-base font-bold text-primary font-heading mb-4">Zamawiane produkty</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">{item.name} × {item.quantity}</span>
|
||||
<span className="font-semibold text-primary">{(item.price * item.quantity).toLocaleString('pl-PL')} zł</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t border-border pt-2 mt-2 flex justify-between font-bold text-primary">
|
||||
<span>Łącznie (urządzenia)</span>
|
||||
<span>{total.toLocaleString('pl-PL')} zł</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formularz */}
|
||||
<form onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const token = await getRecaptchaToken('submit');
|
||||
if (token) {
|
||||
formData.append('recaptchaToken', token);
|
||||
}
|
||||
startTransition(() => {
|
||||
formAction(formData);
|
||||
});
|
||||
}} className="bg-white rounded-xl border border-border p-6 flex flex-col gap-4">
|
||||
<h2 className="text-base font-bold text-primary font-heading">Twoje dane</h2>
|
||||
|
||||
{/* Hidden: produkty jako JSON */}
|
||||
<input type="hidden" name="products" value={JSON.stringify(items)} />
|
||||
<input type="hidden" name="total" value={total} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-primary mb-1 block">Imię i nazwisko *</label>
|
||||
<input name="name" required className="w-full border border-border rounded-lg px-3 py-2.5 text-sm focus:border-accent outline-none" placeholder="Jan Kowalski" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-primary mb-1 block">Telefon *</label>
|
||||
<input name="phone" required type="tel" className="w-full border border-border rounded-lg px-3 py-2.5 text-sm focus:border-accent outline-none" placeholder="+48 600 000 000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-primary mb-1 block">Email *</label>
|
||||
<input name="email" required type="email" className="w-full border border-border rounded-lg px-3 py-2.5 text-sm focus:border-accent outline-none" placeholder="[email protected]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-primary mb-1 block">Adres dostawy / montażu</label>
|
||||
<input name="address" className="w-full border border-border rounded-lg px-3 py-2.5 text-sm focus:border-accent outline-none" placeholder="ul. Przykładowa 1, 50-000 Wrocław" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-primary mb-1 block">Uwagi do zamówienia</label>
|
||||
<textarea name="notes" rows={3} className="w-full border border-border rounded-lg px-3 py-2.5 text-sm focus:border-accent outline-none resize-none" placeholder="Np. preferowany termin montażu, piętro, specjalne wymagania..." />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<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>
|
||||
|
||||
{state.message && !state.success && (
|
||||
<p className="text-sm text-red-500">{state.message}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={pending} className="btn btn-primary text-white font-bold w-full flex items-center justify-center gap-2">
|
||||
{pending ? 'Wysyłanie...' : 'Wyślij zamówienie'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-text-muted text-center">
|
||||
Po złożeniu zamówienia skontaktujemy się z Tobą telefonicznie w ciągu 24 godzin, aby potwierdzić szczegóły i ustalić termin montażu.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getProductBySlug, getProducts } from '@/content/products';
|
||||
import { companyDetails } from '@/content/company';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Phone, Zap, Wind, Volume2, Thermometer } from 'lucide-react';
|
||||
import MultisplitContactForm from '@/components/shop/MultisplitContactForm';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getProducts()
|
||||
.filter(p => p.category === 'multisplit')
|
||||
.map(p => ({ slug: p.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const product = getProductBySlug(slug);
|
||||
if (!product) return { title: 'Produkt nie znaleziony' };
|
||||
return {
|
||||
title: `Zapytaj o ${product.name}`,
|
||||
description: `Zapytaj o wycenę systemu multisplit ${product.name}. Bezpłatna konsultacja i wycena montażu.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MultisplitInquiryPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const product = getProductBySlug(slug);
|
||||
if (!product || product.category !== 'multisplit') notFound();
|
||||
|
||||
const specIcons: Record<string, React.ElementType> = {
|
||||
'Moc chłodnicza': Zap,
|
||||
'Moc grzewcza': Thermometer,
|
||||
'Głośność wewnętrzna (dB)': Volume2,
|
||||
'Klasa energetyczna': Wind,
|
||||
};
|
||||
|
||||
const keySpecs = ['Moc chłodnicza', 'Moc grzewcza', 'Klasa energetyczna', 'Głośność wewnętrzna (dB)'];
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b border-border">
|
||||
<div className="max-w-5xl mx-auto px-4 py-3">
|
||||
<nav className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<Link href="/" className="hover:text-accent transition-colors">Strona główna</Link>
|
||||
<span>/</span>
|
||||
<Link href="/sklep" className="hover:text-accent transition-colors">Sklep</Link>
|
||||
<span>/</span>
|
||||
<Link href="/sklep?category=multisplit" className="hover:text-accent transition-colors">Multisplit</Link>
|
||||
<span>/</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{product.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 py-10">
|
||||
<Link href="/sklep?category=multisplit" className="inline-flex items-center gap-1.5 text-sm text-text-muted hover:text-accent transition-colors mb-6">
|
||||
<ArrowLeft className="size-4" /> Wróć do multisplitów
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-10">
|
||||
{/* Lewa — zdjęcie + specyfikacja */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-white rounded-2xl border border-border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xs font-bold text-white bg-accent px-2.5 py-1 rounded-full">Multisplit</span>
|
||||
<span className="text-xs text-text-muted">{product.brand}</span>
|
||||
</div>
|
||||
<div className="relative w-full h-56 mb-4 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
{product.image && !product.image.includes('placeholder') ? (
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-text-muted">
|
||||
<Wind className="size-12 text-slate-300" />
|
||||
<span className="text-xs">Zdjęcie wkrótce</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-lg font-extrabold text-primary font-heading mb-1">{product.name}</h1>
|
||||
{product.shortDescription && (
|
||||
<p className="text-sm text-text-muted">{product.shortDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kluczowe parametry */}
|
||||
<div className="bg-white rounded-2xl border border-border p-5">
|
||||
<h2 className="text-sm font-bold text-primary mb-3">Kluczowe parametry</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{keySpecs.map(key => {
|
||||
const val = product.specs?.[key];
|
||||
if (!val) return null;
|
||||
const Icon = specIcons[key] || Zap;
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-2 p-3 bg-slate-50 rounded-xl">
|
||||
<Icon className="size-4 text-accent shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs text-text-muted">{key}</p>
|
||||
<p className="text-sm font-bold text-primary">{val}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wycena indywidualna info */}
|
||||
<div className="bg-accent/10 border border-accent/20 rounded-2xl p-5">
|
||||
<p className="text-sm font-bold text-accent mb-1">Wycena indywidualna</p>
|
||||
<p className="text-xs text-text-muted leading-relaxed">
|
||||
Cena systemu multisplit zależy od liczby pomieszczeń, długości instalacji i warunków montażu.
|
||||
Wypełnij formularz — oddzwonimy z bezpłatną wyceną w ciągu 24 godzin.
|
||||
</p>
|
||||
<a href={`tel:${companyDetails.phoneRaw}`} className="inline-flex items-center gap-1.5 mt-3 text-sm font-bold text-accent hover:underline">
|
||||
<Phone className="size-4" /> {companyDetails.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prawa — formularz */}
|
||||
<div>
|
||||
<div className="bg-white rounded-2xl border border-border p-6">
|
||||
<h2 className="text-base font-extrabold text-primary font-heading mb-1">
|
||||
Zapytaj o wycenę
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mb-6">
|
||||
Wypełnij formularz — skontaktujemy się w ciągu 24 godzin z bezpłatną wyceną montażu.
|
||||
</p>
|
||||
<MultisplitContactForm productName={product.name} productSku={product.sku} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { headers } from "next/headers";
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
|
||||
import { getMailClient } from "@/lib/mail";
|
||||
|
||||
const multisplitFormSchema = baseContactSchema.extend({
|
||||
city: z.string().min(1, "Wybierz miasto"),
|
||||
rooms: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
productName: z.string(),
|
||||
productSku: z.string(),
|
||||
recaptchaToken: z.string().optional(),
|
||||
});
|
||||
|
||||
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 || 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);
|
||||
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." };
|
||||
}
|
||||
|
||||
// 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: ${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>
|
||||
</div>
|
||||
<div style="padding: 24px; color: #1e293b;">
|
||||
<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;">${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;">${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;">${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;">${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;">${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;">${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;">
|
||||
${escapeHtml(validatedData.message || 'Brak')}
|
||||
</div>
|
||||
</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, message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe." };
|
||||
} catch (error) {
|
||||
console.error("Błąd formularza:", error);
|
||||
return { success: false, message: "Błąd walidacji lub błąd serwera. Spróbuj ponownie później." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useConsentStore } from '@/lib/consent-store';
|
||||
|
||||
interface CookiePreferences {
|
||||
necessary: boolean;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
advertising: boolean;
|
||||
}
|
||||
|
||||
export default function CookieBanner() {
|
||||
const {
|
||||
isInitialized,
|
||||
isOpen,
|
||||
analytics,
|
||||
marketing,
|
||||
advertising,
|
||||
setConsent,
|
||||
acceptAll: storeAcceptAll,
|
||||
rejectOptional: storeRejectOptional
|
||||
} = useConsentStore();
|
||||
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [prefs, setPrefs] = useState<CookiePreferences>({
|
||||
necessary: true,
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
advertising: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
useConsentStore.getState().initialize();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
setPrefs({
|
||||
necessary: true,
|
||||
analytics: analytics,
|
||||
marketing: marketing,
|
||||
advertising: advertising,
|
||||
});
|
||||
setShowDetails(false);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, analytics, marketing, advertising]);
|
||||
|
||||
const saveSelected = () => {
|
||||
setConsent(prefs.analytics, prefs.marketing, prefs.advertising);
|
||||
};
|
||||
|
||||
if (!isInitialized || !isOpen) return null;
|
||||
|
||||
return (
|
||||
<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-sm sm:text-base font-extrabold text-primary font-heading mb-1">
|
||||
Ta strona używa plików cookie
|
||||
</h2>
|
||||
<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">
|
||||
Polityka prywatności
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="flex flex-col gap-3 mb-5 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Niezbędne</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Wymagane do prawidłowego działania strony. Nie można wyłączyć.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div className="w-11 h-6 bg-accent rounded-full relative cursor-not-allowed opacity-60">
|
||||
<div className="absolute right-1 top-1 size-4 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Google Analytics — pomagają nam zrozumieć jak użytkownicy korzystają ze strony. Dane są anonimowe.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPrefs(p => ({ ...p, analytics: !p.analytics }))}
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className={`w-11 h-6 rounded-full relative transition-colors ${prefs.analytics ? 'bg-accent' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 size-4 bg-white rounded-full transition-all ${prefs.analytics ? 'right-1' : 'left-1'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Google Ads, Facebook Ads — pozwalają mierzyć skuteczność kampanii i docierać do odpowiednich odbiorców.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPrefs(p => ({ ...p, marketing: !p.marketing }))}
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className={`w-11 h-6 rounded-full relative transition-colors ${prefs.marketing ? 'bg-accent' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 size-4 bg-white rounded-full transition-all ${prefs.marketing ? 'right-1' : 'left-1'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Meta Pixel, Google Ads — umożliwiają wyświetlanie spersonalizowanych reklam na platformach takich jak Facebook, Instagram i Google.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPrefs(p => ({ ...p, advertising: !p.advertising }))}
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className={`w-11 h-6 rounded-full relative transition-colors ${prefs.advertising ? 'bg-accent' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 size-4 bg-white rounded-full transition-all ${prefs.advertising ? 'right-1' : 'left-1'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:flex sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={storeAcceptAll}
|
||||
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="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ć
|
||||
</button>
|
||||
{showDetails ? (
|
||||
<button
|
||||
onClick={saveSelected}
|
||||
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
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDetails(true)}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Wind, Wrench, ThermometerSun, Paintbrush, Droplet, Tv, HelpCircle, ArrowRight,
|
||||
PaintBucket, Refrigerator, Hammer, Key, Bath, PaintRoller, Grid, Layers,
|
||||
AlertTriangle, Waves, Droplets, WashingMachine, Snowflake, Store, ScanLine, Settings
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IconCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
iconName: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Wind,
|
||||
Wrench,
|
||||
ThermometerSun,
|
||||
Paintbrush,
|
||||
Droplet,
|
||||
Tv,
|
||||
PaintBucket,
|
||||
Refrigerator,
|
||||
Hammer,
|
||||
Key,
|
||||
Bath,
|
||||
PaintRoller,
|
||||
Grid,
|
||||
Layers,
|
||||
AlertTriangle,
|
||||
Waves,
|
||||
Droplets,
|
||||
WashingMachine,
|
||||
Snowflake,
|
||||
Store,
|
||||
ScanLine,
|
||||
Settings,
|
||||
};
|
||||
|
||||
export function IconCard({ title, description, iconName, href, className }: IconCardProps) {
|
||||
const Icon = iconMap[iconName] || HelpCircle;
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="p-3 rounded-lg bg-slate-100 text-primary w-fit group-hover:bg-accent-light group-hover:text-accent transition-colors duration-300">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-primary font-heading mt-4 group-hover:text-accent transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mt-2 leading-relaxed flex-grow">
|
||||
{description}
|
||||
</p>
|
||||
{href && (
|
||||
<div className="flex items-center gap-1 text-xs font-semibold text-primary group-hover:text-accent mt-4 transition-colors">
|
||||
<span>Dowiedz się więcej</span>
|
||||
<ArrowRight className="size-3.5 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardClasses = cn(
|
||||
"group border border-border rounded-xl p-6 bg-white hover:shadow-md transition-all duration-300 flex flex-col justify-between h-full",
|
||||
className
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={cardClasses}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cardClasses}>{cardContent}</div>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PlaceholderProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
aspect?: "video" | "square" | "portrait" | "auto" | "banner";
|
||||
}
|
||||
|
||||
export function Placeholder({ text, className, aspect = "video" }: PlaceholderProps) {
|
||||
const aspectClasses = {
|
||||
video: "aspect-video",
|
||||
square: "aspect-square",
|
||||
portrait: "aspect-[3/4]",
|
||||
banner: "aspect-[21/9] min-h-[300px]",
|
||||
auto: "h-full w-full",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full bg-slate-100 dark:bg-slate-800 border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl flex flex-col items-center justify-center text-center p-6 text-slate-400 dark:text-slate-500 overflow-hidden group hover:bg-slate-150 transition-colors duration-250",
|
||||
aspectClasses[aspect],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* TODO: docelowe zdjęcie — {text} */}
|
||||
<ImageIcon className="size-10 mb-3 group-hover:scale-110 transition-transform duration-300" />
|
||||
<span className="text-sm font-medium px-4 leading-normal">{text}</span>
|
||||
<span className="text-[10px] text-slate-400 mt-2 uppercase tracking-widest font-semibold font-heading opacity-70">
|
||||
Placeholder Zdjęcia
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
align?: "left" | "center";
|
||||
dark?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
title,
|
||||
subtitle,
|
||||
eyebrow,
|
||||
align = "center",
|
||||
dark = false,
|
||||
className,
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mb-10 md:mb-14",
|
||||
align === "center" ? "items-center text-center" : "items-start text-left",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{eyebrow && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs md:text-sm font-semibold uppercase tracking-wider font-heading",
|
||||
"text-accent"
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className={cn(
|
||||
"text-3xl md:text-4xl font-bold leading-tight font-heading max-w-3xl",
|
||||
dark ? "text-white" : "text-primary"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-base md:text-lg max-w-2xl leading-relaxed mt-1",
|
||||
dark ? "text-slate-300" : "text-text-muted"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2, CheckCircle2, AlertCircle, Check } from 'lucide-react';
|
||||
import { submitContactForm } from '@/app/kontakt/actions';
|
||||
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
|
||||
|
||||
const contactFormSchema = 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(),
|
||||
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>;
|
||||
|
||||
export function ContactForm() {
|
||||
const [serverState, setServerState] = useState<{ success?: boolean; message?: string } | null>(null);
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: hookFormSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
watch
|
||||
} = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
brand: '',
|
||||
age: '',
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ContactFormValues) => {
|
||||
setServerState(null);
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (value !== undefined) formData.append(key, value.toString());
|
||||
});
|
||||
formData.append("consent", consent ? 'true' : 'false');
|
||||
|
||||
const token = await getRecaptchaToken('submit');
|
||||
if (token) {
|
||||
formData.append('recaptchaToken', token);
|
||||
}
|
||||
|
||||
const response = await submitContactForm(null, formData);
|
||||
|
||||
setServerState({
|
||||
success: response.success,
|
||||
message: response.message
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
reset();
|
||||
setConsent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
if (!consent) {
|
||||
e.preventDefault();
|
||||
alert('Proszę zaznaczyć zgodę na przetwarzanie danych.');
|
||||
return;
|
||||
}
|
||||
hookFormSubmit(onSubmit)(e);
|
||||
};
|
||||
|
||||
if (serverState?.success) {
|
||||
return (
|
||||
<div className="bg-green-50 text-green-800 p-8 rounded-2xl border border-green-200 flex flex-col items-center text-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-green-500 mb-4" />
|
||||
<h3 className="text-2xl font-bold font-heading mb-2">Wiadomość wysłana!</h3>
|
||||
<p className="text-green-700">{serverState.message}</p>
|
||||
<button
|
||||
onClick={() => setServerState(null)}
|
||||
className="mt-6 px-6 py-2 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Wyślij kolejną wiadomość
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 bg-white p-6 md:p-8 rounded-2xl border border-border shadow-sm">
|
||||
{serverState && !serverState.success && (
|
||||
<div className="bg-red-50 text-red-800 p-4 rounded-xl border border-red-200 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<p className="text-sm font-medium">{serverState.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Imię */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-semibold text-primary">Imię i nazwisko <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
{...register("name")}
|
||||
id="name"
|
||||
required
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.name ? '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`}
|
||||
placeholder="Jan Kowalski"
|
||||
aria-invalid={errors.name ? "true" : "false"}
|
||||
/>
|
||||
{errors.name && <p className="text-red-500 text-xs font-medium">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Telefon */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className="text-sm font-semibold text-primary">Telefon <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
{...register("phone")}
|
||||
id="phone"
|
||||
type="tel"
|
||||
required
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.phone ? '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`}
|
||||
placeholder="+48 123 456 789"
|
||||
/>
|
||||
{errors.phone && <p className="text-red-500 text-xs font-medium">{errors.phone.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-semibold text-primary">Adres e-mail</label>
|
||||
<input
|
||||
{...register("email")}
|
||||
id="email"
|
||||
type="email"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.email ? '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`}
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-xs font-medium">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Miasto */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="city" className="text-sm font-semibold text-primary">Miasto <span className="text-red-500">*</span></label>
|
||||
<select
|
||||
{...register("city")}
|
||||
id="city"
|
||||
required
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.city ? '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 miasto...</option>
|
||||
<option value="Wrocław">Wrocław</option>
|
||||
<option value="Legnica">Legnica</option>
|
||||
<option value="Wałbrzych">Wałbrzych</option>
|
||||
<option value="Jelenia Góra">Jelenia Góra</option>
|
||||
<option value="Lubin">Lubin</option>
|
||||
<option value="Głogów">Głogów</option>
|
||||
<option value="Świdnica">Świdnica</option>
|
||||
<option value="Dzierżoniów">Dzierżoniów</option>
|
||||
<option value="Oleśnica">Oleśnica</option>
|
||||
<option value="Oława">Oława</option>
|
||||
<option value="Trzebnica">Trzebnica</option>
|
||||
<option value="Środa Śląska">Środa Śląska</option>
|
||||
<option value="Inne">Inne miasto</option>
|
||||
</select>
|
||||
{errors.city && <p className="text-red-500 text-xs font-medium">{errors.city.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temat */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="subject" className="text-sm font-semibold text-primary">Temat rozmowy</label>
|
||||
<select
|
||||
{...register("subject")}
|
||||
id="subject"
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.subject ? '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 temat...</option>
|
||||
<option value="klimatyzacja">Klimatyzacja</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>
|
||||
<textarea
|
||||
{...register("message")}
|
||||
id="message"
|
||||
rows={5}
|
||||
className={`w-full px-4 py-3 rounded-xl border ${errors.message ? '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 resize-y`}
|
||||
placeholder="Opisz krótko, w czym możemy Ci pomóc..."
|
||||
/>
|
||||
{errors.message && <p className="text-red-500 text-xs font-medium">{errors.message.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Załączniki */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-primary">
|
||||
Załącz zdjęcia <span className="text-text-muted font-normal">(opcjonalnie)</span>
|
||||
</label>
|
||||
<label className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-border rounded-xl p-6 cursor-pointer hover:border-accent hover:bg-accent/5 transition-colors">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.pdf"
|
||||
className="sr-only"
|
||||
onChange={(e) => setFiles(Array.from(e.target.files || []))}
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="size-8 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="text-sm text-text-muted text-center">
|
||||
Kliknij aby wybrać zdjęcia lub przeciągnij tutaj<br />
|
||||
<span className="text-xs">JPG, PNG, WEBP, PDF — maks. 10 MB każdy</span>
|
||||
</span>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{files.map((f, i) => (
|
||||
<span key={i} className="text-xs bg-accent/10 text-accent px-2 py-1 rounded-md font-medium">
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<p className="text-xs text-text-muted">Zdjęcia miejsca montażu znacznie przyspieszają wycenę.</p>
|
||||
</div>
|
||||
|
||||
{/* RODO */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 cursor-pointer" onClick={() => setConsent(!consent)}>
|
||||
<div className={`size-5 rounded border-2 shrink-0 mt-0.5 flex items-center justify-center transition-colors ${
|
||||
consent ? 'bg-accent border-accent' : 'bg-white border-slate-300'
|
||||
}`}>
|
||||
{consent && <Check className="size-3 text-white" />}
|
||||
</div>
|
||||
<input type="hidden" name="consent" value={consent ? 'true' : ''} />
|
||||
<span className="text-sm text-text-muted leading-relaxed select-none">
|
||||
Wyrażam zgodę na przetwarzanie moich danych osobowych podanych w formularzu w celu obsługi zapytania przez firmę ThermCool. Zapoznałem/am się z Polityką Prywatności. *
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn btn-primary w-full text-white font-bold py-4 h-auto rounded-xl flex items-center justify-center gap-2 shadow-md"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Wysyłanie...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Wyślij zapytanie</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRight, ArrowLeft, Check, Home, Zap, ShoppingCart, Phone } from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
|
||||
interface Props {
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
// Hook do zapisywania stanu w sessionStorage
|
||||
function useSessionState<T>(key: string, initialValue: T) {
|
||||
const [state, setState] = useState<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const item = window.sessionStorage.getItem(key);
|
||||
if (item) {
|
||||
const timer = setTimeout(() => setState(JSON.parse(item)), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} catch {}
|
||||
}, [key]);
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(state) : value;
|
||||
setState(valueToStore);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return [state, setValue] as const;
|
||||
}
|
||||
|
||||
// Oblicz wymaganą moc chłodniczą w kW
|
||||
function calculatePower(m2: number, floor: string, sun: string, building: string): number {
|
||||
let power = m2 * 0.1; // baza: 1 kW na 10 m²
|
||||
|
||||
// Korekta typu budynku i piętra
|
||||
if (building === 'apartment') {
|
||||
if (floor === 'top') power *= 1.2; // ostatnie piętro - więcej ciepła z dachu
|
||||
if (floor === 'ground') power *= 0.95;
|
||||
} else if (building === 'house') {
|
||||
if (floor === 'top') power *= 1.1; // poddasze - dodatkowe nagrzewanie
|
||||
}
|
||||
|
||||
// Korekta nasłonecznienia
|
||||
if (sun === 'south') power *= 1.15; // południe - duże nasłonecznienie
|
||||
if (sun === 'west') power *= 1.1;
|
||||
if (sun === 'north') power *= 0.9;
|
||||
|
||||
return Math.round(power * 10) / 10;
|
||||
}
|
||||
|
||||
// Szacunkowy koszt montażu
|
||||
function estimateInstall(pipes: string, rooms: number, installMethod: string): { min: number; max: number } {
|
||||
const base = rooms === 1 ? 1400 : rooms === 2 ? 2400 : rooms === 3 ? 3200 : 3800;
|
||||
// Przybliżona liczba dodatkowych metrów (powyżej 3m)
|
||||
const extraMeters = pipes === 'short' ? 0 : pipes === 'medium' ? 3 : pipes === 'long' ? 7 : 15;
|
||||
const methodPrice = installMethod === 'bruzda' ? 90 : 80; // zł za metr
|
||||
// Zostawmy bazowe koszty rur miedzianych (np. miedź, kable) i dodajmy koszt bruzdy/korytka
|
||||
const pipeBaseCost = pipes === 'short' ? 0 : pipes === 'medium' ? 200 : pipes === 'long' ? 500 : 1000;
|
||||
|
||||
const pipeExtra = pipeBaseCost + (extraMeters * methodPrice);
|
||||
return { min: base + pipeExtra, max: base + pipeExtra + 600 };
|
||||
}
|
||||
const STEPS = ['Pomieszczenie', 'Instalacja', 'Budżet'] as const;
|
||||
export function KalkulatorClient({ products }: Props) {
|
||||
const [showWidthWarning, setShowWidthWarning] = useState(false);
|
||||
const [maxWidth, setMaxWidth] = useState(80);
|
||||
const [step, setStep] = useSessionState('calc_step', 0);
|
||||
const [building, setBuilding] = useSessionState('calc_building', 'apartment');
|
||||
const [pomieszczenia, setPomieszczenia] = useSessionState<{ nazwa: string; m2: number }[]>('calc_pomieszczenia', [
|
||||
{ nazwa: 'Pokój 1', m2: 20 }
|
||||
]);
|
||||
const [floor, setFloor] = useSessionState('calc_floor', 'middle'); // ground | middle | top
|
||||
const [sun, setSun] = useSessionState('calc_sun', 'south'); // north | east | south | west
|
||||
|
||||
const addRoom = () => {
|
||||
if (pomieszczenia.length < 5) {
|
||||
setPomieszczenia(prev => [...prev, { nazwa: `Pokój ${prev.length + 1}`, m2: 15 }]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRoom = (i: number) => {
|
||||
if (pomieszczenia.length > 1) {
|
||||
setPomieszczenia(prev => prev.filter((_, idx) => idx !== i));
|
||||
}
|
||||
};
|
||||
|
||||
const updateRoom = (i: number, m2: number) => {
|
||||
setPomieszczenia(prev => prev.map((r, idx) => idx === i ? { ...r, m2 } : r));
|
||||
};
|
||||
const [pipes, setPipes] = useSessionState('calc_pipes', 'medium'); // short(<3m) | medium(3-6m) | long(6-10m) | verylong(>10m)
|
||||
const [installMethod, setInstallMethod] = useSessionState<'korytko' | 'bruzda'>('calc_installMethod', 'korytko');
|
||||
|
||||
const [budget, setBudget] = useSessionState('calc_budget', 8000);
|
||||
const [preferredBrand, setPreferredBrand] = useSessionState('calc_brand', '');
|
||||
const [energyClass, setEnergyClass] = useSessionState('calc_energy', '');
|
||||
|
||||
const [page, setPage] = useSessionState('calc_page', 1);
|
||||
|
||||
const totalM2 = pomieszczenia.reduce((sum, p) => sum + p.m2, 0);
|
||||
const rooms = pomieszczenia.length;
|
||||
const requiredPower = useMemo(() =>
|
||||
pomieszczenia.reduce((sum, p) => sum + calculatePower(p.m2, floor, sun, building), 0),
|
||||
[pomieszczenia, floor, sun, building]
|
||||
);
|
||||
const requiredPowerRounded = Math.round(requiredPower * 10) / 10;
|
||||
const installCost = useMemo(() => estimateInstall(pipes, rooms, installMethod), [pipes, rooms, installMethod]);
|
||||
|
||||
const category = rooms === 1 ? 'scienne' : 'multisplit';
|
||||
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(s => s + 1);
|
||||
setPage(1);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen py-12">
|
||||
<div className={`mx-auto px-4 sm:px-6 transition-all duration-500 ${step === 3 ? 'max-w-5xl' : 'max-w-2xl'}`}>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-extrabold text-primary font-heading mb-2">
|
||||
Dobierz urządzenie
|
||||
</h1>
|
||||
<p className="text-text-muted">Odpowiedz na {STEPS.length} krótkich pytań — pokażemy Ci najlepsze opcje z naszej oferty</p>
|
||||
</div>
|
||||
|
||||
{/* Pasek postępu */}
|
||||
<div className="flex items-center mb-8">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className="flex items-start flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className={`size-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-all ${
|
||||
i < step ? 'bg-green-500 text-white' : i === step ? 'bg-accent text-white' : 'bg-slate-200 text-text-muted'
|
||||
}`}>
|
||||
{i < step ? <Check className="size-3.5" /> : i + 1}
|
||||
</div>
|
||||
<span className={`text-xs font-medium hidden sm:block whitespace-nowrap ${i === step ? 'text-accent' : 'text-text-muted'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 mt-[13px] ${i < step ? 'bg-green-500' : 'bg-slate-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Karty kroków */}
|
||||
<div className="bg-white rounded-2xl border border-border p-6 md:p-8">
|
||||
|
||||
{/* Krok 0: Pomieszczenie */}
|
||||
{step === 0 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="size-10 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||
<Home className="size-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-primary font-heading">Pomieszczenie</h2>
|
||||
<p className="text-xs text-text-muted">Metraż i warunki</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista pomieszczeń */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{pomieszczenia.map((p, i) => (
|
||||
<div key={i} className="bg-slate-50 rounded-xl border border-border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-primary">{p.nazwa}</span>
|
||||
{pomieszczenia.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeRoom(i)}
|
||||
className="text-xs text-red-400 hover:text-red-600 transition-colors"
|
||||
>
|
||||
Usuń
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">Powierzchnia</span>
|
||||
<span className="text-xs font-bold text-primary bg-accent/10 px-2 py-0.5 rounded-md">{p.m2} m²</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={5} max={100} step={5} value={p.m2}
|
||||
onChange={e => updateRoom(i, Number(e.target.value))}
|
||||
className="range range-xs range-accent w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>5 m²</span>
|
||||
<span>100 m²</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const mocPokoju = Math.round(calculatePower(p.m2, floor, sun, building) * 10) / 10;
|
||||
const dostepneMoce = [2.5, 3.5, 5.0, 7.0];
|
||||
const zalecana = dostepneMoce.reduce((prev, curr) =>
|
||||
Math.abs(curr - mocPokoju) < Math.abs(prev - mocPokoju) ? curr : prev
|
||||
);
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between bg-white border border-border rounded-lg px-3 py-2">
|
||||
<span className="text-xs text-text-muted">Zalecana moc jednostki</span>
|
||||
<span className="text-sm font-bold text-accent">{zalecana} kW</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pomieszczenia.length < 5 && (
|
||||
<button
|
||||
onClick={addRoom}
|
||||
className="flex items-center gap-2 text-sm text-accent font-semibold hover:text-accent/80 transition-colors border border-dashed border-accent/40 rounded-xl px-4 py-3 w-full justify-center"
|
||||
>
|
||||
+ Dodaj kolejne pomieszczenie
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ograniczenie szerokości */}
|
||||
<div className="bg-slate-50 border border-border rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Ograniczenie szerokości jednostki?</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">Np. wąska ściana, wnęka, szafa</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWidthWarning(prev => !prev)}
|
||||
className={`shrink-0 text-xs font-bold px-3 py-1.5 rounded-lg transition-colors ${
|
||||
showWidthWarning ? 'bg-amber-100 text-amber-700' : 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showWidthWarning ? 'Tak, mam ograniczenie' : 'Nie dotyczy'}
|
||||
</button>
|
||||
</div>
|
||||
{showWidthWarning && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-text-muted">Maksymalna szerokość jednostki</span>
|
||||
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded-md">{maxWidth} cm</span>
|
||||
</div>
|
||||
<div className="relative h-5 flex items-center">
|
||||
<div className="absolute w-full h-1.5 bg-slate-200 rounded-full" />
|
||||
<div
|
||||
className="absolute h-1.5 bg-amber-400 rounded-full"
|
||||
style={{ width: `${((maxWidth - 40) / 80) * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range" min={40} max={120} step={5} value={maxWidth}
|
||||
onChange={e => setMaxWidth(Number(e.target.value))}
|
||||
className="absolute w-full h-1.5 opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="absolute size-4 bg-white border-2 border-amber-400 rounded-full shadow-md pointer-events-none"
|
||||
style={{ left: `calc(${((maxWidth - 40) / 80) * 100}% - 8px)` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-muted mt-1">
|
||||
<span>40 cm</span>
|
||||
<span>120 cm</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<span className="text-amber-500 text-base shrink-0">⚠️</span>
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
Standardowe jednostki split mają <strong>70–110 cm szerokości</strong>.
|
||||
{maxWidth < 70 && ' Przy tak małej przestrzeni wybór jest bardzo ograniczony — '}
|
||||
{maxWidth >= 70 && maxWidth < 85 && ' Przy tej szerokości dostępne są kompaktowe modele — '}
|
||||
{maxWidth >= 85 && ' Większość modeli powinna się zmieścić — '}
|
||||
skonsultuj wymiary konkretnego modelu przed zakupem.
|
||||
<a href="/kontakt" className="ml-1 font-bold text-amber-700 underline">Zapytaj nas →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-accent/10 rounded-xl">
|
||||
<p className="text-sm text-accent font-semibold">
|
||||
Łączna wymagana moc: ok. <strong>{requiredPowerRounded} kW</strong>
|
||||
<span className="text-xs font-normal text-text-muted ml-2">
|
||||
({pomieszczenia.length === 1 ? 'klimatyzator split' : `multisplit ${pomieszczenia.length}+1`}, łącznie {totalM2} m²)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">Typ budynku</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: 'apartment', label: '🏢 Mieszkanie w bloku', sub: 'Kamienica, wieżowiec, blok' },
|
||||
{ value: 'house', label: '🏠 Dom jednorodzinny', sub: 'Wolnostojący, szeregowiec, bliźniak' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setBuilding(o.value)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${building === 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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{building === 'house' && (
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">Typ przestrzeni</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: 'ground', label: '🏠 Parter' },
|
||||
{ value: 'top', label: '🏠 Piętro / poddasze' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setFloor(o.value)}
|
||||
className={`py-2.5 px-3 rounded-xl text-sm font-semibold border-2 transition-all ${floor === o.value ? 'border-accent bg-accent text-white' : 'border-border bg-white text-primary hover:border-accent'}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{building === 'apartment' && (
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">Piętro</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ value: 'ground', label: 'Parter' },
|
||||
{ value: 'low', label: '1-3 piętro' },
|
||||
{ value: 'middle', label: '4-8 piętro' },
|
||||
{ value: 'high', label: '9+ piętro' },
|
||||
{ value: 'top', label: 'Ostatnie' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setFloor(o.value)}
|
||||
className={`py-2 px-4 rounded-xl text-sm font-semibold border-2 transition-all ${floor === o.value ? 'border-accent bg-accent text-white' : 'border-border bg-white text-primary hover:border-accent'}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">Ekspozycja okien</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: 'north', label: '🧭 Północ (chłodna)' },
|
||||
{ value: 'east', label: '🧭 Wschód' },
|
||||
{ value: 'west', label: '🧭 Zachód' },
|
||||
{ value: 'south', label: '🧭 Południe (mocne słońce)' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setSun(o.value)}
|
||||
className={`py-2.5 px-3 rounded-xl text-sm font-semibold border-2 transition-all ${sun === o.value ? 'border-accent bg-accent text-white' : 'border-border bg-white text-primary hover:border-accent'}`}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Krok 2: Instalacja */}
|
||||
{step === 1 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="size-10 rounded-xl bg-orange-50 flex items-center justify-center">
|
||||
<Zap className="size-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-primary font-heading">Długość instalacji</h2>
|
||||
<p className="text-xs text-text-muted">Rury i kable między jednostkami</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{[
|
||||
{ value: 'short', label: 'Do 3 metrów', sub: 'Jednostka zewnętrzna tuż obok (balkon, taras)' },
|
||||
{ value: 'medium', label: '3–6 metrów', sub: 'Typowa instalacja w bloku lub domu' },
|
||||
{ value: 'long', label: '6–10 metrów', sub: 'Dłuższa trasa, np. przez kilka pomieszczeń' },
|
||||
{ value: 'verylong', label: 'Powyżej 10 metrów', sub: 'Bardzo długa trasa, wymaga dodatkowych materiałów' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setPipes(o.value)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${pipes === 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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">Sposób prowadzenia instalacji (powyżej 3m)</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[
|
||||
{ value: 'korytko', label: 'Korytko PCV', sub: 'Rury schowane w białym korytku (80 zł/mb)' },
|
||||
{ value: 'bruzda', label: 'Bruzda w ścianie', sub: 'Rury ukryte w wykutej bruzdzie (90 zł/mb)' },
|
||||
].map(o => (
|
||||
<button key={o.value} onClick={() => setInstallMethod(o.value as 'korytko' | 'bruzda')}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${installMethod === 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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-3 bg-orange-50 rounded-xl text-xs text-orange-700">
|
||||
Szacowany koszt montażu dla Twojej konfiguracji:
|
||||
<strong className="ml-1">{installCost.min.toLocaleString('pl-PL')}–{installCost.max.toLocaleString('pl-PL')} zł</strong>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<p className="text-sm font-semibold text-blue-900 mb-1">📐 Chcesz dokładną wycenę?</p>
|
||||
<p className="text-xs text-blue-800 leading-relaxed">
|
||||
Skontaktuj się z nami — wycena jest <strong>bezpłatna i niezobowiązująca</strong>.
|
||||
Aby przyspieszyć wycenę, wyślij nam zdjęcia:
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-1">
|
||||
{[
|
||||
'Miejsca gdzie ma wisieć jednostka wewnętrzna',
|
||||
'Ściany przez które przejdzie instalacja',
|
||||
'Miejsca gdzie stanie jednostka zewnętrzna',
|
||||
'Skrzynki elektrycznej (bezpieczniki)',
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs text-blue-800">
|
||||
<span className="text-blue-500 shrink-0">→</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/kontakt" className="mt-3 inline-flex items-center gap-1.5 text-xs font-bold text-white bg-accent px-3 py-1.5 rounded-lg hover:bg-accent/90 transition-colors">
|
||||
Wyślij zdjęcia i zapytaj o wycenę →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Krok 3: Budżet */}
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="size-10 rounded-xl bg-green-50 flex items-center justify-center">
|
||||
<ShoppingCart className="size-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-primary font-heading">Całkowity budżet</h2>
|
||||
<p className="text-xs text-text-muted">Urządzenie + montaż łącznie</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">Budżet</span>
|
||||
<span className="text-xs font-bold text-primary bg-accent/10 px-2 py-0.5 rounded-md">{budget.toLocaleString('pl-PL')} zł</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={3000} max={25000} step={500} value={budget}
|
||||
onChange={e => setBudget(Number(e.target.value))}
|
||||
className="range range-xs range-accent w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>3 000 zł</span>
|
||||
<span>25 000 zł</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3">
|
||||
<p className="text-xs text-text-muted mb-1">Na urządzenie zostaje</p>
|
||||
<p className="text-base font-bold text-primary font-heading">
|
||||
ok. {(budget * 0.75).toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-orange-50 rounded-xl p-3">
|
||||
<p className="text-xs text-orange-700 mb-1">Szac. koszt montażu</p>
|
||||
<p className="text-base font-bold text-orange-700 font-heading">
|
||||
{installCost.min.toLocaleString('pl-PL')}–{installCost.max.toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 rounded-xl text-xs text-blue-700">
|
||||
💡 Podsumowanie: szukamy klimatyzatora <strong>{category === 'scienne' ? 'split ściennego' : 'multisplit'}</strong> o mocy ok. <strong>{requiredPowerRounded} kW</strong> w cenie do <strong>{(budget * 0.75).toLocaleString('pl-PL')} zł</strong>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">
|
||||
Preferowana marka (opcjonalnie)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['Bez preferencji', 'Gree', 'Rotenso', 'Daikin', 'Haier', 'LG', 'Samsung', 'Mitsubishi Electric', 'Toshiba', 'Fuji Electric'].map(b => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setPreferredBrand(b === 'Bez preferencji' ? '' : b)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-semibold border-2 transition-all ${
|
||||
(b === 'Bez preferencji' && preferredBrand === '') || preferredBrand === b
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-white text-primary hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-primary mb-3 block">
|
||||
Klasa energetyczna (opcjonalnie)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['Bez preferencji', 'A+++', 'A++', 'A+'].map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setEnergyClass(c === 'Bez preferencji' ? '' : c)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-semibold border-2 transition-all ${
|
||||
(c === 'Bez preferencji' && energyClass === '') || energyClass === c
|
||||
? 'border-accent bg-accent text-white'
|
||||
: 'border-border bg-white text-primary hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Podsumowanie */}
|
||||
<div className="bg-slate-50 rounded-2xl border border-border p-5">
|
||||
<h2 className="text-lg font-bold text-primary font-heading mb-4">
|
||||
Dopasowane klimatyzatory
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Wymagana moc', value: `${requiredPowerRounded} kW` },
|
||||
{ label: 'Powierzchnia', value: `${totalM2} m²` },
|
||||
{ label: 'Typ instalacji', value: category === 'scienne' ? 'Split (1 pokój)' : `Multisplit (${rooms} pokoje)` },
|
||||
{ label: 'Szac. montaż', value: `${installCost.min.toLocaleString('pl-PL')}–${installCost.max.toLocaleString('pl-PL')} zł` },
|
||||
...(energyClass ? [{ label: 'Klasa energetyczna', value: energyClass }] : []),
|
||||
|
||||
].map((s, i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-3 border border-border">
|
||||
<p className="text-xs text-text-muted mb-1">{s.label}</p>
|
||||
<p className="text-sm font-bold text-primary font-heading">{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl mt-2">
|
||||
<p className="text-xs font-bold text-amber-800 mb-1">⚠️ Ceny mają charakter orientacyjny</p>
|
||||
<p className="text-xs text-amber-700 mb-3">
|
||||
Podane ceny urządzeń są cenami brutto (23% VAT). Koszt montażu jest szacunkowy. Zachęcamy do darmowej wyceny.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<a href={`tel:${companyDetails.phoneRaw}`}>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-accent text-white rounded-xl text-xs font-bold hover:bg-accent/90 transition-colors">
|
||||
<Phone className="size-3.5" /> Bezpłatna wycena
|
||||
</button>
|
||||
</a>
|
||||
<Link href="/kontakt" className="flex items-center gap-2 px-4 py-2 bg-amber-100 text-amber-800 rounded-xl text-xs font-bold hover:bg-amber-200 transition-colors border border-amber-300">
|
||||
|
||||
Prześlij zdjęcia
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nawigacja */}
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-border">
|
||||
{step > 0 ? (
|
||||
<button onClick={() => setStep(s => s - 1)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 border border-border rounded-xl text-sm font-semibold text-primary hover:border-accent transition-colors">
|
||||
<ArrowLeft className="size-4" /> Wstecz
|
||||
</button>
|
||||
) : <div />}
|
||||
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button onClick={handleNext}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent/90 transition-colors">
|
||||
Dalej <ArrowRight className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/sklep" className="flex items-center gap-2 px-6 py-2.5 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent/90 transition-colors">
|
||||
|
||||
Przejdź do sklepu <ArrowRight className="size-4" />
|
||||
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Link from "next/link";
|
||||
import { Phone, Mail, MapPin, Clock, Facebook, Instagram, ShieldCheck } from "lucide-react";
|
||||
import { companyDetails } from "@/content/company";
|
||||
import { CookieSettingsTrigger } from "@/components/ui/CookieSettingsTrigger";
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="bg-[#00188D] text-primary-foreground">
|
||||
{/* Main Footer Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12">
|
||||
{/* Column 1: Company details */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link href="/" className="inline-block">
|
||||
<span className="text-2xl font-bold font-heading text-white">
|
||||
{companyDetails.shortName}
|
||||
<span className="text-white">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-slate-300 max-w-xs leading-relaxed">
|
||||
Profesjonalne usługi w zakresie klimatyzacji i pomp ciepła dla domów, mieszkań i firm.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 mt-2 text-sm text-slate-300">
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center gap-2.5 hover:text-white transition-colors"
|
||||
>
|
||||
<Phone className="size-4 shrink-0 text-white" />
|
||||
<span>{companyDetails.phone}</span>
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${companyDetails.email}`}
|
||||
className="flex items-center gap-2.5 hover:text-white transition-colors"
|
||||
>
|
||||
<Mail className="size-4 shrink-0 text-white" />
|
||||
<span>{companyDetails.email}</span>
|
||||
</a>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin className="size-4 shrink-0 mt-0.5 text-white" />
|
||||
<span>
|
||||
{companyDetails.address.street},<br />
|
||||
{companyDetails.address.zipCode} {companyDetails.address.city}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="size-4 shrink-0 mt-0.5 text-white" />
|
||||
<span>{companyDetails.hours}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
NIP: {companyDetails.nip}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Services */}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold font-heading text-white mb-6 uppercase tracking-wider">
|
||||
Usługi
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3 text-sm text-slate-300">
|
||||
<li>
|
||||
<Link href="/klimatyzacja" className="hover:text-white transition-colors">
|
||||
Klimatyzacja i Montaż
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<Link href="/sklep" className="hover:text-white transition-colors">
|
||||
Sklep online
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 3: Information */}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold font-heading text-white mb-6 uppercase tracking-wider">
|
||||
Informacje
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3 text-sm text-slate-300">
|
||||
{/* <li>
|
||||
<Link href="/o-nas" className="hover:text-white transition-colors">
|
||||
O nas
|
||||
</Link>
|
||||
</li> */}
|
||||
<li>
|
||||
<Link href="/realizacje" className="hover:text-white transition-colors">
|
||||
Realizacje (Portfolio)
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/blog" className="hover:text-white transition-colors">
|
||||
Blog i Poradniki
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/regulamin" className="hover:text-white transition-colors">
|
||||
Regulamin
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/polityka-prywatnosci" className="hover:text-white transition-colors">
|
||||
Polityka prywatności
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/reklamacje" className="hover:text-white transition-colors">
|
||||
Reklamacje
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/kontakt" className="hover:text-white transition-colors">
|
||||
Kontakt
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 4: Obsługa Klienta */}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold font-heading text-white mb-6 uppercase tracking-wider">
|
||||
Obsługa Klienta
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-3 text-sm text-slate-300">
|
||||
<li>
|
||||
<Link href="/sklep" className="hover:text-white transition-colors">
|
||||
Sklep online
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-slate-800 bg-slate-950/40 py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-slate-400 text-center sm:text-left">
|
||||
© {currentYear} {companyDetails.name}. Wszelkie prawa zastrzeżone. Projekt strony i wdrożenie ThermCool.
|
||||
</p>
|
||||
<CookieSettingsTrigger />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Social Icons */}
|
||||
<div className="flex gap-4">
|
||||
<a
|
||||
href={companyDetails.socials.facebook}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
aria-label="Odwiedź nasz profil na Facebooku"
|
||||
>
|
||||
<Facebook className="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href={companyDetails.socials.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-400 hover:text-white transition-colors"
|
||||
aria-label="Odwiedź nasz profil na Instagramie"
|
||||
>
|
||||
<Instagram className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Trust Symbol */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400 border-l border-slate-800 pl-6">
|
||||
<ShieldCheck className="size-4 text-white" />
|
||||
<span>Bezpieczne płatności SSL</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { ShoppingBag, Menu, Phone, Wrench } from "lucide-react";
|
||||
import { NavDropdown } from "./NavDropdown";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { useCartStore } from "@/lib/cart-store";
|
||||
import { companyDetails } from "@/content/company";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonClasses } from '@/components/ui/Button';
|
||||
|
||||
|
||||
export function Header() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const cartItems = useCartStore((state) => state.items);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setMounted(true), 0);
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY > 40) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cartCount = mounted
|
||||
? cartItems.reduce((total, item) => total + item.quantity, 0)
|
||||
: 0;
|
||||
|
||||
const acSections = [
|
||||
{
|
||||
title: "Klimatyzatory",
|
||||
items: [
|
||||
{
|
||||
label: "Ścienne split",
|
||||
href: "/sklep?category=scienne",
|
||||
description: "Klasyczne jednostki ścienne do mieszkań",
|
||||
},
|
||||
{
|
||||
label: "Multisplit",
|
||||
href: "/sklep?category=multisplit",
|
||||
description: "Systemy wielopokojowe (2-5 pomieszczeń)",
|
||||
},
|
||||
/* {
|
||||
label: "Kasetonowe i konsolowe",
|
||||
href: "/sklep?category=kasetonowe",
|
||||
description: "Modele biurowe i przypodłogowe",
|
||||
}, */
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Usługi HVAC",
|
||||
items: [
|
||||
{
|
||||
label: "Montaż klimatyzacji",
|
||||
href: "/montaz-klimatyzacji",
|
||||
description: "Kompleksowy dobór, audyt i montaż",
|
||||
},
|
||||
{
|
||||
label: "Serwis i czyszczenie",
|
||||
href: "/serwis-klimatyzacji",
|
||||
description: "Odgrzybianie, naprawy i przeglądy",
|
||||
},
|
||||
{
|
||||
label: "Cennik montażu",
|
||||
href: "/cennik-montazu",
|
||||
description: "Przejrzyste ceny — split, multisplit, bruzda",
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ----------------- DESKTOP: TOP BAR ----------------- */}
|
||||
<div className="hidden lg:block bg-muted py-2 border-b border-border/50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
{/* Top Bar Left: Logo */}
|
||||
<Link href="/" className="shrink-0 flex items-center gap-3">
|
||||
<span className="text-2xl font-bold font-heading text-primary">
|
||||
{companyDetails.shortName}
|
||||
<span className="text-accent">.</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-medium border-l border-border pl-3">
|
||||
Klimatyzacja na Dolnym Śląsku
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Top Bar Right: Phone & Icons */}
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-primary hover:text-accent transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
<span>{companyDetails.phone}</span>
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href="/koszyk" className="p-2 text-primary hover:text-accent hover:bg-background rounded-lg transition-colors relative outline-none" aria-label="Koszyk">
|
||||
<ShoppingBag className="size-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-accent text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ----------------- DESKTOP: NAV BAR (STICKY) ----------------- */}
|
||||
<header
|
||||
className={cn(
|
||||
"hidden lg:block sticky top-0 z-40 bg-background transition-all duration-300 border-b border-border",
|
||||
isScrolled ? "shadow-md py-2" : "py-4"
|
||||
)}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
|
||||
{/* Main Navigation */}
|
||||
<nav className="flex items-center gap-8">
|
||||
{isScrolled && (
|
||||
<Link href="/" className="shrink-0 flex items-center mr-2">
|
||||
<span className="text-xl font-bold font-heading text-primary">
|
||||
{companyDetails.shortName}
|
||||
<span className="text-accent">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<NavDropdown
|
||||
label="Klimatyzacja"
|
||||
triggerHref="/klimatyzacja"
|
||||
sections={acSections}
|
||||
align="left"
|
||||
/>
|
||||
|
||||
<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ń" },
|
||||
]
|
||||
}
|
||||
]}
|
||||
align="left"
|
||||
/>
|
||||
<Link
|
||||
href="/kalkulator"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
Dobór urządzenia
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
{/* <Link
|
||||
href="/o-nas"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
O nas
|
||||
</Link> */}
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className="text-sm font-medium text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
Kontakt
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="hidden lg:flex items-center gap-3">
|
||||
<Link href="/kontakt" className={buttonClasses({ variant: 'primary', size: 'sm' })}>
|
||||
|
||||
Darmowa wycena
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ----------------- MOBILE: HEADER (STICKY) ----------------- */}
|
||||
<header
|
||||
className={cn(
|
||||
"lg:hidden sticky top-0 z-40 bg-background border-b border-border transition-all duration-300",
|
||||
isScrolled ? "shadow-md py-3" : "py-4"
|
||||
)}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 flex items-center justify-between">
|
||||
{/* Mobile Logo */}
|
||||
<Link href="/" className="shrink-0 flex items-center">
|
||||
<span className="text-xl font-bold font-heading text-primary">
|
||||
{companyDetails.shortName}
|
||||
<span className="text-accent">.</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Mobile Icons (Cart + Hamburger) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/koszyk" className="p-2 text-primary hover:text-accent hover:bg-muted transition-colors rounded-lg relative outline-none" aria-label="Koszyk">
|
||||
<ShoppingBag className="size-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-accent text-white text-[10px] font-bold size-4 rounded-full flex items-center justify-center translate-x-0.5 -translate-y-0.5">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="p-2 text-primary hover:text-accent hover:bg-muted transition-colors rounded-lg outline-none"
|
||||
aria-label="Otwórz menu"
|
||||
>
|
||||
<Menu className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ----------------- MOBILE MENU DRAWER ----------------- */}
|
||||
<MobileMenu
|
||||
isOpen={isMobileMenuOpen}
|
||||
onClose={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { X, Phone, ChevronRight } from "lucide-react";
|
||||
import { companyDetails } from "@/content/company";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MobileMenu({ isOpen, onClose }: MobileMenuProps) {
|
||||
// Prevent body scrolling when menu is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const hvacSections = [
|
||||
{ label: 'Ofertowana klimatyzacja', href: '/klimatyzacja' },
|
||||
{ label: 'Klimatyzatory ścienne', href: '/sklep?category=scienne' },
|
||||
{ label: 'Klimatyzatory multisplit', href: '/sklep?category=multisplit' },
|
||||
/* { label: 'Klimatyzatory kasetonowe', href: '/sklep?category=kasetonowe' },
|
||||
{ label: 'Klimatyzatory konsolowe', href: '/sklep?category=konsolowe' }, */
|
||||
];
|
||||
|
||||
const serwisSections = [
|
||||
{ label: 'Montaż klimatyzacji', href: '/montaz-klimatyzacji' },
|
||||
{ label: 'Serwis i czyszczenie', href: '/serwis-klimatyzacji' },
|
||||
{ label: 'Cennik montażu', href: '/cennik-montazu' },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 lg:hidden transition-all duration-300",
|
||||
isOpen ? "pointer-events-auto" : "pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-black/60 transition-opacity duration-300",
|
||||
isOpen ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<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"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-muted text-primary transition-colors focus:ring-2 focus:ring-accent outline-none"
|
||||
aria-label="Zamknij menu"
|
||||
>
|
||||
<X className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="w-full">
|
||||
{/* 1. Klimatyzacja */}
|
||||
<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">
|
||||
Klimatyzacja
|
||||
</summary>
|
||||
<div className="collapse-content px-0 pb-2">
|
||||
<ul className="flex flex-col gap-2.5 pl-4 pb-2">
|
||||
{hvacSections.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>
|
||||
|
||||
{/* Usługi i serwis */}
|
||||
<details className="collapse collapse-arrow border-b border-border rounded-none">
|
||||
<summary className="collapse-title text-sm font-medium text-primary hover:text-accent py-3 px-0 min-h-0">
|
||||
Usługi i serwis
|
||||
</summary>
|
||||
<div className="collapse-content px-0 pb-2">
|
||||
<ul className="flex flex-col gap-2.5 pl-4 pb-2">
|
||||
{serwisSections.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>
|
||||
|
||||
{/* 2. Pompy Ciepła */}
|
||||
|
||||
|
||||
{/* 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" },
|
||||
].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>
|
||||
|
||||
{/* 3. Dobór urządzenia */}
|
||||
<div className="border-b border-border py-3">
|
||||
<Link
|
||||
href="/kalkulator"
|
||||
onClick={onClose}
|
||||
className="text-sm font-medium text-primary hover:text-accent block"
|
||||
>
|
||||
Dobór urządzenia
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 5. Blog */}
|
||||
<div className="border-b border-border py-3">
|
||||
<Link
|
||||
href="/blog"
|
||||
onClick={onClose}
|
||||
className="text-sm font-medium text-primary hover:text-accent block"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 6. O Nas */}
|
||||
{/* <div className="border-b border-border py-3">
|
||||
<Link
|
||||
href="/o-nas"
|
||||
onClick={onClose}
|
||||
className="text-sm font-medium text-primary hover:text-accent block"
|
||||
>
|
||||
O nas
|
||||
</Link>
|
||||
</div> */}
|
||||
|
||||
{/* 7. Kontakt */}
|
||||
<div className="border-b border-border py-3">
|
||||
<Link
|
||||
href="/kontakt"
|
||||
onClick={onClose}
|
||||
className="text-sm font-medium text-primary hover:text-accent block"
|
||||
>
|
||||
Kontakt
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawer Footer */}
|
||||
<div className="border-t border-border p-6 bg-slate-50 flex flex-col gap-4">
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center justify-center gap-2 p-3 bg-white border border-border rounded-xl text-primary font-semibold hover:text-accent transition-colors shadow-sm"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
<span>Zadzwoń: {companyDetails.phone}</span>
|
||||
</a>
|
||||
<Link href="/kontakt" onClick={onClose} className="btn btn-primary w-full font-bold">
|
||||
|
||||
Darmowa konsultacja
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionItem {
|
||||
label: string;
|
||||
href: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: SectionItem[];
|
||||
}
|
||||
|
||||
interface NavDropdownProps {
|
||||
label: string;
|
||||
triggerHref?: string;
|
||||
sections: NavSection[];
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
export function NavDropdown({ label, triggerHref, sections, align = "left" }: NavDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group"
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
onMouseLeave={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-primary hover:text-accent transition-colors py-2 cursor-pointer">
|
||||
{triggerHref ? (
|
||||
<Link href={triggerHref}>{label}</Link>
|
||||
) : (
|
||||
<span>{label}</span>
|
||||
)}
|
||||
<ChevronDown className={cn("size-4 transition-transform", isOpen && "rotate-180")} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full z-50 pt-2 transition-all duration-200",
|
||||
isOpen ? "opacity-100 translate-y-0 visible" : "opacity-0 translate-y-2 invisible",
|
||||
align === "left" ? "left-0" : "right-0"
|
||||
)}
|
||||
>
|
||||
<div className="bg-white border border-border shadow-xl rounded-xl p-4 min-w-[400px] flex gap-6">
|
||||
{sections.map((section, sIdx) => (
|
||||
<div key={sIdx} className="flex-1 min-w-[200px]">
|
||||
<h4 className="text-sm font-bold text-primary mb-3 font-heading uppercase tracking-wider">{section.title}</h4>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{section.items.map((item, iIdx) => (
|
||||
<li key={iIdx}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="block group/item"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="text-sm font-medium text-primary group-hover/item:text-accent transition-colors">
|
||||
{item.label}
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-text-muted mt-0.5 group-hover/item:text-text-muted/80">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Wrench, CheckCircle, Clock, Star } from "lucide-react";
|
||||
|
||||
export function TrustBar() {
|
||||
const items = [
|
||||
{
|
||||
icon: Wrench,
|
||||
title: "Doświadczeni monterzy",
|
||||
description: "Certyfikat F-Gaz, fachowy montaż",
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: "Kompleksowa usługa",
|
||||
description: "Montaż, serwis i doradztwo",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Bezpłatna wycena",
|
||||
description: "Odpowiadamy w ciągu 24h",
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
title: "Estetyczny montaż",
|
||||
description: "Schludna instalacja, bez bałaganu",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted border-t border-b border-border py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center lg:text-left">
|
||||
{items.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col lg:flex-row items-center lg:items-start gap-3 hover:translate-y-[-2px] transition-transform duration-200"
|
||||
>
|
||||
<div className="p-2 rounded-lg bg-accent-light text-accent shrink-0">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-primary font-heading">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { SectionHeading } from '../common/SectionHeading';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export function AboutStory() {
|
||||
return (
|
||||
<section className="py-16 md:py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
{/* Lewa: Obraz */}
|
||||
<div className="relative">
|
||||
<div className="relative aspect-[4/5] rounded-3xl overflow-hidden shadow-2xl">
|
||||
<Image
|
||||
src="/images/about/about-story.webp"
|
||||
alt="Zespół firmy w trakcie pracy"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Ozdobny element zaufania */}
|
||||
<div className="absolute -bottom-8 -right-8 bg-white p-6 rounded-2xl shadow-xl hidden md:block">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl font-extrabold text-accent font-heading">
|
||||
{companyDetails.stats.find(s => s.label.includes("doświadczenia"))?.value || "15+"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-primary font-heading text-lg leading-none mb-1">Lat na rynku</p>
|
||||
<p className="text-sm text-text-muted">Doświadczenie i zaufanie</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prawa: Tekst */}
|
||||
<div>
|
||||
<SectionHeading
|
||||
eyebrow="Nasza Historia"
|
||||
title="Budujemy komfort od fundamentów aż po dach"
|
||||
align="left"
|
||||
/>
|
||||
|
||||
<div className="prose prose-lg text-text-muted mt-8 mb-10">
|
||||
<p>
|
||||
Firma {companyDetails.name} powstała z pasji do nowoczesnych technologii oraz chęci dostarczania kompleksowych usług dla domów i firm. Zaczynaliśmy jako niewielka, dwuosobowa ekipa specjalizująca się w instalacjach HVAC. Dziś jesteśmy liderem w regionie, realizując setki zaawansowanych projektów instalacyjnych rocznie.
|
||||
</p>
|
||||
<p>
|
||||
Naszym celem jest upraszczanie życia naszym klientom. Zamiast szukać oddzielnych fachowców od hydrauliki, klimatyzacji i wykończeń, u nas otrzymujesz całościowe wsparcie z gwarancją jakości.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{[
|
||||
"Certyfikowani instalatorzy z uprawnieniami F-Gaz",
|
||||
"Dbałość o czystość podczas prac montażowych",
|
||||
"Gwarancja i szybki serwis na terenie miasta i okolic",
|
||||
"Pomoc w uzyskiwaniu dofinansowań"
|
||||
].map((point, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-accent shrink-0" />
|
||||
<span className="text-primary font-medium">{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Wind, LayoutGrid, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const acTypes = [
|
||||
{
|
||||
title: 'Klimatyzatory SPLIT',
|
||||
image: '/images/services/split.webp',
|
||||
description: 'Najpopularniejsze rozwiązanie do jednego pomieszczenia. Składa się z jednej jednostki wewnętrznej i jednej zewnętrznej.',
|
||||
icon: Wind,
|
||||
subtypes: ['Ścienne', 'Konsolowe', 'Kanałowe'],
|
||||
link: '/sklep?category=scienne',
|
||||
},
|
||||
{
|
||||
title: 'Systemy MULTISPLIT',
|
||||
image: '/images/services/split.webp',
|
||||
description: 'Idealne do chłodzenia całego domu. Do jednej jednostki zewnętrznej można podłączyć od 2 do 5 jednostek wewnętrznych.',
|
||||
icon: LayoutGrid,
|
||||
subtypes: ['Dla 2 pomieszczeń', 'Dla 3 pomieszczeń', 'Dla 4-5 pomieszczeń', 'Mieszane typy jednostek'],
|
||||
link: '/sklep?category=multisplit',
|
||||
},
|
||||
/* {
|
||||
title: 'Pozostałe rozwiązania',
|
||||
image: '/images/services/kasetonowe.webp',
|
||||
description: 'Specjalistyczne urządzenia łączące funkcje chłodzenia z zaawansowanym grzaniem lub oczyszczaniem powietrza.',
|
||||
icon: Sparkles,
|
||||
subtypes: ['Z funkcją ogrzewania (do -25°C)', 'Z zaawansowanym oczyszczaczem', 'Klimatyzatory przenośne', 'Klimatyzatory bez jednostki zewn.'],
|
||||
link: '/sklep',
|
||||
}, */
|
||||
];
|
||||
|
||||
export const AcTypesSection = () => {
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Rodzaje klimatyzatorów</h2>
|
||||
<p className="text-gray-600">
|
||||
Dobierzemy rozwiązanie idealnie dopasowane do Twoich potrzeb, niezależnie od tego czy chłodzisz jedno pomieszczenie, czy cały dom.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{acTypes.map((type, idx) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<div key={idx} className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 flex flex-col h-full hover:shadow-md transition-shadow">
|
||||
<div className="relative w-full h-44 rounded-xl overflow-hidden mb-4">
|
||||
<Image src={type.image} alt={type.title} fill className="object-cover" />
|
||||
</div>
|
||||
<div className="w-14 h-14 bg-accent/10 rounded-xl flex items-center justify-center mb-6 text-accent">
|
||||
<Icon className="w-7 h-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-primary mb-3">{type.title}</h3>
|
||||
<p className="text-gray-600 mb-6 text-sm flex-grow">
|
||||
{type.description}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 mb-8">
|
||||
{type.subtypes.map((subtype, sIdx) => (
|
||||
<li key={sIdx} className="flex items-center text-sm text-gray-700">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent mr-3"></div>
|
||||
{subtype}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={type.link}
|
||||
className={cn("btn btn-outline btn-primary bg-white w-full justify-between group mt-auto")}
|
||||
>
|
||||
Sprawdź modele
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CheckCircle2, Zap, ShieldCheck } from "lucide-react";
|
||||
|
||||
export function AdvantagesBar() {
|
||||
const advantages = [
|
||||
{ icon: Zap, text: "Certyfikat F-Gaz" },
|
||||
{ icon: ShieldCheck, text: "Gwarancja na sprzęt i montaż" },
|
||||
{ icon: CheckCircle2, text: "Bezpłatna wycena w 24h" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-primary text-primary-foreground py-6 border-y border-border/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
{advantages.map((adv, idx) => (
|
||||
<div key={idx} className="flex items-center justify-center md:justify-start gap-3">
|
||||
<adv.icon className="size-5 text-accent shrink-0" />
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{adv.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { TrendingDown, Leaf, ThermometerSun, Wallet } from 'lucide-react';
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
title: 'Znaczne oszczędności',
|
||||
description: 'Nawet 70% niższe koszty ogrzewania w porównaniu do tradycyjnych źródeł ciepła, takich jak węgiel, olej czy gaz.',
|
||||
icon: TrendingDown,
|
||||
},
|
||||
{
|
||||
title: 'Ekologiczne ciepło',
|
||||
description: 'Brak emisji spalin i zanieczyszczeń w miejscu działania. Pompa w 75% korzysta z odnawialnej energii z otoczenia.',
|
||||
icon: Leaf,
|
||||
},
|
||||
{
|
||||
title: 'Komfort całoroczny',
|
||||
description: 'Bezobsługowy system zapewniający przyjemne ciepło zimą oraz chłodzenie i produkcję ciepłej wody użytkowej latem.',
|
||||
icon: ThermometerSun,
|
||||
},
|
||||
{
|
||||
title: 'Dotacje i ulgi',
|
||||
description: 'Możliwość odliczenia od podatku oraz skorzystania z programów Czyste Powietrze lub Mój Prąd na pokrycie części kosztów.',
|
||||
icon: Wallet,
|
||||
},
|
||||
];
|
||||
|
||||
export const BenefitsGrid = () => {
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Dlaczego warto wybrać pompę ciepła?</h2>
|
||||
<p className="text-gray-600">
|
||||
Pompa ciepła to obecnie najbardziej efektywne i przyszłościowe rozwiązanie do ogrzewania budynków.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{benefits.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={idx} className="bg-gray-50 border border-gray-100 rounded-2xl p-8 hover:shadow-lg transition-shadow">
|
||||
<div className="w-14 h-14 bg-accent/10 rounded-2xl flex items-center justify-center mb-6 text-accent">
|
||||
<Icon className="w-7 h-7" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-primary mb-3">{item.title}</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { ArrowRight, Calendar } from "lucide-react";
|
||||
import { SectionHeading } from "../common/SectionHeading";
|
||||
import { getLatestPosts } from "@/content/posts";
|
||||
|
||||
export function BlogPreview() {
|
||||
const latestPosts = getLatestPosts(3);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("pl-PL", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryLabel = (cat: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
klimatyzacja: "Klimatyzacja",
|
||||
|
||||
porady: "Porady ogólne",
|
||||
};
|
||||
return labels[cat] || cat;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow="Aktualności"
|
||||
title="Najnowsze poradniki i artykuły"
|
||||
subtitle="Dzielimy się wiedzą i doświadczeniem. Zobacz nasze wpisy dotyczące klimatyzacji i pomp ciepła oraz oszczędzania energii."
|
||||
align="center"
|
||||
/>
|
||||
|
||||
{/* Blog Posts Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{latestPosts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="group flex flex-col justify-between border border-border rounded-xl overflow-hidden hover:shadow-md transition-shadow h-full"
|
||||
>
|
||||
<div>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-video bg-slate-50 border-b border-border">
|
||||
<Image
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Category & Date */}
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted mb-3">
|
||||
<span className="font-semibold uppercase tracking-wider text-accent">
|
||||
{getCategoryLabel(post.category)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3.5" />
|
||||
<span>{formatDate(post.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-bold text-primary font-heading line-clamp-2 leading-snug group-hover:text-accent transition-colors">
|
||||
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h3>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p className="text-sm text-text-muted mt-3 line-clamp-2 leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read More Link */}
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary group-hover:text-accent transition-colors"
|
||||
>
|
||||
<span>Czytaj więcej</span>
|
||||
<ArrowRight className="size-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Blog Catalog Button */}
|
||||
<div className="flex justify-center mt-12">
|
||||
<Link href="/blog" className="btn btn-outline btn-primary bg-white font-bold flex items-center gap-2 group">
|
||||
|
||||
<span>Przejdź do bloga</span>
|
||||
<ArrowRight className="size-4 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
const brands = [
|
||||
{ name: 'Daikin', logo: '/brands/daikin.webp' },
|
||||
{ name: 'Mitsubishi Electric', logo: '/brands/mitsubishi.webp' },
|
||||
{ name: 'Gree', logo: '/brands/gree.webp' },
|
||||
{ name: 'Rotenso', logo: '/brands/rotenso.webp' },
|
||||
{ name: 'LG', logo: '/brands/lg.webp' },
|
||||
{ name: 'Samsung', logo: '/brands/samsung.webp' },
|
||||
{ name: 'Haier', logo: '/brands/haier.webp' },
|
||||
{ name: 'CHV', logo: '/brands/chv.webp' },
|
||||
{ name: 'Toshiba', logo: '/brands/toshiba.webp' },
|
||||
{ name: 'Fuji Electric', logo: '/brands/fuji.webp' },
|
||||
];
|
||||
|
||||
export const BrandsGrid = () => {
|
||||
return (
|
||||
<section className="py-16 bg-white border-t border-border">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-5xl text-center">
|
||||
<h3 className="text-xl font-semibold text-text-muted mb-8 uppercase tracking-wider text-sm">
|
||||
Autoryzowany partner czołowych producentów
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4 lg:gap-6 items-center">
|
||||
{brands.map((brand, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={`/sklep?brand=${encodeURIComponent(brand.name)}`}
|
||||
className="group flex justify-center items-center h-16 bg-slate-50 rounded-xl p-3 border border-border hover:border-accent hover:bg-accent/5 hover:shadow-sm transition-all duration-300"
|
||||
>
|
||||
{/* Tutaj docelowo można dać tag Image */}
|
||||
<span className="font-bold text-primary text-sm md:text-base tracking-tight opacity-70 group-hover:opacity-100 group-hover:text-accent transition-all">{brand.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SectionHeading } from '../common/SectionHeading';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export function CertificatesSection() {
|
||||
return (
|
||||
<section className="py-16 md:py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow="Uprawnienia"
|
||||
title="Nasze certyfikaty"
|
||||
subtitle="Zaufaj wykwalifikowanym specjalistom. Posiadamy wszystkie licencje wymagane do profesjonalnego montażu urządzeń HVAC."
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-10 mt-16">
|
||||
{companyDetails.certificates.map((cert, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="group flex flex-col items-center text-center gap-4 p-6 bg-slate-50 rounded-2xl border border-border hover:border-accent/50 transition-colors"
|
||||
>
|
||||
{/* W rzeczywistości użylibyśmy obrazków. Tutaj dajemy szary prostokąt jako placeholder wg wytycznych */}
|
||||
<div className="relative w-full aspect-[4/3] bg-white rounded-lg border border-border flex items-center justify-center p-4 overflow-hidden group-hover:shadow-md transition-shadow">
|
||||
{/* Jeśli cert.image istnieje realnie, można by użyć Image, na razie tekstowy placeholder */}
|
||||
<div className="text-sm font-bold text-slate-300 font-heading">
|
||||
LOGO / CERTYFIKAT
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-semibold text-primary text-sm leading-tight group-hover:text-accent transition-colors">
|
||||
{cert.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Settings2, Zap, Palette, Calculator, Lightbulb } from 'lucide-react';
|
||||
|
||||
const steps = [
|
||||
{ icon: Settings2, label: 'Marka i model' },
|
||||
{ icon: Zap, label: 'Moc chłodnicza' },
|
||||
{ icon: Palette, label: 'Design i funkcje' },
|
||||
{ icon: Calculator, label: 'Wycena montażu' },
|
||||
];
|
||||
|
||||
export const ConfiguratorTeaser = () => {
|
||||
return (
|
||||
<section className="py-20 bg-primary text-white relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-accent/20 to-transparent pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-64 h-64 bg-accent rounded-full blur-[120px] opacity-20 pointer-events-none" />
|
||||
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Skonfiguruj swój idealny zestaw w 4 krokach
|
||||
</h2>
|
||||
<p className="text-gray-300 text-lg mb-8 max-w-xl">
|
||||
Nie wiesz jakie urządzenie wybrać? Nasz prosty konfigurator krok po kroku pomoże Ci dobrać sprzęt i natychmiast sprawdzić szacunkowy koszt wraz z montażem.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-10 lg:mb-0">
|
||||
<Link
|
||||
href="/kalkulator"
|
||||
className={cn("btn btn-primary text-white group")}
|
||||
>
|
||||
Uruchom konfigurator
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Visual representation of steps */}
|
||||
<div className="bg-white/10 backdrop-blur-sm border border-white/20 rounded-2xl p-8">
|
||||
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-6 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/30 before:to-transparent">
|
||||
{steps.map((step, idx) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div key={idx} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full border-4 border-primary bg-accent text-white shadow md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 z-10">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-3rem)] bg-white/10 border border-white/20 p-4 rounded-xl backdrop-blur-sm group-hover:bg-white/20 transition-colors">
|
||||
<h3 className="font-semibold text-lg">{step.label}</h3>
|
||||
<p className="text-sm text-gray-300">Krok {idx + 1}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pro tip card */}
|
||||
<div className="mt-8 bg-accent/20 border border-accent/40 rounded-xl p-4 flex items-start">
|
||||
<Lightbulb className="w-6 h-6 text-accent shrink-0 mr-3 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-accent-light mb-1">Porada eksperta</h4>
|
||||
<p className="text-sm text-gray-200">
|
||||
Szybki przelicznik mocy: <strong>10 m² = 1 kW</strong> mocy chłodniczej. Dla pokoju 25 m² szukaj urządzenia o mocy ok. 2.5 kW.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
import { ContactForm } from '../forms/ContactForm';
|
||||
import { MapPin, Phone, Mail, Clock, FileText } from 'lucide-react';
|
||||
|
||||
export function ContactSection() {
|
||||
return (
|
||||
<section className="py-16 md:py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-24">
|
||||
{/* Lewa kolumna: Dane kontaktowe */}
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-primary font-heading mb-6">
|
||||
Porozmawiajmy o Twoim projekcie
|
||||
</h2>
|
||||
<p className="text-text-muted text-lg mb-12">
|
||||
Jesteśmy do Twojej dyspozycji. Zadzwoń, napisz e-mail lub skorzystaj z formularza obok. Odpowiadamy najszybciej jak to możliwe, najczęściej w ciągu kilku godzin.
|
||||
</p>
|
||||
|
||||
<div className="space-y-8 flex-grow">
|
||||
{/* Telefon */}
|
||||
<div className="flex items-start gap-4 group">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent/10 text-accent flex items-center justify-center shrink-0 group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<Phone className="w-6 h-6" />
|
||||
</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-base font-bold text-primary hover:text-accent transition-colors font-heading">
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="flex items-start gap-4 group">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent/10 text-accent flex items-center justify-center shrink-0 group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<Mail className="w-6 h-6" />
|
||||
</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-base font-bold text-primary hover:text-accent transition-colors font-heading">
|
||||
{companyDetails.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Adres */}
|
||||
<div className="flex items-start gap-4 group">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent/10 text-accent flex items-center justify-center shrink-0 group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<MapPin className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-1">Nasze biuro</p>
|
||||
<div className="text-sm font-medium text-primary">
|
||||
{companyDetails.name}<br />
|
||||
{companyDetails.address.street}<br />
|
||||
{companyDetails.address.zipCode} {companyDetails.address.city}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Godziny i NIP */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8 pt-8 border-t border-border">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-5 h-5 text-primary/60 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-muted mb-1">Godziny pracy</p>
|
||||
<p className="text-sm text-primary font-medium">{companyDetails.hours}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-primary/60 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-text-muted mb-1">Dane firmy</p>
|
||||
<p className="text-sm text-primary font-medium">NIP: {companyDetails.nip}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prawa kolumna: Formularz */}
|
||||
<div className="relative">
|
||||
{/* Tło dekoracyjne */}
|
||||
<div className="absolute inset-0 bg-primary/5 rounded-[2.5rem] transform translate-x-4 translate-y-4 -z-10 hidden sm:block" />
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { Phone, ArrowRight } from "lucide-react";
|
||||
import { companyDetails } from "@/content/company";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CtaBannerProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
bgVariant?: "primary" | "accent-light";
|
||||
}
|
||||
|
||||
export function CtaBanner({
|
||||
title = "Masz pytania? Umów darmową konsultację",
|
||||
subtitle = "Skontaktuj się z nami! Doradzimy najlepsze rozwiązania, dobierzemy moc urządzeń i przygotujemy bezpłatną wycenę.",
|
||||
ctaText = "Zamów bezpłatną konsultację",
|
||||
ctaLink = "/kontakt",
|
||||
bgVariant = "primary",
|
||||
}: CtaBannerProps) {
|
||||
const isPrimary = bgVariant === "primary";
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden py-16 md:py-20">
|
||||
{/* Background and overlays */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 z-0",
|
||||
isPrimary
|
||||
? "bg-[#0F2A47] text-white"
|
||||
: "bg-accent-light text-primary"
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPrimary && (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,rgba(43,168,224,0.12),transparent_40%)] z-0" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-10">
|
||||
{/* Text Area */}
|
||||
<div className="max-w-2xl text-center lg:text-left flex flex-col gap-3">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-2xl sm:text-3xl md:text-4xl font-extrabold font-heading leading-tight",
|
||||
isPrimary ? "text-white" : "text-primary"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm sm:text-base leading-relaxed max-w-xl",
|
||||
isPrimary ? "text-slate-300" : "text-text-muted"
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 mt-8 w-full sm:w-auto shrink-0 justify-center">
|
||||
<Link href={ctaLink} className="w-fit sm:w-auto btn btn-primary font-bold text-white px-6">
|
||||
<span>{ctaText}</span>
|
||||
<ArrowRight className="size-5" />
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="w-fit sm:w-auto"
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"btn btn-outline btn-primary bg-white w-fit sm:w-auto font-bold flex items-center gap-2 px-6",
|
||||
isPrimary
|
||||
? "text-white hover:bg-white/20 hover:text-white border-white/30"
|
||||
: "text-primary hover:bg-primary/10 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Phone className="size-5" />
|
||||
<span>{companyDetails.phone}</span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { PhoneCall, Clock, ShieldAlert } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export const EmergencyBlock = () => {
|
||||
return (
|
||||
<section className="py-12 bg-cta text-white relative overflow-hidden">
|
||||
{/* Dekoracyjne tło */}
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full bg-gradient-to-l from-white/10 to-transparent pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-64 h-64 bg-white/20 rounded-full blur-[100px] pointer-events-none" />
|
||||
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-5xl relative z-10">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/20 rounded-full text-sm font-semibold mb-4 backdrop-blur-sm border border-white/30">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
Pogotowie hydrauliczne
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
Masz awarię? Zadzwoń natychmiast!
|
||||
</h2>
|
||||
<p className="text-white/90 text-lg mb-0 max-w-xl mx-auto md:mx-0">
|
||||
Pęknięta rura, zatkany odpływ lub wyciek? Zapewniamy szybką diagnozę i skuteczną naprawę usterki.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center sm:items-end gap-4 shrink-0">
|
||||
<a
|
||||
href={`tel:${companyDetails.phone.replace(/\s+/g, '')}`}
|
||||
className="group flex items-center gap-4 bg-white text-primary px-8 py-4 rounded-xl font-bold text-2xl hover:bg-gray-50 transition-colors shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<div className="w-12 h-12 bg-cta/10 rounded-full flex items-center justify-center text-cta group-hover:scale-110 transition-transform">
|
||||
<PhoneCall className="w-6 h-6" />
|
||||
</div>
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium bg-black/10 px-4 py-2 rounded-lg">
|
||||
<Clock className="w-4 h-4 text-white/80" />
|
||||
<span className="text-white/90">Szybki czas reakcji na terenie całego miasta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { faqData, FaqItem } from '@/content/faq';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FaqAccordionProps {
|
||||
category: FaqItem['category'];
|
||||
}
|
||||
|
||||
export const FaqAccordion = ({ category }: FaqAccordionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const faqs = faqData.filter(item => item.category === category);
|
||||
|
||||
if (faqs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-3xl">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Często zadawane pytania</h2>
|
||||
<p className="text-gray-600">
|
||||
Odpowiedzi na najczęstsze wątpliwości naszych klientów.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq) => {
|
||||
const isOpen = openId === faq.id;
|
||||
return (
|
||||
<div
|
||||
key={faq.id}
|
||||
className={cn(
|
||||
"bg-white border rounded-xl overflow-hidden transition-all duration-200",
|
||||
isOpen ? "border-accent shadow-md" : "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left focus:outline-none"
|
||||
onClick={() => setOpenId(isOpen ? null : faq.id)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="font-semibold text-primary pr-4">{faq.question}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-5 h-5 text-gray-400 transition-transform duration-200 shrink-0",
|
||||
isOpen && "rotate-180 text-accent"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 text-gray-600 leading-relaxed overflow-hidden transition-all duration-300 ease-in-out",
|
||||
isOpen ? "max-h-96 pb-5 opacity-100" : "max-h-0 pb-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
{faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from '@/types/types';
|
||||
import { Calendar, User, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface FeaturedPostProps {
|
||||
post: Post;
|
||||
}
|
||||
|
||||
export function FeaturedPost({ post }: FeaturedPostProps) {
|
||||
// Formatowanie daty
|
||||
const dateObj = new Date(post.date);
|
||||
const formattedDate = dateObj.toLocaleDateString('pl-PL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
// Tłumaczenie kategorii
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'klimatyzacja': 'Klimatyzacja',
|
||||
|
||||
'remonty': 'Remonty',
|
||||
'hydraulika': 'Hydraulika',
|
||||
'porady': 'Porady'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-2xl overflow-hidden bg-white border border-border shadow-sm hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Obraz */}
|
||||
<div className="relative w-full md:w-3/5 aspect-video md:aspect-auto md:min-h-[400px]">
|
||||
<Image
|
||||
src={post.image || '/images/hero/hero-section-image.webp'}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-accent text-white text-xs font-bold rounded-full uppercase tracking-wider shadow-md">
|
||||
{categoryLabels[post.category] || post.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Treść */}
|
||||
<div className="p-8 md:p-12 flex flex-col justify-center w-full md:w-2/5">
|
||||
<div className="flex items-center gap-4 text-text-muted text-sm font-medium mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4 text-primary/60" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4 text-primary/60" />
|
||||
<span>{post.author}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl md:text-3xl font-extrabold text-primary font-heading leading-tight mb-4 group-hover:text-accent transition-colors">
|
||||
<Link href={`/blog/${post.slug}`} className="before:absolute before:inset-0">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
<p className="text-text-muted leading-relaxed mb-8 line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-primary font-semibold group-hover:text-accent transition-colors mt-auto">
|
||||
<span>Czytaj pełny artykuł</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { SectionHeading } from "../common/SectionHeading";
|
||||
import { ProductCard } from "../shop/ProductCard";
|
||||
import { getFeaturedProducts } from "@/content/products";
|
||||
|
||||
export function FeaturedProducts() {
|
||||
const featured = getFeaturedProducts(4);
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow="Nasz Asortyment"
|
||||
title="Polecane klimatyzatory z montażem"
|
||||
subtitle="Poznaj wybrane modele urządzeń chłodzących i grzewczych od wiodących marek. Zapewniamy kompleksowy montaż."
|
||||
align="center"
|
||||
/>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8">
|
||||
{featured.map((product) => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Catalog Link */}
|
||||
<div className="flex justify-center mt-12">
|
||||
<Link href="/sklep" className="btn btn-outline btn-primary bg-white font-bold flex items-center gap-2 group">
|
||||
|
||||
<span>Zobacz cały sklep</span>
|
||||
<ArrowRight className="size-4 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PiggyBank, FileText, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export const FundingSection = () => {
|
||||
return (
|
||||
<section className="py-16 bg-accent-light text-primary">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-6xl relative">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/50 backdrop-blur-sm rounded-full text-sm font-semibold mb-6">
|
||||
<PiggyBank className="w-4 h-4 text-accent" />
|
||||
Dotacje i dofinansowania
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-6">
|
||||
Odbierz nawet do 35 200 zł dotacji na pompę ciepła
|
||||
</h2>
|
||||
|
||||
<p className="text-primary/80 text-lg mb-8">
|
||||
[PLACEHOLDER - DO AKTUALIZACJI] Obecnie trwają programy rządowe "Czyste Powietrze" oraz "Mój Prąd", które pozwalają na zwrot znacznej części kosztów inwestycji. Pomożemy Ci przejść przez cały proces formalny i przygotować niezbędne wnioski.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-4 mb-10">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-accent shrink-0" />
|
||||
<span className="font-medium">Bezpłatny audyt możliwości dofinansowania</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-accent shrink-0" />
|
||||
<span className="font-medium">Pomoc w wypełnieniu dokumentacji</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-accent shrink-0" />
|
||||
<span className="font-medium">Możliwość prefinansowania inwestycji</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/dofinansowania"
|
||||
className={cn("btn btn-primary group")}
|
||||
>
|
||||
Dowiedz się więcej o dotacjach
|
||||
</Link>
|
||||
<p className="text-xs text-primary/60 mt-4 max-w-sm">
|
||||
* Wysokość dotacji uzależniona jest od progu dochodowego i zakresu termomodernizacji. Skontaktuj się z nami w celu dokładnej wyceny.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Dekoracyjna karta */}
|
||||
<div className="bg-white rounded-3xl p-8 shadow-xl relative z-10 border border-white/50">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 font-semibold mb-1">Twój kosztorys z dotacją</p>
|
||||
<h3 className="text-xl font-bold text-primary">Przykładowa kalkulacja</h3>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-accent/10 rounded-full flex items-center justify-center">
|
||||
<FileText className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex justify-between pb-4 border-b border-gray-100">
|
||||
<span className="text-gray-600">Wartość inwestycji</span>
|
||||
<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>- 20 500 zł</span>
|
||||
</div>
|
||||
<div className="flex justify-between pb-4 border-b border-gray-100 text-green-600 font-medium">
|
||||
<span>Ulga termomodernizacyjna</span>
|
||||
<span>- 4 600 zł</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-gray-900 font-bold">Ostateczny koszt inwestora</span>
|
||||
<span className="font-bold text-2xl text-primary">19 900 zł</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background elements */}
|
||||
<div className="absolute -top-6 -right-6 w-full h-full bg-accent/20 rounded-3xl -z-10 blur-sm"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-32 h-32 bg-white/40 rounded-full -z-10 blur-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Wind, Droplets, Mountain } from 'lucide-react';
|
||||
|
||||
const pumpTypes = [
|
||||
{
|
||||
title: 'Powietrze - Woda',
|
||||
description: 'Najpopularniejsze rozwiązanie. Urządzenie pobiera energię z powietrza zewnętrznego i przekazuje ją do wodnej instalacji grzewczej (kaloryfery, podłogówka) oraz podgrzewa wodę użytkową.',
|
||||
forWho: 'Idealne do nowych domów i większości termomodernizacji (wymiana pieca).',
|
||||
icon: Droplets,
|
||||
},
|
||||
{
|
||||
title: 'Powietrze - Powietrze',
|
||||
description: 'Działają na zasadzie klimatyzatorów. Ogrzewają bezpośrednio powietrze w pomieszczeniu, bez udziału instalacji wodnej. Szybko nagrzewają i posiadają funkcję chłodzenia.',
|
||||
forWho: 'Świetne jako dodatkowe źródło ciepła, do domków letniskowych i mniejszych przestrzeni.',
|
||||
icon: Wind,
|
||||
},
|
||||
{
|
||||
title: 'Gruntowe',
|
||||
description: 'Pobierają ciepło z wnętrza ziemi za pomocą kolektorów. Zapewniają najwyższą i najbardziej stabilną wydajność niezależnie od mrozów na zewnątrz.',
|
||||
forWho: 'Dla inwestorów z dużą działką, oczekujących najwyższej niezawodności i gotowych na wyższe koszty początkowe.',
|
||||
icon: Mountain,
|
||||
},
|
||||
];
|
||||
|
||||
export const HeatPumpTypes = () => {
|
||||
return (
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Rodzaje pomp ciepła</h2>
|
||||
<p className="text-gray-600">
|
||||
Wybór odpowiedniej technologii zależy od uwarunkowań Twojego budynku, wielkości działki oraz budżetu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{pumpTypes.map((type, idx) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<div key={idx} className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 flex flex-col h-full hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-primary/5 rounded-xl flex items-center justify-center text-primary">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-primary">{type.title}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6 text-sm flex-grow">
|
||||
{type.description}
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mt-auto">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Dla kogo?</h4>
|
||||
<p className="text-sm text-gray-700 font-medium">{type.forWho}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
ctaText?: string;
|
||||
ctaLink?: string;
|
||||
secondaryCtaText?: string;
|
||||
secondaryCtaLink?: string;
|
||||
backgroundImage?: string;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export function HeroSection({
|
||||
eyebrow = "Wrocław i Dolny Śląsk",
|
||||
title = (
|
||||
<>
|
||||
<span className="text-white">Klimatyzacja</span><br />
|
||||
<span className="text-accent">na Dolnym Śląsku</span>
|
||||
</>
|
||||
),
|
||||
subtitle = "Montaż, serwis i naprawa — przyjeżdżamy do Ciebie. Obsługujemy tam gdzie inni odmawiają, w tym klimatyzatory LG.",
|
||||
ctaText = "Darmowa wycena",
|
||||
ctaLink = "/kontakt",
|
||||
secondaryCtaText = "Zobacz ofertę",
|
||||
secondaryCtaLink = "/sklep",
|
||||
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 w-full">
|
||||
{/* TODO ZDJĘCIE HERO: realne zdjęcie firmy/montażu, format poziomy min. 1920×1080, plik public/hero.jpg */}
|
||||
<Image
|
||||
src={backgroundImage}
|
||||
alt="Montaż klimatyzacji i pomp ciepła"
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{/* NAKŁADKA POD TEKST - jednolita, przyciemniająca */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24">
|
||||
<div className="flex flex-col items-start text-left max-w-3xl">
|
||||
{/* Badge */}
|
||||
{eyebrow && (
|
||||
<div className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full bg-accent/20 border border-accent/30 text-white text-xs font-semibold uppercase tracking-wider mb-6 font-heading">
|
||||
<span className="size-2 rounded-full bg-accent" />
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="text-3xl md:text-5xl lg:text-6xl font-extrabold tracking-tight font-heading leading-[1.1] text-white">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="mt-6 text-base md:text-lg lg:text-xl text-white/90 max-w-2xl leading-relaxed">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* Optional Features List */}
|
||||
{features && features.length > 0 && (
|
||||
<ul className="mt-6 grid grid-cols-2 gap-3 text-white/90 font-medium">
|
||||
{features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2">
|
||||
<Star className="size-4 text-yellow-400 fill-yellow-400" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* 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={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>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Link from "next/link";
|
||||
import { Wrench, Thermometer, Wind, Zap, Calendar, Phone, Check } from "lucide-react";
|
||||
import { companyDetails } from "@/content/company";
|
||||
import { buttonClasses } from "../ui/Button";
|
||||
|
||||
export function HomeServices() {
|
||||
return (
|
||||
<section className="bg-white py-16 px-4">
|
||||
<div className="bg-slate-50 max-w-7xl mx-auto rounded-[2.5rem] p-6 sm:p-10 lg:p-14">
|
||||
<div className="max-w-7xl mx-auto flex flex-col gap-8">
|
||||
<div className="mb-2">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold text-accent uppercase tracking-widest mb-6 bg-accent/5 border border-accent/20 px-3 py-1.5 rounded-full">
|
||||
<Check className="size-3.5" />
|
||||
Cennik usług
|
||||
</div>
|
||||
<h2 className="text-3xl sm:text-5xl font-extrabold text-primary font-heading leading-tight mb-4">
|
||||
Montaż, serwis i czyszczenie klimatyzacji –<br />już od <span className="underline decoration-accent underline-offset-4">150 zł</span>
|
||||
</h2>
|
||||
<p className="text-text-muted text-sm sm:text-base max-w-2xl leading-relaxed">
|
||||
Profesjonalna obsługa, certyfikowani specjaliści i szybki czas reakcji na terenie całego Wrocławia. Zadbaj o czyste powietrze i wydajność swoich urządzeń.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* Card 1: Montaż klimatyzacji */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wrench className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Montaż klimatyzacji</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Profesjonalny montaż urządzeń split i multisplit. Zapewniamy estetykę wykonania i pełne uruchomienie.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 1400 zł</p>
|
||||
<p className="text-[10px] text-text-muted mt-1">* cena standardowego montażu do 5m</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Czyszczenie */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Thermometer className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Czyszczenie i odgrzybianie</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Kompleksowe czyszczenie, dezynfekcja chemiczna, odgrzybianie parowe, czyszczenie filtrów.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 250 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Przegląd techniczny */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wind className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Przegląd techniczny</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Sprawdzenie parametrów pracy, kontrola szczelności układu, pomiar ciśnień roboczych.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 150 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Uzupełnienie czynnika */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Zap className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Uzupełnienie czynnika</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Lokalizacja nieszczelności, próba ciśnieniowa azotem, dobicie czynnika chłodniczego.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-primary">od 180 zł</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Naprawa usterek */}
|
||||
<div className="bg-white p-8 flex flex-col rounded-2xl shadow-sm border border-border">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-100 flex items-center justify-center shrink-0 border border-slate-200 rounded-lg">
|
||||
<Wrench className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">Naprawa usterek</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Diagnoza awarii, wymiana uszkodzonych podzespołów elektroniki, sprężarek, wentylatorów.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mb-1">Cena usługi</p>
|
||||
<p className="text-sm font-extrabold text-primary">Wycena indywidualna</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Serwis sezonowy (Dark card) */}
|
||||
<div className="bg-[#1a2332] p-8 flex flex-col border border-[#1a2332] shadow-sm rounded-2xl">
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
<div className="size-12 bg-slate-800 flex items-center justify-center shrink-0 border border-slate-700 rounded-lg">
|
||||
<Calendar className="size-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white font-heading mb-3">Serwis sezonowy / Abonament</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed">Regularna opieka nad instalacją przed sezonem letnim i zimowym. Priorytetowy czas reakcji w przypadku awarii.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-5 mt-auto">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-white/60 mb-1">Cena usługi</p>
|
||||
<p className="text-xl font-extrabold text-white">od 199 zł / rok</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 6: Gotowy na świeże powietrze? (Full width) */}
|
||||
<div className="bg-white md:col-span-3 p-8 md:p-10 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-8 border-l-[6px] border-accent rounded-2xl shadow-sm">
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="text-2xl font-bold text-primary font-heading mb-3">Gotowy na świeże powietrze?</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed">Nasi certyfikowani technicy są dostępni od poniedziałku do soboty. Zarezerwuj termin już dziś, aby uniknąć kolejek w sezonie.</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 shrink-0 w-full lg:w-auto justify-center lg:justify-end">
|
||||
<Link href="/kontakt" className={buttonClasses({ variant: 'primary', size: 'md' }) + ' w-fit sm:w-auto'}>
|
||||
Umów serwis <Calendar className="size-4" />
|
||||
</Link>
|
||||
<Link href={`tel:${companyDetails.phoneRaw}`} className={buttonClasses({ variant: 'outline-light', size: 'md' }) + ' w-fit sm:w-auto'}>
|
||||
<Phone className="size-4" /> Zadzwoń do nas
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Clock, Banknote, MapPin } from 'lucide-react';
|
||||
|
||||
interface InfoHighlightsProps {
|
||||
description: string;
|
||||
highlights: {
|
||||
icon: 'clock' | 'price' | 'map';
|
||||
title: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
clock: Clock,
|
||||
price: Banknote,
|
||||
map: MapPin,
|
||||
};
|
||||
|
||||
export const InfoHighlights = ({ description, highlights }: InfoHighlightsProps) => {
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
<div className="lg:col-span-5">
|
||||
<h2 className="text-3xl font-bold text-primary mb-6">Szybki montaż, jasne zasady</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{highlights.map((item, idx) => {
|
||||
const Icon = iconMap[item.icon];
|
||||
return (
|
||||
<div key={idx} className="bg-gray-50 p-6 rounded-xl border border-gray-100 flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 bg-accent/10 rounded-full flex items-center justify-center mb-4 text-accent">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-primary mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500">{item.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
export function MapEmbed() {
|
||||
return (
|
||||
<section className="w-full h-[400px] md:h-[500px] bg-slate-200 relative overflow-hidden">
|
||||
{/*
|
||||
Docelowo tutaj powinien znaleźć się kod iframe z Google Maps.
|
||||
Przykład:
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2505.465597792618!2d17.031980815754593!3d51.10006297957077!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zNTHCsDA2JzAwLjIiTiAxN8KwMDInMDMuMCJF!5e0!3m2!1spl!2spl!4v1620000000000!5m2!1spl!2spl"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen={false}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="absolute inset-0"
|
||||
></iframe>
|
||||
*/}
|
||||
|
||||
{/* Poniżej znajduje się Placeholder */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-slate-200 border-y border-border">
|
||||
<div className="text-center p-6 bg-white/80 backdrop-blur-sm rounded-xl border border-white/50 shadow-sm max-w-sm mx-4">
|
||||
<svg className="w-12 h-12 text-primary/40 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<p className="font-semibold text-primary font-heading">Mapa Lokalizacji</p>
|
||||
<p className="text-sm text-text-muted mt-1">Tutaj zostanie osadzona interaktywna mapa Google (iframe).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import { ThermometerSun, Home, ChevronRight, ArrowRight } from "lucide-react";
|
||||
|
||||
export function PillarsSection() {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Pillar 1: Chłód i Ogrzewanie */}
|
||||
<div className="group relative rounded-2xl border border-border p-8 md:p-12 bg-slate-50 hover:bg-white hover:border-accent hover:shadow-xl transition-all duration-300 flex flex-col justify-between overflow-hidden">
|
||||
{/* Visual background gradient glow on hover */}
|
||||
<div className="absolute right-0 top-0 size-48 rounded-full bg-accent/5 filter blur-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div>
|
||||
<div className="inline-flex p-4 rounded-xl bg-accent-light text-accent mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<ThermometerSun className="size-8" />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-primary font-heading mb-4">
|
||||
Chłód i Ogrzewanie
|
||||
</h3>
|
||||
<p className="text-text-muted text-base leading-relaxed mb-8 max-w-md">
|
||||
Klimatyzacja dla Twojego domu lub biura. Oferujemy profesjonalny audyt, dobór optymalnej mocy, autoryzowany montaż i stałą opiekę serwisową.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Link href="/klimatyzacja" className="btn btn-primary text-white flex items-center gap-2">
|
||||
|
||||
<span>Przejdź do oferty HVAC</span>
|
||||
<ChevronRight className="size-5 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pillar 2: Twój Dom */}
|
||||
<div className="group relative rounded-2xl border border-border p-8 md:p-12 bg-slate-50 hover:bg-white hover:border-cta hover:shadow-xl transition-all duration-300 flex flex-col justify-between overflow-hidden">
|
||||
{/* Visual background gradient glow on hover */}
|
||||
<div className="absolute right-0 top-0 size-48 rounded-full bg-cta/5 filter blur-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
<div>
|
||||
<div className="inline-flex p-4 rounded-xl bg-cta/10 text-cta mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Home className="size-8" />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-primary font-heading mb-4">
|
||||
Twój Dom
|
||||
</h3>
|
||||
<p className="text-text-muted text-base leading-relaxed mb-8 max-w-md">
|
||||
Instalacje HVAC, prace wykończeniowe, instalacje hydrauliczne oraz kompleksowy serwis sprzętu chłodniczego i AGD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<Link href="/uslugi-domowe" className="btn btn-outline btn-primary bg-white font-bold flex items-center gap-2 mx-auto group">
|
||||
|
||||
<span>Pełna oferta usług</span>
|
||||
<ArrowRight className="size-4 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Post } from '@/types/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Calendar, ArrowRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { FeaturedPost } from '@/components/sections/FeaturedPost';
|
||||
|
||||
interface PostsGridProps {
|
||||
posts: Post[];
|
||||
featuredPost?: Post;
|
||||
}
|
||||
|
||||
const POSTS_PER_PAGE = 6;
|
||||
|
||||
const categories = [
|
||||
{ id: 'wszystkie', label: 'Wszystkie' },
|
||||
{ id: 'klimatyzacja', label: 'Klimatyzacja' },
|
||||
|
||||
{ id: 'porady', label: 'Porady' },
|
||||
];
|
||||
|
||||
export function PostsGrid({ posts, featuredPost }: PostsGridProps) {
|
||||
const [activeCategory, setActiveCategory] = useState('wszystkie');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const filteredPosts = posts.filter(
|
||||
(post) => activeCategory === 'wszystkie' || post.category === activeCategory
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(filteredPosts.length / POSTS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
|
||||
const visiblePosts = filteredPosts.slice(startIndex, startIndex + POSTS_PER_PAGE);
|
||||
|
||||
const handleCategoryChange = (categoryId: string) => {
|
||||
setActiveCategory(categoryId);
|
||||
setCurrentPage(1); // Reset paginacji przy zmianie kategorii
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'klimatyzacja': 'Klimatyzacja',
|
||||
|
||||
'porady': 'Porady'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-12">
|
||||
{/* Artykuł Wyróżniony (tylko na 1 stronie) */}
|
||||
{featuredPost && currentPage === 1 && (
|
||||
<div className="mb-16">
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-8">Artykuł wyróżniony</h2>
|
||||
<FeaturedPost post={featuredPost} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filtry */}
|
||||
<div className="flex flex-wrap items-center gap-3 mb-12">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => handleCategoryChange(category.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-full text-sm font-semibold transition-all duration-300 border",
|
||||
activeCategory === category.id
|
||||
? "bg-primary text-white border-primary shadow-md"
|
||||
: "bg-white text-text-muted border-border hover:border-primary/30 hover:text-primary"
|
||||
)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Siatka Artykułów */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{visiblePosts.map((post) => {
|
||||
const dateObj = new Date(post.date);
|
||||
const formattedDate = dateObj.toLocaleDateString('pl-PL', {
|
||||
day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="group flex flex-col bg-white rounded-2xl overflow-hidden border border-border hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<Link href={`/blog/${post.slug}`} className="relative aspect-[16/10] overflow-hidden">
|
||||
<Image
|
||||
src={post.image || '/images/hero/hero-section-image.webp'}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-accent text-white text-[10px] font-bold rounded-full uppercase tracking-wider shadow-sm">
|
||||
{categoryLabels[post.category] || post.category}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col flex-grow p-6 md:p-8">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-text-muted mb-3">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary/50" />
|
||||
<span>{formattedDate}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-primary font-heading leading-snug mb-3 group-hover:text-accent transition-colors">
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-text-muted leading-relaxed line-clamp-3 mb-6 flex-grow">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary group-hover:text-accent transition-colors"
|
||||
>
|
||||
<span>Czytaj więcej</span>
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{visiblePosts.length === 0 && (
|
||||
<div className="text-center py-16 bg-slate-50 rounded-2xl border border-border">
|
||||
<p className="text-lg text-text-muted font-medium">Brak artykułów w tej kategorii.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paginacja */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-12 flex flex-wrap justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-lg border border-border text-text-muted disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
|
||||
aria-label="Poprzednia strona"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
// Prosta logika dla mniejszych ekranów (opcjonalnie można ukrywać strony w środku, ale flex-wrap sobie poradzi)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setCurrentPage(pageNum);
|
||||
// Opcjonalnie: skroluj na górę po zmianie strony
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className={cn(
|
||||
"w-10 h-10 flex items-center justify-center rounded-lg border transition-all duration-300 font-semibold",
|
||||
currentPage === pageNum
|
||||
? "bg-primary text-white border-primary shadow-md"
|
||||
: "bg-white text-text-muted border-border hover:border-primary/30 hover:text-primary"
|
||||
)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-lg border border-border text-text-muted disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
|
||||
aria-label="Następna strona"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { pricingData, PricingItem } from '@/content/pricing';
|
||||
import { Check, Download } from 'lucide-react';
|
||||
|
||||
export const PricingTable = () => {
|
||||
const splitItems = pricingData.filter((item) => item.category === 'split');
|
||||
const multisplitItems = pricingData.filter((item) => item.category === 'multisplit');
|
||||
const additionalItems = pricingData.filter((item) => item.category === 'additional');
|
||||
|
||||
const renderTableRows = (items: PricingItem[]) => {
|
||||
return items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-4 text-gray-800 font-medium">{item.name}</td>
|
||||
<td className="py-4 px-4 text-center whitespace-nowrap text-gray-600">{item.priceNet} zł</td>
|
||||
<td className="py-4 px-4 text-center whitespace-nowrap font-semibold text-primary">{item.priceGrossCompany} zł</td>
|
||||
</tr>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white" id="cennik">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-5xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Cennik usług instalacyjnych</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Przejrzyste ceny bez ukrytych kosztów. Ostateczna wycena zależy od specyfiki budynku i trudności montażu – dokonywana jest zawsze przed rozpoczęciem prac.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm mb-8">
|
||||
<table className="w-full text-left border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-sm uppercase text-gray-500">
|
||||
<th className="py-4 px-4 font-semibold w-1/2">Usługa</th>
|
||||
<th className="py-4 px-4 font-semibold text-center">Cena netto</th>
|
||||
<th className="py-4 px-4 font-semibold text-center">Brutto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Split */}
|
||||
<tr>
|
||||
<td colSpan={3} className="bg-gray-50/80 py-3 px-4 font-semibold text-primary text-sm uppercase">
|
||||
Montaż układów SPLIT
|
||||
</td>
|
||||
</tr>
|
||||
{renderTableRows(splitItems)}
|
||||
|
||||
{/* Multisplit */}
|
||||
<tr>
|
||||
<td colSpan={3} className="bg-gray-50/80 py-3 px-4 font-semibold text-primary text-sm uppercase">
|
||||
Montaż układów MULTISPLIT
|
||||
</td>
|
||||
</tr>
|
||||
{renderTableRows(multisplitItems)}
|
||||
|
||||
{/* Usługi dodatkowe */}
|
||||
<tr>
|
||||
<td colSpan={3} className="bg-gray-50/80 py-3 px-4 font-semibold text-primary text-sm uppercase">
|
||||
Usługi dodatkowe i serwis
|
||||
</td>
|
||||
</tr>
|
||||
{renderTableRows(additionalItems)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start justify-between bg-gray-50 p-6 rounded-xl border border-gray-100">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-primary mb-3 flex items-center gap-2">
|
||||
<Check className="w-5 h-5 text-accent" />
|
||||
Co obejmuje montaż podstawowy?
|
||||
</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Wykonanie przewiertu przez ścianę</li>
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Instalacja chłodnicza do 3 mb</li>
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Podłączenie do gniazdka (kabel 3m)</li>
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Grawitacyjne odprowadzenie skroplin</li>
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Montaż jednostek do wysokości 3m</li>
|
||||
<li className="flex items-start gap-2"><div className="w-1.5 h-1.5 rounded-full bg-accent mt-1.5 shrink-0" /> Próba szczelności i uruchomienie</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64 shrink-0 flex flex-col items-center md:items-stretch gap-4">
|
||||
<a href="/pdf/cennik-thermcool-2026.pdf" target="_blank" rel="noopener noreferrer" className="btn btn-outline btn-primary bg-white w-fit md:w-full justify-between group flex items-center px-6 py-2 border rounded-lg hover:bg-gray-50 text-sm font-semibold text-primary">
|
||||
Pobierz cennik PDF
|
||||
<Download className="w-4 h-4 ml-2 group-hover:-translate-y-1 transition-transform" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
PhoneCall, ClipboardCheck, Cpu, HeartHandshake, HelpCircle,
|
||||
ShieldAlert, Construction, Layers, MonitorDown, Server, Gauge, Zap, BookOpen
|
||||
} from "lucide-react";
|
||||
import { SectionHeading } from "../common/SectionHeading";
|
||||
import { mainProcessSteps } from "@/content/process";
|
||||
import { ProcessStep } from "@/types/types";
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
PhoneCall,
|
||||
ClipboardCheck,
|
||||
Cpu,
|
||||
HeartHandshake,
|
||||
ShieldAlert,
|
||||
Construction,
|
||||
Layers,
|
||||
MonitorDown,
|
||||
Server,
|
||||
Gauge,
|
||||
Zap,
|
||||
BookOpen,
|
||||
};
|
||||
|
||||
interface ProcessStepsProps {
|
||||
steps?: ProcessStep[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
}
|
||||
|
||||
export function ProcessSteps({
|
||||
steps = mainProcessSteps,
|
||||
title = "Prosty proces współpracy w 4 krokach",
|
||||
subtitle = "Od momentu kontaktu do pełnego uruchomienia i serwisu — dbamy o każdy detal na każdym etapie.",
|
||||
eyebrow = "Jak Działamy"
|
||||
}: ProcessStepsProps) {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
align="center"
|
||||
/>
|
||||
|
||||
{/* Process Steps Timeline */}
|
||||
<div className="relative mt-12">
|
||||
{/* Desktop connecting line (only if few steps, otherwise wrap) */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-y-12 gap-x-8 relative z-10">
|
||||
{steps.map((step, idx) => {
|
||||
const Icon = iconMap[step.icon || ""] || HelpCircle;
|
||||
return (
|
||||
<div key={idx} className="flex flex-col items-center text-center group">
|
||||
{/* Circle Badge with Icon & Number */}
|
||||
<div className="relative size-20 rounded-full bg-slate-50 border-2 border-slate-200 flex items-center justify-center text-primary group-hover:border-accent group-hover:bg-accent-light group-hover:text-accent transition-all duration-300 shadow-sm z-10">
|
||||
{/* Step Number */}
|
||||
{step.icon && iconMap[step.icon] && (
|
||||
<span className="absolute -top-1.5 -right-1.5 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center font-heading border-2 border-white shadow-sm">
|
||||
{step.number}
|
||||
</span>
|
||||
)}
|
||||
{step.icon && iconMap[step.icon]
|
||||
? <Icon className="size-8 group-hover:scale-110 transition-transform duration-300" />
|
||||
: <span className="text-2xl font-extrabold font-heading text-accent">{step.number}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<h3 className="text-lg font-bold text-primary font-heading mt-6 mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed max-w-[240px]">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { projectsData, Project } from '@/content/projects';
|
||||
|
||||
interface ProjectsGalleryProps {
|
||||
category: Project['category'];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ProjectsGallery = ({
|
||||
category,
|
||||
title = "Nasze realizacje",
|
||||
description = "Zobacz przykłady prac wykonanych przez naszych certyfikowanych instalatorów."
|
||||
}: ProjectsGalleryProps) => {
|
||||
const projects = projectsData.filter((project) => project.category === category);
|
||||
|
||||
if (projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-7xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">{title}</h2>
|
||||
<p className="text-gray-600">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id} className="group rounded-2xl overflow-hidden border border-gray-100 shadow-sm hover:shadow-lg transition-all duration-300 bg-white flex flex-col h-full">
|
||||
<div className="relative h-64 overflow-hidden bg-gray-100">
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 flex-grow flex flex-col">
|
||||
<h3 className="font-bold text-lg text-primary mb-2 group-hover:text-accent transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-4 flex-grow">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { projectsData, Project } from '@/content/projects';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MapPin } from 'lucide-react';
|
||||
|
||||
type Category = 'wszystkie' | Project['category'];
|
||||
|
||||
const categories: { id: Category; label: string }[] = [
|
||||
{ id: 'wszystkie', label: 'Wszystkie' },
|
||||
{ id: 'klimatyzacja', label: 'Klimatyzacja' },
|
||||
|
||||
];
|
||||
|
||||
export function ProjectsGrid() {
|
||||
const [activeCategory, setActiveCategory] = useState<Category>('wszystkie');
|
||||
|
||||
const filteredProjects = projectsData.filter(
|
||||
(project) => activeCategory === 'wszystkie' || project.category === activeCategory
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Filtry */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 mb-12">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={cn(
|
||||
"px-5 py-2.5 rounded-full text-sm font-semibold transition-all duration-300 border",
|
||||
activeCategory === category.id
|
||||
? "bg-primary text-white border-primary shadow-md"
|
||||
: "bg-white text-text-muted border-border hover:border-primary/30 hover:text-primary"
|
||||
)}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Siatka */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="group relative rounded-2xl overflow-hidden bg-slate-100 aspect-[4/3]"
|
||||
>
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
{/* Nakładka gradientowa */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary/90 via-primary/40 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300" />
|
||||
|
||||
{/* Treść (Zawsze widoczna lokalizacja i kategoria, pojawiający się opis) */}
|
||||
<div className="absolute inset-0 p-6 flex flex-col justify-end">
|
||||
<div className="transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="px-3 py-1 bg-accent text-white text-xs font-bold rounded-full uppercase tracking-wider">
|
||||
{categories.find(c => c.id === project.category)?.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-white/80 text-sm font-medium">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>{project.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-white mb-2 font-heading">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-white/80 text-sm line-clamp-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredProjects.length === 0 && (
|
||||
<div className="text-center py-20 text-text-muted">
|
||||
<p className="text-lg">Brak realizacji w tej kategorii.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SectionHeading } from "../common/SectionHeading";
|
||||
import { IconCard } from "../common/IconCard";
|
||||
import { services as defaultServices } from "@/content/services";
|
||||
|
||||
interface ServicesGridProps {
|
||||
services?: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
href?: string;
|
||||
}[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
}
|
||||
|
||||
export function ServicesGrid({
|
||||
services = defaultServices,
|
||||
title = "Kompleksowe usługi dla Twojego domu",
|
||||
subtitle = "Zapewniamy profesjonalne wsparcie w zakresie instalacji sanitarnych, grzewczych, chłodniczych oraz prac wykończeniowych.",
|
||||
eyebrow = "Pełna Oferta"
|
||||
}: ServicesGridProps) {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-slate-50 border-t border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-6 md:gap-8 mt-10 ${services.length === 2 ? 'max-w-4xl mx-auto' : 'lg:grid-cols-3'}`}>
|
||||
{services.map((service, idx) => (
|
||||
<IconCard
|
||||
key={idx}
|
||||
title={service.title}
|
||||
description={service.description}
|
||||
iconName={service.icon}
|
||||
href={service.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
export function StatsBar() {
|
||||
return (
|
||||
<section className="bg-primary text-white py-12 md:py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-4 divide-x divide-white/10">
|
||||
{companyDetails.stats.map((stat, idx) => (
|
||||
<div key={idx} className="flex flex-col items-center justify-center text-center px-4">
|
||||
<div className="text-4xl md:text-5xl font-extrabold text-accent mb-2 font-heading">
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="text-sm md:text-base font-medium text-white/80 uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { ThermometerSnowflake, VolumeX, Droplets, Snowflake, PowerOff } from 'lucide-react';
|
||||
|
||||
const symptoms = [
|
||||
{
|
||||
icon: ThermometerSnowflake,
|
||||
title: 'Lodówka nie chłodzi',
|
||||
description: 'Najczęstsza usterka. Może oznaczać problem z termostatem, wyciek czynnika chłodniczego lub uszkodzenie sprężarki.',
|
||||
},
|
||||
{
|
||||
icon: VolumeX,
|
||||
title: 'Głośna praca / Dziwne dźwięki',
|
||||
description: 'Buczenie, stukanie lub piszczenie często wskazuje na zużycie wentylatora, agregatu lub problem z ułożeniem urządzenia.',
|
||||
},
|
||||
{
|
||||
icon: Droplets,
|
||||
title: 'Zbieranie się wody na dnie',
|
||||
description: 'Zazwyczaj spowodowane zatkanym odpływem skroplin w chłodziarce lub problemem z systemem No Frost.',
|
||||
},
|
||||
{
|
||||
icon: Snowflake,
|
||||
title: 'Nadmierne oszranianie',
|
||||
description: 'Brak uszczelnienia drzwi, uszkodzona grzałka odszraniania lub awaria czujnika temperatury.',
|
||||
},
|
||||
{
|
||||
icon: PowerOff,
|
||||
title: 'Urządzenie nie włącza się',
|
||||
description: 'Problemy z zasilaniem, uszkodzony moduł sterujący (płyta główna) lub całkowita awaria agregatu.',
|
||||
},
|
||||
];
|
||||
|
||||
export const SymptomsList = () => {
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-6xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Typowe usterki — kiedy wezwać serwis?</h2>
|
||||
<p className="text-gray-600">
|
||||
Jeśli zauważysz którykolwiek z poniższych objawów, nie zwlekaj. Szybka reakcja może zapobiec poważniejszej awarii i uchronić żywność przed zepsuciem.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{symptoms.map((symptom, idx) => {
|
||||
const Icon = symptom.icon;
|
||||
return (
|
||||
<div key={idx} className="bg-gray-50 p-6 rounded-xl border border-gray-100 flex gap-4 items-start hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 shrink-0 bg-accent/10 rounded-lg flex items-center justify-center text-accent mt-1">
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-primary mb-2">{symptom.title}</h3>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{symptom.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import { Star, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const testimonials = [
|
||||
{ name: 'Anna Kowalska', city: 'Wrocław', rating: 5, text: 'Montaż przeprowadzony sprawnie i bez bałaganu. Ekipa przybyła punktualnie, wszystko wytłumaczyli i zostawili po sobie idealny porządek. Klimatyzator działa cicho i skutecznie.' },
|
||||
{ name: 'Marek Wiśniewski', city: 'Legnica', rating: 5, text: 'Polecam z całego serca. Fachowa obsługa, dobra cena i bardzo miły kontakt. Klimatyzator dobrali idealnie do mojego salonu 40 m². Już pierwszego lata oszczędności widoczne na rachunkach.' },
|
||||
{ name: 'Katarzyna Nowak', city: 'Wrocław', rating: 5, text: 'Bardzo profesjonalna firma. Zamówiłam klimatyzację do sypialni i biura — dwie jednostki zamontowane w jeden dzień. Instalacja ukryta w brudzie, wygląda świetnie.' },
|
||||
{ name: 'Piotr Zając', city: 'Wałbrzych', rating: 5, text: 'Szybka wycena, konkretne podejście bez owijania w bawełnę. Klimatyzator Gree działa już drugi sezon bez żadnych problemów. Serwis też na wysokim poziomie.' },
|
||||
{ name: 'Monika Dąbrowska', city: 'Jelenia Góra', rating: 5, text: 'Świetna obsługa od pierwszego kontaktu do odbioru prac. Montażyści zadbali o każdy detal, przeprowadzenie rur estetyczne. Klimatyzacja grzeje też zimą — duże zaskoczenie!' },
|
||||
{ name: 'Tomasz Lewandowski', city: 'Wrocław', rating: 5, text: 'Bardzo dobra firma. Mam multisplit na 3 pokoje — wszystko działa bez zarzutu. Cena montażu zgodna z wyceną, bez ukrytych kosztów. Zdecydowanie wrócę przy kolejnej inwestycji.' },
|
||||
{ name: 'Agnieszka Wróbel', city: 'Oleśnica', rating: 5, text: 'Polecam każdemu. Szybko, czysto i profesjonalnie. Klimatyzator marki Rotenso — cichy jak myszka, a chłodzi rewelacyjnie. Wreszcie sypialnia w której można spać latem.' },
|
||||
{ name: 'Krzysztof Jabłoński', city: 'Lubin', rating: 5, text: 'Solidna robota. Montaż kasetonowy w biurze 60 m² — ekipa wiedziała co robi, nie tracili czasu. Urządzenie działa cicho, pracownicy zadowoleni. Na pewno polecę znajomym.' },
|
||||
{ name: 'Ewa Kamińska', city: 'Wrocław', rating: 5, text: 'Doskonały kontakt i profesjonalizm na każdym etapie. Doradca pomógł wybrać odpowiednią moc, montaż odbył się następnego dnia. Klimatyzacja w domu to teraz standard.' },
|
||||
{ name: 'Radosław Szymański', city: 'Świdnica', rating: 5, text: 'Bardzo sprawna realizacja. Zamówiłem w środę, w piątek już miałem zamontowaną klimatyzację. Jakość wykonania na wysokim poziomie, wszystko dokładnie wyjaśnione.' },
|
||||
{ name: 'Marta Kowalczyk', city: 'Oława', rating: 5, text: 'Super firma! Klimatyzator Samsung dobrana idealnie do naszego mieszkania. Montażyści schludni, uprzejmi i szybcy. Instalacja w korytku wygląda bardzo estetycznie.' },
|
||||
{ name: 'Grzegorz Woźniak', city: 'Wrocław', rating: 5, text: 'Drugi raz korzystam z usług ThermCool i znowu jestem w pełni zadowolony. Tym razem pompa ciepła — ekipa poradziła sobie z trudnym miejscem montażu bez problemu.' },
|
||||
{ name: 'Joanna Michalska', city: 'Trzebnica', rating: 5, text: 'Bardzo dobra obsługa i uczciwe ceny. Klimatyzacja Daikin pracuje bezbłędnie od ponad roku. Przy okazji montażu doradca wyjaśnił jak dbać o urządzenie żeby służyło jak najdłużej.' },
|
||||
{ name: 'Bartosz Krawczyk', city: 'Głogów', rating: 5, text: 'Polecam! Montaż multisplit do domu jednorodzinnego — 4 jednostki wewnętrzne zamontowane w dwa dni. Praca czysta, instalacja ukryta w brudzie. Efekt końcowy powyżej oczekiwań.' },
|
||||
{ name: 'Sylwia Pawlak', city: 'Środa Śląska', rating: 5, text: 'Klimatyzator Haier dobrany pod kątem rozmiarów pokoju i budżetu. Bardzo pomocny doradca, bez zbędnego wciskania droższych modeli. Montaż szybki i bez bałaganu. Polecam!' },
|
||||
];
|
||||
|
||||
export function Testimonials() {
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(
|
||||
{ loop: true, align: 'start', slidesToScroll: 1 },
|
||||
[Autoplay({ delay: 4000, stopOnInteraction: true })]
|
||||
);
|
||||
|
||||
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
|
||||
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
|
||||
|
||||
return (
|
||||
<section className="py-16 bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-primary font-heading">
|
||||
Co mówią nasi klienci
|
||||
</h2>
|
||||
<p className="text-text-muted mt-1">Opinie klientów</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={scrollPrev} aria-label="Poprzednia opinia"
|
||||
className="size-10 rounded-xl border border-border bg-white flex items-center justify-center text-primary hover:border-accent hover:text-accent transition-colors">
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button onClick={scrollNext} aria-label="Następna opinia"
|
||||
className="size-10 rounded-xl border border-border bg-white flex items-center justify-center text-primary hover:border-accent hover:text-accent transition-colors">
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden" ref={emblaRef}>
|
||||
<div className="flex gap-5">
|
||||
{testimonials.map((t, i) => (
|
||||
<div key={i} className="flex-none w-[320px] md:w-[360px] bg-white rounded-2xl border border-border p-6 flex flex-col gap-3">
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: t.rating }).map((_, j) => (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Home, Zap, Wrench } from 'lucide-react';
|
||||
|
||||
const cases = [
|
||||
{
|
||||
icon: Home,
|
||||
title: 'Nowo budowany dom',
|
||||
description: 'Najlepszy moment na decyzję o pompie ciepła. Pozwala to na uniknięcie budowy komina, przyłącza gazowego i wydzielania kotłowni. Idealnie współpracuje z nowoczesnym ogrzewaniem podłogowym.',
|
||||
},
|
||||
{
|
||||
icon: Wrench,
|
||||
title: 'Wymiana starego kotła (kopciucha)',
|
||||
description: 'Zastąpienie uciążliwego pieca węglowego bezobsługową pompą. Często wymaga dostosowania istniejącej instalacji, ale daje dostęp do najwyższych poziomów dofinansowania z programu Czyste Powietrze.',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Modernizacja i hybryda',
|
||||
description: 'Dołożenie pompy ciepła do istniejącego, wciąż sprawnego kotła gazowego lub olejowego. Pompa przejmuje ogrzewanie przez większość sezonu, a stary kocioł wspomaga system tylko w największe mrozy.',
|
||||
},
|
||||
];
|
||||
|
||||
export const UseCases = () => {
|
||||
return (
|
||||
<section className="py-16 bg-white">
|
||||
<div className="container mx-auto px-4 md:px-8 max-w-5xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary mb-4">Kiedy warto zainwestować?</h2>
|
||||
<p className="text-gray-600">
|
||||
Pompa ciepła to elastyczne rozwiązanie, które sprawdzi się w różnych scenariuszach życiowych i inwestycyjnych.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{cases.map((useCase, idx) => {
|
||||
const Icon = useCase.icon;
|
||||
return (
|
||||
<div key={idx} className="flex flex-col sm:flex-row gap-6 p-6 md:p-8 rounded-2xl border border-gray-100 hover:border-accent/30 transition-colors bg-gray-50/50 hover:bg-white group">
|
||||
<div className="w-16 h-16 shrink-0 bg-white border border-gray-100 shadow-sm rounded-2xl flex items-center justify-center text-primary group-hover:text-accent group-hover:border-accent/20 transition-all">
|
||||
<Icon className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-primary mb-3">{useCase.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
{useCase.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { SectionHeading } from '../common/SectionHeading';
|
||||
import { ShieldCheck, Clock, ThumbsUp, HeartHandshake } from 'lucide-react';
|
||||
|
||||
const values = [
|
||||
{
|
||||
title: 'Najwyższa jakość',
|
||||
description: 'Nie idziemy na kompromisy. Stosujemy wyłącznie sprawdzone materiały i urządzenia renomowanych producentów.',
|
||||
icon: ThumbsUp
|
||||
},
|
||||
{
|
||||
title: 'Terminowość',
|
||||
description: 'Szanujemy Twój czas. Każde zlecenie realizujemy zgodnie z ustalonym harmonogramem.',
|
||||
icon: Clock
|
||||
},
|
||||
{
|
||||
title: 'Bezpieczeństwo',
|
||||
description: 'Posiadamy wszystkie niezbędne uprawnienia (m.in. F-Gaz, SEP) oraz pełne ubezpieczenie OC firmy.',
|
||||
icon: ShieldCheck
|
||||
},
|
||||
{
|
||||
title: 'Długofalowe relacje',
|
||||
description: 'Zależy nam na zaufaniu. Po skończonym montażu czy remoncie oferujemy pełną opiekę serwisową.',
|
||||
icon: HeartHandshake
|
||||
}
|
||||
];
|
||||
|
||||
export function ValuesGrid() {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-slate-50 border-y border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeading
|
||||
eyebrow="Dlaczego my"
|
||||
title="Nasze wartości"
|
||||
subtitle="Zasady, którymi kierujemy się w codziennej pracy z naszymi klientami."
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mt-16">
|
||||
{values.map((val, idx) => {
|
||||
const Icon = val.icon;
|
||||
return (
|
||||
<div key={idx} className="bg-white p-8 rounded-2xl border border-border text-center hover:shadow-lg transition-shadow duration-300">
|
||||
<div className="mx-auto w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mb-6 transform -rotate-3 hover:rotate-0 transition-transform">
|
||||
<Icon className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-3">{val.title}</h3>
|
||||
<p className="text-text-muted leading-relaxed text-sm">{val.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Image from "next/image";
|
||||
import { Award, FileText, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { companyDetails } from "@/content/company";
|
||||
|
||||
export function WhyUs() {
|
||||
const advantages = [
|
||||
{
|
||||
icon: Award,
|
||||
title: "Doświadczenie i certyfikaty",
|
||||
description: "Posiadamy wszystkie wymagane certyfikaty i autoryzacje. Działamy na rynku od ponad 15 lat.",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Jasna wycena bez ukrytych kosztów",
|
||||
description: "Przed montażem otrzymujesz pełny kosztorys. Ceny podane w ofercie są cenami ostatecznymi.",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Gwarancja i wsparcie serwisowe",
|
||||
description: "Zapewniamy pełną opiekę pogwarancyjną oraz coroczne przeglądy zachowujące gwarancję producenta.",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Czystość i dbałość o detale",
|
||||
description: "Pracujemy z użyciem odkurzaczy przemysłowych. Po montażu zostawiamy pomieszczenia idealnie czyste.",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-slate-50 border-t border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-16 items-center">
|
||||
{/* Left Column: Title, Intro and Statistics */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-6">
|
||||
<div>
|
||||
<span className="text-xs md:text-sm font-semibold uppercase tracking-wider text-accent font-heading">
|
||||
{`Dlaczego ${companyDetails.shortName}`}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-primary font-heading leading-tight mt-2">
|
||||
Zaufany partner w instalacjach domowych
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-text-muted mt-4 leading-relaxed">
|
||||
Stawiamy na najwyższą jakość wykonania, terminowość oraz bezkompromisowe bezpieczeństwo. Pomagamy na każdym kroku — od wyboru odpowiedniego urządzenia, poprzez załatwienie dotacji, aż po finalny montaż i regularny serwis.
|
||||
</p>
|
||||
<div className="relative w-full h-64 rounded-2xl overflow-hidden mt-6">
|
||||
<Image
|
||||
src="/images/services/wycena.webp"
|
||||
alt="Bezpłatna wycena klimatyzacji ThermCool"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Column: Advantages Grid */}
|
||||
<div className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{advantages.map((adv, idx) => {
|
||||
const Icon = adv.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-6 rounded-xl bg-white border border-border hover:shadow-md transition-shadow flex flex-col gap-4"
|
||||
>
|
||||
<div className="size-12 rounded-lg bg-accent-light text-accent flex items-center justify-center shrink-0">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-primary font-heading">
|
||||
{adv.title}
|
||||
</h3>
|
||||
<p className="text-xs md:text-sm text-text-muted mt-1.5 leading-relaxed">
|
||||
{adv.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { Minus, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export function CartItems() {
|
||||
const { items, removeItem, updateQuantity } = useCartStore();
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-white p-12 rounded-2xl border border-border text-center">
|
||||
<div className="w-20 h-20 bg-slate-50 text-slate-300 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Trash2 className="w-10 h-10" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-4">Twój koszyk jest pusty</h2>
|
||||
<p className="text-text-muted mb-8">Nie dodałeś jeszcze żadnych produktów do koszyka.</p>
|
||||
<Link href="/sklep" className="inline-flex bg-cta hover:bg-cta-hover text-white font-bold py-3 px-8 rounded-xl transition-colors">
|
||||
Przejdź do sklepu
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const itemPrice = item.price;
|
||||
const totalGross = itemPrice * item.quantity;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="bg-white p-6 rounded-2xl border border-border flex flex-col sm:flex-row gap-6 relative">
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="absolute top-4 right-4 p-2 text-text-muted hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
aria-label="Usuń z koszyka"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-32 shrink-0 bg-slate-50 rounded-xl p-2 border border-border">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-grow justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-text-muted uppercase tracking-wider mb-1">
|
||||
{item.brand}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-primary font-heading leading-tight mb-2 pr-10">
|
||||
<Link href={`/sklep/${item.slug}`} className="hover:text-accent transition-colors">
|
||||
{item.name}
|
||||
</Link>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-end gap-4 mt-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center border border-border rounded-lg bg-slate-50">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity - 1)}
|
||||
disabled={item.quantity <= 1}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-primary disabled:opacity-50"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-bold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.id, item.quantity + 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-text-muted hover:text-primary"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xl font-extrabold text-primary font-heading w-24 text-right">
|
||||
{formatCurrency(totalGross)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { ShieldCheck, ArrowRight, Tag } from 'lucide-react';
|
||||
|
||||
export function CartSummary() {
|
||||
const { items, getTotal } = useCartStore();
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const total = getTotal();
|
||||
const subtotal = total / 1.23;
|
||||
const vat = total - subtotal;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 p-6 md:p-8 rounded-2xl border border-border sticky top-24">
|
||||
<h3 className="text-xl font-bold text-primary font-heading mb-6">Podsumowanie zamówienia</h3>
|
||||
|
||||
<div className="mb-8">
|
||||
<label htmlFor="promo" className="text-sm font-semibold text-text-muted mb-2 block">Posiadasz kod rabatowy?</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-grow">
|
||||
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
id="promo"
|
||||
type="text"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
placeholder="Wprowadź kod"
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-border focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-outline btn-primary bg-white shrink-0">Zastosuj</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6 border-b border-border/50 pb-6">
|
||||
<div className="flex justify-between text-sm text-text-muted">
|
||||
<span>Suma netto</span>
|
||||
<span className="font-semibold text-primary">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-text-muted">
|
||||
<span>Podatek VAT (23%)</span>
|
||||
<span className="font-semibold text-primary">{formatCurrency(vat)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<span className="text-lg font-bold text-primary">Do zapłaty brutto</span>
|
||||
<span className="text-3xl font-extrabold text-primary font-heading text-right">{formatCurrency(total)}</span>
|
||||
</div>
|
||||
|
||||
<Link href="/zamowienie" className="block w-full btn btn-primary font-bold group cursor-pointer text-white">
|
||||
|
||||
Przejdź do zamówienia
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted font-medium justify-center">
|
||||
<ShieldCheck className="w-4 h-4 text-green-600" />
|
||||
Bezpieczne zakupy z certyfikatem SSL
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { X, GitCompare } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CompareBar() {
|
||||
const { items, removeItem, clearAll } = useCompareStore();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-border shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<GitCompare className="size-4 text-accent" />
|
||||
<span className="text-sm font-semibold text-primary hidden sm:block">
|
||||
Porównaj ({items.length}/3)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-1 overflow-x-auto">
|
||||
{items.map((item) => (
|
||||
<div key={item.slug} className="flex items-center gap-2 bg-slate-50 rounded-lg px-3 py-1.5 border border-border shrink-0">
|
||||
<div className="relative size-8 bg-white rounded">
|
||||
<Image src={item.image} alt={item.name} fill className="object-contain p-0.5" unoptimized={item.image.startsWith('http')} />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-xs font-medium text-primary line-clamp-1 max-w-[140px]">{item.name}</p>
|
||||
{item.price && <p className="text-xs text-accent font-bold">{item.price.toLocaleString('pl-PL')} zł</p>}
|
||||
</div>
|
||||
<button onClick={() => removeItem(item.slug)} className="text-text-muted hover:text-primary transition-colors ml-1">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Puste sloty */}
|
||||
{Array.from({ length: 3 - items.length }).map((_, i) => (
|
||||
<div key={i} className="size-11 rounded-lg border-2 border-dashed border-border flex items-center justify-center text-text-muted shrink-0">
|
||||
<span className="text-lg">+</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{items.length >= 2 && (
|
||||
<Link href="/porownaj" className="btn btn-primary text-white font-bold text-sm py-2 px-4">
|
||||
|
||||
Porównaj
|
||||
|
||||
</Link>
|
||||
)}
|
||||
<button onClick={clearAll} className="text-xs text-text-muted hover:text-primary transition-colors">
|
||||
Wyczyść
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { submitMultisplitForm } from '@/app/zapytanie-multisplit/actions';
|
||||
import { getRecaptchaToken } from '@/lib/getRecaptchaToken';
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
productSku: string;
|
||||
}
|
||||
|
||||
export default function MultisplitContactForm({ productName, productSku }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
rooms: '2',
|
||||
message: '',
|
||||
consent: false,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handle = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name || !form.phone || !form.consent) {
|
||||
setError('Wypełnij wymagane pola i zaznacz zgodę.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = await getRecaptchaToken('submit');
|
||||
const recaptchaToken = token || '';
|
||||
|
||||
const res = await submitMultisplitForm({
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
city: form.city,
|
||||
rooms: form.rooms,
|
||||
message: form.message,
|
||||
consent: form.consent,
|
||||
productName,
|
||||
productSku,
|
||||
recaptchaToken,
|
||||
});
|
||||
if (res.success) setSent(true);
|
||||
else setError(res.message || 'Błąd wysyłki. Zadzwoń do nas bezpośrednio.');
|
||||
} catch {
|
||||
setError('Błąd połączenia. Spróbuj ponownie.');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="bg-green-50 text-green-800 p-8 rounded-2xl border border-green-200 flex flex-col items-center text-center">
|
||||
<CheckCircle2 className="w-16 h-16 text-green-500 mb-4" />
|
||||
<h3 className="text-2xl font-bold font-heading mb-2">Zapytanie wysłane!</h3>
|
||||
<p className="text-green-700">Oddzwonimy w ciągu 24 godzin z bezpłatną wyceną.</p>
|
||||
<button
|
||||
onClick={() => setSent(false)}
|
||||
className="mt-6 px-6 py-2 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Wyślij kolejne zapytanie
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Produkt info */}
|
||||
<div className="bg-slate-50 rounded-xl px-4 py-3 text-xs text-text-muted">
|
||||
Zapytanie dotyczy: <span className="font-bold text-primary">{productName}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Imię i nazwisko *</label>
|
||||
<input name="name" value={form.name} onChange={handle}
|
||||
placeholder="Jan Kowalski"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Telefon *</label>
|
||||
<input name="phone" value={form.phone} onChange={handle}
|
||||
placeholder="+48 600 000 000"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Email</label>
|
||||
<input name="email" value={form.email} onChange={handle}
|
||||
placeholder="[email protected]"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Miasto *</label>
|
||||
<input name="city" value={form.city} onChange={handle}
|
||||
placeholder="Wrocław"
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Ile pomieszczeń do klimatyzacji?</label>
|
||||
<select name="rooms" value={form.rooms} onChange={handle}
|
||||
className="h-10 px-3 text-sm border border-border rounded-xl focus:border-accent outline-none bg-white">
|
||||
<option value="2">2 pomieszczenia</option>
|
||||
<option value="3">3 pomieszczenia</option>
|
||||
<option value="4">4 pomieszczenia</option>
|
||||
<option value="5">5 pomieszczeń</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-primary">Dodatkowe informacje (opcjonalnie)</label>
|
||||
<textarea name="message" value={form.message} onChange={handle}
|
||||
rows={3}
|
||||
placeholder="Np. metraż pomieszczeń, typ budynku, piętro..."
|
||||
className="px-3 py-2.5 text-sm border border-border rounded-xl focus:border-accent outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<div className="relative mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="consent"
|
||||
checked={form.consent}
|
||||
onChange={handle}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`size-4 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
form.consent ? 'bg-accent border-accent' : 'bg-white border-slate-300'
|
||||
}`}>
|
||||
{form.consent && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted leading-relaxed">
|
||||
Wyrażam zgodę na przetwarzanie moich danych osobowych w celu obsługi zapytania przez ThermCool. *
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="flex items-center justify-center gap-2 h-11 bg-accent text-white font-bold rounded-xl text-sm hover:bg-accent/90 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{loading ? 'Wysyłanie...' : 'Wyślij zapytanie o wycenę'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ShoppingCart, GitCompare, Phone } from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { useState } from 'react';
|
||||
|
||||
const brandLogos: Record<string, string> = {
|
||||
'Gree': '/brands/gree.webp',
|
||||
'Rotenso': '/brands/rotenso.webp',
|
||||
'Toshiba': '/brands/toshiba.webp',
|
||||
'Daikin': '/brands/daikin.webp',
|
||||
'CHV': '/brands/chv.webp',
|
||||
'Haier': '/brands/haier.webp',
|
||||
'LG': '/brands/lg.webp',
|
||||
'Samsung': '/brands/samsung.webp',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi.webp',
|
||||
'Fuji Electric': '/brands/fuji.webp',
|
||||
'Aux': '/brands/aux-brand.webp',
|
||||
'AUX': '/brands/aux-brand.webp',
|
||||
};
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: ProductCardProps) {
|
||||
const addItem = useCartStore((s) => s.addItem);
|
||||
const { addItem: addCompare, removeItem: removeCompare, isAdded, items: compareItems } = useCompareStore();
|
||||
const [added, setAdded] = useState(false);
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (!product.price) return;
|
||||
addItem({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
image: product.image,
|
||||
price: product.price,
|
||||
});
|
||||
setAdded(true);
|
||||
};
|
||||
|
||||
const handleCompare = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (isAdded(product.slug)) {
|
||||
removeCompare(product.slug);
|
||||
} else {
|
||||
addCompare({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
price: product.price,
|
||||
image: product.image,
|
||||
specs: product.specs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const inCompare = isAdded(product.slug);
|
||||
const compareDisabled = compareItems.length >= 3 && !inCompare;
|
||||
|
||||
return (
|
||||
<div className="group relative bg-white rounded-xl border border-border hover:border-accent hover:shadow-md transition-all duration-200 overflow-hidden flex flex-col">
|
||||
<Link href={`/sklep/${product.slug}`} className="block relative aspect-[4/3] bg-slate-50 overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-contain p-6 group-hover:scale-105 transition-transform duration-300"
|
||||
unoptimized={product.image.startsWith('http')}
|
||||
/>
|
||||
|
||||
<div className="absolute top-3 left-3 z-10 flex flex-col gap-1.5">
|
||||
{brandLogos[product.brand] ? (
|
||||
<div className="bg-white rounded px-1.5 py-1 inline-block shadow-sm">
|
||||
<Image
|
||||
src={brandLogos[product.brand]}
|
||||
alt={product.brand}
|
||||
width={40}
|
||||
height={14}
|
||||
className="object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-primary bg-white/95 px-2 py-1 rounded-md border border-border shadow-sm">
|
||||
{product.brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{product.category === 'multisplit' && (
|
||||
<span className="absolute top-2 right-2 px-2 py-1 bg-accent/10 rounded-md text-xs font-semibold text-accent border border-accent/20">
|
||||
Multisplit
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Porównywarka - pojawia się na hover */}
|
||||
<button
|
||||
onClick={handleCompare}
|
||||
disabled={compareDisabled}
|
||||
title={inCompare ? 'Usuń z porównywarki' : compareDisabled ? 'Max 3 produkty' : 'Dodaj do porównania'}
|
||||
className={`absolute bottom-2 right-2 p-1.5 rounded-lg border transition-all ${
|
||||
inCompare
|
||||
? 'bg-accent text-white border-accent opacity-100'
|
||||
: 'bg-white/90 text-text-muted border-border opacity-0 group-hover:opacity-100 hover:border-accent hover:text-accent'
|
||||
} ${compareDisabled ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<GitCompare className="size-3.5" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<div className="p-4 flex flex-col flex-1 gap-2">
|
||||
<Link href={`/sklep/${product.slug}`}>
|
||||
<h3 className="text-sm font-semibold text-primary font-heading leading-snug line-clamp-2 hover:text-accent transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{product.specs['Moc chłodnicza'] && (
|
||||
<span className="px-2 py-0.5 bg-slate-50 rounded text-xs text-text-muted border border-border">
|
||||
{product.specs['Moc chłodnicza']} kW
|
||||
</span>
|
||||
)}
|
||||
{product.specs['Klasa energetyczna'] && (
|
||||
<span className="px-2 py-0.5 bg-green-50 rounded text-xs text-green-700 border border-green-100 font-medium">
|
||||
{product.specs['Klasa energetyczna']}
|
||||
</span>
|
||||
)}
|
||||
{product.specs['Czynnik chłodniczy'] && (
|
||||
<span className="px-2 py-0.5 bg-slate-50 rounded text-xs text-text-muted border border-border">
|
||||
{product.specs['Czynnik chłodniczy']}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cena + CTA */}
|
||||
<div className="mt-auto pt-2 flex items-end justify-between gap-2">
|
||||
{product.category === 'multisplit' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-accent">
|
||||
<Phone className="size-4" />
|
||||
<span className="text-sm font-semibold">Wycena indywidualna</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/zapytanie-multisplit/${product.slug}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-accent text-white hover:bg-accent/90 transition-colors shrink-0">
|
||||
Zapytaj o wycenę
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
) : product.price !== null ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-primary font-heading leading-none">
|
||||
{product.price.toLocaleString('pl-PL')} zł
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">brutto</p>
|
||||
</div>
|
||||
{added ? (
|
||||
<Link
|
||||
href="/koszyk"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-green-500 text-white hover:bg-green-600 transition-colors shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ShoppingCart className="size-3.5" /> Zobacz koszyk
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-accent text-white hover:bg-accent/90 transition-colors shrink-0"
|
||||
>
|
||||
<ShoppingCart className="size-3.5" /> Do koszyka
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-accent">
|
||||
<Phone className="size-3.5" />
|
||||
<span className="text-sm font-semibold">Zapytaj o cenę</span>
|
||||
</div>
|
||||
<Link href="/kontakt" onClick={e => e.stopPropagation()}>
|
||||
<button className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-bold bg-primary text-white hover:bg-primary/90 transition-colors shrink-0">
|
||||
Zapytaj
|
||||
</button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
image: string;
|
||||
name: string;
|
||||
images?: string[]; // opcjonalna dodatkowa galeria z contentu, u nas na razie w mocku mamy 1 image
|
||||
}
|
||||
|
||||
export function ProductGallery({ image, name, images }: ProductGalleryProps) {
|
||||
const [mainImage, setMainImage] = useState(image || '/images/hero/shop-section-image.webp');
|
||||
|
||||
const gallery = images && images.length > 0
|
||||
? images
|
||||
: [image, '/images/hero/shop-section-image.webp'];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="relative aspect-square w-full bg-slate-50 rounded-2xl border border-border overflow-hidden flex items-center justify-center p-8">
|
||||
<Image
|
||||
src={mainImage}
|
||||
alt={name}
|
||||
width={800}
|
||||
height={800}
|
||||
className="object-contain w-full h-full"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gallery.length > 1 && (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{gallery.map((img, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setMainImage(img)}
|
||||
className={`relative w-24 h-24 shrink-0 rounded-xl border-2 overflow-hidden bg-slate-50 p-2 transition-all ${mainImage === img ? 'border-primary' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt={`${name} - miniatura ${idx + 1}`}
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Phone, ShoppingCart, GitCompare, Check,
|
||||
Thermometer, Volume2, Zap, Wind, Star, Info, Eye, X, type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { Product } from '@/content/products';
|
||||
import { useCartStore } from '@/lib/cart-store';
|
||||
import { useCompareStore } from '@/lib/compare-store';
|
||||
import { getProductVariants } from '@/content/product-variants';
|
||||
import { useRecentlyViewedStore } from '@/lib/recently-viewed-store';
|
||||
import { RecentlyViewedProducts } from './RecentlyViewedProducts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const brandLogos: Record<string, string> = {
|
||||
'Gree': '/brands/gree.webp',
|
||||
'Rotenso': '/brands/rotenso.webp',
|
||||
'Toshiba': '/brands/toshiba.webp',
|
||||
'Daikin': '/brands/daikin.webp',
|
||||
'CHV': '/brands/chv.webp',
|
||||
'Haier': '/brands/haier.webp',
|
||||
'LG': '/brands/lg.webp',
|
||||
'Samsung': '/brands/samsung.webp',
|
||||
'Mitsubishi Electric': '/brands/mitsubishi.webp',
|
||||
'Fuji Electric': '/brands/fuji.webp',
|
||||
'Aux': '/brands/aux-brand.webp',
|
||||
'AUX': '/brands/aux-brand.webp',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
product: Product;
|
||||
seriesProducts: Product[];
|
||||
companyPhone: string;
|
||||
companyPhoneRaw: string;
|
||||
}
|
||||
|
||||
function estimateSEER(klasa: string): number {
|
||||
if (!klasa) return 5.0;
|
||||
const k = klasa.toUpperCase();
|
||||
if (k.includes('A+++')) return 8.5;
|
||||
if (k.includes('A++')) return 6.1;
|
||||
if (k.includes('A+')) return 4.6;
|
||||
if (k.includes('A')) return 3.6;
|
||||
return 5.0;
|
||||
}
|
||||
|
||||
function parseMoc(val: string): number {
|
||||
if (!val) return 0;
|
||||
const nums = val.replace(',', '.').match(/[\d.]+/g);
|
||||
if (!nums) return 0;
|
||||
if (nums.length === 1) return parseFloat(nums[0]);
|
||||
// zakres: bierz środkową
|
||||
const floats = nums.map(parseFloat);
|
||||
return floats[Math.floor(floats.length / 2)];
|
||||
}
|
||||
|
||||
const TABS = ['Opis', 'Specyfikacja', 'Montaż', 'Opinie'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
function seededViews(slug: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < slug.length; i++) {
|
||||
hash = (hash * 31 + slug.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return 18 + (hash % 50);
|
||||
}
|
||||
|
||||
export function ProductPageClient({ product, seriesProducts, companyPhone, companyPhoneRaw }: Props) {
|
||||
const [currentProduct, setCurrentProduct] = useState(product);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Opis');
|
||||
const [godziny, setGodziny] = useState(8);
|
||||
const [cartState, setCartState] = useState<'idle' | 'added' | 'cart'>('idle');
|
||||
const [showViewsBanner, setShowViewsBanner] = useState(true);
|
||||
const viewsCount = seededViews(currentProduct.slug);
|
||||
|
||||
const variants = getProductVariants(currentProduct.slug);
|
||||
|
||||
const addToCart = useCartStore(s => s.addItem);
|
||||
const { addItem: addCompare, removeItem: removeCompare, isAdded } = useCompareStore();
|
||||
const inCompare = isAdded(currentProduct.slug);
|
||||
|
||||
const addRecentlyViewed = useRecentlyViewedStore((s) => s.addItem);
|
||||
useEffect(() => {
|
||||
addRecentlyViewed(currentProduct);
|
||||
}, [currentProduct, addRecentlyViewed]);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentProduct.price) return;
|
||||
addToCart({
|
||||
slug: currentProduct.slug,
|
||||
name: currentProduct.name,
|
||||
brand: currentProduct.brand,
|
||||
image: currentProduct.image,
|
||||
price: currentProduct.price,
|
||||
});
|
||||
setCartState('added');
|
||||
setTimeout(() => setCartState('cart'), 1500);
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
if (inCompare) removeCompare(currentProduct.slug);
|
||||
else addCompare({ slug: currentProduct.slug, name: currentProduct.name, brand: currentProduct.brand, price: currentProduct.price, image: currentProduct.image, specs: currentProduct.specs });
|
||||
};
|
||||
|
||||
const mocChlodnicza = parseMoc(currentProduct.specs['Moc chłodnicza'] || '');
|
||||
const pobórMocy = parseMoc(currentProduct.specs['Pobór mocy chłodzenie (kW)'] || '') || (mocChlodnicza / estimateSEER(currentProduct.specs['Klasa energetyczna'] || ''));
|
||||
const cenaKwh = 0.75;
|
||||
const kosztGodzina = pobórMocy * cenaKwh;
|
||||
const kosztMiesiac = kosztGodzina * godziny * 30;
|
||||
|
||||
const params = [
|
||||
currentProduct.specs['Klasa energetyczna'] && { icon: Star, label: 'Klasa energetyczna', value: currentProduct.specs['Klasa energetyczna'], color: 'text-green-600 bg-green-50' },
|
||||
currentProduct.specs['Moc chłodnicza'] && { icon: Thermometer, label: 'Moc chłodnicza', value: `${currentProduct.specs['Moc chłodnicza']} kW`, color: 'text-blue-600 bg-blue-50' },
|
||||
currentProduct.specs['Moc grzewcza (kW)'] && { icon: Thermometer, label: 'Moc grzewcza', value: `${currentProduct.specs['Moc grzewcza (kW)']} kW`, color: 'text-orange-600 bg-orange-50' },
|
||||
currentProduct.specs['Hałas wewnątrz (dB)'] && { icon: Volume2, label: 'Hałas wewnątrz', value: `${currentProduct.specs['Hałas wewnątrz (dB)']} dB`, color: 'text-purple-600 bg-purple-50' },
|
||||
currentProduct.specs['Czynnik chłodniczy'] && { icon: Wind, label: 'Czynnik', value: currentProduct.specs['Czynnik chłodniczy'], color: 'text-teal-600 bg-teal-50' },
|
||||
currentProduct.specs['Grzanie do'] && { icon: Zap, label: 'Grzanie do', value: `-${currentProduct.specs['Grzanie do']}°C`, color: 'text-accent bg-accent-light' },
|
||||
].filter(Boolean) as { icon: LucideIcon; label: string; value: string; color: string }[];
|
||||
|
||||
return (
|
||||
<main className="bg-slate-50 min-h-screen pb-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
|
||||
<nav className="flex items-center gap-1.5 text-xs text-text-muted mb-6">
|
||||
<Link href="/" className="hover:text-accent transition-colors">Strona główna</Link>
|
||||
<span>/</span>
|
||||
<Link href="/sklep" className="hover:text-accent transition-colors">Sklep</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/sklep?brand=${currentProduct.brand}`} className="hover:text-accent transition-colors">{currentProduct.brand}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-primary font-medium truncate max-w-[200px]">{currentProduct.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 xl:gap-12">
|
||||
|
||||
<div>
|
||||
<div className="relative aspect-[4/3] lg:aspect-auto lg:h-[420px] lg:max-h-[420px] bg-white rounded-2xl border border-border overflow-hidden">
|
||||
<Image
|
||||
src={currentProduct.image}
|
||||
alt={currentProduct.name}
|
||||
fill
|
||||
className="object-contain p-6"
|
||||
priority
|
||||
unoptimized={currentProduct.image.startsWith('http')}
|
||||
/>
|
||||
{currentProduct.specs['Klasa energetyczna'] && (
|
||||
<div className="absolute top-4 left-4 px-2.5 py-1 bg-green-500 text-white text-xs font-bold rounded-lg shadow">
|
||||
{currentProduct.specs['Klasa energetyczna'].split('/')[0]}
|
||||
</div>
|
||||
)}
|
||||
{currentProduct.category === 'multisplit' && (
|
||||
<div className="absolute top-4 right-4 px-2.5 py-1 bg-accent text-white text-xs font-bold rounded-lg shadow">
|
||||
MULTISPLIT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-3 flex-wrap">
|
||||
{brandLogos[currentProduct.brand] ? (
|
||||
<div className="bg-white rounded-lg p-1.5 inline-block border border-border">
|
||||
<Image
|
||||
src={brandLogos[currentProduct.brand]}
|
||||
alt={currentProduct.brand}
|
||||
width={80}
|
||||
height={28}
|
||||
className="object-contain object-left"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-accent uppercase tracking-widest">
|
||||
{currentProduct.brand}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-primary font-heading leading-tight">
|
||||
{currentProduct.name}
|
||||
</h1>
|
||||
<p className="text-xs text-text-muted mt-1.5 mb-4">SKU: {currentProduct.sku}</p>
|
||||
</div>
|
||||
|
||||
{variants && variants.moce.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Dostępne moce (kW):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.moce.map((v) => (
|
||||
<button
|
||||
key={v.slug}
|
||||
onClick={() => {
|
||||
const p = seriesProducts.find(sp => sp.slug === v.slug);
|
||||
if (p) {
|
||||
setCurrentProduct(p);
|
||||
window.history.pushState(null, '', `/sklep/${v.slug}`);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border-2 transition-all ${
|
||||
v.slug === currentProduct.slug
|
||||
? 'border-accent bg-accent text-white shadow-sm'
|
||||
: 'border-border bg-white text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{v.moc} kW
|
||||
{v.slug !== currentProduct.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-70 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
{v.slug === currentProduct.slug && (
|
||||
<span className="block text-[10px] font-normal opacity-80 mt-0.5">
|
||||
{v.cena.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variants && variants.wykończenia.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Wykończenie:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variants.wykończenia.map((v) => (
|
||||
<button
|
||||
key={v.slug}
|
||||
onClick={() => {
|
||||
const p = seriesProducts.find(sp => sp.slug === v.slug);
|
||||
if (p) {
|
||||
setCurrentProduct(p);
|
||||
window.history.pushState(null, '', `/sklep/${v.slug}`);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold border-2 transition-all ${
|
||||
v.slug === currentProduct.slug
|
||||
? 'border-accent bg-accent text-white shadow-sm'
|
||||
: 'border-border bg-white text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{v.wykończenie}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{params.map((p, i) => {
|
||||
const Icon = p.icon;
|
||||
return (
|
||||
<div key={i} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg ${p.color} border border-current/10`}>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[9px] font-medium opacity-70 leading-none mb-0.5">{p.label}</p>
|
||||
<p className="text-[11px] font-bold truncate">{p.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-border p-4">
|
||||
{currentProduct.price !== null ? (
|
||||
<>
|
||||
<div className="flex items-end gap-3 mb-1">
|
||||
<span className="text-2xl font-extrabold text-primary font-heading">
|
||||
{currentProduct.price.toLocaleString('pl-PL')} zł
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mb-1">cena urządzenia brutto (23% VAT)</p>
|
||||
<p className="text-xs text-text-muted mb-4">Montaż wyceniamy indywidualnie — zadzwoń lub napisz</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{cartState === 'idle' && (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-accent text-white hover:bg-accent/90 transition-all"
|
||||
>
|
||||
<ShoppingCart className="size-4" /> Do koszyka
|
||||
</button>
|
||||
)}
|
||||
{cartState === 'added' && (
|
||||
<button
|
||||
disabled
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-green-500 text-white"
|
||||
>
|
||||
<Check className="size-4" /> Dodano do koszyka
|
||||
</button>
|
||||
)}
|
||||
{cartState === 'cart' && (
|
||||
<Link href="/koszyk" className="flex-1 w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm bg-green-500 text-white hover:bg-green-600 transition-colors">
|
||||
|
||||
<ShoppingCart className="size-4" /> Zobacz koszyk
|
||||
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/kontakt" className="flex-1 w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl font-bold text-sm border-2 border-primary text-primary hover:bg-primary hover:text-white transition-all">
|
||||
|
||||
Zapytaj o montaż
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCompare}
|
||||
className={`w-full mt-2 flex items-center justify-center gap-2 py-2 px-4 rounded-xl text-xs font-medium transition-colors ${
|
||||
inCompare ? 'bg-accent/10 text-accent border border-accent' : 'bg-slate-50 text-text-muted border border-border hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<GitCompare className="size-3.5" />
|
||||
{inCompare ? 'Usuń z porównania' : 'Dodaj do porównania'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-primary font-heading mb-1">Cena na zapytanie</p>
|
||||
<p className="text-sm text-text-muted mb-4">Multisplit — cena zależy od konfiguracji jednostek wewnętrznych.</p>
|
||||
<a href={`tel:${companyPhoneRaw}`} className="flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-accent text-white font-bold text-sm hover:bg-accent/90 transition-colors">
|
||||
<Phone className="size-4" /> Zadzwoń: {companyPhone}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`tel:${companyPhoneRaw}`}
|
||||
className="flex items-center justify-between px-5 py-2.5 bg-primary rounded-xl text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-semibold">Profesjonalna porada</span>
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<Phone className="size-4" /> {companyPhone}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{mocChlodnicza > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Zap className="size-4 text-accent" />
|
||||
<h2 className="text-sm font-bold text-primary font-heading">Kalkulator kosztów energii</h2>
|
||||
<div className="group relative ml-auto">
|
||||
<Info className="size-4 text-text-muted cursor-help" />
|
||||
<div className="absolute right-0 bottom-6 w-56 bg-primary text-white text-xs rounded-lg p-2.5 hidden group-hover:block z-10">
|
||||
Szacunek przy cenie prądu {cenaKwh} zł/kWh. Rzeczywiste koszty mogą się różnić.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="text-xs text-text-muted shrink-0">Godziny użycia dziennie:</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={24}
|
||||
value={godziny}
|
||||
onChange={e => setGodziny(Number(e.target.value))}
|
||||
className="range range-xs range-accent flex-1"
|
||||
/>
|
||||
<span className="text-sm font-bold text-primary w-8 text-right">{godziny}h</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<p className="text-xs text-text-muted mb-1">Koszt / godzinę</p>
|
||||
<p className="text-lg font-bold text-primary font-heading">
|
||||
{kosztGodzina.toFixed(2)} zł
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-accent-light rounded-xl p-3 text-center border border-accent/20">
|
||||
<p className="text-xs text-accent mb-1">Koszt miesięczny</p>
|
||||
<p className="text-lg font-bold text-accent font-heading">
|
||||
{kosztMiesiac.toFixed(2)} zł
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-muted mt-2 text-center">
|
||||
Szacunek przy {cenaKwh} zł/kWh · {godziny}h/dzień · 30 dni
|
||||
{!currentProduct.specs['Pobór mocy chłodzenie (kW)'] && ' · pobór szacowany z klasy energetycznej'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="flex gap-1 border-b border-border mb-6 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-5 py-3 text-sm font-semibold whitespace-nowrap transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'Opis' && (
|
||||
<div className="max-w-3xl">
|
||||
{currentProduct.shortDescription ? (
|
||||
<p className="text-text-muted leading-relaxed text-base">{currentProduct.shortDescription}</p>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak opisu produktu.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Specyfikacja' && (
|
||||
<div className="max-w-2xl">
|
||||
{Object.keys(currentProduct.specs).length > 0 ? (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{Object.entries(currentProduct.specs).map(([key, val], idx) => (
|
||||
<tr key={key} className={idx % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
|
||||
<td className="px-5 py-3 text-text-muted font-medium w-1/2 border-r border-border">{key}</td>
|
||||
<td className="px-5 py-3 text-primary font-semibold">{val}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-muted">Brak specyfikacji.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Montaż' && (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="bg-white rounded-xl border border-border p-5">
|
||||
<h3 className="font-bold text-primary font-heading mb-3">Montaż klimatyzacji</h3>
|
||||
<p className="text-sm text-text-muted leading-relaxed mb-4">
|
||||
Oferujemy profesjonalny montaż wszystkich klimatyzatorów z naszego sklepu na terenie Dolnego Śląska.
|
||||
Montaż wykonujemy z uprawnieniami F-Gaz, estetycznie i terminowo.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-text-muted">
|
||||
{['Bezpłatna wycena montażu przed realizacją', 'Uprawnienia F-Gaz — wymóg prawny', 'Próba szczelności i próżnia po montażu', 'Szkolenie z obsługi urządzenia', 'Gwarancja na wykonaną instalację'].map(item => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href={`tel:${companyPhoneRaw}`} className="mt-4 inline-flex items-center gap-2 btn btn-primary text-white font-bold text-sm">
|
||||
<Phone className="size-4" /> Zapytaj o montaż
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Opinie' && (
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-border">
|
||||
<h3 className="font-bold text-primary font-heading mb-4">Dodaj opinię</h3>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); alert('Opinia została wysłana i oczekuje na moderację.'); e.currentTarget.reset(); }}>
|
||||
<div className="flex gap-1 mb-2">
|
||||
{[1,2,3,4,5].map(star => (
|
||||
<Star key={star} className="size-6 text-amber-400 cursor-pointer fill-amber-400 hover:scale-110 transition-transform" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold text-primary">Imię</label>
|
||||
<input type="text" required className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" placeholder="Twoje imię" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-semibold text-primary">Treść opinii</label>
|
||||
<textarea required rows={4} className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50" placeholder="Podziel się swoimi wrażeniami..."></textarea>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary font-bold text-white w-full sm:w-auto">Wyślij opinię</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-bold text-primary font-heading">Opinie klientów (0)</h3>
|
||||
<p className="text-sm text-text-muted">Bądź pierwszą osobą, która oceni ten produkt!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecentlyViewedProducts currentSlug={currentProduct.slug} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showViewsBanner && (
|
||||
<div className="fixed bottom-24 left-6 z-50 flex items-center gap-3 bg-white border border-amber-200 shadow-lg rounded-xl px-4 py-3 max-w-xs animate-in slide-in-from-left-4 duration-300">
|
||||
<Eye className="size-4 text-amber-500 shrink-0" />
|
||||
<p className="text-sm text-gray-800">
|
||||
W ostatnich 30 dniach interesuje się tym produktem <span className="font-bold">{viewsCount}</span> osób.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowViewsBanner(false)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors shrink-0 ml-1"
|
||||
aria-label="Zamknij"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Phone, X } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Product } from '@/content/products';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { companyDetails } from '@/content/company';
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
brands: string[];
|
||||
}
|
||||
|
||||
const PER_PAGE = 24;
|
||||
|
||||
// Parsuj moc chłodniczą — zwróć wartość MAX z zakresu
|
||||
function parseMocMax(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.replace(/[^\d,.-]/g, ' ').split(/[\s\/–-]+/).map(s => parseFloat(s.replace(',', '.'))).filter(n => !isNaN(n));
|
||||
return nums.length > 0 ? Math.max(...nums) : null;
|
||||
}
|
||||
|
||||
// Parsuj hałas — zwróć wartość MIN z zakresu
|
||||
function parseHalasMin(val: string): number | null {
|
||||
if (!val) return null;
|
||||
const nums = val.match(/\d+/g);
|
||||
return nums ? Math.min(...nums.map(Number)) : null;
|
||||
}
|
||||
|
||||
// Mapowanie wykończenia na grupy kolorystyczne
|
||||
function mapWykonczenie(val: string): string {
|
||||
const v = val.toLowerCase();
|
||||
if (v.includes('white') || v === 'biały') return 'Biały';
|
||||
if (v.includes('black') || v === 'czarny' || v === 'dark' || v.includes('satin')) return 'Czarny';
|
||||
if (v.includes('silver') || v === 'srebrny') return 'Srebrny';
|
||||
if (v.includes('gold') || v === 'złoty' || v === 'champagne') return 'Złoty';
|
||||
if (v.includes('graphite') || v.includes('grey') || v.includes('gray')) return 'Grafitowy';
|
||||
if (v.includes('wood') || v.includes('fabric') || v.includes('textile') || v.includes('leather') || v.includes('stone') || v.includes('mirror') || v.includes('caramel') || v.includes('navy') || v.includes('miedziany') || v === 'beige stone') return 'Inne';
|
||||
if (v === 'matt' || v === 'shiny') return 'Biały';
|
||||
return 'Inne';
|
||||
}
|
||||
|
||||
const COLOR_GROUPS = ['Biały', 'Czarny', 'Srebrny', 'Złoty', 'Grafitowy', 'Inne'];
|
||||
|
||||
// Buckety wielkości pomieszczenia → zakres mocy kW
|
||||
const ROOM_BUCKETS = [
|
||||
{ label: 'do 20 m²', min: 0, max: 2.5 },
|
||||
{ label: '21–35 m²', min: 2.5, max: 3.5 },
|
||||
{ label: '36–50 m²', min: 3.5, max: 5.0 },
|
||||
{ label: '51–70 m²', min: 5.0, max: 7.0 },
|
||||
{ label: 'powyżej 70 m²', min: 7.0, max: 99 },
|
||||
];
|
||||
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Accordion({ title, children, defaultOpen = true }: AccordionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border pb-4 mb-4">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<span className="text-sm font-bold text-primary font-heading">{title}</span>
|
||||
{open ? <ChevronUp className="size-4 text-text-muted" /> : <ChevronDown className="size-4 text-text-muted" />}
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductsGrid({ products, brands }: ProductsGridProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const urlSearch = searchParams.get('search') || '';
|
||||
const urlBrand = searchParams.get('brand') || '';
|
||||
const urlCategory = searchParams.get('category') || 'all';
|
||||
|
||||
const [category, setCategory] = useState<'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna'>(
|
||||
urlCategory as 'all' | 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna'
|
||||
);
|
||||
const [selectedBrands, setSelectedBrands] = useState<string[]>(urlBrand ? [urlBrand] : []);
|
||||
const [searchQuery, setSearchQuery] = useState(urlSearch);
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
const [selectedRooms, setSelectedRooms] = useState<string[]>([]);
|
||||
const [selectedColors, setSelectedColors] = useState<string[]>([]);
|
||||
const [selectedUnitCounts, setSelectedUnitCounts] = useState<number[]>([]);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearchQuery(urlSearch);
|
||||
setSelectedBrands(urlBrand ? [urlBrand] : []);
|
||||
if (urlCategory !== 'all') {
|
||||
setCategory(urlCategory as 'scienne' | 'multisplit' | 'podlogowe' | 'jednostka-scienna');
|
||||
} else {
|
||||
setCategory('all');
|
||||
}
|
||||
setPage(1);
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [urlSearch, urlBrand, urlCategory]);
|
||||
|
||||
const toggleBrand = (b: string) => {
|
||||
setSelectedBrands(prev => prev.includes(b) ? prev.filter(x => x !== b) : [...prev, b]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleRoom = (r: string) => {
|
||||
setSelectedRooms(prev => prev.includes(r) ? prev.filter(x => x !== r) : [...prev, r]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleColor = (c: string) => {
|
||||
setSelectedColors(prev => prev.includes(c) ? prev.filter(x => x !== c) : [...prev, c]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleUnitCount = (uc: number) => {
|
||||
setSelectedUnitCounts(prev => prev.includes(uc) ? prev.filter(x => x !== uc) : [...prev, uc]);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return products.filter(p => {
|
||||
// Wyszukiwanie tekstowe
|
||||
if (searchQuery) {
|
||||
const sq = searchQuery.toLowerCase();
|
||||
if (!p.name.toLowerCase().includes(sq) && !p.brand.toLowerCase().includes(sq)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (category !== 'all' && p.category !== category) return false;
|
||||
|
||||
if (selectedBrands.length > 0 && !selectedBrands.includes(p.brand)) return false;
|
||||
|
||||
const pMin = priceMin ? parseInt(priceMin) : null;
|
||||
const pMax = priceMax ? parseInt(priceMax) : null;
|
||||
if (p.price !== null) {
|
||||
if (pMin && p.price < pMin) return false;
|
||||
if (pMax && p.price > pMax) return false;
|
||||
}
|
||||
|
||||
// Wielkość pomieszczenia → moc
|
||||
if (selectedRooms.length > 0) {
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return false;
|
||||
const matches = selectedRooms.some(r => {
|
||||
const bucket = ROOM_BUCKETS.find(b => b.label === r);
|
||||
return bucket ? moc >= bucket.min && moc < bucket.max : false;
|
||||
});
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Kolor
|
||||
if (selectedColors.length > 0) {
|
||||
const wyk = p.specs['Wykończenie'] || '';
|
||||
const group = mapWykonczenie(wyk);
|
||||
if (!selectedColors.includes(group)) return false;
|
||||
}
|
||||
|
||||
// Liczba jednostek wewn. (multisplit)
|
||||
if (selectedUnitCounts.length > 0) {
|
||||
const unitsStr = p.specs['Liczba jednostek wewn.'];
|
||||
if (!unitsStr) return false;
|
||||
const units = parseFloat(unitsStr);
|
||||
if (!selectedUnitCounts.includes(units)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [products, category, selectedBrands, priceMin, priceMax, selectedRooms, selectedColors, selectedUnitCounts, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PER_PAGE);
|
||||
const paginated = filtered.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||||
|
||||
// Liczniki per brand (dla aktualnych filtrów bez filtra marki)
|
||||
const brandCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
counts[p.brand] = (counts[p.brand] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per room bucket
|
||||
const roomCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const moc = parseMocMax(p.specs['Moc chłodnicza'] || '');
|
||||
if (moc === null) return;
|
||||
ROOM_BUCKETS.forEach(b => {
|
||||
if (moc >= b.min && moc < b.max) {
|
||||
counts[b.label] = (counts[b.label] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
// Liczniki per liczba jednostek wewn.
|
||||
const unitCountsAgg = useMemo(() => {
|
||||
const counts: Record<number, number> = {};
|
||||
products.forEach(p => {
|
||||
if (category !== 'all' && p.category !== category) return;
|
||||
const unitsStr = p.specs['Liczba jednostek wewn.'];
|
||||
if (unitsStr) {
|
||||
const units = parseFloat(unitsStr);
|
||||
if (!isNaN(units)) {
|
||||
counts[units] = (counts[units] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [products, category]);
|
||||
|
||||
const resetAll = () => {
|
||||
setCategory('all');
|
||||
setSelectedBrands([]);
|
||||
setSearchQuery('');
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
setSelectedRooms([]);
|
||||
setSelectedColors([]);
|
||||
setSelectedUnitCounts([]);
|
||||
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = category !== 'all' || selectedBrands.length > 0 || searchQuery !== '' || priceMin || priceMax || selectedRooms.length > 0 || selectedColors.length > 0 || selectedUnitCounts.length > 0;
|
||||
|
||||
const sidebarContent = (
|
||||
<aside className="w-full">
|
||||
<Accordion title="Klimatyzatory">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
{ val: 'all', label: 'Wszystkie' },
|
||||
{ val: 'scienne', label: 'Ścienne split' },
|
||||
{ val: 'multisplit', label: 'Multisplit' },
|
||||
|
||||
].map(opt => (
|
||||
<li key={opt.val}>
|
||||
<button
|
||||
onClick={() => { setCategory(opt.val as typeof category); setPage(1); }}
|
||||
className={`flex items-center gap-2.5 text-sm w-full text-left py-0.5 transition-colors ${
|
||||
category === opt.val ? 'text-primary font-semibold' : 'text-text-muted hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
<span className={`size-3 rounded-full border-2 shrink-0 ${
|
||||
category === opt.val ? 'border-accent bg-accent' : 'border-border'
|
||||
}`} />
|
||||
{opt.label}
|
||||
<span className="ml-auto text-xs bg-slate-100 rounded-full px-1.5 py-0.5 text-text-muted">
|
||||
{opt.val === 'all' ? products.length : products.filter(p => p.category === opt.val).length}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Marka">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{brands.map(b => (
|
||||
<li key={b}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBrands.includes(b)}
|
||||
onChange={() => toggleBrand(b)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedBrands.includes(b)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedBrands.includes(b) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedBrands.includes(b) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{brandCounts[b] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
{/* Ilość jednostek wewnętrznych */}
|
||||
{(category === 'all' || category === 'multisplit') && (
|
||||
<Accordion title="Ilość jednostek wewn.">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(uc => {
|
||||
if (unitCountsAgg[uc] === undefined && !selectedUnitCounts.includes(uc)) return null;
|
||||
return (
|
||||
<li key={uc}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUnitCounts.includes(uc)}
|
||||
onChange={() => toggleUnitCount(uc)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedUnitCounts.includes(uc)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedUnitCounts.includes(uc) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedUnitCounts.includes(uc) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{uc} {uc === 1 ? 'jednostka' : uc >= 2 && uc <= 4 ? 'jednostki' : 'jednostek'}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{unitCountsAgg[uc] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Accordion title="Cena (zł)">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="od"
|
||||
value={priceMin}
|
||||
onChange={e => { setPriceMin(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
<span className="text-text-muted shrink-0">–</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="do"
|
||||
value={priceMax}
|
||||
onChange={e => { setPriceMax(e.target.value); setPage(1); }}
|
||||
className="w-full border border-border rounded-lg px-3 py-2 text-sm text-primary focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Wielkość pomieszczenia */}
|
||||
<Accordion title="Wielkość pomieszczenia">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{ROOM_BUCKETS.map(b => (
|
||||
<li key={b.label}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRooms.includes(b.label)}
|
||||
onChange={() => toggleRoom(b.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedRooms.includes(b.label)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedRooms.includes(b.label) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedRooms.includes(b.label) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{roomCounts[b.label] || 0}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-2">Szacunkowo: 10 m² = 1 kW mocy chłodniczej</p>
|
||||
</Accordion>
|
||||
|
||||
|
||||
|
||||
<Accordion title="Kolor dominujący">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{COLOR_GROUPS.map(c => (
|
||||
<li key={c}>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColors.includes(c)}
|
||||
onChange={() => toggleColor(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className={`size-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors ${
|
||||
selectedColors.includes(c)
|
||||
? 'bg-accent border-accent'
|
||||
: 'bg-white border-slate-300 group-hover:border-accent'
|
||||
}`}>
|
||||
{selectedColors.includes(c) && (
|
||||
<svg viewBox="0 0 10 8" className="size-2.5" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-sm transition-colors ${selectedColors.includes(c) ? 'text-primary font-medium' : 'text-text-muted group-hover:text-primary'}`}>
|
||||
{c}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion>
|
||||
|
||||
<div className="rounded-xl bg-slate-50 border border-border p-4 text-center mt-2">
|
||||
<p className="text-sm font-bold text-primary font-heading mb-3">Potrzebujesz pomocy?</p>
|
||||
<a
|
||||
href={`tel:${companyDetails.phoneRaw}`}
|
||||
className="flex items-center justify-center gap-2 text-primary font-bold text-base hover:text-accent transition-colors"
|
||||
>
|
||||
<Phone className="size-4 text-accent" />
|
||||
{companyDetails.phone}
|
||||
</a>
|
||||
<p className="text-xs text-text-muted mt-1">{companyDetails.hours}</p>
|
||||
<Link href="/kontakt" className="mt-3 w-full btn btn-primary text-white font-bold text-sm py-2">
|
||||
|
||||
Zadaj pytanie
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="py-8 md:py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<div className="lg:hidden mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-border rounded-lg text-sm font-medium text-primary hover:border-accent hover:text-accent transition-colors"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
Filtry {hasFilters && <span className="size-2 rounded-full bg-accent inline-block" />}
|
||||
</button>
|
||||
<span className="text-sm text-text-muted">{filtered.length} produktów</span>
|
||||
</div>
|
||||
|
||||
{mobileSidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileSidebarOpen(false)} />
|
||||
<div className="absolute top-0 left-0 bottom-0 w-80 bg-white overflow-y-auto p-6 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="font-bold text-primary font-heading">Filtry</span>
|
||||
<button onClick={() => setMobileSidebarOpen(false)}>
|
||||
<X className="size-5 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="hidden lg:block w-64 shrink-0">
|
||||
<div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pr-2">
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={resetAll}
|
||||
className="flex items-center gap-1.5 text-xs text-accent hover:underline mb-4"
|
||||
>
|
||||
<X className="size-3" /> Wyczyść filtry
|
||||
</button>
|
||||
)}
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-sm text-text-muted hidden lg:block">{filtered.length} produktów</span>
|
||||
{hasFilters && (
|
||||
<button onClick={resetAll} className="lg:hidden text-xs text-accent hover:underline flex items-center gap-1">
|
||||
<X className="size-3" /> Wyczyść
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{paginated.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
{paginated.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-text-muted mb-3">Brak produktów dla wybranych filtrów.</p>
|
||||
<button onClick={resetAll} className="text-accent hover:underline text-sm">
|
||||
Wyczyść filtry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-12">
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.max(1, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Poprzednia
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const p = page <= 3 ? i + 1 : page - 2 + i;
|
||||
if (p < 1 || p > totalPages) return null;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => { setPage(p); window.scrollTo(0, 0); }}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
p === page
|
||||
? 'bg-accent text-white border border-accent'
|
||||
: 'border border-border text-primary hover:border-accent hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setPage(p => Math.min(totalPages, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Następna →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecentlyViewedStore } from '@/lib/recently-viewed-store';
|
||||
import { ProductCard } from './ProductCard';
|
||||
|
||||
export function RecentlyViewedProducts({ currentSlug }: { currentSlug: string }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const items = useRecentlyViewedStore((state) => state.items);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setMounted(true), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const displayItems = items.filter(item => item.slug !== currentSlug).slice(0, 4);
|
||||
|
||||
if (displayItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-16 border-t border-border pt-12">
|
||||
<h2 className="text-2xl font-bold text-primary font-heading mb-6">
|
||||
Ostatnio oglądane przez Ciebie
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{displayItems.map(product => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
import { useConsentStore } from '@/lib/consent-store';
|
||||
|
||||
const GA_ID = 'G-VW3GD6SS7K';
|
||||
const CLARITY_ID = 'xdo6q3i06e';
|
||||
|
||||
export function AnalyticsWrapper() {
|
||||
const { analyticsConsent } = useConsentStore();
|
||||
|
||||
if (!analyticsConsent) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* GA4 */}
|
||||
<Script
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="ga4-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_ID}', {
|
||||
page_path: window.location.pathname,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
|
||||
{/* Microsoft Clarity */}
|
||||
<Script id="clarity-init" strategy="afterInteractive">
|
||||
{`
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "${CLARITY_ID}");
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'outline' | 'error' | 'outline-light';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonClassesProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function buttonClasses({ variant = 'primary', size = 'md', className }: ButtonClassesProps = {}) {
|
||||
const baseClasses = 'inline-flex items-center justify-center gap-2 whitespace-nowrap transition-colors';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'btn btn-primary text-white font-bold',
|
||||
outline: 'btn bg-white/10 text-white border border-white/20 hover:bg-white/20 font-bold',
|
||||
'outline-light': 'btn bg-white text-primary border border-border hover:bg-slate-50 font-bold',
|
||||
error: 'btn btn-error text-white font-bold',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'btn-sm text-sm',
|
||||
md: '', // default DaisyUI btn size
|
||||
lg: 'btn-lg text-lg',
|
||||
};
|
||||
|
||||
return cn(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
);
|
||||
}
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, ButtonClassesProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={buttonClasses({ variant, size, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user