feat: pagina contatti e api email con validazione testata

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 01:26:49 +02:00
parent be4d933ca3
commit e9840105a3
5 changed files with 170 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
export interface ContactData {
firstName: string; lastName: string; phone: string;
email: string; message: string; website: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function str(v: unknown, max: number): string {
return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
}
export function validateContact(data: unknown):
| { ok: true; value: ContactData }
| { ok: false; error: string } {
if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
const d = data as Record<string, unknown>;
const value: ContactData = {
firstName: str(d.firstName, 100),
lastName: str(d.lastName, 100),
phone: str(d.phone, 40),
email: str(d.email, 200),
message: str(d.message, 5000),
website: str(d.website, 200),
};
if (value.website) return { ok: false, error: 'Richiesta non valida.' };
if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
return { ok: true, value };
}
+25
View File
@@ -0,0 +1,25 @@
import nodemailer from 'nodemailer';
import { getEnv } from './env';
import type { ContactData } from './contact';
export async function sendContactEmail(data: ContactData): Promise<void> {
const transporter = nodemailer.createTransport({
host: getEnv('SMTP_HOST'),
port: Number(getEnv('SMTP_PORT', '587')),
secure: Number(getEnv('SMTP_PORT', '587')) === 465,
auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
});
await transporter.sendMail({
from: getEnv('CONTACT_FROM'),
to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
replyTo: data.email,
subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
text: [
`Nome: ${data.firstName} ${data.lastName}`.trim(),
data.phone && `Telefono: ${data.phone}`,
`E-mail: ${data.email}`,
'',
data.message,
].filter(Boolean).join('\n'),
});
}
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { validateContact } from '../../lib/contact';
import { sendContactEmail } from '../../lib/mailer';
import { rateLimit } from '../../lib/rate-limit';
export const prerender = false;
const json = (status: number, body: object) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, clientAddress }) => {
if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
return json(429, { error: 'Troppe richieste, riprova più tardi.' });
}
let data: unknown;
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
const result = validateContact(data);
if (!result.ok) return json(400, { error: result.error });
try {
await sendContactEmail(result.value);
} catch (err) {
console.error('Invio email fallito:', err);
return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
}
return json(200, { ok: true });
};
+55
View File
@@ -0,0 +1,55 @@
---
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
import ContactForm from '../components/ContactForm.astro';
import { site } from '../data/site';
export const prerender = true;
const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`);
---
<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
<PageHero title="Contattaci" />
<section class="section">
<div class="container cgrid">
<div class="cmap" id="map-box">
<button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
<p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
</div>
<ContactForm variant="full" />
</div>
<div class="container cinfo">
<div>
<h3>Indirizzo</h3>
<p>{site.address} {site.cityLine}</p>
</div>
<div>
<h3>Telefono</h3>
<p>{site.phone}</p>
</div>
<div>
<h3>E-mail</h3>
<p><a href={`mailto:${site.email}`}>{site.email}</a></p>
</div>
</div>
<div class="container">
<p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p>
</div>
</section>
</Base>
<style>
.cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
.cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
.cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
.cmap__note { font-size: .8rem; color: var(--c-text-light); }
.cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
.cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
.cinvite { max-width: 640px; }
@media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
</style>
<script define:vars={{ mapsQuery }}>
document.getElementById('map-load')?.addEventListener('click', () => {
const box = document.getElementById('map-box');
box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
});
</script>