// ============================================ // MVA Global Fret — Form Handler // ============================================ // Frontend logic for contact.html (= inscription form): // - validate inputs + Cloudflare Turnstile token // - call Cloudflare Worker mva-hubspot-proxy for HubSpot dedup + // sending verification email (= action requestVerification) or // "Ravis de vous revoir" email for returning customers (= // action sendWelcomeBack) // - reset Turnstile widget after each Worker call (= tokens are // single-use server-side; without reset, a re-submit silently // 403s from Cloudflare's siteverify endpoint) // // All HubSpot/Resend transactions go through the Worker. No direct // EmailJS / Formspree / HubSpot Forms API calls from the browser. // ============================================ // ── PROXY CLOUDFLARE WORKER ────────────────────────────────────── // Worker URL (= deployed via wrangler from cloudflare-worker/). // CORS Access-Control-Allow-Origin: * so the browser can call it // directly. const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev'; document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('contactForm'); if (form) setupContactForm(form); }); // ── TURNSTILE TOKEN MANAGEMENT ─────────────────────────────────── // Reset the Turnstile widget + global token after each Worker call. // Cloudflare Turnstile tokens are single-use server-side: a token // already submitted to siteverify cannot be re-used. Without an // explicit reset, a re-submit (= same form, same widget) would send // the now-consumed token and Cloudflare would 403 silently. function resetTurnstile() { window.turnstileToken = null; if (window.turnstile && typeof window.turnstile.reset === 'function') { try { window.turnstile.reset(); } catch (_) { /* widget absent */ } } } // Vérifie si l'email existe déjà dans HubSpot via le proxy Worker. // Retourne les propriétés du contact existant, ou null si nouveau // client / Worker indisponible. async function checkExistingContact(email) { if (!WORKER_PROXY_URL) return null; try { const res = await fetch(WORKER_PROXY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase().trim() }), }); if (!res.ok) return null; const data = await res.json(); return data.total > 0 ? data.results[0].properties : null; } catch { return null; } } function setupContactForm(form) { form.addEventListener('submit', async (e) => { e.preventDefault(); if (!validateForm(form)) return; // ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ──────────────── if (!window.turnstileToken) { const errEl = document.getElementById('formErrorGlobal'); if (errEl) { errEl.style.display = 'block'; errEl.textContent = '🤖 Veuillez compléter le contrôle anti-robot ci-dessus avant d\'envoyer le formulaire.'; } return; } setLoading(true); const email = form.email.value.trim(); // ── VÉRIFICATION DOUBLON ───────────────────────────────────── // Comme les contacts ne sont créés QU'APRÈS confirmation email, // ce check ne retourne que les vrais clients déjà inscrits. const existing = await checkExistingContact(email); if (existing) { setLoading(false); showAlreadyRegistered(existing); return; } const data = { firstname: form.firstname.value.trim(), lastname: form.lastname.value.trim(), phone: form.phone.value.trim(), email: email, address: form.address.value.trim(), }; // ── ENVOI VERS LE WORKER ───────────────────────────────────── // Le Worker stocke les données en KV (24h) et envoie un email de // validation via Resend. Le contact n'est créé dans HubSpot QUE // quand l'utilisateur clique sur le lien de confirmation // (anti-pollution du CRM). let ok = false; try { const res = await fetch(WORKER_PROXY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'requestVerification', ...data, turnstile_token: window.turnstileToken || '', }), }); const result = await res.json().catch(() => ({})); ok = res.ok && result.ok === true; } catch (err) { console.warn('[requestVerification]', err); } // Reset Turnstile after the Worker call (= regardless of result) resetTurnstile(); setLoading(false); if (ok) { showSuccess(null, data); } else { showError(); } }); } // ── VALIDATION ─────────────────────────────────────────────────── function validateForm(form) { let valid = true; const lang = localStorage.getItem('mva-lang') || 'fr'; const t = translations?.[lang]?.contact || {}; const requiredMsg = t.required || 'Ce champ est obligatoire'; const invalidEmail = t.invalidEmail || 'Adresse email invalide'; const invalidPhone = t.invalidPhone || 'Numéro de téléphone invalide'; const fields = ['firstname', 'lastname', 'phone', 'email', 'address']; fields.forEach(name => clearError(name)); clearError('cgv'); fields.forEach(name => { const el = form[name]; if (!el.value.trim()) { showFieldError(name, requiredMsg); valid = false; } }); const cgvBox = form['cgv']; if (cgvBox && !cgvBox.checked) { showFieldError('cgv', t.cgvRequired || 'Vous devez accepter les Conditions Générales de Vente.'); valid = false; } if (form.email.value.trim() && !isValidEmail(form.email.value.trim())) { showFieldError('email', invalidEmail); valid = false; } if (form.phone.value.trim() && !isValidPhone(form.phone.value.trim())) { showFieldError('phone', invalidPhone); valid = false; } return valid; } function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function isValidPhone(phone) { return /^[+\d][\d\s\-().]{6,20}$/.test(phone); } // ── AFFICHAGE ──────────────────────────────────────────────────── function showFieldError(name, msg) { const el = document.getElementById(`error-${name}`); const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`); if (el) el.textContent = msg; if (input) input.classList.add('error'); } function clearError(name) { const el = document.getElementById(`error-${name}`); const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`); if (el) el.textContent = ''; if (input) input.classList.remove('error'); } function setLoading(isLoading) { const btn = document.getElementById('submitBtn'); const txt = document.getElementById('submitText'); const form = document.getElementById('contactForm'); const lang = localStorage.getItem('mva-lang') || 'fr'; const t = translations?.[lang]?.contact || {}; if (!btn) return; btn.disabled = isLoading; if (txt) { txt.textContent = isLoading ? (t.submitLoading || 'Envoi en cours...') : (t.submitBtn || "S'inscrire"); } form?.classList.toggle('form-loading', isLoading); } function showSuccess(_refNumber, _clientData) { const successEl = document.getElementById('formSuccess'); const form = document.getElementById('contactForm'); if (successEl) { successEl.classList.add('show'); successEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } if (form) form.style.display = 'none'; } // ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ─────────── // Rappelle au client son numéro de référence existant — n'écrit // RIEN dans HubSpot. Passe par le Cloudflare Worker (action: // sendWelcomeBack) qui délègue à Resend. Anti-bot via Turnstile : // transmet le token déjà validé au moment du submit du formulaire. async function sendWelcomeBackEmail(contact) { if (!WORKER_PROXY_URL) return; if (!contact || !contact.email) return; if (!window.turnstileToken) return; try { await fetch(WORKER_PROXY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action : 'sendWelcomeBack', email : contact.email, firstname : contact.firstname || '', turnstile_token : window.turnstileToken, }), }); } catch (err) { // Erreur réseau : on n'interrompt pas l'UX (le client voit // déjà sa référence dans le UI). console.warn('Worker sendWelcomeBack failed:', err); } } // Affiche le message "déjà client" — ne modifie AUCUNE donnée HubSpot async function showAlreadyRegistered(contact) { const lang = localStorage.getItem('mva-lang') || 'fr'; const t = translations?.[lang]?.contact || {}; const alreadyEl = document.getElementById('alreadyRegistered'); const form = document.getElementById('contactForm'); const refDisplay = document.getElementById('existingRefDisplay'); // Affiche la référence existante si disponible, sinon message de contact if (refDisplay) { refDisplay.textContent = contact.reference_client ? contact.reference_client : (t.alreadyRefUnknown || 'Contactez-nous pour retrouver votre référence.'); } if (alreadyEl) { alreadyEl.classList.add('show'); alreadyEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } if (form) form.style.display = 'none'; // Email "Ravis de vous revoir" via Worker + Resend (= footer 2026 // cohérent avec les autres emails transactionnels). await sendWelcomeBackEmail(contact); // Reset Turnstile after the Worker call (= prevent reuse). resetTurnstile(); } function showError() { const errEl = document.getElementById('formErrorGlobal'); const lang = localStorage.getItem('mva-lang') || 'fr'; const t = translations?.[lang]?.contact || {}; if (errEl) { errEl.style.display = 'block'; errEl.textContent = t.errorMsg || 'Une erreur est survenue. Veuillez réessayer ou nous contacter directement.'; } }