feat: security audit, rate limiting, server-side pricing, html escaping and security headers
This commit is contained in:
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -139,14 +140,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
return (
|
||||
<main className="bg-slate-50 pt-24 pb-16">
|
||||
{/* Skrypty JSON-LD dla SEO */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdArticle) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdBreadcrumb) }}
|
||||
/>
|
||||
<JsonLd data={jsonLdArticle} />
|
||||
<JsonLd data={jsonLdBreadcrumb} />
|
||||
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Powrót */}
|
||||
@@ -203,6 +198,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
||||
prose-img:rounded-xl prose-img:shadow-md
|
||||
marker:text-accent"
|
||||
>
|
||||
{/* Dane blogowe są obecnie lokalnym, zaufanym contentem; nie dodajemy ciężkiej biblioteki DOMPurify bez potrzeby.
|
||||
Przy wdrożeniu CMS lub wyświetlaniu treści od użytkowników wymagane będzie sanitizowanie HTML na serwerze. */}
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Check, Phone, ArrowRight, Wrench, Zap, Wind } from 'lucide-react';
|
||||
@@ -111,7 +112,7 @@ export default function CennikMontazuPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Check, Phone, Wrench, ArrowRight, Building2, Thermometer, Wind, Zap, Clock } from 'lucide-react';
|
||||
@@ -108,7 +109,7 @@ export default function KlimatyzacjaDoBiuraPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Phone, Wrench, ArrowRight, Home, Thermometer, Wind, Zap } from 'lucide-react';
|
||||
@@ -86,7 +87,7 @@ export default function KlimatyzacjaDoDomuPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Clock, Phone, ArrowRight } from 'lucide-react';
|
||||
@@ -63,10 +64,7 @@ export default async function MiastoPage({ params }: Props) {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<JsonLd data={jsonLd} />
|
||||
|
||||
{/* HERO */}
|
||||
<HeroSection
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { InfoHighlights } from '@/components/sections/InfoHighlights';
|
||||
import { AcTypesSection } from '@/components/sections/AcTypesSection';
|
||||
@@ -71,7 +72,7 @@ export default function KlimatyzacjaPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
<main>
|
||||
<HeroSection
|
||||
eyebrow="15+ lat doświadczenia · Split i multisplit · Uprawnienia F-gaz"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { MapPin, Clock, Phone, ArrowRight } from 'lucide-react';
|
||||
@@ -67,10 +68,7 @@ export default async function DzielnicaPage({ params }: Props) {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<JsonLd data={jsonLd} />
|
||||
|
||||
<HeroSection
|
||||
eyebrow={`Klimatyzacja ${district.name} · Wrocław · F-Gaz`}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { verifyRecaptcha } from "@/lib/recaptcha";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
// Schemat walidacji zgodny z tym po stronie klienta
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
|
||||
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
|
||||
@@ -30,7 +29,7 @@ export async function submitContactForm(prevState: unknown, formData: FormData)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Walidacja danych z formularza
|
||||
|
||||
const rawData = {
|
||||
name: formData.get("name"),
|
||||
phone: formData.get("phone"),
|
||||
@@ -106,7 +105,6 @@ export async function submitContactForm(prevState: unknown, formData: FormData)
|
||||
|
||||
console.log("Wysłano e-mail (ID):", data?.id);
|
||||
|
||||
// 3. Zwrotka o sukcesie
|
||||
return {
|
||||
success: true,
|
||||
message: "Dziękujemy za wiadomość! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Check, Phone, Wrench, ArrowRight, ShieldCheck, Clock, Star } from 'lucide-react';
|
||||
@@ -87,7 +88,7 @@ export default function MontazKlimatyzacjiPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import { HeroSection } from '@/components/sections/HeroSection';
|
||||
import { BenefitsGrid } from '@/components/sections/BenefitsGrid';
|
||||
import { HeatPumpTypes } from '@/components/sections/HeatPumpTypes';
|
||||
@@ -70,7 +71,7 @@ export default function PompyCieplaPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPompyJsonLd) }} />
|
||||
<JsonLd data={faqPompyJsonLd} />
|
||||
<main>
|
||||
<HeroSection
|
||||
title="Pompy ciepła — niższe rachunki, niezależność, dofinansowanie"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
@@ -72,7 +73,7 @@ export default function SerwisDaikinPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="bg-primary text-white py-16 md:py-24">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Check, Phone, Wrench, ArrowRight, Thermometer, Wind, Zap, ShieldCheck } from 'lucide-react';
|
||||
@@ -120,7 +121,7 @@ export default function SerwisKlimatyzacjiPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="relative text-white py-24 md:py-32 overflow-hidden">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
@@ -68,7 +69,7 @@ export default function SerwisLGPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="bg-primary text-white py-16 md:py-24">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
@@ -72,7 +73,7 @@ export default function SerwisMitsubishiPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="bg-primary text-white py-16 md:py-24">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { JsonLd } from '@/components/ui/JsonLd';
|
||||
import Link from 'next/link';
|
||||
import { Check, Phone, Wrench, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
import { companyDetails } from '@/content/company';
|
||||
@@ -72,7 +73,7 @@ export default function SerwisSamsungPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="bg-primary text-white py-16 md:py-24">
|
||||
|
||||
+63
-26
@@ -1,35 +1,46 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { Resend } from 'resend';
|
||||
import { verifyRecaptcha } from '@/lib/recaptcha';
|
||||
import { headers } from 'next/headers';
|
||||
import { getProductBySlug } from '@/content/products';
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from '@/lib/security';
|
||||
import { getMailClient } from '@/lib/mail';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const orderSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
phone: z.string().min(9),
|
||||
email: z.string().email(),
|
||||
const orderSchema = baseContactSchema.extend({
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
consent: z.string(),
|
||||
products: z.string(),
|
||||
total: z.string(),
|
||||
total: z.string(), // We don't trust this total, just checking if it exists
|
||||
});
|
||||
|
||||
const productItemSchema = z.array(z.object({
|
||||
slug: z.string(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
}));
|
||||
|
||||
export async function submitOrderForm(prevState: unknown, formData: FormData) {
|
||||
try {
|
||||
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
|
||||
if (!rateLimit(ip)) {
|
||||
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
|
||||
const token = formData.get('recaptchaToken') as string;
|
||||
if (!token) {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
const { success } = await verifyRecaptcha(token);
|
||||
const success = await verifyRecaptcha(token);
|
||||
if (!success) {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
}
|
||||
|
||||
const mailClient = getMailClient();
|
||||
if (!mailClient) {
|
||||
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
|
||||
}
|
||||
|
||||
const data = orderSchema.parse({
|
||||
name: formData.get('name'),
|
||||
phone: formData.get('phone'),
|
||||
@@ -41,18 +52,44 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
|
||||
total: formData.get('total'),
|
||||
});
|
||||
|
||||
const products = JSON.parse(data.products);
|
||||
const total = parseFloat(data.total);
|
||||
let rawProducts;
|
||||
try {
|
||||
rawProducts = JSON.parse(data.products);
|
||||
} catch {
|
||||
return { success: false, message: "Błąd przetwarzania koszyka." };
|
||||
}
|
||||
|
||||
const productRows = products.map((p: { name: string; quantity: number; price: number }) =>
|
||||
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${p.name}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
|
||||
const parsedProducts = productItemSchema.parse(rawProducts);
|
||||
|
||||
if (parsedProducts.length === 0) {
|
||||
return { success: false, message: "Koszyk jest pusty." };
|
||||
}
|
||||
|
||||
let calculatedTotal = 0;
|
||||
const validatedProducts = [];
|
||||
|
||||
for (const p of parsedProducts) {
|
||||
const dbProduct = getProductBySlug(p.slug);
|
||||
if (!dbProduct || !dbProduct.price) {
|
||||
return { success: false, message: "Jeden z produktów w koszyku jest niedostępny." };
|
||||
}
|
||||
validatedProducts.push({
|
||||
name: dbProduct.name,
|
||||
quantity: p.quantity,
|
||||
price: dbProduct.price
|
||||
});
|
||||
calculatedTotal += dbProduct.price * p.quantity;
|
||||
}
|
||||
|
||||
const productRows = validatedProducts.map(p =>
|
||||
`<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;">${escapeHtml(p.name)}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;">${p.quantity}</td><td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:right;">${(p.price * p.quantity).toLocaleString('pl-PL')} zł</td></tr>`
|
||||
).join('');
|
||||
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
|
||||
to: process.env.RESEND_TO_EMAIL || '[email protected]',
|
||||
await mailClient.resend.emails.send({
|
||||
from: mailClient.fromEmail,
|
||||
to: mailClient.toEmail,
|
||||
replyTo: data.email,
|
||||
subject: `Nowe zamówienie od ${data.name} — ${total.toLocaleString('pl-PL')} zł`,
|
||||
subject: `Nowe zamówienie od ${escapeHtml(data.name)} — ${calculatedTotal.toLocaleString('pl-PL')} zł`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="background:#0F2A47;padding:20px;text-align:center;">
|
||||
@@ -60,11 +97,11 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
|
||||
<p><strong>Imię:</strong> ${data.name}</p>
|
||||
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
|
||||
<p><strong>Email:</strong> ${data.email}</p>
|
||||
${data.address ? `<p><strong>Adres:</strong> ${data.address}</p>` : ''}
|
||||
${data.notes ? `<p><strong>Uwagi:</strong> ${data.notes}</p>` : ''}
|
||||
<p><strong>Imię:</strong> ${escapeHtml(data.name)}</p>
|
||||
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(data.phone)}">${escapeHtml(data.phone)}</a></p>
|
||||
<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>
|
||||
${data.address ? `<p><strong>Adres:</strong> ${escapeHtml(data.address)}</p>` : ''}
|
||||
${data.notes ? `<p><strong>Uwagi:</strong> ${escapeHtml(data.notes)}</p>` : ''}
|
||||
<h2 style="font-size:16px;">Zamówione produkty</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead><tr style="background:#f8fafc;">
|
||||
@@ -75,7 +112,7 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
|
||||
<tbody>${productRows}</tbody>
|
||||
<tfoot><tr>
|
||||
<td colspan="2" style="padding:8px;font-weight:bold;">Łącznie (urządzenia)</td>
|
||||
<td style="padding:8px;font-weight:bold;text-align:right;">${total.toLocaleString('pl-PL')} zł</td>
|
||||
<td style="padding:8px;font-weight:bold;text-align:right;">${calculatedTotal.toLocaleString('pl-PL')} zł</td>
|
||||
</tr></tfoot>
|
||||
</table>
|
||||
<p style="margin-top:16px;font-size:12px;color:#64748b;">+ koszt montażu do ustalenia telefonicznie</p>
|
||||
@@ -86,6 +123,6 @@ export async function submitOrderForm(prevState: unknown, formData: FormData) {
|
||||
|
||||
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 {
|
||||
return { success: false, message: 'Wystąpił błąd. Spróbuj ponownie lub zadzwoń do nas bezpośrednio.' };
|
||||
return { success: false, message: 'Wystąpił błąd podczas wysyłania zamówienia. Spróbuj ponownie.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +1,102 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { Resend } from "resend";
|
||||
import { verifyRecaptcha } from "@/lib/recaptcha";
|
||||
import { headers } from "next/headers";
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
|
||||
import { getMailClient } from "@/lib/mail";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const multisplitFormSchema = z.object({
|
||||
name: z.string().min(2, "Imię musi mieć min. 2 znaki"),
|
||||
phone: z.string().min(9, "Wprowadź poprawny numer telefonu"),
|
||||
email: z.union([z.literal(''), z.string().email("Niepoprawny adres e-mail")]).optional(),
|
||||
const multisplitFormSchema = baseContactSchema.extend({
|
||||
city: z.string().min(1, "Wybierz miasto"),
|
||||
rooms: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
consent: z.boolean().refine((val) => val === true, "Zgoda RODO jest wymagana"),
|
||||
productName: z.string(),
|
||||
productSku: z.string(),
|
||||
recaptchaToken: z.string().optional(),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function submitMultisplitForm(data: Record<string, any>) {
|
||||
export async function submitMultisplitForm(data: Record<string, unknown>) {
|
||||
try {
|
||||
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
|
||||
if (!rateLimit(ip)) {
|
||||
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
|
||||
if (!data.recaptchaToken) {
|
||||
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
const { success } = await verifyRecaptcha(data.recaptchaToken);
|
||||
const success = await verifyRecaptcha(data.recaptchaToken);
|
||||
if (!success) {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
}
|
||||
|
||||
const validatedData = multisplitFormSchema.parse(data);
|
||||
const mailClient = getMailClient();
|
||||
if (!mailClient) {
|
||||
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
|
||||
}
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || "[email protected]",
|
||||
to: process.env.RESEND_TO_EMAIL || "[email protected]",
|
||||
// Convert boolean consent to string for base schema compatibility if it comes as boolean
|
||||
const processData = { ...data };
|
||||
if (typeof processData.consent === 'boolean') {
|
||||
processData.consent = processData.consent ? "true" : "false";
|
||||
}
|
||||
|
||||
const validatedData = multisplitFormSchema.parse(processData);
|
||||
|
||||
const { error } = await mailClient.resend.emails.send({
|
||||
from: mailClient.fromEmail,
|
||||
to: mailClient.toEmail,
|
||||
replyTo: validatedData.email || undefined,
|
||||
subject: `Zapytanie o multisplit: ${validatedData.productName} (${validatedData.productSku})`,
|
||||
subject: `Zapytanie o multisplit: ${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; background-color: #ffffff;">
|
||||
<div style="background-color: #0F2A47; padding: 20px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Zapytanie o multisplit</h1>
|
||||
<p style="color: #2BA8E0; margin: 5px 0 0 0; font-size: 14px;">Domena: thermcool.pl</p>
|
||||
</div>
|
||||
<div style="padding: 24px; color: #1e293b;">
|
||||
<h2 style="font-size: 18px; margin-top: 0; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dane kontaktowe:</h2>
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9; width: 120px;"><strong>Imię:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.name}</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.name)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Telefon:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="tel:${validatedData.phone}" style="color: #0F2A47; text-decoration: none;">${validatedData.phone}</a></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.phone)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>E-mail:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><a href="mailto:${validatedData.email}" style="color: #0F2A47; text-decoration: none;">${validatedData.email || 'Brak'}</a></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.email || 'Brak')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Miasto:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.city}</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.city)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Produkt:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.productName} (${validatedData.productSku})</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.productName)} (${escapeHtml(validatedData.productSku)})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;"><strong>Pokoje:</strong></td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${validatedData.rooms}</td>
|
||||
<td style="padding: 8px 0; border-bottom: 1px solid #f1f5f9;">${escapeHtml(validatedData.rooms || '')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size: 18px; border-bottom: 2px solid #f1f5f9; padding-bottom: 10px;">Dodatkowe informacje:</h2>
|
||||
<div style="background-color: #f8fafc; padding: 15px; border-radius: 6px; font-size: 15px; line-height: 1.6; white-space: pre-wrap;">
|
||||
${validatedData.message || 'Brak'}
|
||||
${escapeHtml(validatedData.message || 'Brak')}
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 24px; font-size: 12px; color: #64748b; text-align: center;">
|
||||
Zgoda RODO została zaakceptowana: <strong>${validatedData.consent ? "Tak" : "Nie"}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div style="background-color: #f1f5f9; padding: 15px; text-align: center; font-size: 12px; color: #64748b;">
|
||||
Wiadomość wygenerowana automatycznie z formularza multisplit na stronie thermcool.pl
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Resend Error:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Nie udało się wysłać wiadomości ze względu na błąd serwera pocztowego.",
|
||||
};
|
||||
return { success: false, message: "Wystąpił problem z wysłaniem. Spróbuj ponownie." };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe.",
|
||||
};
|
||||
return { success: true, message: "Dziękujemy za zapytanie! Skontaktujemy się z Tobą najszybciej jak to możliwe." };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Wystąpił błąd walidacji. Sprawdź poprawność danych.",
|
||||
errors: error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "Wystąpił nieoczekiwany błąd serwera. Spróbuj ponownie później.",
|
||||
};
|
||||
return { success: false, message: "Błąd walidacji lub błąd serwera. Spróbuj ponownie później." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,87 @@
|
||||
'use server';
|
||||
|
||||
import { Resend } from 'resend';
|
||||
import { verifyRecaptcha } from '@/lib/recaptcha';
|
||||
import { z } from 'zod';
|
||||
import { headers } from "next/headers";
|
||||
import { escapeHtml, rateLimit, verifyRecaptcha, baseContactSchema } from "@/lib/security";
|
||||
import { getMailClient } from "@/lib/mail";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const awariaFormSchema = baseContactSchema.omit({ consent: true }).extend({
|
||||
isUrgent: z.boolean().optional(),
|
||||
brand: z.string().min(1, "Wymagana nazwa urządzenia"),
|
||||
model: z.string().optional(),
|
||||
errorCode: z.string().optional(),
|
||||
problemType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
warranty: z.string().optional(),
|
||||
lastService: z.string().optional(),
|
||||
city: z.string().min(1, "Wymagane miasto"),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
recaptchaToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function submitAwariaForm(data: {
|
||||
isUrgent: boolean;
|
||||
brand: string;
|
||||
model: string;
|
||||
errorCode: string;
|
||||
problemType: string;
|
||||
description: string;
|
||||
warranty: string;
|
||||
lastService: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
city: string;
|
||||
preferredDate: string;
|
||||
preferredTime: string;
|
||||
recaptchaToken?: string;
|
||||
}) {
|
||||
export async function submitAwariaForm(data: Record<string, unknown>) {
|
||||
try {
|
||||
const ip = (await headers()).get('x-forwarded-for') || 'unknown';
|
||||
if (!rateLimit(ip)) {
|
||||
return { success: false, message: 'Wysłano zbyt wiele zapytań. Spróbuj ponownie później.' };
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
|
||||
if (!data.recaptchaToken) {
|
||||
if (!data.recaptchaToken || typeof data.recaptchaToken !== 'string') {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
const { success } = await verifyRecaptcha(data.recaptchaToken);
|
||||
const success = await verifyRecaptcha(data.recaptchaToken);
|
||||
if (!success) {
|
||||
return { success: false, message: "Weryfikacja bezpieczeństwa nie powiodła się. Odśwież stronę i spróbuj ponownie." };
|
||||
}
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
|
||||
to: process.env.RESEND_TO_EMAIL || '[email protected]',
|
||||
subject: `${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${data.brand} — ${data.city}`,
|
||||
const mailClient = getMailClient();
|
||||
if (!mailClient) {
|
||||
return { success: false, message: "Funkcja wysyłania wiadomości jest tymczasowo niedostępna." };
|
||||
}
|
||||
|
||||
// Default missing fields for zod validation
|
||||
const parsedData = awariaFormSchema.parse({
|
||||
...data,
|
||||
email: data.email || '[email protected]',
|
||||
});
|
||||
|
||||
await mailClient.resend.emails.send({
|
||||
from: mailClient.fromEmail,
|
||||
to: mailClient.toEmail,
|
||||
subject: `${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe: ${escapeHtml(parsedData.brand)} — ${escapeHtml(parsedData.city)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="background:${data.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
|
||||
<div style="background:${parsedData.isUrgent ? '#ef4444' : '#0F2A47'};padding:20px;text-align:center;">
|
||||
<h1 style="color:#fff;margin:0;font-size:20px;">
|
||||
${data.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
|
||||
${parsedData.isUrgent ? '🚨 PILNE — ' : ''}Zgłoszenie serwisowe ThermCool
|
||||
</h1>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<h2 style="font-size:16px;margin-top:0;">Dane klienta</h2>
|
||||
<p><strong>Imię:</strong> ${data.name}</p>
|
||||
<p><strong>Telefon:</strong> <a href="tel:${data.phone}">${data.phone}</a></p>
|
||||
<p><strong>Miasto:</strong> ${data.city}</p>
|
||||
${data.preferredDate ? `<p><strong>Preferowany termin:</strong> ${data.preferredDate} ${data.preferredTime || ''}</p>` : ''}
|
||||
<p><strong>Imię:</strong> ${escapeHtml(parsedData.name)}</p>
|
||||
<p><strong>Telefon:</strong> <a href="tel:${escapeHtml(parsedData.phone)}">${escapeHtml(parsedData.phone)}</a></p>
|
||||
<p><strong>Miasto:</strong> ${escapeHtml(parsedData.city)}</p>
|
||||
${parsedData.preferredDate ? `<p><strong>Preferowany termin:</strong> ${escapeHtml(parsedData.preferredDate)} ${escapeHtml(parsedData.preferredTime || '')}</p>` : ''}
|
||||
|
||||
<h2 style="font-size:16px;">Urządzenie</h2>
|
||||
<p><strong>Marka:</strong> ${data.brand}</p>
|
||||
${data.model ? `<p><strong>Model:</strong> ${data.model}</p>` : ''}
|
||||
${data.warranty ? `<p><strong>Gwarancja:</strong> ${data.warranty}</p>` : ''}
|
||||
${data.lastService ? `<p><strong>Ostatni serwis:</strong> ${data.lastService}</p>` : ''}
|
||||
<p><strong>Marka:</strong> ${escapeHtml(parsedData.brand)}</p>
|
||||
${parsedData.model ? `<p><strong>Model:</strong> ${escapeHtml(parsedData.model)}</p>` : ''}
|
||||
${parsedData.warranty ? `<p><strong>Gwarancja:</strong> ${escapeHtml(parsedData.warranty)}</p>` : ''}
|
||||
${parsedData.lastService ? `<p><strong>Ostatni serwis:</strong> ${escapeHtml(parsedData.lastService)}</p>` : ''}
|
||||
|
||||
<h2 style="font-size:16px;">Problem</h2>
|
||||
<p><strong>Typ problemu:</strong> ${data.problemType}</p>
|
||||
${data.errorCode ? `<p><strong>Kod błędu:</strong> ${data.errorCode}</p>` : ''}
|
||||
${data.description ? `<p><strong>Opis:</strong> ${data.description}</p>` : ''}
|
||||
<p><strong>Typ problemu:</strong> ${escapeHtml(parsedData.problemType || '')}</p>
|
||||
${parsedData.errorCode ? `<p><strong>Kod błędu:</strong> ${escapeHtml(parsedData.errorCode)}</p>` : ''}
|
||||
${parsedData.description ? `<p><strong>Opis:</strong> ${escapeHtml(parsedData.description)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
return { success: false, message: 'Wystąpił błąd podczas wysyłania.' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user