Compare commits
26 Commits
fix/worker
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f71d1b327e | |||
|
|
616f9d75a1 | ||
| 499109f8a9 | |||
|
|
43e576249c | ||
| 5399fc1a40 | |||
|
|
61a2a67b00 | ||
| 2a2ea2f5e4 | |||
|
|
df8bf01759 | ||
| ee17b2b48c | |||
|
|
84b4b7753e | ||
| b6b492f224 | |||
|
|
32c7d65698 | ||
| 24104ac9f4 | |||
|
|
a6d219453c | ||
| 2878e8e01a | |||
|
|
af58c04776 | ||
| 61397720e8 | |||
|
|
7217f12bd2 | ||
| bc919b07e0 | |||
|
|
605fa63f70 | ||
| 2774c25a61 | |||
| e14b0ff01a | |||
| 5c34e59a8d | |||
| db43583a62 | |||
| 5b84e5697e | |||
| 5f88891a83 |
14
about.html
14
about.html
@ -27,7 +27,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -47,14 +47,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -213,7 +209,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -239,7 +235,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
13
accueil.html
13
accueil.html
@ -31,7 +31,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -54,13 +54,8 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -235,7 +230,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
<li><a href="application.html" data-i18n="nav.app">Prochainement</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -274,7 +269,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Application</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -43,14 +43,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Application</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -283,7 +279,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -309,7 +305,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<script>
|
||||
document.getElementById('notifyForm')?.addEventListener('submit', function(e) {
|
||||
|
||||
14
cgv.html
14
cgv.html
@ -65,7 +65,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -83,14 +83,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -324,7 +320,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -351,7 +347,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
cloudflare-worker/.gitignore
vendored
1
cloudflare-worker/.gitignore
vendored
@ -1 +0,0 @@
|
||||
.wrangler/
|
||||
@ -1,149 +0,0 @@
|
||||
# Déploiement Cloudflare Worker — Double opt-in MVA via Resend
|
||||
|
||||
Ce Worker gère le flow double opt-in du formulaire de contact :
|
||||
|
||||
1. **`requestVerification`** — génère un token, stocke les données du formulaire en KV, envoie un email de validation via Resend.
|
||||
2. **`verifyToken`** — appelé quand le client clique sur le lien dans l'email. Crée le contact dans HubSpot (avec une référence à la volée) et envoie le welcome email avec la référence + l'adresse du dépôt Paris.
|
||||
|
||||
Le Worker est aussi un proxy HubSpot pour la vérification de doublon par email et la génération du prochain numéro de référence séquentiel.
|
||||
|
||||
L'email de bienvenue contient le **numéro de référence client** ET **l'adresse du dépôt à Paris**. Ces infos ne sont **jamais** envoyées avant validation de l'email — protection contre les bots et les expéditions de cartons vides.
|
||||
|
||||
---
|
||||
|
||||
## Étapes de déploiement (Phase D du plan WordPress → static)
|
||||
|
||||
### 1. Mettre à jour le code du Worker
|
||||
|
||||
Préférer `wrangler` :
|
||||
|
||||
```bash
|
||||
cd cloudflare-worker
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
Ou via le dashboard Cloudflare :
|
||||
|
||||
1. Aller sur https://dash.cloudflare.com/
|
||||
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
|
||||
3. Onglet **Modifier le code** (ou *Quick edit*)
|
||||
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js`
|
||||
5. Cliquer **Déployer / Save and deploy**
|
||||
|
||||
### 2. Secrets
|
||||
|
||||
Dans **Paramètres → Variables et secrets** (ou via CLI : `wrangler secret put <name>`) :
|
||||
|
||||
| Nom | Valeur |
|
||||
|-----|--------|
|
||||
| `HUBSPOT_TOKEN` | `pat-eu1-...` (scope read+write contacts) |
|
||||
| `RESEND_API_KEY` | `re_...` (compte Resend partagé avec m4s-auth) |
|
||||
| `RESEND_FROM_EMAIL` | adresse expéditrice (domaine vérifié chez Resend) |
|
||||
| `RESEND_FROM_NAME` | nom affiché à l'expéditeur (ex: `MVA Global Fret`) |
|
||||
| `PARIS_DEPOT_ADDRESS` | **Ton adresse exacte à Paris** (rue, code postal, ville, étage, nom à indiquer sur le carton…) |
|
||||
| `TURNSTILE_SECRET` | secret Cloudflare Turnstile (anti-bot) |
|
||||
| `SITE_URL` | base URL du site (ex: `https://mva-globalfret.com`) |
|
||||
|
||||
> `PARIS_DEPOT_ADDRESS` est l'info la plus sensible — c'est l'adresse à protéger. Elle ne quitte jamais Cloudflare/Resend et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email.
|
||||
|
||||
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
|
||||
|
||||
Dans **Paramètres → Stockage et bases de données → Bindings KV** :
|
||||
|
||||
1. Cliquer **Ajouter un binding**
|
||||
2. Variable name : **`WELCOME_KV`**
|
||||
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
|
||||
4. Sauvegarder
|
||||
|
||||
Si déployé via `wrangler` : mettre à jour `wrangler.toml` avec l'ID du namespace KV créé (remplacer `REPLACE_AT_DEPLOY_TIME`).
|
||||
|
||||
### 4. Vérifier le scope HubSpot
|
||||
|
||||
Le token HubSpot doit avoir :
|
||||
|
||||
- `crm.objects.contacts.read`
|
||||
- `crm.objects.contacts.write`
|
||||
- `crm.lists.read`
|
||||
|
||||
Si tu obtiens des erreurs 403, regénère le token sur https://app-eu1.hubspot.com/private-apps/148163754/
|
||||
|
||||
### 5. Resend — vérifier le domaine expéditeur
|
||||
|
||||
Le compte Resend (partagé avec m4s-auth) doit avoir le domaine de `RESEND_FROM_EMAIL` vérifié (DNS records SPF + DKIM). Voir https://resend.com/domains.
|
||||
|
||||
---
|
||||
|
||||
## Test manuel
|
||||
|
||||
Une fois tout déployé, tu peux tester le flow `requestVerification` (token de test, ne pas réutiliser en prod) :
|
||||
|
||||
```bash
|
||||
curl -X POST https://mva-hubspot-proxy.<account>.workers.dev \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"action": "requestVerification",
|
||||
"firstname": "Test",
|
||||
"lastname": "User",
|
||||
"email": "test@example.com",
|
||||
"phone": "+33000000000",
|
||||
"address": "Test address",
|
||||
"turnstile_token": "<token from Turnstile widget>"
|
||||
}'
|
||||
```
|
||||
|
||||
Réponse attendue :
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Le client reçoit alors un email de validation. En cliquant sur le lien, il déclenche `verifyToken`, qui crée le contact dans HubSpot et envoie le welcome email avec la référence + l'adresse Paris.
|
||||
|
||||
---
|
||||
|
||||
## Logs en production
|
||||
|
||||
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution du Worker.
|
||||
|
||||
---
|
||||
|
||||
## En cas de problème
|
||||
|
||||
| Symptôme | Cause probable | Fix |
|
||||
|---|---|---|
|
||||
| `Resend 401: API key invalid` | Mauvaise valeur dans `RESEND_API_KEY` | Re-vérifier la clé sur https://resend.com/api-keys |
|
||||
| `Resend 422: domain is not verified` | Domaine de `RESEND_FROM_EMAIL` pas vérifié | Vérifier le domaine sur https://resend.com/domains |
|
||||
| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope `contacts.write` |
|
||||
| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token |
|
||||
| `KV not bound` | Binding `WELCOME_KV` pas créé | Vérifier *Storage & Databases → KV bindings* |
|
||||
| `Turnstile validation failed` | Secret côté Worker ne match pas la sitekey côté front | Re-vérifier `TURNSTILE_SECRET` et la sitekey HTML |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 1. User remplit le formulaire sur contact.html │
|
||||
│ ↓ │
|
||||
│ 2. form-handler.js → POST action: requestVerification │
|
||||
│ ↓ │
|
||||
│ 3. Worker valide Turnstile, génère un token, stocke les │
|
||||
│ données du formulaire en KV (TTL 24h), envoie l'email │
|
||||
│ de validation via Resend │
|
||||
│ ↓ │
|
||||
│ ─────── User clique sur « Confirmer » ─────── │
|
||||
│ ↓ │
|
||||
│ 4. confirmation.html → POST action: verifyToken │
|
||||
│ ↓ │
|
||||
│ 5. Worker récupère les données du KV, génère la prochaine │
|
||||
│ référence client, soumet via HubSpot Forms API (= contact │
|
||||
│ créé dans le CRM), envoie le welcome email via Resend │
|
||||
│ (avec la référence + adresse Paris), marque le token │
|
||||
│ consommé en KV (TTL 7j pour idempotence). │
|
||||
│ ↓ │
|
||||
│ 6. Le client reçoit son welcome email avec sa référence ET │
|
||||
│ l'adresse de dépôt à Paris. │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Conclusion** : il est impossible pour un bot de récupérer la référence client ou l'adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé.
|
||||
@ -1,581 +0,0 @@
|
||||
// ============================================================
|
||||
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend
|
||||
// ============================================================
|
||||
// Ce Worker gère le formulaire de contact via un flow double opt-in :
|
||||
//
|
||||
// 1) requestVerification : génère un token, stocke les données du formulaire en KV,
|
||||
// envoie un email de validation (lien de confirmation) via Resend.
|
||||
//
|
||||
// 2) verifyToken : appelé quand le client clique sur le lien de confirmation.
|
||||
// Crée le contact dans HubSpot (avec une référence générée à la volée),
|
||||
// puis envoie le welcome email avec la référence + l'adresse du dépôt Paris.
|
||||
// Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email.
|
||||
//
|
||||
// La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant
|
||||
// validation de l'email — protection anti-bot et anti-cartons-vides.
|
||||
//
|
||||
// ============================================================
|
||||
// DÉPLOIEMENT (Phase D du plan WordPress → static)
|
||||
// ============================================================
|
||||
//
|
||||
// Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète.
|
||||
//
|
||||
// Secrets requis (`wrangler secret put <name>`) :
|
||||
// • HUBSPOT_TOKEN = pat-eu1-... (read+write contacts)
|
||||
// • RESEND_API_KEY = re_... (compte Resend partagé avec m4s-auth)
|
||||
// • RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
|
||||
// • RESEND_FROM_NAME = nom affiché à l'expéditeur
|
||||
// • PARIS_DEPOT_ADDRESS = adresse complète du dépôt Paris
|
||||
// • TURNSTILE_SECRET = secret Cloudflare Turnstile (anti-bot)
|
||||
// • SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
|
||||
//
|
||||
// Bindings KV requis :
|
||||
// • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken)
|
||||
//
|
||||
// ============================================================
|
||||
|
||||
const HUBSPOT_API = 'https://api.hubapi.com';
|
||||
const HUBSPOT_PORTAL_ID = '148163754';
|
||||
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin' : '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
|
||||
export default {
|
||||
// -----------------------------------------------------------
|
||||
// 1) Handler navigateur (POST depuis le formulaire / page de confirmation)
|
||||
// -----------------------------------------------------------
|
||||
async fetch(request, env) {
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method Not Allowed', { status: 405 });
|
||||
}
|
||||
|
||||
const token = env.HUBSPOT_TOKEN;
|
||||
if (!token) {
|
||||
return jsonResponse({ error: 'HUBSPOT_TOKEN env var not set' }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, action } = body;
|
||||
|
||||
// ── action: nextRef ─────────────────────────────────────
|
||||
if (action === 'nextRef') {
|
||||
return jsonResponse({ nextRef: await getNextRef(token) });
|
||||
}
|
||||
|
||||
// ── action: requestVerification ──────────────────────────
|
||||
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
|
||||
// et envoie un email de validation via Resend. Le contact n'est créé
|
||||
// dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
|
||||
// les inscriptions non vérifiées ne polluent pas le CRM).
|
||||
// Anti-bot : Turnstile vérifié d'abord.
|
||||
if (action === 'requestVerification') {
|
||||
if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
|
||||
|
||||
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
|
||||
if (!turnstileOk) {
|
||||
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const verToken = crypto.randomUUID().replace(/-/g, '');
|
||||
const tokenData = {
|
||||
firstname : body.firstname || '',
|
||||
lastname : body.lastname || '',
|
||||
phone : body.phone || '',
|
||||
email : body.email.toLowerCase().trim(),
|
||||
address : body.address || '',
|
||||
createdAt : new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (!env.WELCOME_KV) {
|
||||
return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
|
||||
}
|
||||
// Token valide 24h
|
||||
await env.WELCOME_KV.put(`verify:${verToken}`, JSON.stringify(tokenData), {
|
||||
expirationTtl: 60 * 60 * 24,
|
||||
});
|
||||
|
||||
await sendVerificationEmail(env, tokenData, verToken);
|
||||
return jsonResponse({ ok: true });
|
||||
} catch (err) {
|
||||
return jsonResponse({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── action: verifyToken ──────────────────────────────────
|
||||
// Appelé par confirmation.html quand l'utilisateur clique sur
|
||||
// le lien dans l'email de validation. C'est ICI que le contact
|
||||
// est CRÉÉ dans HubSpot (avec une référence générée à la volée),
|
||||
// puis le welcome email est envoyé (ref + adresse Paris).
|
||||
// Idempotent : un 2ème clic ne re-crée pas de contact.
|
||||
if (action === 'verifyToken') {
|
||||
if (!body.token) return jsonResponse({ error: 'token requis' }, 400);
|
||||
if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500);
|
||||
|
||||
const key = `verify:${body.token}`;
|
||||
const raw = await env.WELCOME_KV.get(key);
|
||||
if (!raw) {
|
||||
return jsonResponse({ ok: false, error: 'Token invalide ou expiré' }, 404);
|
||||
}
|
||||
const tokenData = JSON.parse(raw);
|
||||
|
||||
// Idempotence : si déjà consommé, retourne le résultat précédent
|
||||
// sans recréer le contact ni renvoyer d'email.
|
||||
if (tokenData.used) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
firstname : tokenData.firstname,
|
||||
reference_client : tokenData.reference_client || '',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Récupère la ref existante si le contact est déjà dans HubSpot
|
||||
// (réinscription après suppression d'un test, ou création via
|
||||
// l'ancien flow Forms API). Sinon génère la prochaine ref.
|
||||
let refNumber;
|
||||
try {
|
||||
const existing = await searchContactByEmail(token, tokenData.email);
|
||||
const existingResult = (existing.results || [])[0];
|
||||
const existingRef = existingResult?.properties?.reference_client;
|
||||
refNumber = existingRef || await getNextRef(token);
|
||||
} catch (_) {
|
||||
// Si la search échoue (scope manquant, etc.), fallback : génère
|
||||
// une nouvelle ref. Le Forms API gérera la dédup côté HubSpot.
|
||||
refNumber = await getNextRef(token);
|
||||
}
|
||||
|
||||
// 2) Création directe via CRM API (= more deterministic que Forms API
|
||||
// qui peut accepter une submission sans réellement créer le contact
|
||||
// à cause des filtres anti-spam ou de la config du Form HubSpot).
|
||||
// Requires scope crm.objects.contacts.write.
|
||||
// En cas de 409 (contact déjà existant), fallback sur PATCH par ID
|
||||
// pour update les propriétés (= notamment reference_client).
|
||||
const crmRes = await fetch(
|
||||
`${HUBSPOT_API}/crm/v3/objects/contacts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type' : 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
firstname : tokenData.firstname || '',
|
||||
lastname : tokenData.lastname || '',
|
||||
phone : tokenData.phone || '',
|
||||
email : tokenData.email,
|
||||
address : tokenData.address || '',
|
||||
reference_client : refNumber,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (crmRes.status === 409) {
|
||||
// Contact existe déjà — update via PATCH par ID
|
||||
const search = await searchContactByEmail(token, tokenData.email);
|
||||
const existing = (search.results || [])[0];
|
||||
if (existing?.id) {
|
||||
const patchRes = await fetch(
|
||||
`${HUBSPOT_API}/crm/v3/objects/contacts/${existing.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type' : 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: {
|
||||
firstname : tokenData.firstname || existing.properties?.firstname || '',
|
||||
lastname : tokenData.lastname || existing.properties?.lastname || '',
|
||||
phone : tokenData.phone || existing.properties?.phone || '',
|
||||
address : tokenData.address || '',
|
||||
reference_client : refNumber,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!patchRes.ok) {
|
||||
const errTxt = await patchRes.text();
|
||||
throw new Error(`HubSpot CRM patch failed ${patchRes.status}: ${errTxt.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
} else if (!crmRes.ok) {
|
||||
const errTxt = await crmRes.text();
|
||||
throw new Error(`HubSpot CRM create failed ${crmRes.status}: ${errTxt.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// 3) Envoie le welcome email avec ref + adresse Paris
|
||||
const welcomeContact = { ...tokenData, reference_client: refNumber };
|
||||
await sendWelcomeViaResend(env, welcomeContact);
|
||||
|
||||
// 4) Marque le token consommé (gardé 7j pour idempotence)
|
||||
await env.WELCOME_KV.put(key, JSON.stringify({
|
||||
...tokenData,
|
||||
used : true,
|
||||
usedAt : new Date().toISOString(),
|
||||
reference_client : refNumber,
|
||||
}), {
|
||||
expirationTtl: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
firstname : tokenData.firstname,
|
||||
reference_client : refNumber,
|
||||
});
|
||||
} catch (err) {
|
||||
return jsonResponse({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ── action: 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 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' },
|
||||
});
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
name = "mva-hubspot-proxy"
|
||||
main = "hubspot-proxy.js"
|
||||
compatibility_date = "2026-04-01"
|
||||
workers_dev = true
|
||||
|
||||
# KV namespace — placeholder ID, populated at deploy time (Phase D3)
|
||||
[[kv_namespaces]]
|
||||
binding = "WELCOME_KV"
|
||||
id = "c02656ba22064923ab1c6db06b0f4a56"
|
||||
|
||||
# Required secrets to configure via `wrangler secret put` :
|
||||
# - HUBSPOT_TOKEN
|
||||
# - RESEND_API_KEY
|
||||
# - RESEND_FROM_EMAIL
|
||||
# - RESEND_FROM_NAME
|
||||
# - PARIS_DEPOT_ADDRESS
|
||||
# - TURNSTILE_SECRET
|
||||
# - SITE_URL
|
||||
# (BREVO_* and EMAILJS_* deprecated — removed in this version)
|
||||
@ -13,9 +13,6 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
|
||||
<!-- EmailJS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
|
||||
|
||||
<style>
|
||||
.confirmation-shell {
|
||||
min-height: 100vh;
|
||||
@ -175,7 +172,7 @@
|
||||
© 2026 MVA Global Fret. Tous droits réservés.
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/confirmation.js"></script>
|
||||
</body>
|
||||
|
||||
19
contact.html
19
contact.html
@ -25,7 +25,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -43,14 +43,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -59,8 +55,8 @@
|
||||
<!-- HERO -->
|
||||
<section class="hero hero-sub" style="background-image: url('images/hero/contact-hero.jpg');">
|
||||
<div class="hero-content animate-on-scroll">
|
||||
<h1 data-i18n="contact.heroTitle">Contactez-Nous</h1>
|
||||
<p data-i18n="contact.heroSubtitle">Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui</p>
|
||||
<h1 data-i18n="contact.heroTitle">Inscrivez-vous</h1>
|
||||
<p data-i18n="contact.heroSubtitle">Commencez à envoyer vos colis dès aujourd'hui</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -284,7 +280,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -310,8 +306,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<script src="js/form-handler.js"></script>
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -43,14 +43,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -77,7 +73,7 @@
|
||||
<div class="prohibited-item">
|
||||
<i class="fa-solid fa-bomb" aria-hidden="true"></i>
|
||||
<h4 data-i18n="guide.cat1Title">Explosifs</h4>
|
||||
<p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards</p>
|
||||
<p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards, armes à feu</p>
|
||||
</div>
|
||||
<div class="prohibited-item">
|
||||
<i class="fa-solid fa-fire-flame-curved" aria-hidden="true"></i>
|
||||
@ -102,7 +98,7 @@
|
||||
<div class="prohibited-item">
|
||||
<i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i>
|
||||
<h4 data-i18n="guide.cat6Title">Substances toxiques</h4>
|
||||
<p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses</p>
|
||||
<p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses, substances illégales</p>
|
||||
</div>
|
||||
<div class="prohibited-item">
|
||||
<i class="fa-solid fa-flask" aria-hidden="true"></i>
|
||||
@ -265,7 +261,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -291,7 +287,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script>
|
||||
/* i18n minimal ------------------------------------------------------- */
|
||||
(function () {
|
||||
|
||||
@ -1,23 +1,27 @@
|
||||
// ============================================================
|
||||
// MVA Global Fret — Page de confirmation post-validation email
|
||||
// ============================================================
|
||||
// Cette page est la cible du lien dans l'email de validation
|
||||
// (envoyé par Brevo après soumission du formulaire).
|
||||
// Cette page est la cible du lien dans l'email de validation envoyé
|
||||
// par mva-api (= Resend) lors d'une inscription via contact.html.
|
||||
//
|
||||
// URL : https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?token=XXX
|
||||
// URL : https://mva-globalfret.com/confirmation.html?token=XXX
|
||||
//
|
||||
// Étapes :
|
||||
// 1. Lire le token depuis l'URL
|
||||
// 2. POST au Worker avec action 'verifyToken'
|
||||
// 3. Worker valide le token, envoie le welcome email (avec ref +
|
||||
// adresse Paris) via Brevo, puis renvoie OK
|
||||
// 4. Page affiche "Inscription confirmée !"
|
||||
// 2. POST mva-api /leads/verify-token avec { token }
|
||||
// 3. mva-api INSERT le lead en DB, génère la ref MVA-NNN, envoie le
|
||||
// welcome email (= ref + adresse Paris) via Resend, puis renvoie
|
||||
// { ok: true, firstname, reference_client }
|
||||
// 4. Page affiche "Inscription confirmée !" + la ref
|
||||
//
|
||||
// Si le token est invalide / expiré : affichage d'un message d'erreur
|
||||
// avec invitation à contacter le support.
|
||||
// Si le token est invalide / expiré / déjà consommé : affichage d'un
|
||||
// message d'erreur avec invitation à contacter le support.
|
||||
//
|
||||
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
|
||||
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné).
|
||||
// ============================================================
|
||||
|
||||
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
|
||||
const API_BASE_URL = 'https://api.mva.mind4solutions.com';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const token = new URLSearchParams(window.location.search).get('token');
|
||||
@ -28,18 +32,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(WORKER_PROXY_URL, {
|
||||
const res = await fetch(`${API_BASE_URL}/leads/verify-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'verifyToken', token }),
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
showSuccess(data.reference_client || null);
|
||||
} else {
|
||||
// Token expiré, déjà utilisé, ou inconnu
|
||||
showError(data.error === 'Token invalide ou expiré'
|
||||
// Token invalide, expiré, ou inconnu
|
||||
const isInvalid = data.code === 'INVALID_OR_EXPIRED'
|
||||
|| data.message === 'Token invalide ou expiré';
|
||||
showError(isInvalid
|
||||
? 'Ce lien de confirmation a expiré ou a déjà été utilisé.'
|
||||
: 'Une erreur est survenue lors de la confirmation.');
|
||||
}
|
||||
|
||||
@ -1,78 +1,61 @@
|
||||
// ============================================
|
||||
// MVA Global Fret — Form Handler
|
||||
// HubSpot Portal ID : 148163754
|
||||
// HubSpot Form GUID : 1d9b75c9-8b60-4966-aa18-4bf503452e9a
|
||||
// ============================================
|
||||
// Frontend logic for contact.html (= inscription form):
|
||||
// - validate inputs + Cloudflare Turnstile token
|
||||
// - call mva-api /leads/* routes for dedup check + double opt-in flow
|
||||
// (= verification email + welcome / welcome-back emails via Resend)
|
||||
// - reset Turnstile widget after each API call (= tokens are
|
||||
// single-use server-side; without reset, a re-submit silently
|
||||
// 403s from Cloudflare's siteverify endpoint)
|
||||
//
|
||||
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
|
||||
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné) par
|
||||
// les routes mva-api Fastify. La DB Postgres remplace HubSpot Contacts.
|
||||
// ============================================
|
||||
|
||||
const HUBSPOT_PORTAL_ID = '148163754';
|
||||
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
|
||||
const FORMSPREE_ID = 'mojrvokp';
|
||||
|
||||
// ── 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';
|
||||
// ── MVA API BASE URL ─────────────────────────────────────────────
|
||||
// Routes leads servies par mva-api derrière Caddy. CORS strict :
|
||||
// le serveur whitelist explicitement https://mva-globalfret.com.
|
||||
const API_BASE_URL = 'https://api.mva.mind4solutions.com';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('contactForm');
|
||||
if (form) setupContactForm(form);
|
||||
});
|
||||
|
||||
// Génération séquentielle via le Worker HubSpot : MVA-001, MVA-002, etc.
|
||||
// Fallback sur un timestamp court si le Worker est indisponible.
|
||||
async function generateRefNumber() {
|
||||
if (WORKER_PROXY_URL) {
|
||||
try {
|
||||
const res = await fetch(WORKER_PROXY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'nextRef' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.nextRef) return data.nextRef;
|
||||
// ── TURNSTILE TOKEN MANAGEMENT ───────────────────────────────────
|
||||
// Reset the Turnstile widget + global token after each Worker call.
|
||||
// Cloudflare Turnstile tokens are single-use server-side: a token
|
||||
// already submitted to siteverify cannot be re-used. Without an
|
||||
// explicit reset, a re-submit (= same form, same widget) would send
|
||||
// the now-consumed token and Cloudflare would 403 silently.
|
||||
function resetTurnstile() {
|
||||
window.turnstileToken = null;
|
||||
if (window.turnstile && typeof window.turnstile.reset === 'function') {
|
||||
try { window.turnstile.reset(); } catch (_) { /* widget absent */ }
|
||||
}
|
||||
} catch { /* fallback ci-dessous */ }
|
||||
}
|
||||
// Fallback : numéro aléatoire court pour éviter les doublons en cas d'indisponibilité
|
||||
const rand = String(Math.floor(Math.random() * 900) + 100);
|
||||
return `MVA-F${rand}`;
|
||||
}
|
||||
|
||||
// Vérifie si l'email existe déjà dans HubSpot via le proxy Cloudflare Worker.
|
||||
// Retourne les propriétés du contact existant, ou null si nouveau client / proxy non configuré.
|
||||
// Vérifie si l'email existe déjà dans la table leads via mva-api.
|
||||
// Retourne les propriétés du lead existant, ou null si nouveau
|
||||
// client / API indisponible.
|
||||
async function checkExistingContact(email) {
|
||||
// Si le proxy n'est pas encore déployé, on laisse passer sans bloquer
|
||||
if (!WORKER_PROXY_URL) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(WORKER_PROXY_URL, {
|
||||
const res = await fetch(`${API_BASE_URL}/leads/check-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.toLowerCase().trim() }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.total > 0 ? data.results[0].properties : null;
|
||||
if (!data.exists) return null;
|
||||
// Forme attendue par showAlreadyRegistered : { firstname, reference_client }
|
||||
return {
|
||||
firstname: data.firstname || '',
|
||||
reference_client: data.reference_client || '',
|
||||
};
|
||||
} catch {
|
||||
// Erreur réseau ou Worker indisponible : on laisse passer
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -82,7 +65,7 @@ function setupContactForm(form) {
|
||||
e.preventDefault();
|
||||
if (!validateForm(form)) return;
|
||||
|
||||
// ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ────────────────────────────
|
||||
// ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ────────────────
|
||||
if (!window.turnstileToken) {
|
||||
const errEl = document.getElementById('formErrorGlobal');
|
||||
if (errEl) {
|
||||
@ -91,23 +74,20 @@ function setupContactForm(form) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const email = form.email.value.trim();
|
||||
|
||||
// ── VÉRIFICATION DOUBLON ──────────────────────────────────────────────────
|
||||
// Vérifie HubSpot. Comme les contacts ne sont créés QU'APRÈS confirmation
|
||||
// email, ce check ne retourne que les vrais clients déjà inscrits (pas
|
||||
// les inscriptions en attente de confirmation).
|
||||
// ── VÉRIFICATION DOUBLON ─────────────────────────────────────
|
||||
// Comme les contacts ne sont créés QU'APRÈS confirmation email,
|
||||
// ce check ne retourne que les vrais clients déjà inscrits.
|
||||
const existing = await checkExistingContact(email);
|
||||
if (existing) {
|
||||
setLoading(false);
|
||||
showAlreadyRegistered(existing);
|
||||
return;
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const data = {
|
||||
firstname: form.firstname.value.trim(),
|
||||
@ -117,17 +97,17 @@ function setupContactForm(form) {
|
||||
address: form.address.value.trim(),
|
||||
};
|
||||
|
||||
// ── ENVOI VERS LE WORKER ──────────────────────────────────────────────────
|
||||
// Le Worker stocke les données en KV (24h), envoie un email de validation
|
||||
// via Brevo. Le contact n'est créé dans HubSpot QUE quand l'utilisateur
|
||||
// clique sur le lien de confirmation (anti-pollution du CRM).
|
||||
// ── ENVOI VERS MVA-API ────────────────────────────────────────
|
||||
// L'API stocke les données en `leads_pending` (24h TTL) et envoie un
|
||||
// email de validation via Resend. Le lead n'est INSERT en `leads` QUE
|
||||
// quand l'utilisateur clique sur le lien de confirmation
|
||||
// (anti-pollution DB + anti-bot complémentaire à Turnstile).
|
||||
let ok = false;
|
||||
try {
|
||||
const res = await fetch(WORKER_PROXY_URL, {
|
||||
const res = await fetch(`${API_BASE_URL}/leads/request-verification`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'requestVerification',
|
||||
...data,
|
||||
turnstile_token: window.turnstileToken || '',
|
||||
}),
|
||||
@ -138,6 +118,8 @@ function setupContactForm(form) {
|
||||
console.warn('[requestVerification]', err);
|
||||
}
|
||||
|
||||
// Reset Turnstile after the Worker call (= regardless of result)
|
||||
resetTurnstile();
|
||||
setLoading(false);
|
||||
|
||||
if (ok) {
|
||||
@ -148,73 +130,7 @@ function setupContactForm(form) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────────────────
|
||||
// ── VALIDATION ───────────────────────────────────────────────────
|
||||
function validateForm(form) {
|
||||
let valid = true;
|
||||
const lang = localStorage.getItem('mva-lang') || 'fr';
|
||||
@ -263,7 +179,7 @@ function isValidPhone(phone) {
|
||||
return /^[+\d][\d\s\-().]{6,20}$/.test(phone);
|
||||
}
|
||||
|
||||
// ── AFFICHAGE ─────────────────────────────────────────────────────────────────
|
||||
// ── AFFICHAGE ────────────────────────────────────────────────────
|
||||
function showFieldError(name, msg) {
|
||||
const el = document.getElementById(`error-${name}`);
|
||||
const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
|
||||
@ -296,9 +212,6 @@ function setLoading(isLoading) {
|
||||
}
|
||||
|
||||
function showSuccess(_refNumber, _clientData) {
|
||||
// L'envoi de l'email de validation est déjà fait dans setupContactForm
|
||||
// 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) {
|
||||
@ -308,25 +221,32 @@ function showSuccess(_refNumber, _clientData) {
|
||||
if (form) form.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ──────────────────────────
|
||||
// Rappelle au client son numéro de référence existant — n'écrit RIEN dans HubSpot.
|
||||
// ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ───────────
|
||||
// Rappelle au client son numéro de référence existant — zéro write DB.
|
||||
// Passe par mva-api /leads/welcome-back qui délègue à Resend.
|
||||
// Anti-bot via Turnstile : transmet le token déjà validé au moment du
|
||||
// submit du formulaire.
|
||||
async function sendWelcomeBackEmail(contact) {
|
||||
if (typeof emailjs === 'undefined') return;
|
||||
if (!contact || !contact.email) return;
|
||||
if (!window.turnstileToken) return;
|
||||
try {
|
||||
await emailjs.send(EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_WELCOME_BACK, {
|
||||
firstname: contact.firstname || '',
|
||||
await fetch(`${API_BASE_URL}/leads/welcome-back`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email : contact.email,
|
||||
reference_client: contact.reference_client || '',
|
||||
turnstile_token : window.turnstileToken,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
// Si le template n'existe pas encore ou erreur réseau : on n'interrompt rien
|
||||
console.warn('EmailJS welcome-back email failed:', err);
|
||||
// Erreur réseau : on n'interrompt pas l'UX (le client voit
|
||||
// déjà sa référence dans le UI).
|
||||
console.warn('welcome-back failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Affiche le message "déjà client" — ne modifie AUCUNE donnée HubSpot
|
||||
function showAlreadyRegistered(contact) {
|
||||
async function showAlreadyRegistered(contact) {
|
||||
const lang = localStorage.getItem('mva-lang') || 'fr';
|
||||
const t = translations?.[lang]?.contact || {};
|
||||
|
||||
@ -348,11 +268,11 @@ function showAlreadyRegistered(contact) {
|
||||
|
||||
if (form) form.style.display = 'none';
|
||||
|
||||
// Envoi d'une notification interne à MVA (sans modifier les données du client)
|
||||
notifyDuplicateViaFormspree(contact);
|
||||
|
||||
// Envoi d'un email "Ravis de te revoir" au client avec son n° de référence
|
||||
sendWelcomeBackEmail(contact);
|
||||
// Email "Ravis de vous revoir" via Worker + Resend (= footer 2026
|
||||
// cohérent avec les autres emails transactionnels).
|
||||
await sendWelcomeBackEmail(contact);
|
||||
// Reset Turnstile after the Worker call (= prevent reuse).
|
||||
resetTurnstile();
|
||||
}
|
||||
|
||||
function showError() {
|
||||
|
||||
@ -118,7 +118,12 @@ function setupAnimations() {
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
|
||||
// threshold: 0 (= fire dès qu'1px du bloc entre dans le viewport).
|
||||
// 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));
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ const translations = {
|
||||
pricing: "Tarifs",
|
||||
serviceCommande: "Service Commande",
|
||||
guide: "Guide d'envoi",
|
||||
contact: "Contact",
|
||||
contact: "Inscription",
|
||||
app: "Prochainement"
|
||||
},
|
||||
home: {
|
||||
@ -134,8 +134,8 @@ const translations = {
|
||||
delivery2Note: "Retrait au bureau Cotisse de votre ville"
|
||||
},
|
||||
contact: {
|
||||
heroTitle: "Contactez-Nous",
|
||||
heroSubtitle: "Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui",
|
||||
heroTitle: "Inscrivez-vous",
|
||||
heroSubtitle: "Commencez à envoyer vos colis dès aujourd'hui",
|
||||
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.",
|
||||
labelNom: "Nom",
|
||||
@ -189,7 +189,7 @@ const translations = {
|
||||
prohibitedTitle: "Articles Interdits",
|
||||
prohibitedSubtitle: "Ces articles ne peuvent pas être transportés par fret aérien (réglementation IATA)",
|
||||
cat1Title: "Explosifs",
|
||||
cat1Desc: "Dynamite, munitions, feux d'artifice, pétards",
|
||||
cat1Desc: "Dynamite, munitions, feux d'artifice, pétards, armes à feu",
|
||||
cat2Title: "Gaz comprimés",
|
||||
cat2Desc: "Bouteilles de gaz, aérosols pressurisés, butane, propane",
|
||||
cat3Title: "Liquides inflammables",
|
||||
@ -199,7 +199,7 @@ const translations = {
|
||||
cat5Title: "Batteries lithium",
|
||||
cat5Desc: "Piles lithium seules, hoverboards, certains appareils",
|
||||
cat6Title: "Substances toxiques",
|
||||
cat6Desc: "Poisons, pesticides, substances infectieuses",
|
||||
cat6Desc: "Poisons, pesticides, substances infectieuses, substances illégales",
|
||||
cat7Title: "Matières corrosives",
|
||||
cat7Desc: "Acides, mercure, soude caustique",
|
||||
cat8Title: "Matières radioactives",
|
||||
@ -383,7 +383,7 @@ const translations = {
|
||||
pricing: "Pricing",
|
||||
serviceCommande: "Order Service",
|
||||
guide: "Shipping Guide",
|
||||
contact: "Contact",
|
||||
contact: "Sign Up",
|
||||
app: "Coming Soon"
|
||||
},
|
||||
home: {
|
||||
@ -496,8 +496,8 @@ const translations = {
|
||||
ctaBtn: "Contact Us"
|
||||
},
|
||||
contact: {
|
||||
heroTitle: "Contact Us",
|
||||
heroSubtitle: "Register and start sending your parcels today",
|
||||
heroTitle: "Sign Up",
|
||||
heroSubtitle: "Start sending your parcels today",
|
||||
formTitle: "Registration Form",
|
||||
formSubtitle: "Fill out this form to receive your client reference number and the Paris drop-off address.",
|
||||
labelNom: "Last Name",
|
||||
@ -551,7 +551,7 @@ const translations = {
|
||||
prohibitedTitle: "Prohibited Items",
|
||||
prohibitedSubtitle: "These items cannot be transported by air freight (IATA regulations)",
|
||||
cat1Title: "Explosives",
|
||||
cat1Desc: "Dynamite, ammunition, fireworks, firecrackers",
|
||||
cat1Desc: "Dynamite, ammunition, fireworks, firecrackers, firearms",
|
||||
cat2Title: "Compressed Gases",
|
||||
cat2Desc: "Gas cylinders, pressurized aerosols, butane, propane",
|
||||
cat3Title: "Flammable Liquids",
|
||||
@ -561,7 +561,7 @@ const translations = {
|
||||
cat5Title: "Lithium Batteries",
|
||||
cat5Desc: "Loose lithium batteries, hoverboards, certain devices",
|
||||
cat6Title: "Toxic Substances",
|
||||
cat6Desc: "Poisons, pesticides, infectious substances",
|
||||
cat6Desc: "Poisons, pesticides, infectious substances, illegal substances",
|
||||
cat7Title: "Corrosive Materials",
|
||||
cat7Desc: "Acids, mercury, caustic soda",
|
||||
cat8Title: "Radioactive Materials",
|
||||
@ -745,7 +745,7 @@ const translations = {
|
||||
pricing: "Sarany",
|
||||
serviceCommande: "Tolotra Fividianana",
|
||||
guide: "Toromarika fandefasana",
|
||||
contact: "Fifandraisana",
|
||||
contact: "Fisoratana anarana",
|
||||
app: "Avy tsy ho ela"
|
||||
},
|
||||
home: {
|
||||
@ -858,8 +858,8 @@ const translations = {
|
||||
ctaBtn: "Mifandraisa aminay"
|
||||
},
|
||||
contact: {
|
||||
heroTitle: "Mifandraisa Aminay",
|
||||
heroSubtitle: "Misoratra anarana ary manomboha mandefa ny entanareo anio",
|
||||
heroTitle: "Misoratra anarana",
|
||||
heroSubtitle: "Manomboha mandefa ny entanareo anio",
|
||||
formTitle: "Taratasy fisoratana anarana",
|
||||
formSubtitle: "Fenoy ity taratasy ity mba handraisana ny laharan'ny mpanjifa sy ny adiresy fametrahana any Paris.",
|
||||
labelNom: "Anarana",
|
||||
@ -913,7 +913,7 @@ const translations = {
|
||||
prohibitedTitle: "Entana Voarara",
|
||||
prohibitedSubtitle: "Ireto entana ireto dia tsy azo alefa amin'ny fandefasana entana an'habakabaka (fitsipika IATA)",
|
||||
cat1Title: "Zavatra mipoaka",
|
||||
cat1Desc: "Dynamita, bala, afo artifisialy, petarada",
|
||||
cat1Desc: "Dynamita, bala, afo artifisialy, petarada, basy",
|
||||
cat2Title: "Gazy voatery",
|
||||
cat2Desc: "Tavoahangy gazy, aérosol misy tsindry, butane, propane",
|
||||
cat3Title: "Ranoka mampirehitra",
|
||||
@ -923,7 +923,7 @@ const translations = {
|
||||
cat5Title: "Bateria lithium",
|
||||
cat5Desc: "Bateria lithium irery, hoverboards, fitaovana sasany",
|
||||
cat6Title: "Zava-mahapoizina",
|
||||
cat6Desc: "Poizina, fanafody bibikely, zava-mifindra",
|
||||
cat6Desc: "Poizina, fanafody bibikely, zava-mifindra, zavatra tsy ara-dalàna",
|
||||
cat7Title: "Zava-mandevona",
|
||||
cat7Desc: "Asida, mercure, soude caustique",
|
||||
cat8Title: "Zava-misy radioactivité",
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -67,13 +67,8 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -278,7 +273,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -305,7 +300,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -76,13 +76,8 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -449,7 +444,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -476,7 +471,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -4,30 +4,78 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Redirection — MVA Global Fret</title>
|
||||
<title>Réinitialisation du mot de passe — MVA Global Fret</title>
|
||||
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
|
||||
<script>
|
||||
// Bridge mobile deep link MVA Expo : redirect vers auth.mind4solutions.com
|
||||
// qui héberge le UI reset-password Phase 2.2 m4s-auth.
|
||||
// Conserve le query param ?token=... pour que GoTrue PKCE flow continue.
|
||||
// Bridge mobile deep link MVA : redirect vers le custom scheme natif
|
||||
// mvaglobalfret://reset-password pour ouvrir le flow in-app de l'app Expo.
|
||||
// Le lien email porte token_hash + type (recovery) ; l'app fait verifyOtp.
|
||||
(function() {
|
||||
var params = window.location.search || '';
|
||||
var hash = window.location.hash || '';
|
||||
window.location.replace('https://auth.mind4solutions.com/reset-password' + params + hash);
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var tokenHash = params.get('token_hash');
|
||||
var type = params.get('type') || 'recovery';
|
||||
if (tokenHash) {
|
||||
window.location.replace(
|
||||
'mvaglobalfret://reset-password?token_hash=' +
|
||||
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type)
|
||||
);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 2rem; color: #333; }
|
||||
a { color: #c5a55a; font-weight: 600; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #333;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { color: #2d3748; font-size: 1.5rem; }
|
||||
p { line-height: 1.6; }
|
||||
a.cta {
|
||||
display: inline-block;
|
||||
background: #c5a55a;
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
a.cta:hover { background: #b3954a; }
|
||||
.hint { color: #666; font-size: 0.9rem; margin-top: 2rem; }
|
||||
.error { color: #b91c1c; font-size: 0.95rem; line-height: 1.6; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Redirection en cours...</h1>
|
||||
<p>Si la redirection automatique ne fonctionne pas,
|
||||
<a id="manual-link" href="https://auth.mind4solutions.com/reset-password">cliquez ici</a>.
|
||||
<h1>Ouvrir l'app MVA Global Fret</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 id="link-wrap">
|
||||
<a id="manual-link" class="cta" href="#">Réinitialiser mon mot de passe</a>
|
||||
</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>
|
||||
document.getElementById('manual-link').href = 'https://auth.mind4solutions.com/reset-password' + (window.location.search || '');
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var tokenHash = params.get('token_hash');
|
||||
var type = params.get('type') || 'recovery';
|
||||
|
||||
if (!tokenHash) {
|
||||
// Lien invalide : pas de token_hash → on masque le bouton et on prévient.
|
||||
document.getElementById('intro').style.display = 'none';
|
||||
document.getElementById('link-wrap').style.display = 'none';
|
||||
document.getElementById('hint').style.display = 'none';
|
||||
document.getElementById('error').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep link vers l'app : l'app lit token_hash + type et fait verifyOtp.
|
||||
document.getElementById('manual-link').href =
|
||||
'mvaglobalfret://reset-password?token_hash=' +
|
||||
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -45,13 +45,8 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -247,7 +242,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -273,7 +268,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
286
setup-password.html
Normal file
286
setup-password.html
Normal file
@ -0,0 +1,286 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Créez votre compte — MVA Global Fret</title>
|
||||
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #1a1a3e 0%, #2d2d5e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
padding: 2.5rem 2rem;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.header .brand {
|
||||
color: #c5a55a;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: #7a7a8a;
|
||||
font-size: 13px;
|
||||
}
|
||||
h1 {
|
||||
color: #1a1a3e;
|
||||
font-size: 1.5rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-badge {
|
||||
background: #f0ead8;
|
||||
border-left: 4px solid #c5a55a;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0 1.5rem;
|
||||
font-size: 14px;
|
||||
color: #1a1a3e;
|
||||
}
|
||||
.ref-badge strong { letter-spacing: 1px; }
|
||||
label {
|
||||
display: block;
|
||||
color: #1a1a3e;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.4rem;
|
||||
}
|
||||
input[type="email"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
}
|
||||
input[disabled] { background: #f7f7f9; color: #6b6b75; cursor: not-allowed; }
|
||||
input:focus { outline: none; border-color: #c5a55a; box-shadow: 0 0 0 3px rgba(197, 165, 90, 0.18); }
|
||||
.hint {
|
||||
color: #7a7a8a;
|
||||
font-size: 12px;
|
||||
margin-top: 0.35rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.85rem;
|
||||
background: #c5a55a;
|
||||
color: #1a1a3e;
|
||||
border: 0;
|
||||
border-radius: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #b3954a; }
|
||||
button:disabled { opacity: 0.6; cursor: wait; }
|
||||
.alert {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
margin: 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.alert-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
|
||||
.alert-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
|
||||
.footer {
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.footer a { color: #c5a55a; text-decoration: none; }
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #7a7a8a;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="brand">MVA GLOBAL FRET</div>
|
||||
<div class="subtitle">Fret Aérien Paris — Antananarivo</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Chargement du lien…</div>
|
||||
|
||||
<div id="error-state" class="hidden">
|
||||
<h1>Lien invalide</h1>
|
||||
<div id="error-message" class="alert alert-error"></div>
|
||||
<p class="footer">
|
||||
Si le problème persiste, contactez-nous via <a href="contact.html">la page contact</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="success-state" class="hidden">
|
||||
<h1>Compte créé ✓</h1>
|
||||
<div class="alert alert-success">
|
||||
Votre compte MVA Global Fret a été créé avec succès.
|
||||
Téléchargez l'application sur votre téléphone pour commander :
|
||||
</div>
|
||||
<p style="text-align:center;margin:1rem 0;">
|
||||
<a href="application.html" style="color:#c5a55a;font-weight:600;text-decoration:none;">
|
||||
📱 Télécharger l'application
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="setup-form" class="hidden" novalidate>
|
||||
<h1>Créez votre compte</h1>
|
||||
<p id="welcome-text" style="color:#1a1a3e;text-align:center;font-size:14px;margin-bottom:0;"></p>
|
||||
<div id="ref-display" class="ref-badge hidden">
|
||||
Référence client : <strong id="ref-value"></strong>
|
||||
</div>
|
||||
|
||||
<label for="email-display">Email</label>
|
||||
<input id="email-display" type="email" disabled>
|
||||
|
||||
<label for="password">Mot de passe</label>
|
||||
<input id="password" type="password" autocomplete="new-password" required minlength="8" maxlength="72">
|
||||
<div class="hint">Minimum 8 caractères, avec au moins une majuscule et un chiffre.</div>
|
||||
|
||||
<label for="password-confirm">Confirmer le mot de passe</label>
|
||||
<input id="password-confirm" type="password" autocomplete="new-password" required>
|
||||
|
||||
<div id="form-error" class="alert alert-error hidden"></div>
|
||||
|
||||
<button type="submit" id="submit-btn">Créer mon compte</button>
|
||||
|
||||
<p class="footer">
|
||||
En créant votre compte, vous acceptez nos <a href="cgv.html">CGV</a>
|
||||
et notre <a href="politique-confidentialite.html">politique de confidentialité</a>.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const API_BASE = "https://api.mva.mind4solutions.com";
|
||||
const token = new URLSearchParams(window.location.search).get('token');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const show = (id) => $(id).classList.remove('hidden');
|
||||
const hide = (id) => $(id).classList.add('hidden');
|
||||
|
||||
function showError(message) {
|
||||
hide('loading');
|
||||
hide('setup-form');
|
||||
$('error-message').textContent = message;
|
||||
show('error-state');
|
||||
}
|
||||
|
||||
function showFormError(message) {
|
||||
$('form-error').textContent = message;
|
||||
show('form-error');
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!token || token.length !== 96 || !/^[a-f0-9]+$/i.test(token)) {
|
||||
showError("Lien invalide ou incomplet.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE + "/auth/lookup-setup-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showError(data.message || "Lien invalide.");
|
||||
return;
|
||||
}
|
||||
|
||||
$('email-display').value = data.email || '';
|
||||
$('welcome-text').textContent = data.firstname
|
||||
? `Bonjour ${data.firstname}, choisissez votre mot de passe pour activer votre compte.`
|
||||
: "Choisissez votre mot de passe pour activer votre compte.";
|
||||
if (data.reference_client) {
|
||||
$('ref-value').textContent = data.reference_client;
|
||||
show('ref-display');
|
||||
}
|
||||
hide('loading');
|
||||
show('setup-form');
|
||||
} catch (err) {
|
||||
showError("Impossible de joindre le serveur. Vérifiez votre connexion.");
|
||||
}
|
||||
}
|
||||
|
||||
$('setup-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hide('form-error');
|
||||
|
||||
const password = $('password').value;
|
||||
const confirm = $('password-confirm').value;
|
||||
|
||||
if (password.length < 8) {
|
||||
showFormError("Le mot de passe doit contenir au moins 8 caractères.");
|
||||
return;
|
||||
}
|
||||
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
|
||||
showFormError("Le mot de passe doit contenir au moins une majuscule et un chiffre.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
showFormError("Les deux mots de passe ne correspondent pas.");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = $('submit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Création en cours…";
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE + "/auth/setup-from-lead", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Créer mon compte";
|
||||
showFormError(data.message || "Erreur lors de la création du compte.");
|
||||
return;
|
||||
}
|
||||
|
||||
hide('setup-form');
|
||||
show('success-state');
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Créer mon compte";
|
||||
showFormError("Impossible de joindre le serveur. Réessayez dans un instant.");
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
tarifs.html
14
tarifs.html
@ -25,7 +25,7 @@
|
||||
<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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
@ -43,14 +43,10 @@
|
||||
<a href="accueil.html" data-i18n="nav.home">Accueil</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="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="contact.html" data-i18n="nav.contact">Contact</a>
|
||||
<a href="contact.html" data-i18n="nav.contact">Inscription</a>
|
||||
<a href="application.html" data-i18n="nav.app">Prochainement</a>
|
||||
<div class="lang-switcher" style="margin-top:16px">
|
||||
<button data-lang="fr" class="active">FR</button>
|
||||
<button data-lang="en">EN</button>
|
||||
<button data-lang="mg">MG</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="overlay" id="overlay"></div>
|
||||
|
||||
@ -262,7 +258,7 @@
|
||||
<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="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
|
||||
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@ -288,7 +284,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/translations.js"></script>
|
||||
<script src="js/translations.js?v=20260603"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user