Zastosowanie wszystkich poprawek UI i logiki kalkulatora

This commit is contained in:
AI Assistant
2026-08-10 23:26:52 +02:00
commit 7324a6f800
218 changed files with 33628 additions and 0 deletions
+145
View File
@@ -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>
);
}
+105
View File
@@ -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." };
}
}