improve(booking): confirmation /book → « Ajouter à mon calendrier » (.ics universel)

Après confirmation d'un créneau (Option A), le client obtient un bouton .ics (Apple/Google/Outlook) pré-rempli :
date/heure + durée, « Rendez-vous Gigafibre — <service> », technicien en description, adresse en LOCATION. Réduit les
oublis/no-shows côté client. Heure flottante (fuseau de l'appareil = Québec). Vérifié : flux confirmer→lien, .ics
bien formé (DTSTART/DTEND/SUMMARY), 0 erreur console ; live à msg.gigafibre.ca/book. Hub redémarré, sain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 16:38:08 -04:00
parent f8af4235fb
commit 6b9f7b41fa

View File

@ -938,6 +938,7 @@ h1{font-size:21px;margin:0 0 4px;letter-spacing:-.02em}
.slot.sel{background:#e7f9ef;border-color:var(--g);color:var(--g-d)} .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} .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{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}
.btn2{display:block;text-align:center;text-decoration:none;background:#fff;color:var(--g);border:1.5px solid var(--g);border-radius:11px;padding:12px 18px;font-size:14px;font-weight:700;margin-top:10px}
.btn:disabled{background:#b9c7c0;cursor:default} .btn:disabled{background:#b9c7c0;cursor:default}
.hint{font-size:12px;color:var(--muted);margin:9px 0 0;text-align:center} .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{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)}
@ -966,7 +967,12 @@ h1{font-size:21px;margin:0 0 4px;letter-spacing:-.02em}
var token=new URLSearchParams(location.search).get('token')||''; var token=new URLSearchParams(location.search).get('token')||'';
var FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi']; 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 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; var mode='pick',picks=[],sel=null,windows=[],dur=1,timer=null,deadline=0,loc='',svc='';
function pad(n){return (n<10?'0':'')+n}
// Lien .ics universel (Apple/Google/Outlook) — heure « flottante » (interprétée dans le fuseau de l'appareil, = Québec).
function icsLink(date,start,durH,tech){var t=start.split(':'),d=date.replace(/-/g,'');var sh=Number(t[0])||9,sm=Number(t[1])||0;var eh=Math.min(23,sh+Math.max(1,Math.round(durH||1)));var dt=function(h,m){return d+'T'+pad(h)+pad(m)+'00'};
var ics=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Gigafibre//RDV//FR','BEGIN:VEVENT','DTSTART:'+dt(sh,sm),'DTEND:'+dt(eh,sm),'SUMMARY:Rendez-vous Gigafibre'+(svc?' - '+svc:''),'DESCRIPTION:'+((tech?'Technicien: '+tech+'. ':'')+'Rendez-vous technique Gigafibre.'),'LOCATION:'+(loc||''),'END:VEVENT','END:VCALENDAR'].join('\r\n');
return 'data:text/calendar;charset=utf-8,'+encodeURIComponent(ics)}
function d2(iso){var 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){var 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){var dt=d2(iso);var 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)}
@ -977,7 +983,7 @@ function load(){api('/book/api/options?token='+encodeURIComponent(token)).then(f
var info=el('jobinfo'),c=el('content'); 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.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
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} 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||[]; dur=Number(r.job&&r.job.duration)||1;windows=r.windows||[];loc=(r.job&&r.job.location)||'';svc=(r.job&&r.job.service_type)||'';
info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||''); info.textContent=(r.job&&r.job.service_type?r.job.service_type+' · ':'')+((r.job&&r.job.location)||'');
if(!windows.length){renderFreeform();return} if(!windows.length){renderFreeform();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>' 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>'
@ -1029,7 +1035,7 @@ function confirmPick(){if(!sel)return;var go=el('go');go.disabled=true;go.textCo
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]}}); 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)} 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'); 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>'; 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><a class=btn2 href="'+icsLink(r.date,r.start,dur,r.tech)+'" download="rendez-vous-gigafibre.ics">📅 Ajouter à mon calendrier</a>';
else if(r&&r.ok)c.innerHTML='<div class=ok>Merci !<br>'+((r&&r.message)||'Nous vous confirmerons sous peu.')+'</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>'} else c.innerHTML='<div class=err>'+((r&&r.message)||(r&&r.error)||'Une erreur est survenue.')+'</div>'}
load(); load();