// ============================================ // MVA Global Fret — Form Handler // ============================================ // Frontend logic for contact.html (= inscription form): // - validate inputs + Cloudflare Turnstile token // - call mva-api /leads/* routes for dedup check + double opt-in flow // (= verification email + welcome / welcome-back emails via Resend) // - reset Turnstile widget after each API call (= tokens are // single-use server-side; without reset, a re-submit silently // 403s from Cloudflare's siteverify endpoint) // // Migration 2026-05-10 : remplace l'ancien Cloudflare Worker // `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné) par // les routes mva-api Fastify. La DB Postgres remplace HubSpot Contacts. // ============================================ // ── MVA API BASE URL ───────────────────────────────────────────── // Routes leads servies par mva-api derrière Caddy. CORS strict : // le serveur whitelist explicitement https://mva-globalfret.com. const API_BASE_URL = 'https://api.mva.mind4solutions.com'; 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 la table leads via mva-api. // Retourne les propriétés du lead existant, ou null si nouveau // client / API indisponible. async function checkExistingContact(email) { try { const res = await fetch(`${API_BASE_URL}/leads/check-email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase().trim() }), }); if (!res.ok) return null; const data = await res.json(); if (!data.exists) return null; // Forme attendue par showAlreadyRegistered : { firstname, reference_client } return { firstname: data.firstname || '', reference_client: data.reference_client || '', }; } 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 MVA-API ──────────────────────────────────────── // L'API stocke les données en `leads_pending` (24h TTL) et envoie un // email de validation via Resend. Le lead n'est INSERT en `leads` QUE // quand l'utilisateur clique sur le lien de confirmation // (anti-pollution DB + anti-bot complémentaire à Turnstile). let ok = false; try { const res = await fetch(`${API_BASE_URL}/leads/request-verification`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...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 — zéro write DB. // Passe par mva-api /leads/welcome-back 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 (!contact || !contact.email) return; if (!window.turnstileToken) return; try { await fetch(`${API_BASE_URL}/leads/welcome-back`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email : contact.email, 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('welcome-back 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.'; } }