1/** 2 * demo-share.js â Partage & documents du Carnet de Vie (Lot 6). 3 * 4 * Sheet « Partager » (3 types de liens read-only : passeport / fiche d'urgence / dossier 5 * véto), création + copie/Web Share du lien, liste des liens actifs + révocation. 6 * Scan intelligent d'une photo de document (Vision) â proposition d'événement pré-rempli. 7 * Toast « Annuler » réutilisable (undo 6 s de la suppression de pièce jointe, B-6). 8 * 9 * Requires: WD.state, WD.dom, WD.api, WD.ENDPOINTS, WD.esc, WD.escAttr, WD.t, WD.te, 10 * WD.showToast, WD.confirmDialog, WD.formatDate (demo-core.js), 11 * WD.openHealthForm (runtime, demo-health.js) 12 * Exposes: WD.openShareSheet, WD.scanPhotoDocument, WD.undoToast, WD.closeShareSheet 13 */ 14(function (W) { 15 'use strict'; 16 17 var state = W.state; 18 var api = W.api; 19 var EP = W.ENDPOINTS; 20 var esc = W.esc; 21 var t = W.t; 22 var te = W.te; 23 24 function haptic(style) { if (W.native && W.native.haptic) W.native.haptic(style); } 25 26 // Types de liens : icône + TTL par défaut + choix de durée (véto) + choix de période 27 // des événements (passeport & véto ont une timeline ; l'urgence n'en a pas). 28 var SHARE_TYPES = [ 29 { type: 'passport', icon: 'ð', ttl: 30, hasPeriod: true }, 30 { type: 'emergency', icon: 'ð¨', ttl: 30, hasPeriod: false }, 31 { type: 'vet', icon: 'ð©º', ttl: 30, ttlChoices: [1, 7, 30], hasPeriod: true } 32 ]; 33 var TYPE_ICONS = { passport: 'ð', emergency: 'ð¨', vet: 'ð©º' }; 34 // Fenêtres d'événements proposées ([valeur jours, clé i18n]) ; '' = tout l'historique. 35 var PERIOD_CHOICES = [['', 'share_period_all'], ['30', 'share_period_30'], ['180', 'share_period_180'], ['365', 'share_period_365']]; 36 37 // ------------------------------------------------------------------ 38 // Bottom sheet autonome (indépendant de demo-carnet) 39 // ------------------------------------------------------------------ 40 41 var sheet = { overlay: null, open: false, petId: null, pet: null, prevFocus: null }; 42 43 function buildSheet() { 44 if (sheet.overlay) return;
45 sheet.overlay = document.createElement('div'); 46 sheet.overlay.className = 'share-sheet-overlay'; 47 sheet.overlay.innerHTML = '<div class="share-sheet" role="dialog" aria-modal="true">' 48 + '<div class="share-sheet-body" id="shareSheetBody"></div>' 49 + '</div>'; 50 document.body.appendChild(sheet.overlay); 51 sheet.overlay.addEventListener('click', function (e) { 52 if (e.target === sheet.overlay) W.closeShareSheet(); 53 }); 54 // Délégation liée UNE fois (le div persiste entre les ouvertures). 55 sheet.overlay.querySelector('#shareSheetBody').addEventListener('click', onSheetClick); 56 } 57 58 function closeBtnHtml() { 59 return '<button type="button" class="share-sheet-close" data-share-close aria-label="' + W.escAttr(t('dialog_cancel')) + '">' 60 + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>'; 61 } 62
63 function renderSheet(html) { 64 buildSheet(); 65 var body = document.getElementById('shareSheetBody'); 66 body.innerHTML = html; 67 if (!sheet.open) sheet.prevFocus = document.activeElement; 68 sheet.open = true; 69 sheet.overlay.classList.add('active'); 70 var first = body.querySelector('button, input, select'); 71 if (first) { try { first.focus(); } catch (e) {} } 72 } 73 74 W.closeShareSheet = function () { 75 if (!sheet.overlay) return; 76 sheet.open = false; 77 sheet.overlay.classList.remove('active'); 78 var prev = sheet.prevFocus; 79 sheet.prevFocus = null; 80 if (prev && prev.isConnected) { try { prev.focus(); } catch (e) {} } 81 }; 82 83 W.isShareSheetOpen = function () { return !!sheet.open; }; 84 85 // ------------------------------------------------------------------ 86 // Ãcran d'accueil du partage : 3 types + liens actifs 87 // ------------------------------------------------------------------ 88 89 W.openShareSheet = function (petId, pet) { 90 if (!state.user) { if (W.openLoginModal) W.openLoginModal(); return; } 91 if (!petId) return; 92 sheet.petId = petId; 93 sheet.pet = pet || (state.pets || []).find(function (p) { return p.id === petId; }) || null; 94 renderHome(); 95 loadLinks(petId); 96 }; 97 98 function renderHome() { 99 var name = (sheet.pet && sheet.pet.name) ? sheet.pet.name : ''; 100 var parts = ['<div class="share-sheet-head"><span class="share-sheet-title">' + esc(t('share_sheet_title').replace('{name}', name)) + '</span>' + closeBtnHtml() + '</div>']; 101 parts.push('<p class="share-sheet-sub">' + esc(t('share_sheet_sub')) + '</p>'); 102 103 parts.push('<div class="share-opts">'); 104 SHARE_TYPES.forEach(function (st) { 105 parts.push('<div class="share-opt" data-share-type="' + st.type + '">' 106 + '<span class="share-opt-ico" aria-hidden="true">' + st.icon + '</span>' 107 + '<span class="share-opt-body">' 108 + '<span class="share-opt-title">' + esc(t('share_type_' + st.type)) + '</span>' 109 + '<span class="share-opt-desc">' + esc(t('share_type_' + st.type + '_desc')) + '</span>' 110 + '</span>' 111 + '<span class="share-opt-arrow" aria-hidden="true">âº</span>' 112 + '</div>'); 113 }); 114 parts.push('</div>'); 115 116 // Lot 7 : carte-bilan partageable (image canvas). Affichée UNIQUEMENT dans le 117 // shell (l'app) : le partage de fichier via navigator.share n'est pas fiable sur 118 // desktop web (« clic sans effet » remonté). Dans l'app, le partage natif marche. 119 if (W.openBilan && window.WOOPETS_IS_SHELL) { 120 parts.push('<div class="share-opts share-opts--bilan">'); 121 parts.push('<div class="share-opt" data-share-bilan>' 122 + '<span class="share-opt-ico" aria-hidden="true">ð</span>' 123 + '<span class="share-opt-body">' 124 + '<span class="share-opt-title">' + esc(W.t('bilan_share')) + '</span>' 125 + '<span class="share-opt-desc">' + esc(W.t('bilan_title').replace('{name}', name)) + '</span>' 126 + '</span>' 127 + '<span class="share-opt-arrow" aria-hidden="true">âº</span>' 128 + '</div>'); 129 parts.push('</div>'); 130 } 131 132 // Zone des liens actifs (remplie en async par renderLinks). 133 parts.push('<div class="share-links" id="shareLinksList" hidden></div>'); 134 135 renderSheet(parts.join('')); 136 } 137 138 // Ãcran de création : choix de la période des événements (passeport/véto) + de la durée 139 // du lien (véto). L'urgence n'a ni l'un ni l'autre â création directe. 140 function openCreate(type) { 141 var st = null; 142 SHARE_TYPES.forEach(function (s) { if (s.type === type) st = s; }); 143 if (!st) return; 144 145 if (!st.hasPeriod && !st.ttlChoices) { 146 createLink(type, st.ttl, null); 147 return; 148 } 149 150 var parts = ['<div class="share-sheet-head">' 151 + '<button type="button" class="share-sheet-back" data-share-back aria-label="' + W.escAttr(t('dialog_cancel')) + '">
151<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>' 152 + '<span class="share-sheet-title">' + st.icon + ' ' + esc(t('share_type_' + type)) + '</span>' 153 + closeBtnHtml() + '</div>']; 154 155 if (st.hasPeriod) { 156 parts.push('<p class="share-sheet-label">' + esc(t('share_period_prompt')) + '</p>'); 157 parts.push('<div class="share-chip-group" data-group="period">'); 158 PERIOD_CHOICES.forEach(function (p) { 159 parts.push('<button type="button" class="share-chip' + (p[0] === '' ? ' active' : '') + '" data-period="' + p[0] + '">' + esc(t(p[1])) + '</button>'); 160 }); 161 parts.push('</div>'); 162 } 163 164 if (st.ttlChoices) { 165 parts.push('<p class="share-sheet-label">' + esc(t('share_ttl_prompt')) + '</p>'); 166 parts.push('<div class="share-chip-group" data-group="ttl">'); 167 st.ttlChoices.forEach(function (days) { 168 parts.push('<button type="button" class="share-chip' + (days === 30 ? ' active' : '') + '" data-ttl="' + days + '">' + esc(t('share_ttl_' + days)) + '</button>'); 169 }); 170 parts.push('</div>'); 171 } 172 173 parts.push('<p class="share-sheet-note">' + esc(t('share_readonly_note')) + '</p>'); 174 parts.push('<button type="button" class="share-create-btn" data-share-do-create="' + type + '">' + esc(t('share_create_link')) + '</button>'); 175 renderSheet(parts.join('')); 176 } 177 178 // Lit les chips actives (période + durée) et lance la création. 179 function submitCreate(type) { 180 var body = document.getElementById('shareSheetBody'); 181 if (!body) return; 182 var periodEl = body.querySelector('[data-group="period"] .share-chip.active'); 183 var ttlEl = body.querySelector('[data-group="ttl"] .share-chip.active'); 184 var period = periodEl ? periodEl.getAttribute('data-period') : ''; 185 var ttl = ttlEl ? parseInt(ttlEl.getAttribute('data-ttl'), 10) : 30; 186 createLink(type, ttl, period ? parseInt(period, 10) : null); 187 } 188 189 function createLink(type, ttlDays, periodDays) { 190 if (sheet.creating) return; 191 sheet.creating = true; 192 api(EP.shareLinks(sheet.petId), { method: 'POST', body: { type: type, ttl_days: ttlDays, period_days: periodDays } }) 193 .then(function (data) { 194 if (!data || !data.success) throw new Error((data && data.error) || te('server_error')); 195 haptic('success'); 196 renderCreated(data); 197 loadLinks(sheet.petId); 198 }) 199 .catch(function (err) { W.showToast(err.message, 'error'); }) 200 .finally(function () { sheet.creating = false; }); 201 } 202 203 // Ãcran « lien créé » : URL copiable + partage natif (le token n'est montré qu'ici). 204 function renderCreated(data) { 205 var url = data.url || ''; 206 var parts = ['<div class="share-sheet-head"><span class="share-sheet-title">â ' + esc(t('share_created_title')) + '</span>' + closeBtnHtml() + '</div>']; 207 parts.push('<p class="share-sheet-sub">' + esc(t('share_created_sub').replace('{date}', data.expires_at && W.formatDate ? W.formatDate(String(data.expires_at).slice(0, 10)) : '')) + '</p>'); 208 parts.push('<div class="share-url-row">' 209 + '<input type="text" class="share-url-input" id="shareUrlInput" readonly value="' + W.escAttr(url) + '" aria-label="' + W.escAttr(t('share_url_label')) + '">' 210 + '<button type="button" class="share-url-copy" data-share-copy="' + W.escAttr(url) + '">' + esc(t('share_copy')) + '</button>' 211 + '</div>'); 212 if (navigator.share) { 213 parts.push('<button type="button" class="share-native-btn" data-share-native="' + W.escAttr(url) + '">' + esc(t('share_via')) + '</button>'); 214 } 215 parts.push('<button type="button" class="share-done-btn" data-share-close>' + esc(t('share_done')) + '</button>'); 216 renderSheet(parts.join('')); 217 var input = document.getElementById('shareUrlInput'); 218 if (input) { try { input.focus(); input.select(); } catch (e) {} } 219 } 220 221 // ------------------------------------------------------------------ 222 // Liens actifs (management + révocation) 223 // ------------------------------------------------------------------ 224 225 function loadLinks(petId) { 226 api(EP.shareLinks(petId), { method: 'GET' }).then(function (data) { 227 if (!data || !data.success || sheet.petId !== petId) return; 228 renderLinks(data.links || []); 229 }).catch(function () {}); 230 } 231 232 function renderLinks(links) { 233 var box = document.getElementById('shareLinksList'); 234 if (!box) return; 235 if (!links.length) { box.hidden = true; box.innerHTML = ''; return; } 236 237 var parts = ['<div class="share-links-title">' + esc(t('share_active_links')) + '</div>']; 238 links.forEach(function (link) { 239 var expiry = link.expires_at && W.formatDate ? W.formatDate(String(link.expires_at).slice(0, 10)) : ''; 240 parts.push('<div class="share-link-row">' 241 + '<span class="share-link-ico" aria-hidden="true">' + (TYPE_ICONS[link.type] || 'ð') + '</span>' 242 + '<span class="share-link-body">' 243 + '<span class="share-link-type">' + esc(t('share_type_' + link.type)) + '</span>' 244 + '<span class="share-link-meta">' + esc(t('share_expires_short').replace('{date}', expiry)) 245 + ' · ' + esc(viewsLabel(link.view_count | 0)) + '</span>' 246 + '</span>' 247 + '<button type="button" class="share-link-revoke" data-share-revoke="' + (link.id | 0) + '">' + esc(t('share_revoke')) + '</button>' 248 + '</div>'); 249 }); 250 box.innerHTML = parts.join(''); 251 box.hidden = false;
252 } 253 254 // Libellé « N vue(s) » avec la bonne règle de pluriel par langue : le français met 255 // 0 ET 1 au singulier (« 0 vue »), les autres langues seulement 1. 256 function viewsLabel(count) { 257 var locale = (window.CURRENT_LOCALE || 'fr').toLowerCase(); 258 var singular = locale === 'fr' ? count <= 1 : count === 1; 259 return t(singular ? 'share_views_one' : 'share_views').replace('{count}', String(count)); 260 } 261 262 function revokeLink(id) { 263 W.confirmDialog(t('share_revoke_confirm'), { confirmLabel: t('share_revoke'), danger: true }).then(function (ok) { 264 if (!ok) return; 265 api(EP.shareRevoke(id), { method: 'POST', body: {} }) 266 .then(function (data) { 267 if (!data || !data.success) throw new Error((data && data.error) || te('server_error')); 268 W.showToast(t('share_revoked'), 'success'); 269 loadLinks(sheet.petId); 270 }) 271 .catch(function (err) { W.showToast(err.message, 'error'); }); 272 }); 273 } 274 275 function copyToClipboard(url) { 276 if (navigator.clipboard && navigator.clipboard.writeText) { 277 navigator.clipboard.writeText(url).then(function () { W.showToast(t('share_copied'), 'success'); }) 278 .catch(function () { fallbackCopy(url); }); 279 } else { 280 fallbackCopy(url); 281 } 282 } 283 284 function fallbackCopy(url) { 285 var input = document.getElementById('shareUrlInput'); 286 if (input) { try { input.focus(); input.select(); document.execCommand('copy'); W.showToast(t('share_copied'), 'success'); return; } catch (e) {} } 287 W.showToast(url, 'info'); 288 } 289 290 function onSheetClick(e) { 291 if (e.target.closest('[data-share-close]')) { W.closeShareSheet(); return; } 292 if (e.target.closest('[data-share-back]')) { renderHome(); loadLinks(sheet.petId); return; } 293 var opt = e.target.closest('[data-share-type]'); 294 if (opt) { haptic('light'); openCreate(opt.getAttribute('data-share-type')); return; } 295 // Lot 7 : carte-bilan (image canvas) â ferme la sheet et lance le partage. 296 if (e.target.closest('[data-share-bilan]')) { 297 var pid = sheet.petId; 298 W.closeShareSheet(); 299 if (W.openBilan && pid) W.openBilan(pid); 300 return; 301 } 302 // Toggle d'une chip (période ou durée) : une seule active dans son groupe. 303 var chip = e.target.closest('.share-chip'); 304 if (chip) { 305 var group = chip.closest('.share-chip-group'); 306 if (group) { group.querySelectorAll('.share-chip').forEach(function (c) { c.classList.remove('active'); }); } 307 chip.classList.add('active'); 308 return; 309 } 310 var doCreate = e.target.closest('[data-share-do-create]'); 311 if (doCreate) { submitCreate(doCreate.getAttribute('data-share-do-create')); return; } 312 var copy = e.target.closest('[data-share-copy]'); 313 if (copy) { copyToClipboard(copy.getAttribute('data-share-copy')); return; } 314 var nat = e.target.closest('[data-share-native]'); 315 if (nat) { 316 var url = nat.getAttribute('data-share-native'); 317 if (navigator.share) { navigator.share({ title: t('share_native_title'), url: url }).catch(function () {}); } 318 return; 319 } 320 var rev = e.target.closest('[data-share-revoke]'); 321 if (rev) { revokeLink(parseInt(rev.getAttribute('data-share-revoke'), 10)); return; } 322 } 323 324 // ------------------------------------------------------------------ 325 // Scan intelligent de document (Vision) â proposition d'événement 326 // ------------------------------------------------------------------ 327 328 W.scanPhotoDocument = function (photoId, petId) { 329 if (!photoId || sheet.scanning) return; 330 sheet.scanning = true; 331 sheet.petId = petId || sheet.petId; 332 // Sheet de progression pendant l'appel Vision (5-15 s). 333 renderSheet('<div class="share-sheet-head"><span class="share-sheet-title">⨠' + esc(t('scan_title')) + '</span>' + closeBtnHtml() + '</div>' 334 + '<div class="share-scan-loading"><span class="share-scan-spinner" aria-hidden="true"></span>' + esc(t('scan_loading')) + '</div>'); 335 336 api(EP.docScan(photoId), { method: 'POST', body: {} }, 60000) 337 .then(function (data) { 338 if (!data || !data.success || !data.scan) throw new Error((data && data.error) || te('server_error')); 339 renderScanResult(data.scan); 340 }) 341 .catch(function (err) { 342 renderScanFailed(err.message); 343 }) 344 .finally(function () { sheet.scanning = false; }); 345 }; 346
347 function renderScanResult(scan) { 348 var confidencePct = Math.round((scan.confidence || 0) * 100); 349 var products = (scan.product_names || []).slice(0, 5); 350 var title = scan.title || t('scan_doc_' + (scan.doc_type || 'other')); 351 352 var parts = ['<div class="share-sheet-head"><span class="share-sheet-title">⨠' + esc(t('scan_result_title')) + '</span>' + closeBtnHtml() + '</div>']; 353 parts.push('<div class="share-scan-card">'); 354 parts.push('<div class="share-scan-type"><span class="share-scan-badge">' + esc(t('scan_doc_' + (scan.doc_type || 'other'))) + '</span>' 355 + '<span class="share-scan-conf" title="' + W.escAttr(t('scan_confidence')) + '">' + confidencePct + '%</span></div>'); 356 parts.push('<div class="share-scan-field"><span class="share-scan-k">' + esc(t('scan_field_title')) + '</span><span class="share-scan-v">' + esc(title) + '</span></div>'); 357 if (scan.event_date) { 358 parts.push('<div class="share-scan-field"><span class="share-scan-k">' + esc(t('scan_field_date')) + '</span><span class="share-scan-v">' + esc(W.formatDate ? W.formatDate(scan.event_date) : scan.event_date) + '</span></div>'); 359 } 360 if (products.length) { 361 parts.push('<div class="share-scan-field"><span class="share-scan-k">' + esc(t('scan_field_products')) + '</span><span class="share-scan-v">' + esc(products.join(', ')) + '</span></div>'); 362 } 363 parts.push('</div>'); 364 parts.push('<p class="share-scan-disclaimer">' + esc(t('scan_disclaimer')) + '</p>'); 365 parts.push('<button type="button" class="share-scan-create" id="shareScanCreate">' + esc(t('scan_create_event')) + '</button>'); 366 parts.push('<button type="button" class="share-scan-archive" data-share-close>' + esc(t('scan_just_archive')) + '</button>'); 367 renderSheet(parts.join('')); 368 369 // Stocker le scan pour la création (évite de le ré-encoder en data-attr). 370 sheet.lastScan = scan; 371 sheet.lastScanTitle = title; 372 var btn = document.getElementById('shareScanCreate'); 373 if (btn) { 374 btn.addEventListener('click', function () { createEventFromScan(); }); 375 } 376 } 377
378 function renderScanFailed(message) { 379 renderSheet('<div class="share-sheet-head"><span class="share-sheet-title">' + esc(t('scan_title')) + '</span>' + closeBtnHtml() + '</div>' 380 + '<div class="share-scan-failed"><p>' + esc(t('scan_failed')) + '</p>' 381 + '<button type="button" class="share-scan-archive" data-share-close>' + esc(t('scan_just_archive')) + '</button></div>'); 382 } 383 384 // Ouvre le formulaire carnet pré-rempli avec les métadonnées extraites (l'utilisateur 385 // valide/corrige puis enregistre â le scan reste une SUGGESTION, jamais une écriture auto). 386 function createEventFromScan() { 387 var scan = sheet.lastScan; 388 if (!scan || !W.openHealthForm) { W.closeShareSheet(); return; } 389 W.closeShareSheet(); 390 W.openHealthForm(null); 391 // Remplissage après ouverture du formulaire (les champs existent alors). 392 setTimeout(function () { 393 var cat = document.getElementById('healthFormCategory'); 394 if (cat && scan.suggested_category) { 395 cat.value = scan.suggested_category; 396 try { cat.dispatchEvent(new Event('change')); } catch (e) {} 397 } 398 var title = document.getElementById('healthFormTitle'); 399 if (title && sheet.lastScanTitle) title.value = sheet.lastScanTitle; 400 var date = document.getElementById('healthFormDate'); 401 if (date && scan.event_date) date.value = scan.event_date; 402 var desc = document.getElementById('healthFormDesc'); 403 if (desc && (scan.product_names || []).length) { 404 desc.value = (scan.product_names || []).join(', '); 405 } 406 }, 60); 407 } 408 409 // ------------------------------------------------------------------ 410 // Toast « Annuler » réutilisable (undo 6 s â suppression de pièce jointe B-6) 411 // ------------------------------------------------------------------ 412 413 // onCommit() est appelé à l'expiration SANS annulation ; onUndo() au clic « Annuler ». 414 // Un seul toast undo à la fois (le précédent commit immédiatement). 415 var activeUndo = null; 416 417 W.undoToast = function (message, onCommit, onUndo, seconds) { 418 commitPendingUndo(); 419 var secs = seconds || 6; 420 var el = document.createElement('div'); 421 el.className = 'undo-toast'; 422 el.innerHTML = '<span class="undo-toast-msg"></span>' 423 + '<button type="button" class="undo-toast-btn">' + esc(t('action_undo')) + '</button>'; 424 el.querySelector('.undo-toast-msg').textContent = message; 425 (W.dom && W.dom.toastContainer ? W.dom.toastContainer : document.body).appendChild(el); 426 requestAnimationFrame(function () { el.classList.add('show'); }); 427 428 var committed = false; 429 var timer = setTimeout(function () { finish(false); }, secs * 1000); 430 431 function finish(undone) { 432 if (committed) return; 433 committed = true; 434 clearTimeout(timer); 435 if (activeUndo === handle) activeUndo = null; 436 el.classList.remove('show'); 437 setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 300); 438 try { if (undone) { if (onUndo) onUndo(); } else if (onCommit) { onCommit(); } } catch (e) {} 439 } 440 441 el.querySelector('.undo-toast-btn').addEventListener('click', function () { haptic('light'); finish(true); }); 442 var handle = { commit: function () { finish(false); } }; 443 activeUndo = handle; 444 return handle; 445 }; 446 447 function commitPendingUndo() { 448 if (activeUndo) { try { activeUndo.commit(); } catch (e) {} activeUndo = null; } 449 } 450 451 // Commit tout undo en attente si l'utilisateur quitte la page (la suppression part). 452 window.addEventListener('beforeunload', commitPendingUndo); 453 454})(window.WD);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.