Compare commits

..

1 Commits

Author SHA1 Message Date
Serge RAKOTO HARRY-NAIVO
239e2e503d chore(post-cutover): fix 3 polish bugs (welcome-back email + mobile lang switcher + animate-on-scroll)
Three small post-cutover fixes reported during E2E retest 2026-05-07:

1. js/form-handler.js: disable EmailJS welcome-back call in
   showAlreadyRegistered. The EmailJS account is Melissa-only
   (no Serge access) and its template still has a '(c) 2025' footer
   inconsistent with the Resend emails sent by the new Worker.
   The user already sees their existing reference number in the
   showAlreadyRegistered modal (= cosmetic email, not critical).
   To re-implement properly: route through Worker + Resend in a
   future PR.

2. 10 HTML pages: remove duplicate .lang-switcher block from
   .mobile-nav. The header already has a lang-switcher visible on
   mobile, the second one inside the slide-in mobile menu was redundant.

3. js/main.js: fix IntersectionObserver threshold for animate-on-scroll.
   Was threshold: 0.1 — never fires on mobile portrait for cgv.html
   and politique-confidentialite.html because those pages have
   data-lang-block elements 2000-3000px tall (verbose FR/EN/MG
   trilingual content) and viewport (~600px) never reaches 10%
   intersection ratio. Now threshold: 0 — fires as soon as any
   pixel enters viewport. mentions-legales.html unaffected because
   its blocks were short enough to satisfy the previous 10%.

Refs: WordPress \xe2\x86\x92 static migration, post-cutover polish.
2026-05-07 15:36:21 +02:00
22 changed files with 1000 additions and 514 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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +47,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -209,7 +208,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -235,7 +234,7 @@
</div> </div>
</footer> </footer>
<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>
</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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</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?v=20260603"></script> <script src="js/translations.js"></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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +43,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -279,7 +278,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -305,7 +304,7 @@
</div> </div>
</footer> </footer>
<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> <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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +83,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -320,7 +319,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -347,7 +346,7 @@
</div> </div>
</footer> </footer>
<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>
</body> </body>
</html> </html>

1
cloudflare-worker/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,149 @@
# 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

@ -0,0 +1,581 @@
// ============================================================
// 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: 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,
});
}
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

@ -0,0 +1,19 @@
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,6 +13,9 @@
<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;
@ -172,7 +175,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?v=20260603"></script> <script src="js/translations.js"></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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +43,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -55,8 +54,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">Inscrivez-vous</h1> <h1 data-i18n="contact.heroTitle">Contactez-Nous</h1>
<p data-i18n="contact.heroSubtitle">Commencez à envoyer vos colis dès aujourd'hui</p> <p data-i18n="contact.heroSubtitle">Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui</p>
</div> </div>
</section> </section>
@ -280,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -306,7 +305,8 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +43,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -73,7 +72,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, armes à feu</p> <p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards</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>
@ -98,7 +97,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, substances illégales</p> <p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses</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>
@ -261,7 +260,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -287,7 +286,7 @@
</div> </div>
</footer> </footer>
<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>
</body> </body>
</html> </html>

View File

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

View File

@ -1,27 +1,23 @@
// ============================================================ // ============================================================
// 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 envoyé // Cette page est la cible du lien dans l'email de validation
// par mva-api (= Resend) lors d'une inscription via contact.html. // (envoyé par Brevo après soumission du formulaire).
// //
// URL : https://mva-globalfret.com/confirmation.html?token=XXX // URL : https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?token=XXX
// //
// Étapes : // Étapes :
// 1. Lire le token depuis l'URL // 1. Lire le token depuis l'URL
// 2. POST mva-api /leads/verify-token avec { token } // 2. POST au Worker avec action 'verifyToken'
// 3. mva-api INSERT le lead en DB, génère la ref MVA-NNN, envoie le // 3. Worker valide le token, envoie le welcome email (avec ref +
// welcome email (= ref + adresse Paris) via Resend, puis renvoie // adresse Paris) via Brevo, puis renvoie OK
// { ok: true, firstname, reference_client } // 4. Page affiche "Inscription confirmée !"
// 4. Page affiche "Inscription confirmée !" + la ref
// //
// Si le token est invalide / expiré / déjà consommé : affichage d'un // Si le token est invalide / expiré : affichage d'un message d'erreur
// message d'erreur avec invitation à contacter le support. // avec invitation à contacter le support.
//
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné).
// ============================================================ // ============================================================
const API_BASE_URL = 'https://api.mva.mind4solutions.com'; const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
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');
@ -32,20 +28,18 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const res = await fetch(`${API_BASE_URL}/leads/verify-token`, { const res = await fetch(WORKER_PROXY_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }), body: JSON.stringify({ action: 'verifyToken', 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 invalide, expiré, ou inconnu // Token expiré, déjà utilisé, ou inconnu
const isInvalid = data.code === 'INVALID_OR_EXPIRED' showError(data.error === 'Token invalide ou expiré'
|| 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,61 +1,78 @@
// ============================================ // ============================================
// MVA Global Fret — Form Handler // MVA Global Fret — Form Handler
// ============================================ // HubSpot Portal ID : 148163754
// Frontend logic for contact.html (= inscription form): // HubSpot Form GUID : 1d9b75c9-8b60-4966-aa18-4bf503452e9a
// - validate inputs + Cloudflare Turnstile token
// - call mva-api /leads/* routes for dedup check + double opt-in flow
// (= verification email + welcome / welcome-back emails via Resend)
// - reset Turnstile widget after each API call (= tokens are
// single-use server-side; without reset, a re-submit silently
// 403s from Cloudflare's siteverify endpoint)
//
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné) par
// les routes mva-api Fastify. La DB Postgres remplace HubSpot Contacts.
// ============================================ // ============================================
// ── MVA API BASE URL ───────────────────────────────────────────── const HUBSPOT_PORTAL_ID = '148163754';
// Routes leads servies par mva-api derrière Caddy. CORS strict : const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
// le serveur whitelist explicitement https://mva-globalfret.com. const FORMSPREE_ID = 'mojrvokp';
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);
}); });
// ── TURNSTILE TOKEN MANAGEMENT ─────────────────────────────────── // Génération séquentielle via le Worker HubSpot : MVA-001, MVA-002, etc.
// Reset the Turnstile widget + global token after each Worker call. // Fallback sur un timestamp court si le Worker est indisponible.
// Cloudflare Turnstile tokens are single-use server-side: a token async function generateRefNumber() {
// already submitted to siteverify cannot be re-used. Without an if (WORKER_PROXY_URL) {
// explicit reset, a re-submit (= same form, same widget) would send try {
// the now-consumed token and Cloudflare would 403 silently. const res = await fetch(WORKER_PROXY_URL, {
function resetTurnstile() { method: 'POST',
window.turnstileToken = null; headers: { 'Content-Type': 'application/json' },
if (window.turnstile && typeof window.turnstile.reset === 'function') { body: JSON.stringify({ action: 'nextRef' }),
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 la table leads via mva-api. // Vérifie si l'email existe déjà dans HubSpot via le proxy Cloudflare Worker.
// Retourne les propriétés du lead existant, ou null si nouveau // Retourne les propriétés du contact existant, ou null si nouveau client / proxy non configuré.
// 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(`${API_BASE_URL}/leads/check-email`, { const res = await fetch(WORKER_PROXY_URL, {
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();
if (!data.exists) return null; return data.total > 0 ? data.results[0].properties : 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;
} }
} }
@ -65,7 +82,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) {
@ -74,20 +91,23 @@ 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 ──────────────────────────────────────────────────
// Comme les contacts ne sont créés QU'APRÈS confirmation email, // Vérifie HubSpot. Comme les contacts ne sont créés QU'APRÈS confirmation
// ce check ne retourne que les vrais clients déjà inscrits. // email, ce check ne retourne que les vrais clients déjà inscrits (pas
// 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(),
@ -97,17 +117,17 @@ function setupContactForm(form) {
address: form.address.value.trim(), address: form.address.value.trim(),
}; };
// ── ENVOI VERS MVA-API ──────────────────────────────────────── // ── ENVOI VERS LE WORKER ──────────────────────────────────────────────────
// L'API stocke les données en `leads_pending` (24h TTL) et envoie un // Le Worker stocke les données en KV (24h), envoie un email de validation
// email de validation via Resend. Le lead n'est INSERT en `leads` QUE // via Brevo. Le contact n'est créé dans HubSpot QUE quand l'utilisateur
// quand l'utilisateur clique sur le lien de confirmation // clique sur le lien de confirmation (anti-pollution du CRM).
// (anti-pollution DB + anti-bot complémentaire à Turnstile).
let ok = false; let ok = false;
try { try {
const res = await fetch(`${API_BASE_URL}/leads/request-verification`, { const res = await fetch(WORKER_PROXY_URL, {
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 || '',
}), }),
@ -118,8 +138,6 @@ 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) {
@ -130,7 +148,73 @@ function setupContactForm(form) {
}); });
} }
// ── VALIDATION ─────────────────────────────────────────────────── // ── SOUMISSION HUBSPOT ────────────────────────────────────────────────────────
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';
@ -179,7 +263,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}"]`);
@ -212,8 +296,11 @@ function setLoading(isLoading) {
} }
function showSuccess(_refNumber, _clientData) { function showSuccess(_refNumber, _clientData) {
const successEl = document.getElementById('formSuccess'); // L'envoi de l'email de validation est déjà fait dans setupContactForm
const form = document.getElementById('contactForm'); // via l'appel Worker requestVerification — on n'a plus rien à faire ici
// 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' });
@ -221,32 +308,25 @@ function showSuccess(_refNumber, _clientData) {
if (form) form.style.display = 'none'; if (form) form.style.display = 'none';
} }
// ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ─────────── // ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ──────────────────────────
// Rappelle au client son numéro de référence existant — zéro write DB. // Rappelle au client son numéro de référence existant — n'écrit RIEN dans HubSpot.
// Passe par mva-api /leads/welcome-back qui délègue à Resend.
// Anti-bot via Turnstile : transmet le token déjà validé au moment du
// submit du formulaire.
async function sendWelcomeBackEmail(contact) { async function sendWelcomeBackEmail(contact) {
if (typeof emailjs === 'undefined') return;
if (!contact || !contact.email) return; if (!contact || !contact.email) return;
if (!window.turnstileToken) return;
try { try {
await fetch(`${API_BASE_URL}/leads/welcome-back`, { await emailjs.send(EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_WELCOME_BACK, {
method: 'POST', firstname: contact.firstname || '',
headers: { 'Content-Type': 'application/json' }, email: contact.email,
body: JSON.stringify({ reference_client: contact.reference_client || '',
email : contact.email,
turnstile_token : window.turnstileToken,
}),
}); });
} catch (err) { } catch (err) {
// Erreur réseau : on n'interrompt pas l'UX (le client voit // Si le template n'existe pas encore ou erreur réseau : on n'interrompt rien
// déjà sa référence dans le UI). console.warn('EmailJS welcome-back email failed:', err);
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
async function showAlreadyRegistered(contact) { 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 || {};
@ -268,11 +348,14 @@ async function showAlreadyRegistered(contact) {
if (form) form.style.display = 'none'; if (form) form.style.display = 'none';
// Email "Ravis de vous revoir" via Worker + Resend (= footer 2026 // Envoi d'une notification interne à MVA (sans modifier les données du client)
// cohérent avec les autres emails transactionnels). notifyDuplicateViaFormspree(contact);
await sendWelcomeBackEmail(contact);
// Reset Turnstile after the Worker call (= prevent reuse). // Email "Ravis de te revoir" via EmailJS désactivé (= post-migration 2026-05-07).
resetTurnstile(); // Le compte EmailJS n'est plus accessible (Melissa-only) et son template a un
// footer "(c) 2025" obsolète. Le client a déjà sa référence affichée via
// `showAlreadyRegistered` ci-dessus — l'email est cosmétique. À ré-implémenter
// via Worker + Resend en follow-up si jugé utile.
} }
function showError() { function showError() {

View File

@ -118,12 +118,7 @@ function setupAnimations() {
observer.unobserve(entry.target); observer.unobserve(entry.target);
} }
}); });
// threshold: 0 (= fire dès qu'1px du bloc entre dans le viewport). }, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
// Avant : threshold 0.1 (= 10%) ne fire jamais sur mobile portrait pour
// les pages cgv.html + politique-confidentialite.html dont les blocs
// .animate-on-scroll font 2000-3000px de hauteur (contenu trilingue
// FR/EN/MG verbose) — la viewport mobile ~600px n'atteint jamais 10%.
}, { threshold: 0, rootMargin: '0px 0px -50px 0px' });
elements.forEach(el => observer.observe(el)); elements.forEach(el => observer.observe(el));
} }

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: "Inscription", contact: "Contact",
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: "Inscrivez-vous", heroTitle: "Contactez-Nous",
heroSubtitle: "Commencez à envoyer vos colis dès aujourd'hui", heroSubtitle: "Inscrivez-vous et 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, armes à feu", cat1Desc: "Dynamite, munitions, feux d'artifice, pétards",
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, substances illégales", cat6Desc: "Poisons, pesticides, substances infectieuses",
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: "Sign Up", contact: "Contact",
app: "Coming Soon" app: "Coming Soon"
}, },
home: { home: {
@ -496,8 +496,8 @@ const translations = {
ctaBtn: "Contact Us" ctaBtn: "Contact Us"
}, },
contact: { contact: {
heroTitle: "Sign Up", heroTitle: "Contact Us",
heroSubtitle: "Start sending your parcels today", heroSubtitle: "Register and 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, firearms", cat1Desc: "Dynamite, ammunition, fireworks, firecrackers",
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, illegal substances", cat6Desc: "Poisons, pesticides, infectious 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: "Fisoratana anarana", contact: "Fifandraisana",
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: "Misoratra anarana", heroTitle: "Mifandraisa Aminay",
heroSubtitle: "Manomboha mandefa ny entanareo anio", heroSubtitle: "Misoratra anarana ary 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, basy", cat1Desc: "Dynamita, bala, afo artifisialy, petarada",
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, zavatra tsy ara-dalàna", cat6Desc: "Poizina, fanafody bibikely, zava-mifindra",
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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</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?v=20260603"></script> <script src="js/translations.js"></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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</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?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -4,78 +4,30 @@
<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>Réinitialisation du mot de passe — MVA Global Fret</title> <title>Redirection — 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 : redirect vers le custom scheme natif // Bridge mobile deep link MVA Expo : redirect vers auth.mind4solutions.com
// mvaglobalfret://reset-password pour ouvrir le flow in-app de l'app Expo. // qui héberge le UI reset-password Phase 2.2 m4s-auth.
// Le lien email porte token_hash + type (recovery) ; l'app fait verifyOtp. // Conserve le query param ?token=... pour que GoTrue PKCE flow continue.
(function() { (function() {
var params = new URLSearchParams(window.location.search); var params = window.location.search || '';
var tokenHash = params.get('token_hash'); var hash = window.location.hash || '';
var type = params.get('type') || 'recovery'; window.location.replace('https://auth.mind4solutions.com/reset-password' + params + hash);
if (tokenHash) {
window.location.replace(
'mvaglobalfret://reset-password?token_hash=' +
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type)
);
}
})(); })();
</script> </script>
<style> <style>
body { body { font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 2rem; color: #333; }
font-family: system-ui, -apple-system, sans-serif; a { color: #c5a55a; font-weight: 600; }
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>Ouvrir l'app MVA Global Fret</h1> <h1>Redirection en cours...</h1>
<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> <p>Si la redirection automatique ne fonctionne pas,
<p id="link-wrap"> <a id="manual-link" href="https://auth.mind4solutions.com/reset-password">cliquez ici</a>.
<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>
(function() { document.getElementById('manual-link').href = 'https://auth.mind4solutions.com/reset-password' + (window.location.search || '');
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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -268,7 +268,7 @@
</div> </div>
</footer> </footer>
<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>
</body> </body>
</html> </html>

View File

@ -1,286 +0,0 @@
<!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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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,9 +43,8 @@
<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">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</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>
@ -258,7 +257,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">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -284,7 +283,7 @@
</div> </div>
</footer> </footer>
<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>
</body> </body>
</html> </html>