Zastosowanie wszystkich poprawek UI i logiki kalkulatora
This commit is contained in:
@@ -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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user