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