fix(forms): Naprawa formularza multisplit i dodanie reCAPTCHA v3 do wszystkich formularzy

This commit is contained in:
MichalC
2026-07-27 21:45:32 +02:00
parent 17a43fc6d6
commit e338c95da0
10 changed files with 223 additions and 16 deletions
+12 -1
View File
@@ -1,8 +1,8 @@
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { verifyRecaptcha } from "@/lib/recaptcha";
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -19,6 +19,17 @@ const contactFormSchema = z.object({
export async function submitContactForm(prevState: any, formData: FormData) {
try {
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." };
}
}
// 1. Walidacja danych z formularza
const rawData = {
name: formData.get("name"),
+7
View File
@@ -4,6 +4,7 @@ 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";
@@ -67,6 +68,12 @@ export default function RootLayout({
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-screen flex flex-col bg-background text-foreground">
{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 className="flex-grow">{children}</main>
<TrustBar />
+12
View File
@@ -2,6 +2,7 @@
import { z } from 'zod';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -18,6 +19,17 @@ const orderSchema = z.object({
export async function submitOrderForm(prevState: any, formData: FormData) {
try {
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 data = orderSchema.parse({
name: formData.get('name'),
phone: formData.get('phone'),
+15 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { useCartStore } from '@/lib/cart-store';
import { useActionState, useEffect } from 'react';
import { useActionState, useEffect, startTransition } from 'react';
import Link from 'next/link';
import { submitOrderForm } from './actions';
import { ArrowLeft, CheckCircle } from 'lucide-react';
@@ -75,7 +75,20 @@ Wróć do sklepu
</div>
{/* Formularz */}
<form action={formAction} className="bg-white rounded-xl border border-border p-6 flex flex-col gap-4">
<form onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) {
const token = await (window as any).grecaptcha.execute(
process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
{ action: 'submit' }
);
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 */}
+117
View File
@@ -0,0 +1,117 @@
"use server";
import { z } from "zod";
import { Resend } from "resend";
import { verifyRecaptcha } from "@/lib/recaptcha";
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(),
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(),
});
export async function submitMultisplitForm(data: any) {
try {
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
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 validatedData = multisplitFormSchema.parse(data);
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || "[email protected]",
to: process.env.RESEND_TO_EMAIL || "[email protected]",
replyTo: validatedData.email || undefined,
subject: `Zapytanie o multisplit: ${validatedData.productName} (${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>
</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>
</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>
</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>
</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>
</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>
</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'}
</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: 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.",
};
}
}
+12
View File
@@ -1,6 +1,7 @@
'use server';
import { Resend } from 'resend';
import { verifyRecaptcha } from '@/lib/recaptcha';
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -18,8 +19,19 @@ export async function submitAwariaForm(data: {
city: string;
preferredDate: string;
preferredTime: string;
recaptchaToken?: string;
}) {
try {
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && process.env.RECAPTCHA_SECRET_KEY) {
if (!data.recaptchaToken) {
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." };
}
}
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL || '[email protected]',
to: process.env.RESEND_TO_EMAIL || '[email protected]',
+9
View File
@@ -125,6 +125,14 @@ export function AwariaClient() {
}
setIsSubmitting(true);
let recaptchaToken = '';
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) {
recaptchaToken = await (window as any).grecaptcha.execute(
process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
{ action: 'submit' }
);
}
await submitAwariaForm({
isUrgent,
brand,
@@ -139,6 +147,7 @@ export function AwariaClient() {
city,
preferredDate: '',
preferredTime: '',
recaptchaToken,
});
setIsSubmitting(false);
+8
View File
@@ -49,6 +49,14 @@ export function ContactForm() {
});
formData.append("consent", consent ? 'true' : 'false');
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) {
const token = await (window as any).grecaptcha.execute(
process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
{ action: 'submit' }
);
formData.append('recaptchaToken', token);
}
const response = await submitContactForm(null, formData);
setServerState({
+22 -13
View File
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Check, Loader2 } from 'lucide-react';
import { submitMultisplitForm } from '@/app/zapytanie-multisplit/actions';
interface Props {
productName: string;
@@ -38,20 +39,28 @@ export default function MultisplitContactForm({ productName, productSku }: Props
setLoading(true);
setError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
phone: form.phone,
email: form.email,
city: form.city,
subject: `Zapytanie o multisplit: ${productName} (${productSku})`,
message: `Liczba pomieszczeń: ${form.rooms}\n\n${form.message}`,
}),
let recaptchaToken = '';
if (process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY) {
recaptchaToken = await (window as any).grecaptcha.execute(
process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY,
{ action: 'submit' }
);
}
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.ok) setSent(true);
else setError('Błąd wysyłki. Zadzwoń do nas bezpośrednio.');
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.');
}
+9
View File
@@ -0,0 +1,9 @@
export async function verifyRecaptcha(token: string): Promise<{ success: boolean; score?: number }> {
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
});
const data = await res.json();
return { success: data.success && (data.score === undefined || data.score >= 0.5), score: data.score };
}