Portail self-service abonnement (staging /signup): stepper épuré Forfait→Coordonnées→Récap→Confirmation, catalogue réel, capture Lead

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-03 18:16:19 -04:00
parent 6ff4a324ca
commit 7267a27cda
2 changed files with 131 additions and 0 deletions

View File

@ -0,0 +1,129 @@
'use strict'
/**
* signup.js portail self-service d'abonnement (STAGING, servi par le hub).
* Parcours épuré type e-commerce : Forfait Coordonnées Récap Confirmation.
* Réutilise /api/catalog (existant). PAS publié sur Lovable tant que non validé.
*
* Routes publiques :
* GET /signup page (stepper)
* POST /signup/submit crée un Lead ERPNext + retourne la confirmation
*/
const { json, parseBody, erpFetch } = require('./helpers')
async function createLead (body) {
const name = [body.first_name, body.last_name].filter(Boolean).join(' ').trim() || 'Client web'
const payload = {
lead_name: name,
email_id: body.email || '',
mobile_no: body.phone || '',
status: 'Lead',
// résumé de la demande dans le titre de la requête pour le suivi staff
request_type: 'Product Enquiry',
}
try {
const r = await erpFetch('/api/resource/Lead', { method: 'POST', body: JSON.stringify(payload) })
if (r.status === 200 && r.data?.data) return r.data.data.name
} catch (e) { /* best-effort */ }
return null
}
const PAGE = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Abonnement Gigafibre</title>
<style>
:root{--g:#019547;--b:#1565c0}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f6f8;color:#1a1a1a;line-height:1.5}
.wrap{max-width:600px;margin:0 auto;padding:18px}
.steps{display:flex;gap:6px;margin-bottom:18px}
.steps .s{flex:1;height:4px;border-radius:2px;background:#dfe3e8}
.steps .s.on{background:var(--g)}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 4px rgba(0,0,0,.07);padding:22px;margin-bottom:14px}
h1{font-size:21px;margin-bottom:2px}h1 .br{color:var(--g);font-weight:800}
.sub{color:#6b7280;font-size:13px;margin-bottom:18px}
.plan{border:2px solid #e3e7eb;border-radius:12px;padding:14px 16px;margin-bottom:10px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:.12s}
.plan:hover{border-color:var(--g)}
.plan.sel{border-color:var(--g);background:#f3fbf6}
.plan .n{font-weight:700;font-size:15px}.plan .d{color:#6b7280;font-size:12px}
.plan .p{font-weight:800;color:var(--g);font-size:18px;white-space:nowrap}.plan .p small{font-weight:500;color:#9ca3af;font-size:11px}
label.f{display:block;margin-bottom:10px}
label.f span{display:block;font-size:12px;color:#6b7280;margin-bottom:3px}
input{width:100%;padding:11px 12px;border:1px solid #d0d7de;border-radius:9px;font-size:15px}
input:focus{outline:none;border-color:var(--g)}
.row2{display:flex;gap:10px}.row2 label.f{flex:1}
.recap .l{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
.recap .l:last-child{border:none}.recap .l .v{font-weight:600}
.incl{background:#f3fbf6;border:1px solid #bde5cb;border-radius:10px;padding:11px 13px;margin:12px 0;font-size:13px;color:#00733a}
.btn{display:block;width:100%;padding:15px;background:var(--g);color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;margin-top:8px}
.btn:disabled{background:#b0bec5}
.btn.ghost{background:#fff;color:#6b7280;border:1px solid #d0d7de;margin-top:8px}
.ok{text-align:center;padding:30px 10px}.ok .c{width:60px;height:60px;border-radius:50%;background:var(--g);color:#fff;display:flex;align-items:center;justify-content:center;margin:0 auto 14px;font-size:30px}
.foot{text-align:center;color:#9ca3af;font-size:12px;margin-top:6px}
</style></head><body><div class=wrap>
<div class=steps><div class="s on" id=s1></div><div class=s id=s2></div><div class=s id=s3></div></div>
<div class=card id=app><div class=sub>Chargement</div></div>
<div class=foot>Gigafibre · propulsé par Targo</div>
</div>
<script>
const FR={};let CAT=[],sel=null,form={first_name:'',last_name:'',email:'',phone:'',address:''},step=1;
const app=()=>document.getElementById('app');
function setStep(n){step=n;document.getElementById('s1').className='s on';document.getElementById('s2').className='s'+(n>=2?' on':'');document.getElementById('s3').className='s'+(n>=3?' on':'');render()}
async function load(){
try{const j=await fetch('/api/catalog').then(r=>r.json());const items=Array.isArray(j)?j:(j.items||j.catalog||[]);
CAT=items.filter(i=>(i.rate||i.standard_rate||0)>=50).map(i=>({code:i.item_code,name:i.item_name,rate:i.rate||i.standard_rate||0,desc:i.description||i.service_category||i.item_group||''})).sort((a,b)=>a.rate-b.rate);
}catch(e){CAT=[]}
render();
}
function render(){
if(step===1){
app().innerHTML='<h1>Choisissez votre <span class=br>forfait</span></h1><div class=sub>Internet fibre — sans surprise, installation incluse.</div>'+
(CAT.length?CAT.map((p,i)=>'<div class="plan'+(sel===i?' sel':'')+'" data-i="'+i+'"><div><div class=n>'+p.name+'</div>'+(p.desc?'<div class=d>'+p.desc+'</div>':'')+'</div><div class=p>'+p.rate+' $<small>/mois</small></div></div>').join(''):'<div class=sub>Catalogue indisponible.</div>')+
'<button class=btn id=next disabled>Continuer</button>';
app().querySelectorAll('.plan').forEach(el=>el.onclick=()=>{sel=+el.dataset.i;render();});
if(sel!==null)document.getElementById('next').disabled=false;
document.getElementById('next').onclick=()=>setStep(2);
} else if(step===2){
app().innerHTML='<h1>Vos <span class=br>coordonnées</span></h1><div class=sub>Pour préparer votre entente et planifier l\\'installation.</div>'+
'<div class=row2><label class=f><span>Prénom</span><input id=fn value="'+form.first_name+'"></label><label class=f><span>Nom</span><input id=ln value="'+form.last_name+'"></label></div>'+
'<label class=f><span>Courriel</span><input id=em type=email value="'+form.email+'"></label>'+
'<label class=f><span>Téléphone</span><input id=ph type=tel value="'+form.phone+'"></label>'+
'<label class=f><span>Adresse d\\'installation</span><input id=ad value="'+form.address+'"></label>'+
'<button class=btn id=next>Continuer</button><button class="btn ghost" id=back>Retour</button>';
document.getElementById('back').onclick=()=>setStep(1);
document.getElementById('next').onclick=()=>{
form={first_name:fn.value.trim(),last_name:ln.value.trim(),email:em.value.trim(),phone:ph.value.trim(),address:ad.value.trim()};
if(!form.first_name||!form.phone){alert('Prénom et téléphone requis');return;}
setStep(3);
};
} else if(step===3){
const p=CAT[sel];
app().innerHTML='<h1><span class=br>Récapitulatif</span></h1><div class=sub>Tout est clair — confirmez votre demande.</div>'+
'<div class=recap><div class=l><span>'+p.name+'</span><span class=v>'+p.rate+' $/mois</span></div>'+
'<div class=l><span>Installation</span><span class=v style=color:#019547>Incluse</span></div>'+
'<div class=l><span>Client</span><span class=v>'+form.first_name+' '+form.last_name+'</span></div>'+
'<div class=l><span>Adresse</span><span class=v>'+(form.address||'—')+'</span></div></div>'+
'<div class=incl>✓ <b>Installation offerte</b> tant que vous demeurez client. Aucune pénalité de résiliation — détails dans votre entente.</div>'+
'<button class=btn id=go>Confirmer ma demande</button><button class="btn ghost" id=back>Retour</button>';
document.getElementById('back').onclick=()=>setStep(2);
document.getElementById('go').onclick=submit;
}
}
async function submit(){
const b=document.getElementById('go');b.disabled=true;b.textContent='Envoi…';
const p=CAT[sel];
const r=await fetch('/signup/submit',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...form,plan_code:p.code,plan_name:p.name,plan_rate:p.rate})}).then(x=>x.json()).catch(()=>({ok:false}));
if(r.ok)app().innerHTML='<div class=ok><div class=c>✓</div><h1>Merci '+form.first_name+' !</h1><div class=sub style=margin-top:8px>Votre demande pour <b>'+p.name+'</b> est reçue. Nous vous envoyons votre <b>entente</b> et un lien pour <b>choisir votre rendez-vous d\\'installation</b> sous peu.</div></div>';
else{b.disabled=false;b.textContent='Réessayer';alert('Une erreur est survenue.');}
}
load();
</script></body></html>`
async function handle (req, res, method, path) {
if (path === '/signup' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(PAGE) }
if (path === '/signup/submit' && method === 'POST') {
const b = await parseBody(req)
const lead = await createLead(b)
return json(res, 200, { ok: true, lead, plan: b.plan_name })
}
return json(res, 404, { error: 'not found' })
}
module.exports = { handle }

View File

@ -130,6 +130,8 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
// Gift redirect wrapper — short public URLs in campaign emails that
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).