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:
parent
1fc0e903eb
commit
21d6538e33
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<style>
|
||||
: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>
|
||||
const token=new URLSearchParams(location.search).get('token')||'';
|
||||
const 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.'];
|
||||
let picks=[];
|
||||
function d2(iso){const 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 wkMon(iso){const dt=d2(iso);const 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()]}
|
||||
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');
|
||||
var token=new URLSearchParams(location.search).get('token')||'';
|
||||
var FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
|
||||
var MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
|
||||
var mode='pick',picks=[],sel=null,windows=[],dur=1,timer=null,deadline=0;
|
||||
function d2(iso){var a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
|
||||
function dayLabel(iso){var dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
|
||||
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){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()]}
|
||||
function api(path,opts){return fetch(path,opts).then(function(x){return x.json()}).catch(function(){return{ok:false}})}
|
||||
function el(id){return document.getElementById(id)}
|
||||
function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(function(r){
|
||||
var info=el('jobinfo'),c=el('content');
|
||||
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
|
||||
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return}
|
||||
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusqu’à 3). On confirme le 1er créneau possible.';
|
||||
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</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='';
|
||||
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)})});
|
||||
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;
|
||||
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
|
||||
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()}
|
||||
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}
|
||||
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');
|
||||
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
|
||||
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
|
||||
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
|
||||
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}
|
||||
dur=Number(r.job&&r.job.duration)||1;windows=r.windows||[];
|
||||
info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||'');
|
||||
if(!windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
|
||||
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>'
|
||||
+'<div class=modehint id=modehint></div><div id=grid></div>'
|
||||
+'<div class=hold id=hold><span>Réservé pour vous</span><span class=cd id=cd>5:00</span></div>'
|
||||
+'<button class=btn id=go disabled>Confirmer ce rendez-vous</button><div class=hint id=hint></div>';
|
||||
el('seg').onclick=function(e){var b=e.target.closest('button');if(b)setMode(b.dataset.m)};
|
||||
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();
|
||||
</script></body></html>`
|
||||
</script></body></html>
|
||||
`
|
||||
|
||||
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) }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user