Introduces services/targo-hub/lib/ui/ as the shared kit for every magic-link
page served from the hub (tech mobile, acceptance, payments):
design.css tokens (--brand, --success, etc) + reset + all primitives
components.js server-side HTML builders (badge/section/card/panel/statRow/
tabBar) + shared date helpers (fmtTime/dateLabelFr/montrealDate)
+ canonical STATUS_META
client.js client-side api wrapper ($, toast, api.get/post, router.on/go)
baked into every page — no more hand-rolled fetch+hashchange
scanner.js Gemini field-scan overlay (window.scanner.open(field,label,cb,ctx))
shell.js ui.page({title, body, bootVars, cfg, script, includeScanner})
inlines everything into one self-contained HTML doc
index.js barrel
Migrates tech-mobile.js to the kit:
- drops inline esc/toast/fmtTime/dlbl/STATUS_META/badge helpers
- api.post('/status', {...}) instead of fetch(H+'/t/'+T+'/status', {...})
- router.on('#job/:name', handler) instead of hand-rolled route()
- scanner.open(field, label, cb, ctx) instead of ~60 lines of field-scan logic
Behavior preserved — rendered HTML keeps tabs, detail view, notes editor,
photo upload, per-field Gemini scans, Montreal-TZ date labels, v16 link-label
resolution. Verified live at msg.gigafibre.ca with a real TECH-4 token.
Sets up acceptance.js and payments.js to drop from ~700 → ~300 lines each
in the next commits by consuming the same primitives.
114 lines
4.9 KiB
JavaScript
114 lines
4.9 KiB
JavaScript
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Field-targeted scanner overlay (Gemini single-value extraction).
|
|
// Opt-in: pages include this when ui.page({ includeScanner: true }).
|
|
//
|
|
// Usage from page code:
|
|
// scanner.open('serial_number', 'Numéro de série', function (value) {
|
|
// // value is confirmed by the tech; write it into the target input.
|
|
// }, { equipment_type, brand, model })
|
|
//
|
|
// The page must also include <div id="fsOv"> markup, which shell.js injects
|
|
// automatically when includeScanner is on.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
(function () {
|
|
var stream = null
|
|
var field = ''
|
|
var fieldLabel = ''
|
|
var ctx = {}
|
|
var callback = null
|
|
|
|
function $ (id) { return document.getElementById(id) }
|
|
function esc (s) { return window._esc(s) }
|
|
|
|
function open (fieldKey, label, cb, context) {
|
|
field = fieldKey; fieldLabel = label; callback = cb; ctx = context || {}
|
|
$('fsLabel').textContent = 'Scanner : ' + label
|
|
$('fsResult').innerHTML = '<div class="sr sm ta-c">Cadrez l\'étiquette puis appuyez sur <b>Capturer</b></div>'
|
|
$('fsOv').classList.add('open')
|
|
startCam()
|
|
}
|
|
|
|
function close () {
|
|
$('fsOv').classList.remove('open')
|
|
stopCam()
|
|
callback = null
|
|
}
|
|
|
|
function startCam () {
|
|
var v = $('fsVid')
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
v.style.display = 'none'
|
|
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Caméra indisponible</div>'
|
|
return
|
|
}
|
|
navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
|
|
}).then(function (s) {
|
|
stream = s
|
|
v.srcObject = s
|
|
v.style.display = 'block'
|
|
}).catch(function (e) {
|
|
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Caméra refusée: ' + (e.message || '') + '</div>'
|
|
})
|
|
}
|
|
function stopCam () {
|
|
if (stream) { stream.getTracks().forEach(function (t) { t.stop() }); stream = null }
|
|
var v = $('fsVid'); if (v) v.srcObject = null
|
|
}
|
|
|
|
function capture () {
|
|
var v = $('fsVid'), c = $('fsCnv')
|
|
if (!v.videoWidth) { window.toast('Caméra non prête', false); return }
|
|
c.width = v.videoWidth; c.height = v.videoHeight
|
|
var cctx = c.getContext('2d'); cctx.drawImage(v, 0, 0, c.width, c.height)
|
|
var b64 = c.toDataURL('image/jpeg', 0.85)
|
|
|
|
$('fsResult').innerHTML = '<div class="sr sm ta-c">🤖 Analyse par Gemini Vision…</div>'
|
|
|
|
var payload = { image: b64, field: field }
|
|
if (ctx.equipment_type) payload.equipment_type = ctx.equipment_type
|
|
if (ctx.brand) payload.brand = ctx.brand
|
|
if (ctx.model) payload.model = ctx.model
|
|
if (ctx.hint) payload.hint = ctx.hint
|
|
|
|
window.api.post('/field-scan', payload).then(function (r) {
|
|
var d = r.data || {}
|
|
if (!d.ok || !d.value) {
|
|
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444;text-align:center">❌ Non détecté — rapprochez-vous et réessayez.</div>'
|
|
return
|
|
}
|
|
var pct = Math.round((d.confidence || 0) * 100)
|
|
var color = pct >= 70 ? '#22c55e' : pct >= 40 ? '#f59e0b' : '#ef4444'
|
|
$('fsResult').innerHTML =
|
|
'<div class="sr">' +
|
|
'<div class="fs-val">' + esc(d.value) + '</div>' +
|
|
'<div class="sm ta-c">Confiance : <b style="color:' + color + '">' + pct + '%</b></div>' +
|
|
'<div class="conf-bar"><div class="f" style="width:' + pct + '%;background:' + color + '"></div></div>' +
|
|
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
|
'<button class="btn btn-sm" style="background:' + color + ';color:#fff;flex:1" onclick="scanner.confirm("' + esc(d.value) + '")">✓ Utiliser</button>' +
|
|
'<button class="btn btn-sm btn-muted" onclick="scanner.capture()">↻ Réessayer</button>' +
|
|
'</div>' +
|
|
'</div>'
|
|
}).catch(function (e) {
|
|
$('fsResult').innerHTML = '<div class="sr" style="color:#ef4444">Erreur réseau : ' + (e.message || '') + '</div>'
|
|
})
|
|
}
|
|
|
|
function confirm (value) {
|
|
if (callback) callback(value)
|
|
close()
|
|
}
|
|
|
|
// Bind close button + ESC
|
|
window.addEventListener('load', function () {
|
|
var x = document.querySelector('#fsOv .fs-close')
|
|
if (x) x.addEventListener('click', close)
|
|
var cap = document.querySelector('#fsOv .fs-capture')
|
|
if (cap) cap.addEventListener('click', capture)
|
|
var can = document.querySelector('#fsOv .fs-cancel')
|
|
if (can) can.addEventListener('click', close)
|
|
})
|
|
|
|
window.scanner = { open: open, close: close, capture: capture, confirm: confirm }
|
|
})()
|