feat(booking): page client Gigafibre à 2 modes (choisir un créneau / proposer 3 dispos)

Réécrit BOOK_HTML : marque Gigafibre (vert #00C853, esprit gigafibre.ca) au lieu du bleu générique, mobile-first, 2 modes
via bascule segmentée —
- « Choisir un créneau » (Option A) : sélection unique → hold EXCLUSIF 5 min avec compte à rebours visible → confirmer ;
  à l'expiration le créneau redevient disponible (message + rechargement).
- « Proposer mes dispos » (Option B) : jusqu'à 3 créneaux classés → 1er possible confirmé, sinon enregistrés en 'Proposé'.
Consomme les endpoints publics /book/api/{options,hold,submit} (déjà déployés). Vérifié : rendu + les 2 flux (mock),
puis live à msg.gigafibre.ca/book (marqueurs présents). Hub redémarré, sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 14:40:13 -04:00
parent 1fc0e903eb
commit 21d6538e33

View File

@ -912,34 +912,116 @@ async function confirmWindow (jobName, date, start, duration, skill) {
} }
const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title> const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}.week{background:#1565c0;color:#fff;font-weight:700;font-size:13px;padding:8px 12px;border-radius:8px;margin:18px 0 4px}.ampm{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px;font-weight:700}.empty{color:#999;font-size:12px;font-style:italic}</style></head> <style>
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div> :root{--g:#00C853;--g-d:#00A344;--ink:#1B2E24;--muted:#64748B;--line:#e6ebe8;--bg:#f4f7f5;--card:#fff}
*{box-sizing:border-box}
body{font-family:'Plus Jakarta Sans',-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
.wrap{max-width:560px;margin:0 auto;padding:16px}
.card{background:var(--card);border-radius:16px;box-shadow:0 1px 3px rgba(16,40,28,.06),0 12px 40px -24px rgba(16,40,28,.3);padding:22px;margin-bottom:12px}
.brandrow{display:flex;align-items:center;gap:9px;margin-bottom:14px}
.logo{width:30px;height:30px;border-radius:8px;background:var(--g);display:grid;place-items:center;color:#fff;font-weight:800}
.logo svg{width:18px;height:18px}
.brandrow b{font-size:16px;letter-spacing:-.01em}
h1{font-size:21px;margin:0 0 4px;letter-spacing:-.02em}
.sub{color:var(--muted);font-size:13px;margin-bottom:4px}
.jobline{font-size:12.5px;color:var(--muted);font-weight:600;margin-bottom:6px}
.seg{display:flex;background:var(--bg);border:1px solid var(--line);border-radius:11px;padding:3px;margin:16px 0 6px}
.seg button{flex:1;appearance:none;border:0;background:transparent;font-family:inherit;font-size:13px;font-weight:700;color:var(--muted);padding:9px 8px;border-radius:8px;cursor:pointer;transition:.15s}
.seg button.on{background:var(--card);color:var(--g-d);box-shadow:0 1px 3px rgba(16,40,28,.12)}
.modehint{font-size:12.5px;color:var(--muted);margin:8px 0 2px;line-height:1.45}
.week{background:var(--g);color:#fff;font-weight:700;font-size:12.5px;padding:8px 12px;border-radius:9px;margin:18px 0 6px}
.day{font-weight:700;margin:14px 0 4px;font-size:13px;color:var(--ink)}
.ampm{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin:10px 0 5px;font-weight:800}
.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
.slot{border:1.5px solid var(--line);border-radius:10px;padding:11px 6px;text-align:center;cursor:pointer;font-size:14px;font-weight:600;position:relative;background:var(--card);transition:.12s}
.slot:hover{border-color:var(--g)}
.slot.sel{background:#e7f9ef;border-color:var(--g);color:var(--g-d)}
.rank{position:absolute;top:-8px;right:-8px;background:var(--g);color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;font-weight:700;line-height:20px}
.btn{background:var(--g);color:#fff;border:0;border-radius:11px;padding:14px 18px;font-size:15px;font-weight:700;font-family:inherit;cursor:pointer;width:100%;margin-top:14px}
.btn:disabled{background:#b9c7c0;cursor:default}
.hint{font-size:12px;color:var(--muted);margin:9px 0 0;text-align:center}
.hold{display:none;align-items:center;gap:9px;background:#e7f9ef;border:1px solid #b6ebcd;border-radius:10px;padding:10px 13px;margin-top:12px;font-size:13px;font-weight:600;color:var(--g-d)}
.hold.on{display:flex}
.hold .cd{margin-left:auto;font-variant-numeric:tabular-nums;font-weight:800}
.ok{background:#e7f9ef;color:#0d6b34;padding:18px;border-radius:12px;text-align:center;font-size:15px}
.err{background:#fdecea;color:#b3261e;padding:13px;border-radius:10px;font-size:13.5px}
.empty{color:#9aa5a0;font-size:12.5px;font-style:italic;padding:8px 0}
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:4px}
.foot b{color:var(--g-d)}
</style></head>
<body><div class=wrap>
<div class=card>
<div class=brandrow><span class=logo><svg viewBox="0 0 24 24" fill=none stroke=currentColor stroke-width=2.4 stroke-linecap=round stroke-linejoin=round><path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/></svg></span><b>Gigafibre</b></div>
<h1>Votre rendez-vous</h1>
<div class=jobline id=jobinfo>Chargement</div>
<div id=content></div>
</div>
<div class=foot>Gigafibre · propulsé par <b>Targo</b></div>
</div>
<script> <script>
const token=new URLSearchParams(location.search).get('token')||''; var token=new URLSearchParams(location.search).get('token')||'';
const FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi']; var FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
const MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.']; var MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
let picks=[]; var mode='pick',picks=[],sel=null,windows=[],dur=1,timer=null,deadline=0;
function d2(iso){const a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))} function d2(iso){var a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
function dayLabel(iso){const dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]} function dayLabel(iso){var dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
function wkMon(iso){const dt=d2(iso);const off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)} function wkMon(iso){var dt=d2(iso);var off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
function wkLabel(m){const a=d2(m);const b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' '+b.getUTCDate()+' '+MO[b.getUTCMonth()]} function wkLabel(m){var a=d2(m);var b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content'); function api(path,opts){return fetch(path,opts).then(function(x){return x.json()}).catch(function(){return{ok:false}})}
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return} function el(id){return document.getElementById(id)}
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return} function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(function(r){
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusquà 3). On confirme le 1er créneau possible.'; var info=el('jobinfo'),c=el('content');
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return} if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
const weeks={};r.windows.forEach(w=>{const mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});let h=''; if(r.job&&r.job.scheduled){info.textContent='';c.innerHTML='<div class=ok>Votre rendez-vous est déjà confirmé :<br><b>'+dayLabel(r.job.scheduled)+'</b>.</div>';return}
Object.keys(weeks).sort().forEach(mk=>{h+='<div class=week>'+wkLabel(mk)+'</div>';Object.keys(weeks[mk]).sort().forEach(d=>{const ws=weeks[mk][d].sort((a,b)=>a.start_h-b.start_h);const am=ws.filter(w=>w.start_h<12),pm=ws.filter(w=>w.start_h>=12);h+='<div class=day>'+dayLabel(d)+'</div>';const sect=(lab,arr)=>arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>').join('')+'</div>'):'';h+=sect('Matin',am)+sect('Après-midi',pm)})}); dur=Number(r.job&&r.job.duration)||1;windows=r.windows||[];
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h; info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||'');
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit} if(!windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()} c.innerHTML='<div class=seg id=seg><button data-m=pick class=on>Choisir un créneau</button><button data-m=propose>Proposer mes dispos</button></div>'
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length} +'<div class=modehint id=modehint></div><div id=grid></div>'
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content'); +'<div class=hold id=hold><span>Réservé pour vous</span><span class=cd id=cd>5:00</span></div>'
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>'; +'<button class=btn id=go disabled>Confirmer ce rendez-vous</button><div class=hint id=hint></div>';
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>'; el('seg').onclick=function(e){var b=e.target.closest('button');if(b)setMode(b.dataset.m)};
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'} el('go').onclick=function(){mode==='pick'?confirmPick():submitPropose()};
setMode('pick')
})}
function setMode(m){mode=m;picks=[];sel=null;stopCd();el('hold').classList.remove('on');
Array.prototype.forEach.call(document.querySelectorAll('#seg button'),function(b){b.classList.toggle('on',b.dataset.m===m)});
el('modehint').textContent=m==='pick'?'Touchez un créneau — il est réservé pour vous pendant 5 minutes, le temps de confirmer.':'Touchez 1 à 3 créneaux en ordre de préférence. On confirme le 1er possible.';
el('go').textContent=m==='pick'?'Confirmer ce rendez-vous':'Envoyer mes disponibilités';el('go').disabled=true;
renderTiles();render()}
function renderTiles(){var weeks={};windows.forEach(function(w){var mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});
var h='';Object.keys(weeks).sort().forEach(function(mk){h+='<div class=week>'+wkLabel(mk)+'</div>';
Object.keys(weeks[mk]).sort().forEach(function(d){var ws=weeks[mk][d].sort(function(a,b){return a.start_h-b.start_h});var am=ws.filter(function(w){return w.start_h<12}),pm=ws.filter(function(w){return w.start_h>=12});h+='<div class=day>'+dayLabel(d)+'</div>';
var sect=function(lab,arr){return arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(function(w){return '<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>'}).join('')+'</div>'):''};
h+=sect('Matin',am)+sect('Après-midi',pm)})});
el('grid').innerHTML=h;
Array.prototype.forEach.call(document.querySelectorAll('#grid .slot'),function(s){s.onclick=function(){mode==='pick'?pickOne(s):togglePref(s)}})}
function pickOne(s){var k=s.dataset.d+'|'+s.dataset.s;if(sel===k){sel=null;el('hold').classList.remove('on');stopCd();el('go').disabled=true;render();return}
sel=k;render();el('go').disabled=true;
api('/book/api/hold?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({date:s.dataset.d,start:s.dataset.s})}).then(function(r){
if(sel!==k)return;el('hold').classList.add('on');startCd((r&&r.expires_in_s)||300);el('go').disabled=false})}
function togglePref(s){var k=s.dataset.d+'|'+s.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render();el('go').disabled=!picks.length}
function render(){Array.prototype.forEach.call(document.querySelectorAll('#grid .slot'),function(s){var k=s.dataset.d+'|'+s.dataset.s;
if(mode==='pick'){s.classList.toggle('sel',sel===k);var b=s.querySelector('.rank');if(b)b.remove()}
else{var i=picks.indexOf(k);s.classList.toggle('sel',i>=0);var r=s.querySelector('.rank');if(i>=0){if(!r){r=document.createElement('div');r.className='rank';s.appendChild(r)}r.textContent=i+1}else if(r)r.remove()}});
if(el('hint'))el('hint').textContent=mode==='propose'?(picks.length?picks.length+' choix — le 1er sera priorisé.':''):''}
function startCd(sec){deadline=Date.now()+sec*1000;stopCd();tick();timer=setInterval(tick,1000)}
function stopCd(){if(timer){clearInterval(timer);timer=null}}
function tick(){var left=Math.max(0,Math.round((deadline-Date.now())/1000));var m=Math.floor(left/60),s=left%60;if(el('cd'))el('cd').textContent=m+':'+(s<10?'0':'')+s;if(left<=0)expire()}
function expire(){stopCd();var k=sel;sel=null;el('hold').classList.remove('on');el('go').disabled=true;render();
if(k)api('/book/api/hold?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({date:k.split('|')[0],start:k.split('|')[1],release:true})});
el('hint').textContent='Le créneau a expiré et est de nouveau disponible — touchez-en un autre.'}
function confirmPick(){if(!sel)return;var go=el('go');go.disabled=true;go.textContent='Confirmation…';stopCd();
api('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'pick',date:sel.split('|')[0],start:sel.split('|')[1]})}).then(done)}
function submitPropose(){var go=el('go');go.disabled=true;go.textContent='Envoi…';var prefs=picks.map(function(k){return{date:k.split('|')[0],start:k.split('|')[1]}});
api('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs:prefs})}).then(done)}
function done(r){var c=el('content');
if(r&&r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé :<br><b>'+dayLabel(r.date)+' à '+r.start+'</b>'+(r.tech?'<br><span style=color:#0d6b34;font-weight:600>Technicien : '+r.tech+'</span>':'')+'<br>Merci !</div>';
else if(r&&r.ok)c.innerHTML='<div class=ok>Merci !<br>'+((r&&r.message)||'Nous vous confirmerons sous peu.')+'</div>';
else c.innerHTML='<div class=err>'+((r&&r.message)||(r&&r.error)||'Une erreur est survenue.')+'</div>'}
load(); load();
</script></body></html>` </script></body></html>
`
async function handlePublicBooking (req, res, method, path, url) { async function handlePublicBooking (req, res, method, path, url) {
if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) } if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }