diff --git a/src/data/home/cta.ts b/src/data/home/cta.ts index 43662e9..e39ab03 100644 --- a/src/data/home/cta.ts +++ b/src/data/home/cta.ts @@ -1,7 +1,9 @@ /** Call-to-action (blocco contatti / chiusura pagina) */ const env = import.meta.env; const contactActionUrl = (env.PUBLIC_CONTACT_FORM_ACTION_URL ?? '/api/contact').trim(); -const contactSuccessRedirect = (env.PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT ?? '').trim(); +const contactSuccessRedirect = ( + env.PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT ?? '/#contatti' +).trim(); const contactMailto = (env.PUBLIC_CONTACT_FORM_MAILTO ?? '').trim(); const contactTurnstileSiteKey = (env.PUBLIC_CONTACT_TURNSTILE_SITE_KEY ?? '').trim(); diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 5bd8de2..66b4e75 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -33,6 +33,59 @@ function required(value: FormDataEntryValue | null): string { return typeof value === 'string' ? value.trim() : ''; } +function wantsJson(request: Request): boolean { + return (request.headers.get('Accept') || '').includes('application/json'); +} + +function fail(request: Request, message: string, status: number): Response { + if (wantsJson(request)) { + return Response.json({ ok: false, error: message }, { status }); + } + return new Response(message, { status }); +} + +function succeed(request: Request, redirectTarget: string): Response { + if (wantsJson(request)) { + return Response.json({ ok: true, redirect: redirectTarget }); + } + return Response.redirect(redirectTarget, 303); +} + +function emailErrorMessage(error: unknown): string { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code || '') + : ''; + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message?: unknown }).message || '') + : ''; + + switch (code) { + case 'E_SENDER_NOT_VERIFIED': + case 'E_SENDER_DOMAIN_NOT_AVAILABLE': + return 'Dominio mittente non verificato su Cloudflare Email Service.'; + case 'E_RECIPIENT_NOT_ALLOWED': + return 'Destinatario non autorizzato dal binding email.'; + case 'E_RECIPIENT_SUPPRESSED': + return 'Destinatario in lista di soppressione (bounce/spam).'; + case 'E_RATE_LIMIT_EXCEEDED': + case 'E_DAILY_LIMIT_EXCEEDED': + return 'Limite di invio email raggiunto. Riprova più tardi.'; + case 'E_VALIDATION_ERROR': + case 'E_FIELD_MISSING': + return message + ? `Dati email non validi: ${message}` + : 'Dati email non validi.'; + default: + return code + ? `Invio email non riuscito (${code}). Riprova più tardi.` + : 'Invio email non riuscito. Riprova più tardi.'; + } +} + async function verifyTurnstile(token: string, secret: string, ip?: string | null): Promise { const body = new URLSearchParams(); body.set('secret', secret); @@ -59,83 +112,115 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st } export const POST: APIRoute = async ({ request, url }) => { - const runtimeEnv = getRuntimeEnv(); - const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev'; - const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv); - const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv); - const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv); + try { + const runtimeEnv = getRuntimeEnv(); + const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev'; + const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv); + const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv); + const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv); - const form = await request.formData(); - const redirectTarget = - sanitizeRedirect(required(form.get('_next')), url) || - sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) || - sanitizeRedirect(request.headers.get('referer') ?? '/', url) || - '/'; - - const name = required(form.get('name')); - const company = required(form.get('company')); - const email = required(form.get('email')); - const country = required(form.get('country')); - const department = required(form.get('department')) || 'info'; - const message = required(form.get('message')); - - if (!name || !email || !country || !message) { - return new Response('Missing required contact fields.', { status: 400 }); - } - - const turnstileToken = required(form.get('cf-turnstile-response')); - if (turnstileSecret) { - const isHuman = await verifyTurnstile( - turnstileToken, - turnstileSecret, - request.headers.get('CF-Connecting-IP'), - ); - if (!isHuman) { - return new Response('Turnstile verification failed.', { status: 400 }); + const form = await request.formData(); + if (required(form.get('_gotcha'))) { + const redirectTarget = + sanitizeRedirect(required(form.get('_next')), url) || + sanitizeRedirect(request.headers.get('referer') ?? '/', url) || + new URL('/#contatti', url).toString(); + return succeed(request, redirectTarget); } - } - const subject = required(form.get('_subject')) || 'Richiesta contatto - sito NexStudio'; - const text = [ - `Nome: ${name}`, - company ? `Azienda: ${company}` : '', - `Email: ${email}`, - `Paese: ${country}`, - `Reparto: ${department}`, - '', - message, - ] - .filter(Boolean) - .join('\n'); + const redirectTarget = + sanitizeRedirect(required(form.get('_next')), url) || + sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) || + sanitizeRedirect(request.headers.get('referer') ?? '/', url) || + new URL('/#contatti', url).toString(); - if (mode === 'live') { - const emailBinding = getEmailBinding(runtimeEnv); - if (!emailBinding || !fromEmail || !toEmail) { - return new Response( - 'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.', - { - status: 500, - }, + const name = required(form.get('name')); + const company = required(form.get('company')); + const email = required(form.get('email')); + const country = required(form.get('country')); + const department = required(form.get('department')) || 'info'; + const message = required(form.get('message')); + + if (!name || !email || !country || !message) { + return fail(request, 'Missing required contact fields.', 400); + } + + const turnstileToken = required(form.get('cf-turnstile-response')); + if (turnstileSecret) { + const isHuman = await verifyTurnstile( + turnstileToken, + turnstileSecret, + request.headers.get('CF-Connecting-IP'), ); + if (!isHuman) { + return fail(request, 'Turnstile verification failed.', 400); + } } - const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department); - await sendCloudflareEmail(emailBinding, { - fromEmail, - toEmail: destination, - subject, - text, - replyTo: email, - }); - } else { - console.info('[contact:dev] Contact payload received', { - name, - company, - email, - country, - department, - subject, - }); - } - return Response.redirect(redirectTarget, 303); + const subject = required(form.get('_subject')) || 'Richiesta contatto - sito NexStudio'; + const text = [ + `Nome: ${name}`, + company ? `Azienda: ${company}` : '', + `Email: ${email}`, + `Paese: ${country}`, + `Reparto: ${department}`, + '', + message, + ] + .filter(Boolean) + .join('\n'); + + if (mode === 'live') { + const emailBinding = getEmailBinding(runtimeEnv); + if (!emailBinding || !fromEmail || !toEmail) { + return fail( + request, + 'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.', + 500, + ); + } + const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department); + if (!destination) { + return fail(request, 'Nessun destinatario configurato per il reparto selezionato.', 500); + } + + try { + await sendCloudflareEmail(emailBinding, { + fromEmail, + toEmail: destination, + subject, + text, + replyTo: email, + }); + } catch (error) { + console.error('[contact] email failed', error); + // Retry without replyTo: some payloads reject Reply-To even when From/To are valid. + try { + await sendCloudflareEmail(emailBinding, { + fromEmail, + toEmail: destination, + subject, + text, + }); + } catch (retryError) { + console.error('[contact] email retry failed', retryError); + return fail(request, emailErrorMessage(retryError), 502); + } + } + } else { + console.info('[contact:dev] Contact payload received', { + name, + company, + email, + country, + department, + subject, + }); + } + + return succeed(request, redirectTarget); + } catch (error) { + console.error('[contact] unexpected error', error); + return fail(request, 'Errore interno. Riprova più tardi.', 500); + } }; diff --git a/src/scripts/nx-contact-form.ts b/src/scripts/nx-contact-form.ts index c63e76a..2c8c9f7 100644 --- a/src/scripts/nx-contact-form.ts +++ b/src/scripts/nx-contact-form.ts @@ -1,4 +1,4 @@ -/** Modulo contatti in pagina (#contatti): mailto o POST Formspree. */ +/** Modulo contatti in pagina (#contatti): mailto o POST /api/contact. */ interface ContactFormCfg { actionUrl: string; mailto: string; @@ -52,11 +52,18 @@ function readCfg(): ContactFormCfg { return JSON.parse(raw) as ContactFormCfg; } +function showError(message: string): void { + const err = document.getElementById('nx-contact-form-error'); + if (!err) return; + err.textContent = message; + err.classList.remove('hidden'); +} + function bind(): void { const form = document.getElementById('nx-contact-form') as HTMLFormElement | null; if (!form) return; - form.addEventListener('submit', (e) => { + form.addEventListener('submit', async (e) => { const cfg = readCfg(); const err = document.getElementById('nx-contact-form-error'); if (err) err.classList.add('hidden'); @@ -69,17 +76,44 @@ function bind(): void { )?.value; if (!token) { e.preventDefault(); - if (err) { - err.textContent = cfg.turnstileError; - err.classList.remove('hidden'); - } + showError(cfg.turnstileError); return; } } if (cfg.actionUrl) { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.disabled = true; + try { + const res = await fetch(cfg.actionUrl, { + method: 'POST', + headers: { Accept: 'application/json' }, + body: new FormData(form), + }); + const data = (await res.json().catch(() => null)) as + | { ok?: boolean; error?: string; redirect?: string } + | null; + if (!res.ok) { + showError( + (data && typeof data.error === 'string' && data.error) || + 'Invio non riuscito. Riprova tra poco.', + ); + return; + } + const target = + (data && typeof data.redirect === 'string' && data.redirect) || + cfg.successRedirect || + `${window.location.pathname}${window.location.search}#contatti`; + window.location.assign(target); + } catch { + showError('Errore di rete. Controlla la connessione e riprova.'); + } finally { + if (submitBtn) submitBtn.disabled = false; + } return; } + e.preventDefault(); const fd = new FormData(form); const name = String(fd.get(cfg.fieldName) ?? '').trim(); @@ -101,10 +135,7 @@ function bind(): void { window.location.href = `mailto:${cfg.mailto}?subject=${subject}&body=${body}`; return; } - if (err) { - err.textContent = cfg.configError; - err.classList.remove('hidden'); - } + showError(cfg.configError); }); }