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:
parent
6ff4a324ca
commit
7267a27cda
129
services/targo-hub/lib/signup.js
Normal file
129
services/targo-hub/lib/signup.js
Normal 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 }
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user