Compare commits

..

22 Commits

Author SHA1 Message Date
f71d1b327e Merge pull request 'fix(nav): Service Commande dans le menu mobile (6 pages)' (#21) from fix/mobile-nav-service-commande into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Failing after 2m2s
2026-06-22 10:48:15 +03:00
Serge RAKOTO HARRY-NAIVO
616f9d75a1 fix(nav): ajouter « Service Commande » au menu mobile (6 pages)
Le menu mobile (<nav class="mobile-nav">) omettait le lien « Service Commande »
sur about, application, cgv, contact, guide-envoi et tarifs — incoherent avec le
menu desktop et le footer (qui l'ont) et avec accueil (mobile complet). Insertion
du lien apres « Tarifs », identique aux autres entrees.

Constat issu de l'audit du site (revue qualite 2026-06-21).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:09:18 +02:00
499109f8a9 Merge pull request 'chore(site): cache-bust translations.js' (#20) from chore/cache-bust-translations into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-06-03 12:32:21 +03:00
Serge RAKOTO HARRY-NAIVO
43e576249c chore(site): cache-bust translations.js (?v=20260603)
translations.js a Cache-Control max-age=7j sans versioning -> les visiteurs
gardaient l'ancien menu en cache. Cache-buster pour rendre le renommage
Contact->Inscription visible immédiatement par tous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:32:13 +02:00
5399fc1a40 Merge pull request 'feat(site): Contact -> Inscription (menu + page)' (#19) from fix/contact-to-inscription into main
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
2026-06-03 12:21:47 +03:00
Serge RAKOTO HARRY-NAIVO
61a2a67b00 feat(site): renomme "Contact" en "Inscription" (menu + page contact)
- Menu : "Contact" -> "Inscription" (FR) / "Sign Up" (EN) / "Fisoratana anarana" (MG)
- Page : titre "Contactez-Nous" -> "Inscrivez-vous"
- Sous-titre : retire "Inscrivez-vous et" -> "Commencez à envoyer vos colis dès aujourd'hui"
- i18n (translations.js, 3 langues) + fallback HTML en dur sur toutes les pages
- footer "Contact" (coordonnées) laissé inchangé

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:21:15 +02:00
2a2ea2f5e4 Merge pull request 'fix: relayer token_hash vers le deep link reset password' (#18) from fix/reset-password-token-hash into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-05-30 06:04:51 +03:00
Serge RAKOTO HARRY-NAIVO
df8bf01759 fix: relayer token_hash vers le deep link reset password
Le nouveau flux reset (Edge Function mva-password-reset, deploye 2026-05-30)
envoie un lien ?token_hash=HASH&type=recovery au lieu de ?token=. La page-relais
lit desormais token_hash + type et construit le deep link
mvaglobalfret://reset-password?token_hash=...&type=... que l'app consomme via
verifyOtp({ token_hash, type }). Si token_hash est absent, on affiche un message
d'erreur (lien invalide) au lieu de tenter le redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 04:49:51 +02:00
ee17b2b48c Merge pull request 'feat(guide): add armes � feu + substances ill�gales aux articles interdits' (#16) from feat/add-firearms-illegal-substances into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-05-22 02:02:45 +03:00
Serge RAKOTO HARRY-NAIVO
84b4b7753e feat(guide): add armes à feu in Explosifs + substances illégales in Substances toxiques
Demande Serge — clarifier les articles interdits sur mva-globalfret.com/guide-envoi.html.

Changes :
- guide-envoi.html : append 'armes à feu' à cat1Desc + 'substances illégales' à cat6Desc (visible immédiat sans JS i18n).
- js/translations.js : sync les 3 langues FR/EN/MG sur cat1Desc + cat6Desc.

FR : armes à feu / substances illégales
EN : firearms / illegal substances
MG : basy / zavatra tsy ara-dalàna

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:02:14 +02:00
b6b492f224 Merge pull request 'chore: remove decommissioned cloudflare-worker' (#15) from chore/remove-decommissioned-cloudflare-worker into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-05-16 17:41:54 +03:00
Serge RAKOTO HARRY-NAIVO
32c7d65698 chore: remove decommissioned cloudflare-worker
Le worker hubspot-proxy.js a ete decommissione le 2026-05-10 :
migration HubSpot CRM -> mva-api Fastify + Postgres + Resend (= mention
explicite dans js/form-handler.js).

Le dossier cloudflare-worker/ (DEPLOIEMENT.md + hubspot-proxy.js + wrangler.toml)
n'est plus utilise par le frontend mais traine dans le repo. Cleanup.

Refs:
- js/form-handler.js commentaire 'Migration 2026-05-10 : remplace l ancien
  Cloudflare Worker mva-hubspot-proxy.sergemind4s.workers.dev (= decommissionne)
  par les routes mva-api Fastify. La DB Postgres remplace HubSpot Contacts.'
- Audit hygiene M4S 2026-05-16
2026-05-16 16:39:22 +02:00
24104ac9f4 Merge pull request 'fix(site): setup-password token length 64 -> 96' (#14) from fix/setup-token-length-96 into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-05-11 00:14:37 +03:00
Serge RAKOTO HARRY-NAIVO
a6d219453c fix(site): setup-password.html validation token 64 -> 96 chars
Bug compagnon de api PR #57 : crypto.randomBytes(48).toString hex = 96
caracteres, pas 64. La validation JS cote site rejetait tous les vrais
tokens avec 'Lien invalide ou incomplet'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:14:30 +02:00
2878e8e01a Merge pull request 'feat(site): page setup-password.html (bridge lead -> user)' (#13) from feat/setup-password-page into main
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
2026-05-10 23:31:46 +03:00
Serge RAKOTO HARRY-NAIVO
af58c04776 feat(site): page setup-password.html pour bridge lead -> user
Compagnon de api PR #55 (bridge lead -> user).

Flow:
- Lead clique email setup compte
- Arrive sur /setup-password.html?token=XXX
- JS POST /auth/lookup-setup-token pre-remplit email + firstname + ref
- Form 2 password avec validation (min 8 majuscule chiffre)
- Submit POST /auth/setup-from-lead -> user cree + JWT
- Affiche success + lien vers application.html pour download app

Design: card centree, MVA gold/navy, mobile responsive, vanilla JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:31:36 +02:00
61397720e8 Merge pull request 'feat(api): migrate Worker mva-hubspot-proxy → mva-api /leads/* routes' (#12) from feat/migrate-worker-to-mva-api into main
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
2026-05-10 14:02:18 +03:00
Serge RAKOTO HARRY-NAIVO
7217f12bd2 feat(api): migrate Worker mva-hubspot-proxy → mva-api /leads/* routes
Le Cloudflare Worker hubspot-proxy était une solution temporaire car
le panel admin AdminJS n'était pas encore disponible. Maintenant qu'
AdminJS marche, on rapatrie tout le flow inscription côté MVA backend.

## Changements

- `js/form-handler.js`
  - WORKER_PROXY_URL → API_BASE_URL = https://api.mva.mind4solutions.com
  - checkExistingContact : POST /leads/check-email
    (response shape : {exists, firstname, reference_client})
  - setupContactForm : POST /leads/request-verification
  - sendWelcomeBackEmail : POST /leads/welcome-back

- `js/confirmation.js`
  - WORKER_PROXY_URL → API_BASE_URL
  - POST /leads/verify-token (= au lieu de Worker action verifyToken)
  - Detection token expiré/invalide via code INVALID_OR_EXPIRED

## Aucun changement HTML

Les forms HTML, IDs des éléments, validation côté client, gestion
Turnstile sont tous inchangés. Seules les URLs API changent.

## Côté backend (PR #44 mva-prestige-v2)

Les routes mva-api /leads/* sont déployées séparément avec :
- Validation Zod + Turnstile + rate limit
- DB Postgres (table leads + leads_pending) remplace HubSpot Contacts + KV
- Resend pour les emails (= unification écosystème M4S)
- AdminJS Resource leads pour Mélissa CRUD

## Cutover

Cette PR doit être merged + déployée APRÈS la migration backend
(PR #44) + après le run du script migrate-hubspot-to-postgres.js
(= les 4 contacts HubSpot existants en DB).
2026-05-10 11:34:07 +02:00
bc919b07e0 Merge pull request 'fix(reset-password): redirect to mvaglobalfret:// custom scheme' (#11) from fix/reset-password-deep-link-mva into main
Some checks failed
Deploy site to GitHub Pages / deploy (push) Has been cancelled
2026-05-07 22:31:54 +03:00
Serge RAKOTO HARRY-NAIVO
605fa63f70 fix(reset-password): redirect to mvaglobalfret:// custom scheme for mva-api tokens
Le bridge HTML redirige maintenant vers le custom scheme natif
mvaglobalfret://reset-password?token=... au lieu de
https://auth.mind4solutions.com/reset-password.

Le flow Supabase (auth.m4s.com / Phase 2.1) ne sait pas valider les
tokens UUID custom émis par mva-api (Fastify). Sans ce fix, les emails
reset password Cluster A B2 atterriraient sur une page d'erreur
"Lien invalide".

UI fallback HTML conservée + bouton CTA stylé pour les cas où le
deep-link automatique ne déclenche pas l'app (browser desktop, app
non installée).

Fixes Cluster A B2 blocker (= session 2026-05-07 MVA app).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:27:59 +02:00
2774c25a61 chore: post-review cleanup (3 Important + dead code purge -116 lines) (#10)
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
2026-05-07 18:37:10 +03:00
e14b0ff01a fix(worker): restore original Ravis de vous revoir email template (#9)
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
2026-05-07 17:07:36 +03:00
21 changed files with 501 additions and 1117 deletions

View File

@ -27,7 +27,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -47,8 +47,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -208,7 +209,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -234,7 +235,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -31,7 +31,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -54,7 +54,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -230,7 +230,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
<li><a href="application.html" data-i18n="nav.app">Prochainement</a></li> <li><a href="application.html" data-i18n="nav.app">Prochainement</a></li>
</ul> </ul>
</div> </div>
@ -269,7 +269,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Application</a> <a href="application.html" data-i18n="nav.app">Application</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,8 +43,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Application</a> <a href="application.html" data-i18n="nav.app">Application</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -278,7 +279,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -304,7 +305,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script> <script>
document.getElementById('notifyForm')?.addEventListener('submit', function(e) { document.getElementById('notifyForm')?.addEventListener('submit', function(e) {

View File

@ -65,7 +65,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -83,8 +83,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -319,7 +320,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -346,7 +347,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -1 +0,0 @@
.wrangler/

View File

@ -1,149 +0,0 @@
# Déploiement Cloudflare Worker — Double opt-in MVA via Resend
Ce Worker gère le flow double opt-in du formulaire de contact :
1. **`requestVerification`** — génère un token, stocke les données du formulaire en KV, envoie un email de validation via Resend.
2. **`verifyToken`** — appelé quand le client clique sur le lien dans l'email. Crée le contact dans HubSpot (avec une référence à la volée) et envoie le welcome email avec la référence + l'adresse du dépôt Paris.
Le Worker est aussi un proxy HubSpot pour la vérification de doublon par email et la génération du prochain numéro de référence séquentiel.
L'email de bienvenue contient le **numéro de référence client** ET **l'adresse du dépôt à Paris**. Ces infos ne sont **jamais** envoyées avant validation de l'email — protection contre les bots et les expéditions de cartons vides.
---
## Étapes de déploiement (Phase D du plan WordPress → static)
### 1. Mettre à jour le code du Worker
Préférer `wrangler` :
```bash
cd cloudflare-worker
wrangler deploy
```
Ou via le dashboard Cloudflare :
1. Aller sur https://dash.cloudflare.com/
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
3. Onglet **Modifier le code** (ou *Quick edit*)
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js`
5. Cliquer **Déployer / Save and deploy**
### 2. Secrets
Dans **Paramètres → Variables et secrets** (ou via CLI : `wrangler secret put <name>`) :
| Nom | Valeur |
|-----|--------|
| `HUBSPOT_TOKEN` | `pat-eu1-...` (scope read+write contacts) |
| `RESEND_API_KEY` | `re_...` (compte Resend partagé avec m4s-auth) |
| `RESEND_FROM_EMAIL` | adresse expéditrice (domaine vérifié chez Resend) |
| `RESEND_FROM_NAME` | nom affiché à l'expéditeur (ex: `MVA Global Fret`) |
| `PARIS_DEPOT_ADDRESS` | **Ton adresse exacte à Paris** (rue, code postal, ville, étage, nom à indiquer sur le carton…) |
| `TURNSTILE_SECRET` | secret Cloudflare Turnstile (anti-bot) |
| `SITE_URL` | base URL du site (ex: `https://mva-globalfret.com`) |
> `PARIS_DEPOT_ADDRESS` est l'info la plus sensible — c'est l'adresse à protéger. Elle ne quitte jamais Cloudflare/Resend et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email.
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
Dans **Paramètres → Stockage et bases de données → Bindings KV** :
1. Cliquer **Ajouter un binding**
2. Variable name : **`WELCOME_KV`**
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
4. Sauvegarder
Si déployé via `wrangler` : mettre à jour `wrangler.toml` avec l'ID du namespace KV créé (remplacer `REPLACE_AT_DEPLOY_TIME`).
### 4. Vérifier le scope HubSpot
Le token HubSpot doit avoir :
- `crm.objects.contacts.read`
- `crm.objects.contacts.write`
- `crm.lists.read`
Si tu obtiens des erreurs 403, regénère le token sur https://app-eu1.hubspot.com/private-apps/148163754/
### 5. Resend — vérifier le domaine expéditeur
Le compte Resend (partagé avec m4s-auth) doit avoir le domaine de `RESEND_FROM_EMAIL` vérifié (DNS records SPF + DKIM). Voir https://resend.com/domains.
---
## Test manuel
Une fois tout déployé, tu peux tester le flow `requestVerification` (token de test, ne pas réutiliser en prod) :
```bash
curl -X POST https://mva-hubspot-proxy.<account>.workers.dev \
-H "Content-Type: application/json" \
-d '{
"action": "requestVerification",
"firstname": "Test",
"lastname": "User",
"email": "test@example.com",
"phone": "+33000000000",
"address": "Test address",
"turnstile_token": "<token from Turnstile widget>"
}'
```
Réponse attendue :
```json
{ "ok": true }
```
Le client reçoit alors un email de validation. En cliquant sur le lien, il déclenche `verifyToken`, qui crée le contact dans HubSpot et envoie le welcome email avec la référence + l'adresse Paris.
---
## Logs en production
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution du Worker.
---
## En cas de problème
| Symptôme | Cause probable | Fix |
|---|---|---|
| `Resend 401: API key invalid` | Mauvaise valeur dans `RESEND_API_KEY` | Re-vérifier la clé sur https://resend.com/api-keys |
| `Resend 422: domain is not verified` | Domaine de `RESEND_FROM_EMAIL` pas vérifié | Vérifier le domaine sur https://resend.com/domains |
| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope `contacts.write` |
| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token |
| `KV not bound` | Binding `WELCOME_KV` pas créé | Vérifier *Storage & Databases → KV bindings* |
| `Turnstile validation failed` | Secret côté Worker ne match pas la sitekey côté front | Re-vérifier `TURNSTILE_SECRET` et la sitekey HTML |
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ 1. User remplit le formulaire sur contact.html │
│ ↓ │
│ 2. form-handler.js → POST action: requestVerification │
│ ↓ │
│ 3. Worker valide Turnstile, génère un token, stocke les │
│ données du formulaire en KV (TTL 24h), envoie l'email │
│ de validation via Resend │
│ ↓ │
│ ─────── User clique sur « Confirmer » ─────── │
│ ↓ │
│ 4. confirmation.html → POST action: verifyToken │
│ ↓ │
│ 5. Worker récupère les données du KV, génère la prochaine │
│ référence client, soumet via HubSpot Forms API (= contact │
│ créé dans le CRM), envoie le welcome email via Resend │
│ (avec la référence + adresse Paris), marque le token │
│ consommé en KV (TTL 7j pour idempotence). │
│ ↓ │
│ 6. Le client reçoit son welcome email avec sa référence ET │
│ l'adresse de dépôt à Paris. │
└──────────────────────────────────────────────────────────────────┘
```
**Conclusion** : il est impossible pour un bot de récupérer la référence client ou l'adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé.

View File

@ -1,700 +0,0 @@
// ============================================================
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend
// ============================================================
// Ce Worker gère le formulaire de contact via un flow double opt-in :
//
// 1) requestVerification : génère un token, stocke les données du formulaire en KV,
// envoie un email de validation (lien de confirmation) via Resend.
//
// 2) verifyToken : appelé quand le client clique sur le lien de confirmation.
// Crée le contact dans HubSpot (avec une référence générée à la volée),
// puis envoie le welcome email avec la référence + l'adresse du dépôt Paris.
// Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email.
//
// La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant
// validation de l'email — protection anti-bot et anti-cartons-vides.
//
// ============================================================
// DÉPLOIEMENT (Phase D du plan WordPress → static)
// ============================================================
//
// Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète.
//
// Secrets requis (`wrangler secret put <name>`) :
// • HUBSPOT_TOKEN = pat-eu1-... (read+write contacts)
// • RESEND_API_KEY = re_... (compte Resend partagé avec m4s-auth)
// • RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// • RESEND_FROM_NAME = nom affiché à l'expéditeur
// • PARIS_DEPOT_ADDRESS = adresse complète du dépôt Paris
// • TURNSTILE_SECRET = secret Cloudflare Turnstile (anti-bot)
// • SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// Bindings KV requis :
// • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken)
//
// ============================================================
const HUBSPOT_API = 'https://api.hubapi.com';
const HUBSPOT_PORTAL_ID = '148163754';
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
const corsHeaders = {
'Access-Control-Allow-Origin' : '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export default {
// -----------------------------------------------------------
// 1) Handler navigateur (POST depuis le formulaire / page de confirmation)
// -----------------------------------------------------------
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const token = env.HUBSPOT_TOKEN;
if (!token) {
return jsonResponse({ error: 'HUBSPOT_TOKEN env var not set' }, 500);
}
try {
const body = await request.json();
const { email, action } = body;
// ── action: nextRef ─────────────────────────────────────
if (action === 'nextRef') {
return jsonResponse({ nextRef: await getNextRef(token) });
}
// ── action: requestVerification ──────────────────────────
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
// et envoie un email de validation via Resend. Le contact n'est créé
// dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
// les inscriptions non vérifiées ne polluent pas le CRM).
// Anti-bot : Turnstile vérifié d'abord.
if (action === 'requestVerification') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
if (!turnstileOk) {
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
}
try {
const verToken = crypto.randomUUID().replace(/-/g, '');
const tokenData = {
firstname : body.firstname || '',
lastname : body.lastname || '',
phone : body.phone || '',
email : body.email.toLowerCase().trim(),
address : body.address || '',
createdAt : new Date().toISOString(),
};
if (!env.WELCOME_KV) {
return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
}
// Token valide 24h
await env.WELCOME_KV.put(`verify:${verToken}`, JSON.stringify(tokenData), {
expirationTtl: 60 * 60 * 24,
});
await sendVerificationEmail(env, tokenData, verToken);
return jsonResponse({ ok: true });
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: verifyToken ──────────────────────────────────
// Appelé par confirmation.html quand l'utilisateur clique sur
// le lien dans l'email de validation. C'est ICI que le contact
// est CRÉÉ dans HubSpot (avec une référence générée à la volée),
// puis le welcome email est envoyé (ref + adresse Paris).
// Idempotent : un 2ème clic ne re-crée pas de contact.
if (action === 'verifyToken') {
if (!body.token) return jsonResponse({ error: 'token requis' }, 400);
if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
const key = `verify:${body.token}`;
const raw = await env.WELCOME_KV.get(key);
if (!raw) {
return jsonResponse({ ok: false, error: 'Token invalide ou expiré' }, 404);
}
const tokenData = JSON.parse(raw);
// Idempotence : si déjà consommé, retourne le résultat précédent
// sans recréer le contact ni renvoyer d'email.
if (tokenData.used) {
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : tokenData.reference_client || '',
});
}
try {
// 1) Récupère la ref existante si le contact est déjà dans HubSpot
// (réinscription après suppression d'un test, ou création via
// l'ancien flow Forms API). Sinon génère la prochaine ref.
let refNumber;
try {
const existing = await searchContactByEmail(token, tokenData.email);
const existingResult = (existing.results || [])[0];
const existingRef = existingResult?.properties?.reference_client;
refNumber = existingRef || await getNextRef(token);
} catch (_) {
// Si la search échoue (scope manquant, etc.), fallback : génère
// une nouvelle ref. Le Forms API gérera la dédup côté HubSpot.
refNumber = await getNextRef(token);
}
// 2) Création directe via CRM API (= more deterministic que Forms API
// qui peut accepter une submission sans réellement créer le contact
// à cause des filtres anti-spam ou de la config du Form HubSpot).
// Requires scope crm.objects.contacts.write.
// En cas de 409 (contact déjà existant), fallback sur PATCH par ID
// pour update les propriétés (= notamment reference_client).
const crmRes = await fetch(
`${HUBSPOT_API}/crm/v3/objects/contacts`,
{
method: 'POST',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
properties: {
firstname : tokenData.firstname || '',
lastname : tokenData.lastname || '',
phone : tokenData.phone || '',
email : tokenData.email,
address : tokenData.address || '',
reference_client : refNumber,
},
}),
}
);
if (crmRes.status === 409) {
// Contact existe déjà — update via PATCH par ID
const search = await searchContactByEmail(token, tokenData.email);
const existing = (search.results || [])[0];
if (existing?.id) {
const patchRes = await fetch(
`${HUBSPOT_API}/crm/v3/objects/contacts/${existing.id}`,
{
method: 'PATCH',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
properties: {
firstname : tokenData.firstname || existing.properties?.firstname || '',
lastname : tokenData.lastname || existing.properties?.lastname || '',
phone : tokenData.phone || existing.properties?.phone || '',
address : tokenData.address || '',
reference_client : refNumber,
},
}),
}
);
if (!patchRes.ok) {
const errTxt = await patchRes.text();
throw new Error(`HubSpot CRM patch failed ${patchRes.status}: ${errTxt.slice(0, 200)}`);
}
}
} else if (!crmRes.ok) {
const errTxt = await crmRes.text();
throw new Error(`HubSpot CRM create failed ${crmRes.status}: ${errTxt.slice(0, 200)}`);
}
// 3) Envoie le welcome email avec ref + adresse Paris
const welcomeContact = { ...tokenData, reference_client: refNumber };
await sendWelcomeViaResend(env, welcomeContact);
// 4) Marque le token consommé (gardé 7j pour idempotence)
await env.WELCOME_KV.put(key, JSON.stringify({
...tokenData,
used : true,
usedAt : new Date().toISOString(),
reference_client : refNumber,
}), {
expirationTtl: 60 * 60 * 24 * 7,
});
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : refNumber,
});
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: sendWelcomeBack ─────────────────────────────
// Envoie un email "Vous êtes déjà inscrit" au client qui tente
// une ré-inscription. Idempotent côté HubSpot (= aucune création
// ni update de contact). Anti-bot via Turnstile + sanity check
// que l'email existe vraiment dans HubSpot avant d'envoyer.
if (action === 'sendWelcomeBack') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
if (!turnstileOk) {
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
}
try {
// Vérification : le contact existe bien (= prevent spam vers
// emails inconnus en passant un faux turnstile)
const search = await searchContactByEmail(token, body.email);
const existing = (search.results || [])[0];
if (!existing) {
return jsonResponse({ ok: false, error: 'Contact not found' }, 404);
}
await sendWelcomeBackViaResend(env, {
firstname : body.firstname || existing.properties?.firstname || '',
email : body.email,
reference_client : existing.properties?.reference_client || '',
});
return jsonResponse({ ok: true });
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: listSubscriptions (debug : trouver les IDs) ──
if (action === 'listSubscriptions') {
// Endpoint legacy email/public/v1 nécessite scope content au lieu de
// communication_preferences (que notre token n'a pas)
const r = await fetch(`${HUBSPOT_API}/email/public/v1/subscriptions`, {
headers: { 'Authorization': `Bearer ${token}` },
});
return jsonResponse(await r.json());
}
// ── action: subscribe ────────────────────────────────────
// Inscrit un contact à un type d'abonnement marketing (déclenche
// l'envoi du mail de double opt-in si DOI activé au niveau compte).
if (action === 'subscribe') {
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const subId = body.subscriptionId;
if (!subId) return jsonResponse({ error: 'subscriptionId requis' }, 400);
const r = await fetch(`${HUBSPOT_API}/communication-preferences/v3/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
emailAddress: email.toLowerCase().trim(),
subscriptionId: subId,
legalBasis: 'LEGITIMATE_INTEREST_CLIENT',
legalBasisExplanation: 'Soumission du formulaire MVA Global Fret',
}),
});
const data = await r.text();
return jsonResponse({ status: r.status, body: data });
}
// ── action par défaut : vérification doublon par email ──
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const data = await searchContactByEmail(token, email);
return jsonResponse(data);
} catch (err) {
return jsonResponse({ error: err.message }, 500);
}
},
};
// =============================================================
// HubSpot : recherches & lectures
// =============================================================
async function searchContactByEmail(token, email) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'email', operator: 'EQ', value: email.toLowerCase().trim() }],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client'],
}),
});
if (!res.ok) {
throw new Error(`HubSpot lookup failed: ${res.status}`);
}
return res.json();
}
async function getNextRef(token) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' }],
}],
properties: ['reference_client'],
limit: 100,
}),
});
if (!res.ok) {
throw new Error(`HubSpot search failed: ${res.status}`);
}
const data = await res.json();
let maxNum = 0;
(data.results || []).forEach(c => {
const m = (c.properties?.reference_client || '').match(/^MVA-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxNum) maxNum = n;
}
});
return 'MVA-' + String(maxNum + 1).padStart(3, '0');
}
// =============================================================
// Resend : envoi d'emails transactionnels (verification + welcome)
// =============================================================
// Aligné avec m4s-auth (Phase 2.1) qui utilise déjà Resend en production.
// Le compte Resend (et le domaine vérifié) sont partagés entre m4s-auth et
// ce Worker — un seul fournisseur SMTP pour tout Mind4Solutions.
//
// Setup requis (`wrangler secret put <name>`) :
// - env.RESEND_API_KEY = clé API Resend (re_...)
// - env.RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// - env.RESEND_FROM_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
// - env.SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// API doc : https://resend.com/docs/api-reference/emails/send-email
const RESEND_API = 'https://api.resend.com/emails';
async function resendSend(env, { to, subject, html }) {
if (!env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY env var not set');
}
const fromEmail = env.RESEND_FROM_EMAIL || 'noreply@mva-globalfret.com';
const fromName = env.RESEND_FROM_NAME || 'MVA Global Fret';
const res = await fetch(RESEND_API, {
method: 'POST',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from : `${fromName} <${fromEmail}>`,
to : [to],
subject: subject,
html : html,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Resend ${res.status}: ${text}`);
}
return res.json();
}
async function sendVerificationEmail(env, contact, verToken) {
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Merci pour votre inscription chez <strong>MVA Global Fret</strong> !
</p>
<p style="color:#333;line-height:1.6;">
Pour finaliser votre inscription et recevoir votre <strong>numéro de référence client</strong>
ainsi que <strong>l'adresse de notre dépôt à Paris</strong>, cliquez sur le bouton ci-dessous :
</p>
<div style="text-align:center;margin:32px 0;">
<a href="${verifyUrl}" style="display:inline-block;background:#c5a55a;color:#1a1a3e;padding:16px 40px;border-radius:50px;text-decoration:none;font-weight:700;font-size:16px;letter-spacing:0.5px;">
Confirmer mon email
</a>
</div>
<p style="color:#666;font-size:13px;line-height:1.6;">
Ce lien est valable <strong>24 heures</strong>. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email.
</p>
<p style="color:#666;font-size:12px;line-height:1.6;border-top:1px solid #eee;padding-top:18px;margin-top:30px;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="color:#c5a55a;word-break:break-all;">${verifyUrl}</span>
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret Tous droits réservés
</div>
</div>
</body>
</html>`;
return resendSend(env, {
to: contact.email,
subject: 'Confirmez votre inscription chez MVA Global Fret',
html,
});
}
async function sendWelcomeViaResend(env, contact) {
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const ref = escapeHtml(contact.reference_client || '');
const refRaw = contact.reference_client || '';
// Format adresse Paris : la 1ère ligne (nom du destinataire) reçoit
// automatiquement la référence client entre parenthèses, comme ça
// le client a directement la bonne forme à recopier sur son colis.
// Support aussi un placeholder {{ref}} si présent dans l'env var.
let parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
if (parisAddrRaw.includes('{{ref}}')) {
parisAddrRaw = parisAddrRaw.replace(/\{\{ref\}\}/g, refRaw);
} else if (refRaw && parisAddrRaw) {
const lines = parisAddrRaw.split('\n');
lines[0] = `${lines[0]} (${refRaw})`;
parisAddrRaw = lines.join('\n');
}
const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '<br>');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Bienvenu(e) chez <strong>MVA Global Fret</strong> ! Votre email est confirmé,
votre inscription est désormais active.
</p>
<div style="background:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;">
<p style="margin:0;color:#1a1a3e;font-size:14px;">Votre numéro de référence client :</p>
<p style="margin:8px 0 0;color:#1a1a3e;font-size:22px;font-weight:bold;letter-spacing:2px;">${ref}</p>
<p style="margin:6px 0 0;color:#666;font-size:12px;">Conservez ce numéro précieusement.</p>
</div>
<p style="color:#333;margin-top:28px;"><strong>L'adresse à Paris pour l'envoi de vos colis :</strong></p>
<div style="background:#f9f9f9;border:1px solid #ddd;padding:20px 24px;border-radius:6px;margin:12px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e;">
${parisAddr}
</div>
<div style="background:#fff3cd;border:1px solid #ffc107;padding:14px 18px;border-radius:6px;margin:16px 0;">
<p style="margin:0 0 8px;color:#856404;font-size:14px;font-weight:bold;">
Important : ne modifiez rien à ces informations.
</p>
<p style="margin:0;color:#856404;font-size:14px;line-height:1.5;">
Recopiez l'adresse <strong>exactement telle qu'elle est indiquée ci-dessus</strong>,
sans rien retirer ni ajouter. Votre numéro de référence <strong>${ref}</strong>
fait partie intégrante de l'adresse — c'est ce qui garantit que votre colis nous arrive bien.
</p>
</div>
<p style="color:#333;line-height:1.6;">
Pour toute question, contactez-nous :<br>
📧 mvaglobalfret@gmail.com<br>
📞 +33 7 80 97 08 25 (France) +261 38 49 737 51 (Madagascar)
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret Tous droits réservés
</div>
</div>
</body>
</html>`;
return resendSend(env, {
to: contact.email,
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
html,
});
}
// Email "Ravis de vous revoir" pour les clients déjà inscrits qui retentent
// le formulaire de contact. Reprend EXACTEMENT le template original (= avant
// migration EmailJS \xe2\x86\x92 Resend) car son contenu est strat\xc3\xa9gique \xe2\x80\x94 rappel
// adresse Paris + warning anti-modification + r\xe9f\xe9rence client. Seules
// modifications : footer (c) 2025 \xe2\x86\x92 \xc2\xa9 2026, suppression du tag
// "Email sent via EmailJS.com" (obsol\xe8te depuis Resend), URL logo
// pointe vers le nouveau domaine, et adresse Paris injecte la ref via
// le placeholder {{ref}} de PARIS_DEPOT_ADDRESS.
//
// Idempotent c\xf4t\xe9 HubSpot (= z\xe9ro write).
async function sendWelcomeBackViaResend(env, contact) {
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstnameRaw = contact.firstname || '';
const firstname = escapeHtml(firstnameRaw);
const refRaw = contact.reference_client || '';
const ref = escapeHtml(refRaw);
// Construction adresse Paris (= m\xeame logique que sendWelcomeViaResend) :
// injecte la ref client soit via placeholder {{ref}}, soit en l'ajoutant
// entre parenth\xe8ses sur la 1\xe8re ligne (= pattern original "VASTA Mélissa (MVA-XXX)").
let parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
if (parisAddrRaw.includes('{{ref}}')) {
parisAddrRaw = parisAddrRaw.replace(/\{\{ref\}\}/g, refRaw);
} else if (refRaw && parisAddrRaw) {
const lines = parisAddrRaw.split('\n');
lines[0] = `${lines[0]} (${refRaw})`;
parisAddrRaw = lines.join('\n');
}
// 1\xe8re ligne en gras (= match original `<strong>VASTA Melissa (MVA-XXX)</strong>`)
const addrLines = escapeHtml(parisAddrRaw).split('\n');
const parisAddrHtml = addrLines.length > 1
? `<strong>${addrLines[0]}</strong><br>${addrLines.slice(1).join('<br>')}`
: escapeHtml(parisAddrRaw);
const greetingTitle = firstnameRaw
? `Ravis de vous revoir, ${firstname} !`
: 'Ravis de vous revoir !';
const html = `<html lang=""><body><div style="font-family:Arial,sans-serif;font-size:16px;background-color:#f5f5f5;padding:20px">
<div style="max-width:600px;margin:auto;background-color:#ffffff;border-radius:8px;overflow:hidden">
<div style="background-color:#1a1a3e;padding:30px 40px;text-align:center">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td width="145" style="padding:15px 0 15px 20px;vertical-align:middle"><img src="${logoUrl}" width="130" height="130" alt="MVA" style="display:block;"></td><td style="text-align:center;padding:15px 75px 15px 0;vertical-align:middle"><div style="color:#c5a55a;font-size:22px;font-weight:700;letter-spacing:2px;font-family:Arial,sans-serif">MVA GLOBAL FRET</div><div style="color:#ffffff;font-size:12px;margin-top:4px;font-family:Arial,sans-serif">Fret Aerien Paris - Antananarivo</div></td></tr></table>
</div>
<div style="padding:40px">
<p style="color:#1a1a3e;font-size:22px;font-weight:bold;margin-top:0">${greetingTitle}</p>
<p style="color:#333333">Nous avons bien recu votre nouvelle tentative d&#39;inscription. Pas d&#39;inquietude : vous etes <strong>deja client</strong> chez MVA Global Fret !</p>
<p style="color:#333333">Voici un rappel de votre numero de reference client :</p>
<div style="background-color:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;text-align:center">
<p style="margin:0;color:#1a1a3e;font-size:14px;letter-spacing:1px">VOTRE NUMERO DE REFERENCE CLIENT</p>
<p style="margin:8px 0 0 0;color:#1a1a3e;font-size:28px;font-weight:bold;letter-spacing:2px">${ref}</p>
<p style="margin:6px 0 0 0;color:#666666;font-size:12px">Conservez ce numero precieusement.</p>
</div>
<p style="color:#333333;margin-top:28px"><strong>L&#39;adresse a Paris pour l&#39;envoi de vos colis est :</strong></p>
<div style="background-color:#f9f9f9;border:1px solid #dddddd;padding:20px 24px;border-radius:6px;margin:12px 0 24px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e">
${parisAddrHtml}
</div>
<div style="background-color:#fff3cd;border:1px solid #ffc107;padding:16px 20px;border-radius:6px;margin:24px 0">
<p style="margin:0;color:#856404;font-size:14px"><strong>IMPORTANT :</strong> Cette adresse ne doit etre changee sous aucun pretexte. Toute modification empecherait la bonne transmission de votre colis a notre depot a Paris.</p>
</div>
<p style="color:#333333">Pour toute question, n&#39;hesitez pas a nous contacter :</p>
<ul style="color:#333333;line-height:2">
<li><a href="mailto:mvaglobalfret@gmail.com" style="color:#c5a55a">mvaglobalfret@gmail.com</a></li>
<li><a href="tel:+33780970825" style="color:#c5a55a">+33 7 80 97 08 25</a> (France)</li>
<li><a href="tel:+261384973751" style="color:#c5a55a">+261 38 49 737 51</a> (Madagascar)</li>
</ul>
<p style="color:#333333;margin-top:32px">A tres bientot pour votre prochain envoi,<br><strong>L&#39;equipe MVA Global Fret</strong></p>
</div>
<div style="background-color:#1a1a3e;color:rgba(255,255,255,0.6);padding:16px;text-align:center;font-size:12px">
(c) 2026 MVA Global Fret - Antananarivo 101, Madagascar
</div>
</div>
</div></body></html>`;
// Subject : reprend strictement le sujet original "Ravis de vous revoir, [firstname] !"
// (= en cas de firstname vide, fallback sans virgule).
const subjectFirstname = firstnameRaw.replace(/[\r\n]/g, '').trim();
return resendSend(env, {
to: contact.email,
subject: subjectFirstname
? `Ravis de vous revoir, ${subjectFirstname} !`
: 'Ravis de vous revoir !',
html,
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// =============================================================
// Cloudflare Turnstile : validation anti-bot
// =============================================================
// Reçoit le token généré côté client (window.turnstileToken) et
// l'envoie à l'API Cloudflare avec le secret pour validation.
// Renvoie true uniquement si Cloudflare confirme que c'est un
// utilisateur humain.
async function verifyTurnstile(env, token, request) {
if (!token) return false;
if (!env.TURNSTILE_SECRET) {
// En dev / si pas configuré, on laisse passer (à durcir en prod)
console.warn('TURNSTILE_SECRET not set, skipping validation');
return true;
}
const ip = request.headers.get('CF-Connecting-IP') || '';
const formData = new FormData();
formData.append('secret', env.TURNSTILE_SECRET);
formData.append('response', token);
if (ip) formData.append('remoteip', ip);
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data.success === true;
} catch (err) {
console.warn('Turnstile verification error:', err);
return false;
}
}
// =============================================================
// Helpers
// =============================================================
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}

View File

@ -1,19 +0,0 @@
name = "mva-hubspot-proxy"
main = "hubspot-proxy.js"
compatibility_date = "2026-04-01"
workers_dev = true
# KV namespace — placeholder ID, populated at deploy time (Phase D3)
[[kv_namespaces]]
binding = "WELCOME_KV"
id = "c02656ba22064923ab1c6db06b0f4a56"
# Required secrets to configure via `wrangler secret put` :
# - HUBSPOT_TOKEN
# - RESEND_API_KEY
# - RESEND_FROM_EMAIL
# - RESEND_FROM_NAME
# - PARIS_DEPOT_ADDRESS
# - TURNSTILE_SECRET
# - SITE_URL
# (BREVO_* and EMAILJS_* deprecated — removed in this version)

View File

@ -13,9 +13,6 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<!-- EmailJS -->
<script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
<style> <style>
.confirmation-shell { .confirmation-shell {
min-height: 100vh; min-height: 100vh;
@ -175,7 +172,7 @@
© 2026 MVA Global Fret. Tous droits réservés. © 2026 MVA Global Fret. Tous droits réservés.
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script src="js/confirmation.js"></script> <script src="js/confirmation.js"></script>
</body> </body>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,8 +43,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -54,8 +55,8 @@
<!-- HERO --> <!-- HERO -->
<section class="hero hero-sub" style="background-image: url('images/hero/contact-hero.jpg');"> <section class="hero hero-sub" style="background-image: url('images/hero/contact-hero.jpg');">
<div class="hero-content animate-on-scroll"> <div class="hero-content animate-on-scroll">
<h1 data-i18n="contact.heroTitle">Contactez-Nous</h1> <h1 data-i18n="contact.heroTitle">Inscrivez-vous</h1>
<p data-i18n="contact.heroSubtitle">Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui</p> <p data-i18n="contact.heroSubtitle">Commencez à envoyer vos colis dès aujourd'hui</p>
</div> </div>
</section> </section>
@ -279,7 +280,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -305,8 +306,7 @@
</div> </div>
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script src="js/form-handler.js"></script> <script src="js/form-handler.js"></script>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,8 +43,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -72,7 +73,7 @@
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-bomb" aria-hidden="true"></i> <i class="fa-solid fa-bomb" aria-hidden="true"></i>
<h4 data-i18n="guide.cat1Title">Explosifs</h4> <h4 data-i18n="guide.cat1Title">Explosifs</h4>
<p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards</p> <p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards, armes à feu</p>
</div> </div>
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-fire-flame-curved" aria-hidden="true"></i> <i class="fa-solid fa-fire-flame-curved" aria-hidden="true"></i>
@ -97,7 +98,7 @@
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i> <i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i>
<h4 data-i18n="guide.cat6Title">Substances toxiques</h4> <h4 data-i18n="guide.cat6Title">Substances toxiques</h4>
<p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses</p> <p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses, substances illégales</p>
</div> </div>
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-flask" aria-hidden="true"></i> <i class="fa-solid fa-flask" aria-hidden="true"></i>
@ -260,7 +261,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -286,7 +287,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -62,7 +62,7 @@
</main> </main>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script> <script>
/* i18n minimal ------------------------------------------------------- */ /* i18n minimal ------------------------------------------------------- */
(function () { (function () {

View File

@ -1,23 +1,27 @@
// ============================================================ // ============================================================
// MVA Global Fret — Page de confirmation post-validation email // MVA Global Fret — Page de confirmation post-validation email
// ============================================================ // ============================================================
// Cette page est la cible du lien dans l'email de validation // Cette page est la cible du lien dans l'email de validation envoyé
// (envoyé par Brevo après soumission du formulaire). // par mva-api (= Resend) lors d'une inscription via contact.html.
// //
// URL : https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?token=XXX // URL : https://mva-globalfret.com/confirmation.html?token=XXX
// //
// Étapes : // Étapes :
// 1. Lire le token depuis l'URL // 1. Lire le token depuis l'URL
// 2. POST au Worker avec action 'verifyToken' // 2. POST mva-api /leads/verify-token avec { token }
// 3. Worker valide le token, envoie le welcome email (avec ref + // 3. mva-api INSERT le lead en DB, génère la ref MVA-NNN, envoie le
// adresse Paris) via Brevo, puis renvoie OK // welcome email (= ref + adresse Paris) via Resend, puis renvoie
// 4. Page affiche "Inscription confirmée !" // { ok: true, firstname, reference_client }
// 4. Page affiche "Inscription confirmée !" + la ref
// //
// Si le token est invalide / expiré : affichage d'un message d'erreur // Si le token est invalide / expiré / déjà consommé : affichage d'un
// avec invitation à contacter le support. // message d'erreur avec invitation à contacter le support.
//
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné).
// ============================================================ // ============================================================
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev'; const API_BASE_URL = 'https://api.mva.mind4solutions.com';
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const token = new URLSearchParams(window.location.search).get('token'); const token = new URLSearchParams(window.location.search).get('token');
@ -28,18 +32,20 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const res = await fetch(WORKER_PROXY_URL, { const res = await fetch(`${API_BASE_URL}/leads/verify-token`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'verifyToken', token }), body: JSON.stringify({ token }),
}); });
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
showSuccess(data.reference_client || null); showSuccess(data.reference_client || null);
} else { } else {
// Token expiré, déjà utilisé, ou inconnu // Token invalide, expiré, ou inconnu
showError(data.error === 'Token invalide ou expiré' const isInvalid = data.code === 'INVALID_OR_EXPIRED'
|| data.message === 'Token invalide ou expiré';
showError(isInvalid
? 'Ce lien de confirmation a expiré ou a déjà été utilisé.' ? 'Ce lien de confirmation a expiré ou a déjà été utilisé.'
: 'Une erreur est survenue lors de la confirmation.'); : 'Une erreur est survenue lors de la confirmation.');
} }

View File

@ -1,78 +1,61 @@
// ============================================ // ============================================
// MVA Global Fret — Form Handler // MVA Global Fret — Form Handler
// HubSpot Portal ID : 148163754 // ============================================
// HubSpot Form GUID : 1d9b75c9-8b60-4966-aa18-4bf503452e9a // 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.
// ============================================ // ============================================
const HUBSPOT_PORTAL_ID = '148163754'; // ── MVA API BASE URL ─────────────────────────────────────────────
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a'; // Routes leads servies par mva-api derrière Caddy. CORS strict :
const FORMSPREE_ID = 'mojrvokp'; // le serveur whitelist explicitement https://mva-globalfret.com.
const API_BASE_URL = 'https://api.mva.mind4solutions.com';
// ── EMAILJS (email de bienvenue au client) ────────────────────────────────────
const EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP';
const EMAILJS_SERVICE_ID = 'service_aeamo3x';
const EMAILJS_TEMPLATE_ID = 'template_s1kr2et';
// Template pour les clients déjà inscrits ("Ravis de te revoir")
// ⚠️ À créer dans EmailJS puis remplacer la valeur ci-dessous
const EMAILJS_TEMPLATE_WELCOME_BACK = 'template_welcome_back';
// Initialisation EmailJS (une seule fois au chargement)
if (typeof emailjs !== 'undefined') {
emailjs.init({ publicKey: EMAILJS_PUBLIC_KEY });
}
// ── PROXY CLOUDFLARE WORKER ───────────────────────────────────────────────────
// URL du Worker qui proxifie l'API HubSpot CRM (contourne le CORS).
// Après déploiement du Worker (voir cloudflare-worker/hubspot-proxy.js),
// remplacer la chaîne vide par l'URL obtenue, ex :
// 'https://mva-hubspot-proxy.moncompte.workers.dev'
// Tant que cette constante est vide, la vérification doublon est désactivée
// (le formulaire s'envoie normalement — aucun blocage).
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('contactForm'); const form = document.getElementById('contactForm');
if (form) setupContactForm(form); if (form) setupContactForm(form);
}); });
// Génération séquentielle via le Worker HubSpot : MVA-001, MVA-002, etc. // ── TURNSTILE TOKEN MANAGEMENT ───────────────────────────────────
// Fallback sur un timestamp court si le Worker est indisponible. // Reset the Turnstile widget + global token after each Worker call.
async function generateRefNumber() { // Cloudflare Turnstile tokens are single-use server-side: a token
if (WORKER_PROXY_URL) { // already submitted to siteverify cannot be re-used. Without an
try { // explicit reset, a re-submit (= same form, same widget) would send
const res = await fetch(WORKER_PROXY_URL, { // the now-consumed token and Cloudflare would 403 silently.
method: 'POST', function resetTurnstile() {
headers: { 'Content-Type': 'application/json' }, window.turnstileToken = null;
body: JSON.stringify({ action: 'nextRef' }), if (window.turnstile && typeof window.turnstile.reset === 'function') {
}); try { window.turnstile.reset(); } catch (_) { /* widget absent */ }
if (res.ok) {
const data = await res.json();
if (data.nextRef) return data.nextRef;
}
} catch { /* fallback ci-dessous */ }
} }
// Fallback : numéro aléatoire court pour éviter les doublons en cas d'indisponibilité
const rand = String(Math.floor(Math.random() * 900) + 100);
return `MVA-F${rand}`;
} }
// Vérifie si l'email existe déjà dans HubSpot via le proxy Cloudflare Worker. // Vérifie si l'email existe déjà dans la table leads via mva-api.
// Retourne les propriétés du contact existant, ou null si nouveau client / proxy non configuré. // Retourne les propriétés du lead existant, ou null si nouveau
// client / API indisponible.
async function checkExistingContact(email) { async function checkExistingContact(email) {
// Si le proxy n'est pas encore déployé, on laisse passer sans bloquer
if (!WORKER_PROXY_URL) return null;
try { try {
const res = await fetch(WORKER_PROXY_URL, { const res = await fetch(`${API_BASE_URL}/leads/check-email`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.toLowerCase().trim() }), body: JSON.stringify({ email: email.toLowerCase().trim() }),
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
return data.total > 0 ? data.results[0].properties : null; if (!data.exists) return null;
// Forme attendue par showAlreadyRegistered : { firstname, reference_client }
return {
firstname: data.firstname || '',
reference_client: data.reference_client || '',
};
} catch { } catch {
// Erreur réseau ou Worker indisponible : on laisse passer
return null; return null;
} }
} }
@ -82,7 +65,7 @@ function setupContactForm(form) {
e.preventDefault(); e.preventDefault();
if (!validateForm(form)) return; if (!validateForm(form)) return;
// ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ──────────────────────────── // ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ────────────────
if (!window.turnstileToken) { if (!window.turnstileToken) {
const errEl = document.getElementById('formErrorGlobal'); const errEl = document.getElementById('formErrorGlobal');
if (errEl) { if (errEl) {
@ -91,23 +74,20 @@ function setupContactForm(form) {
} }
return; return;
} }
// ─────────────────────────────────────────────────────────────────────────
setLoading(true); setLoading(true);
const email = form.email.value.trim(); const email = form.email.value.trim();
// ── VÉRIFICATION DOUBLON ────────────────────────────────────────────────── // ── VÉRIFICATION DOUBLON ─────────────────────────────────────
// Vérifie HubSpot. Comme les contacts ne sont créés QU'APRÈS confirmation // Comme les contacts ne sont créés QU'APRÈS confirmation email,
// email, ce check ne retourne que les vrais clients déjà inscrits (pas // ce check ne retourne que les vrais clients déjà inscrits.
// les inscriptions en attente de confirmation).
const existing = await checkExistingContact(email); const existing = await checkExistingContact(email);
if (existing) { if (existing) {
setLoading(false); setLoading(false);
showAlreadyRegistered(existing); showAlreadyRegistered(existing);
return; return;
} }
// ─────────────────────────────────────────────────────────────────────────
const data = { const data = {
firstname: form.firstname.value.trim(), firstname: form.firstname.value.trim(),
@ -117,17 +97,17 @@ function setupContactForm(form) {
address: form.address.value.trim(), address: form.address.value.trim(),
}; };
// ── ENVOI VERS LE WORKER ────────────────────────────────────────────────── // ── ENVOI VERS MVA-API ────────────────────────────────────────
// Le Worker stocke les données en KV (24h), envoie un email de validation // L'API stocke les données en `leads_pending` (24h TTL) et envoie un
// via Brevo. Le contact n'est créé dans HubSpot QUE quand l'utilisateur // email de validation via Resend. Le lead n'est INSERT en `leads` QUE
// clique sur le lien de confirmation (anti-pollution du CRM). // quand l'utilisateur clique sur le lien de confirmation
// (anti-pollution DB + anti-bot complémentaire à Turnstile).
let ok = false; let ok = false;
try { try {
const res = await fetch(WORKER_PROXY_URL, { const res = await fetch(`${API_BASE_URL}/leads/request-verification`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action: 'requestVerification',
...data, ...data,
turnstile_token: window.turnstileToken || '', turnstile_token: window.turnstileToken || '',
}), }),
@ -138,6 +118,8 @@ function setupContactForm(form) {
console.warn('[requestVerification]', err); console.warn('[requestVerification]', err);
} }
// Reset Turnstile after the Worker call (= regardless of result)
resetTurnstile();
setLoading(false); setLoading(false);
if (ok) { if (ok) {
@ -148,73 +130,7 @@ function setupContactForm(form) {
}); });
} }
// ── SOUMISSION HUBSPOT ──────────────────────────────────────────────────────── // ── VALIDATION ───────────────────────────────────────────────────
async function submitToHubSpot(data) {
const payload = {
fields: [
{ name: 'firstname', value: data.firstname },
{ name: 'lastname', value: data.lastname },
{ name: 'phone', value: data.phone },
{ name: 'email', value: data.email },
{ name: 'address', value: data.address },
{ name: 'reference_client', value: data.reference_client },
],
context: {
pageUri: window.location.href,
pageName: document.title,
},
};
const res = await fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_GUID}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
);
if (!res.ok) throw new Error(`HubSpot error: ${res.status}`);
return res.json();
}
// ── SOUMISSION FORMSPREE (email de backup) ────────────────────────────────────
async function submitToFormspree(data) {
if (FORMSPREE_ID === 'YOUR_FORMSPREE_ID') return;
const res = await fetch(`https://formspree.io/f/${FORMSPREE_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
nom: data.lastname,
prenom: data.firstname,
telephone: data.phone,
email: data.email,
adresse_livraison: data.address,
reference_client: data.reference_client,
}),
});
if (!res.ok) throw new Error(`Formspree error: ${res.status}`);
return res.json();
}
// ── NOTIFICATION DOUBLON (email interne seulement, sans toucher aux données) ──
async function notifyDuplicateViaFormspree(contact) {
if (FORMSPREE_ID === 'YOUR_FORMSPREE_ID') return;
try {
await fetch(`https://formspree.io/f/${FORMSPREE_ID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
_subject: `[MVA] Tentative double inscription — ${contact.firstname || ''} ${contact.lastname || ''}`,
message: `Le client ${contact.firstname || ''} ${contact.lastname || ''} (${contact.email}) a tenté de s'inscrire à nouveau. Référence existante : ${contact.reference_client || 'non définie'}.`,
}),
});
} catch { /* Ne pas bloquer l'interface si la notification échoue */ }
}
// ── VALIDATION ────────────────────────────────────────────────────────────────
function validateForm(form) { function validateForm(form) {
let valid = true; let valid = true;
const lang = localStorage.getItem('mva-lang') || 'fr'; const lang = localStorage.getItem('mva-lang') || 'fr';
@ -263,7 +179,7 @@ function isValidPhone(phone) {
return /^[+\d][\d\s\-().]{6,20}$/.test(phone); return /^[+\d][\d\s\-().]{6,20}$/.test(phone);
} }
// ── AFFICHAGE ───────────────────────────────────────────────────────────────── // ── AFFICHAGE ────────────────────────────────────────────────────
function showFieldError(name, msg) { function showFieldError(name, msg) {
const el = document.getElementById(`error-${name}`); const el = document.getElementById(`error-${name}`);
const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`); const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
@ -296,11 +212,8 @@ function setLoading(isLoading) {
} }
function showSuccess(_refNumber, _clientData) { function showSuccess(_refNumber, _clientData) {
// L'envoi de l'email de validation est déjà fait dans setupContactForm const successEl = document.getElementById('formSuccess');
// via l'appel Worker requestVerification — on n'a plus rien à faire ici const form = document.getElementById('contactForm');
// sauf afficher la confirmation à l'écran.
const successEl = document.getElementById('formSuccess');
const form = document.getElementById('contactForm');
if (successEl) { if (successEl) {
successEl.classList.add('show'); successEl.classList.add('show');
successEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); successEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
@ -308,34 +221,32 @@ function showSuccess(_refNumber, _clientData) {
if (form) form.style.display = 'none'; if (form) form.style.display = 'none';
} }
// ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ────────────────────────── // ── 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. // Rappelle au client son numéro de référence existant — zéro write DB.
// Passe par le Cloudflare Worker (action: sendWelcomeBack) qui délègue à Resend // Passe par mva-api /leads/welcome-back qui délègue à Resend.
// — footer 2026 cohérent avec les autres emails transactionnels. Anti-bot via // Anti-bot via Turnstile : transmet le token déjà validé au moment du
// Turnstile : on transmet le token déjà validé au moment du submit du formulaire. // submit du formulaire.
async function sendWelcomeBackEmail(contact) { async function sendWelcomeBackEmail(contact) {
if (!WORKER_PROXY_URL) return;
if (!contact || !contact.email) return; if (!contact || !contact.email) return;
if (!window.turnstileToken) return; if (!window.turnstileToken) return;
try { try {
await fetch(WORKER_PROXY_URL, { await fetch(`${API_BASE_URL}/leads/welcome-back`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action : 'sendWelcomeBack',
email : contact.email, email : contact.email,
firstname : contact.firstname || '',
turnstile_token : window.turnstileToken, turnstile_token : window.turnstileToken,
}), }),
}); });
} catch (err) { } catch (err) {
// Erreur réseau : on n'interrompt pas l'UX (le client voit déjà sa ref dans le UI) // Erreur réseau : on n'interrompt pas l'UX (le client voit
console.warn('Worker sendWelcomeBack failed:', err); // 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 // Affiche le message "déjà client" — ne modifie AUCUNE donnée HubSpot
function showAlreadyRegistered(contact) { async function showAlreadyRegistered(contact) {
const lang = localStorage.getItem('mva-lang') || 'fr'; const lang = localStorage.getItem('mva-lang') || 'fr';
const t = translations?.[lang]?.contact || {}; const t = translations?.[lang]?.contact || {};
@ -357,11 +268,11 @@ function showAlreadyRegistered(contact) {
if (form) form.style.display = 'none'; if (form) form.style.display = 'none';
// Envoi d'une notification interne à MVA (sans modifier les données du client) // Email "Ravis de vous revoir" via Worker + Resend (= footer 2026
notifyDuplicateViaFormspree(contact); // cohérent avec les autres emails transactionnels).
await sendWelcomeBackEmail(contact);
// Email "Ravis de te revoir" via Worker + Resend (= footer 2026 cohérent) // Reset Turnstile after the Worker call (= prevent reuse).
sendWelcomeBackEmail(contact); resetTurnstile();
} }
function showError() { function showError() {

View File

@ -21,7 +21,7 @@ const translations = {
pricing: "Tarifs", pricing: "Tarifs",
serviceCommande: "Service Commande", serviceCommande: "Service Commande",
guide: "Guide d'envoi", guide: "Guide d'envoi",
contact: "Contact", contact: "Inscription",
app: "Prochainement" app: "Prochainement"
}, },
home: { home: {
@ -134,8 +134,8 @@ const translations = {
delivery2Note: "Retrait au bureau Cotisse de votre ville" delivery2Note: "Retrait au bureau Cotisse de votre ville"
}, },
contact: { contact: {
heroTitle: "Contactez-Nous", heroTitle: "Inscrivez-vous",
heroSubtitle: "Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui", heroSubtitle: "Commencez à envoyer vos colis dès aujourd'hui",
formTitle: "Formulaire d'inscription", formTitle: "Formulaire d'inscription",
formSubtitle: "Remplissez ce formulaire pour recevoir votre numéro de référence client et l'adresse de dépôt à Paris.", formSubtitle: "Remplissez ce formulaire pour recevoir votre numéro de référence client et l'adresse de dépôt à Paris.",
labelNom: "Nom", labelNom: "Nom",
@ -189,7 +189,7 @@ const translations = {
prohibitedTitle: "Articles Interdits", prohibitedTitle: "Articles Interdits",
prohibitedSubtitle: "Ces articles ne peuvent pas être transportés par fret aérien (réglementation IATA)", prohibitedSubtitle: "Ces articles ne peuvent pas être transportés par fret aérien (réglementation IATA)",
cat1Title: "Explosifs", cat1Title: "Explosifs",
cat1Desc: "Dynamite, munitions, feux d'artifice, pétards", cat1Desc: "Dynamite, munitions, feux d'artifice, pétards, armes à feu",
cat2Title: "Gaz comprimés", cat2Title: "Gaz comprimés",
cat2Desc: "Bouteilles de gaz, aérosols pressurisés, butane, propane", cat2Desc: "Bouteilles de gaz, aérosols pressurisés, butane, propane",
cat3Title: "Liquides inflammables", cat3Title: "Liquides inflammables",
@ -199,7 +199,7 @@ const translations = {
cat5Title: "Batteries lithium", cat5Title: "Batteries lithium",
cat5Desc: "Piles lithium seules, hoverboards, certains appareils", cat5Desc: "Piles lithium seules, hoverboards, certains appareils",
cat6Title: "Substances toxiques", cat6Title: "Substances toxiques",
cat6Desc: "Poisons, pesticides, substances infectieuses", cat6Desc: "Poisons, pesticides, substances infectieuses, substances illégales",
cat7Title: "Matières corrosives", cat7Title: "Matières corrosives",
cat7Desc: "Acides, mercure, soude caustique", cat7Desc: "Acides, mercure, soude caustique",
cat8Title: "Matières radioactives", cat8Title: "Matières radioactives",
@ -383,7 +383,7 @@ const translations = {
pricing: "Pricing", pricing: "Pricing",
serviceCommande: "Order Service", serviceCommande: "Order Service",
guide: "Shipping Guide", guide: "Shipping Guide",
contact: "Contact", contact: "Sign Up",
app: "Coming Soon" app: "Coming Soon"
}, },
home: { home: {
@ -496,8 +496,8 @@ const translations = {
ctaBtn: "Contact Us" ctaBtn: "Contact Us"
}, },
contact: { contact: {
heroTitle: "Contact Us", heroTitle: "Sign Up",
heroSubtitle: "Register and start sending your parcels today", heroSubtitle: "Start sending your parcels today",
formTitle: "Registration Form", formTitle: "Registration Form",
formSubtitle: "Fill out this form to receive your client reference number and the Paris drop-off address.", formSubtitle: "Fill out this form to receive your client reference number and the Paris drop-off address.",
labelNom: "Last Name", labelNom: "Last Name",
@ -551,7 +551,7 @@ const translations = {
prohibitedTitle: "Prohibited Items", prohibitedTitle: "Prohibited Items",
prohibitedSubtitle: "These items cannot be transported by air freight (IATA regulations)", prohibitedSubtitle: "These items cannot be transported by air freight (IATA regulations)",
cat1Title: "Explosives", cat1Title: "Explosives",
cat1Desc: "Dynamite, ammunition, fireworks, firecrackers", cat1Desc: "Dynamite, ammunition, fireworks, firecrackers, firearms",
cat2Title: "Compressed Gases", cat2Title: "Compressed Gases",
cat2Desc: "Gas cylinders, pressurized aerosols, butane, propane", cat2Desc: "Gas cylinders, pressurized aerosols, butane, propane",
cat3Title: "Flammable Liquids", cat3Title: "Flammable Liquids",
@ -561,7 +561,7 @@ const translations = {
cat5Title: "Lithium Batteries", cat5Title: "Lithium Batteries",
cat5Desc: "Loose lithium batteries, hoverboards, certain devices", cat5Desc: "Loose lithium batteries, hoverboards, certain devices",
cat6Title: "Toxic Substances", cat6Title: "Toxic Substances",
cat6Desc: "Poisons, pesticides, infectious substances", cat6Desc: "Poisons, pesticides, infectious substances, illegal substances",
cat7Title: "Corrosive Materials", cat7Title: "Corrosive Materials",
cat7Desc: "Acids, mercury, caustic soda", cat7Desc: "Acids, mercury, caustic soda",
cat8Title: "Radioactive Materials", cat8Title: "Radioactive Materials",
@ -745,7 +745,7 @@ const translations = {
pricing: "Sarany", pricing: "Sarany",
serviceCommande: "Tolotra Fividianana", serviceCommande: "Tolotra Fividianana",
guide: "Toromarika fandefasana", guide: "Toromarika fandefasana",
contact: "Fifandraisana", contact: "Fisoratana anarana",
app: "Avy tsy ho ela" app: "Avy tsy ho ela"
}, },
home: { home: {
@ -858,8 +858,8 @@ const translations = {
ctaBtn: "Mifandraisa aminay" ctaBtn: "Mifandraisa aminay"
}, },
contact: { contact: {
heroTitle: "Mifandraisa Aminay", heroTitle: "Misoratra anarana",
heroSubtitle: "Misoratra anarana ary manomboha mandefa ny entanareo anio", heroSubtitle: "Manomboha mandefa ny entanareo anio",
formTitle: "Taratasy fisoratana anarana", formTitle: "Taratasy fisoratana anarana",
formSubtitle: "Fenoy ity taratasy ity mba handraisana ny laharan'ny mpanjifa sy ny adiresy fametrahana any Paris.", formSubtitle: "Fenoy ity taratasy ity mba handraisana ny laharan'ny mpanjifa sy ny adiresy fametrahana any Paris.",
labelNom: "Anarana", labelNom: "Anarana",
@ -913,7 +913,7 @@ const translations = {
prohibitedTitle: "Entana Voarara", prohibitedTitle: "Entana Voarara",
prohibitedSubtitle: "Ireto entana ireto dia tsy azo alefa amin'ny fandefasana entana an'habakabaka (fitsipika IATA)", prohibitedSubtitle: "Ireto entana ireto dia tsy azo alefa amin'ny fandefasana entana an'habakabaka (fitsipika IATA)",
cat1Title: "Zavatra mipoaka", cat1Title: "Zavatra mipoaka",
cat1Desc: "Dynamita, bala, afo artifisialy, petarada", cat1Desc: "Dynamita, bala, afo artifisialy, petarada, basy",
cat2Title: "Gazy voatery", cat2Title: "Gazy voatery",
cat2Desc: "Tavoahangy gazy, aérosol misy tsindry, butane, propane", cat2Desc: "Tavoahangy gazy, aérosol misy tsindry, butane, propane",
cat3Title: "Ranoka mampirehitra", cat3Title: "Ranoka mampirehitra",
@ -923,7 +923,7 @@ const translations = {
cat5Title: "Bateria lithium", cat5Title: "Bateria lithium",
cat5Desc: "Bateria lithium irery, hoverboards, fitaovana sasany", cat5Desc: "Bateria lithium irery, hoverboards, fitaovana sasany",
cat6Title: "Zava-mahapoizina", cat6Title: "Zava-mahapoizina",
cat6Desc: "Poizina, fanafody bibikely, zava-mifindra", cat6Desc: "Poizina, fanafody bibikely, zava-mifindra, zavatra tsy ara-dalàna",
cat7Title: "Zava-mandevona", cat7Title: "Zava-mandevona",
cat7Desc: "Asida, mercure, soude caustique", cat7Desc: "Asida, mercure, soude caustique",
cat8Title: "Zava-misy radioactivité", cat8Title: "Zava-misy radioactivité",

View File

@ -47,7 +47,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -67,7 +67,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -273,7 +273,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -300,7 +300,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -56,7 +56,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -76,7 +76,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -444,7 +444,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -471,7 +471,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -4,30 +4,78 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Redirection — MVA Global Fret</title> <title>Réinitialisation du mot de passe — MVA Global Fret</title>
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png"> <link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
<script> <script>
// Bridge mobile deep link MVA Expo : redirect vers auth.mind4solutions.com // Bridge mobile deep link MVA : redirect vers le custom scheme natif
// qui héberge le UI reset-password Phase 2.2 m4s-auth. // mvaglobalfret://reset-password pour ouvrir le flow in-app de l'app Expo.
// Conserve le query param ?token=... pour que GoTrue PKCE flow continue. // Le lien email porte token_hash + type (recovery) ; l'app fait verifyOtp.
(function() { (function() {
var params = window.location.search || ''; var params = new URLSearchParams(window.location.search);
var hash = window.location.hash || ''; var tokenHash = params.get('token_hash');
window.location.replace('https://auth.mind4solutions.com/reset-password' + params + hash); var type = params.get('type') || 'recovery';
if (tokenHash) {
window.location.replace(
'mvaglobalfret://reset-password?token_hash=' +
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type)
);
}
})(); })();
</script> </script>
<style> <style>
body { font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 2rem; color: #333; } body {
a { color: #c5a55a; font-weight: 600; } font-family: system-ui, -apple-system, sans-serif;
text-align: center;
padding: 2rem;
color: #333;
max-width: 480px;
margin: 0 auto;
}
h1 { color: #2d3748; font-size: 1.5rem; }
p { line-height: 1.6; }
a.cta {
display: inline-block;
background: #c5a55a;
color: #fff;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin-top: 1rem;
}
a.cta:hover { background: #b3954a; }
.hint { color: #666; font-size: 0.9rem; margin-top: 2rem; }
.error { color: #b91c1c; font-size: 0.95rem; line-height: 1.6; margin-top: 1rem; }
</style> </style>
</head> </head>
<body> <body>
<h1>Redirection en cours...</h1> <h1>Ouvrir l'app MVA Global Fret</h1>
<p>Si la redirection automatique ne fonctionne pas, <p id="intro">Pour réinitialiser votre mot de passe, ouvrez le lien ci-dessous dans l'application MVA Global Fret installée sur votre téléphone.</p>
<a id="manual-link" href="https://auth.mind4solutions.com/reset-password">cliquez ici</a>. <p id="link-wrap">
<a id="manual-link" class="cta" href="#">Réinitialiser mon mot de passe</a>
</p> </p>
<p id="hint" class="hint">Si rien ne se passe, vérifiez que l'application MVA Global Fret est bien installée sur votre appareil.</p>
<p id="error" class="error" style="display:none;">Ce lien de réinitialisation est invalide ou incomplet. Veuillez relancer la procédure « Mot de passe oublié » depuis l'application MVA Global Fret.</p>
<script> <script>
document.getElementById('manual-link').href = 'https://auth.mind4solutions.com/reset-password' + (window.location.search || ''); (function() {
var params = new URLSearchParams(window.location.search);
var tokenHash = params.get('token_hash');
var type = params.get('type') || 'recovery';
if (!tokenHash) {
// Lien invalide : pas de token_hash → on masque le bouton et on prévient.
document.getElementById('intro').style.display = 'none';
document.getElementById('link-wrap').style.display = 'none';
document.getElementById('hint').style.display = 'none';
document.getElementById('error').style.display = 'block';
return;
}
// Deep link vers l'app : l'app lit token_hash + type et fait verifyOtp.
document.getElementById('manual-link').href =
'mvaglobalfret://reset-password?token_hash=' +
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type);
})();
</script> </script>
</body> </body>
</html> </html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -45,7 +45,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -242,7 +242,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -268,7 +268,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

286
setup-password.html Normal file
View File

@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Créez votre compte — MVA Global Fret</title>
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #1a1a3e 0%, #2d2d5e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
background: #fff;
border-radius: 16px;
max-width: 480px;
width: 100%;
padding: 2.5rem 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
text-align: center;
margin-bottom: 1.5rem;
}
.header .brand {
color: #c5a55a;
font-size: 22px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 0.25rem;
}
.header .subtitle {
color: #7a7a8a;
font-size: 13px;
}
h1 {
color: #1a1a3e;
font-size: 1.5rem;
margin: 1.5rem 0 0.5rem;
text-align: center;
}
.ref-badge {
background: #f0ead8;
border-left: 4px solid #c5a55a;
padding: 12px 16px;
border-radius: 4px;
margin: 1rem 0 1.5rem;
font-size: 14px;
color: #1a1a3e;
}
.ref-badge strong { letter-spacing: 1px; }
label {
display: block;
color: #1a1a3e;
font-size: 13px;
font-weight: 600;
margin: 1rem 0 0.4rem;
}
input[type="email"], input[type="password"] {
width: 100%;
padding: 0.7rem 0.9rem;
border: 1px solid #d4d4d8;
border-radius: 8px;
font-size: 15px;
font-family: inherit;
}
input[disabled] { background: #f7f7f9; color: #6b6b75; cursor: not-allowed; }
input:focus { outline: none; border-color: #c5a55a; box-shadow: 0 0 0 3px rgba(197, 165, 90, 0.18); }
.hint {
color: #7a7a8a;
font-size: 12px;
margin-top: 0.35rem;
line-height: 1.4;
}
button {
width: 100%;
margin-top: 1.5rem;
padding: 0.85rem;
background: #c5a55a;
color: #1a1a3e;
border: 0;
border-radius: 50px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) { background: #b3954a; }
button:disabled { opacity: 0.6; cursor: wait; }
.alert {
padding: 0.85rem 1rem;
border-radius: 8px;
font-size: 14px;
margin: 1rem 0;
line-height: 1.5;
}
.alert-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
.alert-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
.footer {
margin-top: 1.75rem;
text-align: center;
font-size: 12px;
color: #9ca3af;
}
.footer a { color: #c5a55a; text-decoration: none; }
.loading {
text-align: center;
padding: 2rem;
color: #7a7a8a;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="brand">MVA GLOBAL FRET</div>
<div class="subtitle">Fret Aérien Paris — Antananarivo</div>
</div>
<div id="loading" class="loading">Chargement du lien…</div>
<div id="error-state" class="hidden">
<h1>Lien invalide</h1>
<div id="error-message" class="alert alert-error"></div>
<p class="footer">
Si le problème persiste, contactez-nous via <a href="contact.html">la page contact</a>.
</p>
</div>
<div id="success-state" class="hidden">
<h1>Compte créé ✓</h1>
<div class="alert alert-success">
Votre compte MVA Global Fret a été créé avec succès.
Téléchargez l'application sur votre téléphone pour commander :
</div>
<p style="text-align:center;margin:1rem 0;">
<a href="application.html" style="color:#c5a55a;font-weight:600;text-decoration:none;">
📱 Télécharger l'application
</a>
</p>
</div>
<form id="setup-form" class="hidden" novalidate>
<h1>Créez votre compte</h1>
<p id="welcome-text" style="color:#1a1a3e;text-align:center;font-size:14px;margin-bottom:0;"></p>
<div id="ref-display" class="ref-badge hidden">
Référence client : <strong id="ref-value"></strong>
</div>
<label for="email-display">Email</label>
<input id="email-display" type="email" disabled>
<label for="password">Mot de passe</label>
<input id="password" type="password" autocomplete="new-password" required minlength="8" maxlength="72">
<div class="hint">Minimum 8 caractères, avec au moins une majuscule et un chiffre.</div>
<label for="password-confirm">Confirmer le mot de passe</label>
<input id="password-confirm" type="password" autocomplete="new-password" required>
<div id="form-error" class="alert alert-error hidden"></div>
<button type="submit" id="submit-btn">Créer mon compte</button>
<p class="footer">
En créant votre compte, vous acceptez nos <a href="cgv.html">CGV</a>
et notre <a href="politique-confidentialite.html">politique de confidentialité</a>.
</p>
</form>
</div>
<script>
(function() {
const API_BASE = "https://api.mva.mind4solutions.com";
const token = new URLSearchParams(window.location.search).get('token');
const $ = (id) => document.getElementById(id);
const show = (id) => $(id).classList.remove('hidden');
const hide = (id) => $(id).classList.add('hidden');
function showError(message) {
hide('loading');
hide('setup-form');
$('error-message').textContent = message;
show('error-state');
}
function showFormError(message) {
$('form-error').textContent = message;
show('form-error');
}
async function init() {
if (!token || token.length !== 96 || !/^[a-f0-9]+$/i.test(token)) {
showError("Lien invalide ou incomplet.");
return;
}
try {
const res = await fetch(API_BASE + "/auth/lookup-setup-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (!res.ok) {
showError(data.message || "Lien invalide.");
return;
}
$('email-display').value = data.email || '';
$('welcome-text').textContent = data.firstname
? `Bonjour ${data.firstname}, choisissez votre mot de passe pour activer votre compte.`
: "Choisissez votre mot de passe pour activer votre compte.";
if (data.reference_client) {
$('ref-value').textContent = data.reference_client;
show('ref-display');
}
hide('loading');
show('setup-form');
} catch (err) {
showError("Impossible de joindre le serveur. Vérifiez votre connexion.");
}
}
$('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
hide('form-error');
const password = $('password').value;
const confirm = $('password-confirm').value;
if (password.length < 8) {
showFormError("Le mot de passe doit contenir au moins 8 caractères.");
return;
}
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
showFormError("Le mot de passe doit contenir au moins une majuscule et un chiffre.");
return;
}
if (password !== confirm) {
showFormError("Les deux mots de passe ne correspondent pas.");
return;
}
const btn = $('submit-btn');
btn.disabled = true;
btn.textContent = "Création en cours…";
try {
const res = await fetch(API_BASE + "/auth/setup-from-lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
btn.disabled = false;
btn.textContent = "Créer mon compte";
showFormError(data.message || "Erreur lors de la création du compte.");
return;
}
hide('setup-form');
show('success-state');
} catch (err) {
btn.disabled = false;
btn.textContent = "Créer mon compte";
showFormError("Impossible de joindre le serveur. Réessayez dans un instant.");
}
});
init();
})();
</script>
</body>
</html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,8 +43,9 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Contact</a> <a href="contact.html" data-i18n="nav.contact">Inscription</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -257,7 +258,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li> <li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -283,7 +284,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js"></script> <script src="js/translations.js?v=20260603"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>