refactor(targo-hub): extract ui/ kit, migrate tech-mobile to it

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.
This commit is contained in:
louispaulb 2026-04-22 22:47:19 -04:00
parent 1d23aa7814
commit 169426a6d8
7 changed files with 1379 additions and 685 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,131 @@
// ─────────────────────────────────────────────────────────────────────────────
// Shared client-side JS baked into every magic-link page.
// Exposes: $, esc, toast, fmtTime, dlbl, api, router, go
// Configure via window.UI_CFG = { token, hub, today, base } BEFORE this loads.
// ─────────────────────────────────────────────────────────────────────────────
(function () {
var CFG = window.UI_CFG || {}
var HUB = CFG.hub || ''
var BASE = CFG.base || '' // e.g. "/t/<token>" — prepended to all api paths
// ── DOM / string ────────────────────────────────────────────────────────
function $ (id) { return document.getElementById(id) }
function esc (s) {
return (s == null ? '' : String(s))
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
// ── Toast ───────────────────────────────────────────────────────────────
function toast (message, ok) {
var t = $('toast')
if (!t) {
t = document.createElement('div')
t.id = 'toast'
t.className = 'toast'
document.body.appendChild(t)
}
t.textContent = message
t.style.background = ok ? '#22c55e' : '#ef4444'
t.classList.add('on')
clearTimeout(t._timer)
t._timer = setTimeout(function () { t.classList.remove('on') }, 2500)
}
// ── Date / time (mirror of server helpers) ──────────────────────────────
function fmtTime (t) {
if (!t) return ''
var p = String(t).split(':')
return p[0] + 'h' + (p[1] || '00')
}
function dlbl (d, today) {
today = today || CFG.today
if (!d) return 'Sans date'
if (d === today) return "Aujourd'hui"
try {
var diff = Math.round((new Date(d + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000)
if (diff === -1) return 'Hier'
if (diff === 1) return 'Demain'
return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
} catch (e) { return d }
}
// ── API wrapper ─────────────────────────────────────────────────────────
// Single place to prepend HUB+BASE and parse JSON. Page-specific code
// just does api.post('/note', {job, notes}) — no URL glue, no error swallowing.
function apiUrl (path) {
// Allow absolute paths through unchanged (e.g. fully qualified URLs)
if (/^https?:/i.test(path)) return path
if (path[0] !== '/') path = '/' + path
return HUB + BASE + path
}
function apiFetch (path, opts) {
opts = opts || {}
var headers = Object.assign({}, opts.headers || {})
if (opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json'
opts.body = JSON.stringify(opts.body)
}
opts.headers = headers
return fetch(apiUrl(path), opts).then(function (r) {
var ct = r.headers.get('content-type') || ''
if (ct.indexOf('application/json') >= 0) {
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data } })
}
return r.text().then(function (text) { return { ok: r.ok, status: r.status, text: text } })
})
}
var api = {
url: apiUrl,
get: function (p) { return apiFetch(p) },
post: function (p, body){ return apiFetch(p, { method: 'POST', body: body }) },
put: function (p, body){ return apiFetch(p, { method: 'PUT', body: body }) },
}
// ── Hash router ─────────────────────────────────────────────────────────
// Patterns: "#home" (literal) or "#job/:name" (param). Handlers are called
// with (params, rawHash). Calling router.go('#foo') updates location.hash.
var routes = []
function on (pattern, handler) { routes.push({ pattern: pattern, handler: handler }) }
function matchRoute (hash) {
for (var i = 0; i < routes.length; i++) {
var r = routes[i]
if (r.pattern === hash) return { handler: r.handler, params: {} }
// "#job/:name" — matches "#job/DJ-123"
var parts = r.pattern.split('/')
var actual = hash.split('/')
if (parts.length !== actual.length) continue
var params = {}, ok = true
for (var j = 0; j < parts.length; j++) {
if (parts[j][0] === ':') params[parts[j].slice(1)] = decodeURIComponent(actual[j] || '')
else if (parts[j] !== actual[j]) { ok = false; break }
}
if (ok) return { handler: r.handler, params: params }
}
return null
}
function dispatch () {
var hash = location.hash || (routes[0] && routes[0].pattern) || '#'
var m = matchRoute(hash)
if (m) { m.handler(m.params, hash) }
else if (routes[0]) { routes[0].handler({}, routes[0].pattern) }
window.scrollTo(0, 0)
}
function go (hash) {
if (location.hash === hash) dispatch()
else location.hash = hash
}
window.addEventListener('hashchange', dispatch)
var router = { on: on, dispatch: dispatch, go: go }
// ── Expose globals (only ones pages reference directly) ─────────────────
window.$ = $
window._esc = esc // underscore to avoid colliding with page esc
window.toast = toast
window.fmtTime = fmtTime
window.dlbl = dlbl
window.api = api
window.router = router
window.go = go
})()

View File

@ -0,0 +1,134 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Server-side HTML fragment builders for magic-link pages.
// Pure functions returning strings. Pairs with ui/design.css classes.
// ─────────────────────────────────────────────────────────────────────────────
function esc (s) {
return (s == null ? '' : String(s))
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
// Safely inline JSON in a <script> tag (blocks </script> + --> breakouts)
function jsonScript (value) {
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e')
}
// ── Canonical status metadata ─────────────────────────────────────────────
// One source of truth for FR labels + colors. Both tech mobile and detail
// views read from this so a status rename propagates everywhere.
const STATUS_META = {
Scheduled: { label: 'Planifié', color: '#818cf8' },
assigned: { label: 'Assigné', color: '#818cf8' },
open: { label: 'Ouvert', color: '#818cf8' },
'In Progress': { label: 'En cours', color: '#f59e0b' },
in_progress: { label: 'En cours', color: '#f59e0b' },
Completed: { label: 'Terminé', color: '#22c55e' },
Cancelled: { label: 'Annulé', color: '#94a3b8' },
}
function statusMeta (s) {
return STATUS_META[s] || { label: s || '—', color: '#94a3b8' }
}
function badge (status) {
const m = statusMeta(status)
return `<span class="bdg" style="background:${m.color}22;color:${m.color}">${esc(m.label)}</span>`
}
// ── Section with count pill ───────────────────────────────────────────────
// title: human heading, e.g. "En retard"
// items: array of pre-rendered HTML strings (from jobCard() or similar)
// modifier: 'danger' | '' — adds .sec.danger for red accent
function section (title, items, modifier = '') {
if (!items.length) return ''
const mod = modifier ? ' ' + modifier : ''
return `<div class="sec${mod}">${esc(title)} <span class="cnt">${items.length}</span></div>${items.join('')}`
}
// ── Primitive wrappers ────────────────────────────────────────────────────
function card (inner, { onclick, extraClass = '', style = '' } = {}) {
const cls = 'card' + (extraClass ? ' ' + extraClass : '')
const click = onclick ? ` onclick="${onclick}"` : ''
const st = style ? ` style="${style}"` : ''
return `<div class="${cls}"${click}${st}>${inner}</div>`
}
function panel (title, bodyHtml, { extra = '' } = {}) {
return `<div class="panel"><h3>${esc(title)}${extra ? ` <span class="rt">${extra}</span>` : ''}</h3>${bodyHtml}</div>`
}
function emptyState (emoji, message) {
return `<div class="empty"><div class="em">${esc(emoji)}</div><p>${message}</p></div>`
}
function placeholder (emoji, title, body) {
return `<div class="placeholder"><div class="em">${esc(emoji)}</div><h2>${esc(title)}</h2><p>${body}</p></div>`
}
function button (label, { kind = 'pri', onclick, id, block = false, extraClass = '', disabled = false, style = '' } = {}) {
const cls = ['btn', 'btn-' + kind, block ? 'btn-block' : '', extraClass].filter(Boolean).join(' ')
return `<button class="${cls}"${id ? ` id="${id}"` : ''}${onclick ? ` onclick="${onclick}"` : ''}${disabled ? ' disabled' : ''}${style ? ` style="${style}"` : ''}>${label}</button>`
}
// ── Stat row (inside header) ──────────────────────────────────────────────
// items: [{ value, label, onclick? }]
function statRow (items) {
return `<div class="sts">${items.map(it => {
const click = it.onclick ? ` onclick="${it.onclick}"` : ''
const id = it.id ? ` id="${it.id}"` : ''
return `<div class="st"${click}><b${id}>${esc(it.value)}</b><small>${esc(it.label)}</small></div>`
}).join('')}</div>`
}
// ── Tab bar ───────────────────────────────────────────────────────────────
// tabs: [{ id, label, icon, active? }]
function tabBar (tabs) {
return `<div class="tbar">${tabs.map(t => {
const cls = 'tab' + (t.active ? ' on' : '')
return `<button class="${cls}" data-v="${esc(t.id)}" onclick="go('#${esc(t.id)}')"><span class="ic">${esc(t.icon)}</span>${esc(t.label)}</button>`
}).join('')}</div>`
}
// ── Date / time helpers ───────────────────────────────────────────────────
function fmtTime (t) {
if (!t) return ''
const [h, m] = String(t).split(':')
return `${h}h${m || '00'}`
}
function fmtDate (d) {
if (!d) return ''
try { return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' }) }
catch { return d }
}
// "Aujourd'hui" / "Hier" / "Demain" / weekday / date
function dateLabelFr (d, today) {
if (!d) return 'Sans date'
if (d === today) return "Aujourd'hui"
try {
const diff = Math.round((new Date(d + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000)
if (diff === -1) return 'Hier'
if (diff === 1) return 'Demain'
if (diff > 1 && diff <= 6) return new Date(d + 'T00:00:00').toLocaleDateString('fr-CA', { weekday: 'long' })
return fmtDate(d)
} catch { return fmtDate(d) }
}
function todayFr () {
return new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
}
// YYYY-MM-DD in America/Montreal regardless of server TZ
function montrealDate (d = new Date()) {
return d.toLocaleDateString('en-CA', { timeZone: 'America/Montreal', year: 'numeric', month: '2-digit', day: '2-digit' })
}
module.exports = {
esc, jsonScript,
STATUS_META, statusMeta, badge,
section, card, panel, emptyState, placeholder, button, statRow, tabBar,
fmtTime, fmtDate, dateLabelFr, todayFr, montrealDate,
}

View File

@ -0,0 +1,364 @@
/*
Targo design tokens + primitives
Used by every magic-link page served from targo-hub (tech, acceptance, pay).
One place to tweak brand colors, spacing, radii, shadows.
*/
:root {
/* Brand */
--brand: #5c59a8;
--brand-dark: #3f3d7a;
--brand-soft: #eef2ff;
--brand-tint: #ddd8ff;
/* Semantic status */
--success: #22c55e;
--success-dark: #16a34a;
--success-soft: #dcfce7;
--warning: #f59e0b;
--warning-dark: #d97706;
--warning-soft: #fef3c7;
--danger: #ef4444;
--danger-dark: #dc2626;
--danger-soft: #fee2e2;
--info: #818cf8;
--info-soft: #e0e7ff;
/* Neutrals */
--bg: #f1f5f9;
--surface: #fff;
--surface-alt: #f8fafc;
--border: #e2e8f0;
--border-soft: #f1f5f9;
--text: #0f172a;
--text-muted: #64748b;
--text-dim: #94a3b8;
/* Motion */
--tap-scale: .985;
--t-fast: .15s;
--t-med: .25s;
}
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { min-height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-text-size-adjust: 100%;
line-height: 1.4;
}
/* ── Header (brand gradient) ───────────────────────────────────────────── */
.hdr {
background: linear-gradient(135deg, var(--brand-dark), var(--brand));
color: #fff;
padding: 14px 16px 28px;
border-radius: 0 0 18px 18px;
position: relative;
z-index: 1;
}
.hdr-d { font-size: 12px; opacity: .75; text-transform: capitalize; }
.hdr-n { font-size: 19px; font-weight: 700; margin: 2px 0 12px; display: flex; align-items: center; gap: 8px; }
.hdr-n .ic {
width: 32px; height: 32px; border-radius: 50%;
background: rgba(255,255,255,.2);
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
/* ── Stat row inside header ────────────────────────────────────────────── */
.sts { display: flex; gap: 8px; }
.st {
flex: 1;
background: rgba(255,255,255,.14);
border-radius: 10px;
padding: 10px 4px;
text-align: center;
cursor: pointer;
transition: background var(--t-fast);
}
.st:active { background: rgba(255,255,255,.24); }
.st b { display: block; font-size: 19px; font-weight: 700; }
.st small { font-size: 10px; opacity: .75; letter-spacing: .3px; }
/* ── Page wrap ─────────────────────────────────────────────────────────── */
.wrap { padding: 14px 12px; margin-top: -14px; }
/* ── Section heading ───────────────────────────────────────────────────── */
.sec {
font-size: 11px; font-weight: 700; color: var(--brand);
letter-spacing: 1.2px; margin: 16px 4px 8px; text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.sec .cnt {
background: var(--brand-tint); color: var(--brand);
padding: 1px 7px; border-radius: 10px; font-size: 10px;
}
.sec.danger { color: var(--danger); }
.sec.danger .cnt { background: var(--danger-soft); color: var(--danger); }
/* ── Card ──────────────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border-radius: 12px;
padding: 12px;
margin-bottom: 10px;
box-shadow: 0 1px 2px rgba(15,23,42,.06);
transition: transform .1s;
}
.card[onclick], .card.tappable { cursor: pointer; }
.card[onclick]:active, .card.tappable:active { transform: scale(var(--tap-scale)); }
.card.dim { opacity: .6; }
.card.od { box-shadow: 0 1px 2px rgba(239,68,68,.15), 0 0 0 1px #fecaca; }
/* ── Badge ─────────────────────────────────────────────────────────────── */
.bdg {
padding: 2px 8px; border-radius: 8px;
font-size: 10px; font-weight: 700; letter-spacing: .2px;
display: inline-block;
}
/* ── Row ───────────────────────────────────────────────────────────────── */
.row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* ── Inputs ────────────────────────────────────────────────────────────── */
.inp {
padding: 10px 12px;
border: 1.5px solid var(--border);
border-radius: 10px;
font-size: 14px;
background: var(--surface);
font-family: inherit;
outline: none;
width: 100%;
}
.inp:focus { border-color: var(--brand); }
textarea.inp { resize: vertical; min-height: 90px; }
/* ── Buttons ───────────────────────────────────────────────────────────── */
.btn {
padding: 12px 16px; border: none; border-radius: 10px;
font-family: inherit; font-weight: 700; font-size: 14px;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
transition: background var(--t-fast), transform .1s;
}
.btn:active { transform: scale(var(--tap-scale)); }
.btn:disabled { opacity: .5; pointer-events: none; }
.btn-pri { background: var(--brand); color: #fff; }
.btn-pri:active { background: var(--brand-dark); }
.btn-ok { background: var(--success); color: #fff; }
.btn-ok:active { background: var(--success-dark); }
.btn-warn { background: var(--warning); color: #fff; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:active { background: var(--danger-dark); }
.btn-ghost { background: var(--brand-soft); color: var(--brand); }
.btn-ghost:active { background: var(--brand-tint); }
.btn-muted { background: var(--border-soft); color: var(--text-muted); }
.btn-block { width: 100%; }
.btn-sm { padding: 9px 12px; font-size: 13px; border-radius: 8px; }
/* ── Empty state ───────────────────────────────────────────────────────── */
.empty {
text-align: center;
padding: 50px 24px;
color: var(--text-dim);
}
.empty .em { font-size: 44px; margin-bottom: 8px; opacity: .5; }
.placeholder {
background: var(--surface);
border-radius: 12px;
padding: 40px 24px;
text-align: center;
color: var(--text-dim);
margin-top: 14px;
}
.placeholder .em { font-size: 48px; margin-bottom: 10px; opacity: .5; }
.placeholder h2 { font-size: 16px; color: #475569; margin-bottom: 6px; }
.placeholder p { font-size: 13px; line-height: 1.5; }
/* ── Toast ─────────────────────────────────────────────────────────────── */
.toast {
position: fixed; top: 14px; left: 12px; right: 12px;
padding: 12px 14px; border-radius: 10px;
color: #fff; font-size: 13px; font-weight: 600; text-align: center;
z-index: 300; opacity: 0;
transform: translateY(-12px);
transition: all var(--t-med);
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,.2);
}
.toast.on { opacity: 1; transform: translateY(0); }
/* ── Sticky search bar ─────────────────────────────────────────────────── */
.sbar {
position: sticky; top: 0;
background: var(--bg);
padding: 10px 4px 8px;
z-index: 5;
margin: 0 -4px;
}
/* ── Panel (detail-view section) ───────────────────────────────────────── */
.panel {
background: var(--surface);
border-radius: 12px;
padding: 14px;
margin: 12px 12px 0;
box-shadow: 0 1px 2px rgba(15,23,42,.06);
}
.panel h3 {
font-size: 11px; font-weight: 700; color: var(--brand);
letter-spacing: 1.2px; margin-bottom: 8px; text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.panel h3 .rt {
margin-left: auto; font-size: 10px; color: var(--text-dim);
font-weight: 600; letter-spacing: .5px;
}
/* ── Bottom tab bar ────────────────────────────────────────────────────── */
.tbar {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex; height: 62px; z-index: 40;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.tab {
flex: 1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px;
color: var(--text-dim);
font-size: 10px; font-weight: 600;
cursor: pointer; border: none; background: none;
font-family: inherit;
}
.tab .ic { font-size: 22px; line-height: 1; }
.tab.on { color: var(--brand); }
/* ── Overlay (fullscreen pane over page) ───────────────────────────────── */
.ov {
display: none;
position: fixed; inset: 0;
background: var(--bg);
z-index: 100;
flex-direction: column;
overflow-y: auto;
}
.ov.open { display: flex; }
.ov-hdr {
display: flex; align-items: center;
padding: 12px 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 2;
gap: 8px;
}
.ov-hdr h2 { font-size: 16px; font-weight: 700; flex: 1; margin: 0; color: var(--text); }
.ov-x {
background: none; border: none;
font-size: 22px; cursor: pointer; padding: 4px 8px; color: var(--text-muted);
width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
border-radius: 8px;
}
.ov-x:active { background: var(--bg); }
.ov-body { padding: 14px; flex: 1; }
.ov-sec {
font-size: 11px; font-weight: 700; color: var(--brand);
letter-spacing: 1.2px; margin: 16px 0 8px; text-transform: uppercase;
}
/* ── Scanner camera + results ──────────────────────────────────────────── */
.cam-wrap {
position: relative;
width: 100%; max-width: 400px;
margin: 0 auto 10px;
border-radius: 12px;
overflow: hidden;
background: #000;
aspect-ratio: 4/3;
}
.cam-wrap video, .cam-wrap canvas { width: 100%; display: block; }
.cam-overlay {
position: absolute; inset: 0;
pointer-events: none;
border: 2px solid rgba(92,89,168,.6);
border-radius: 12px;
}
.sr {
background: var(--surface);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
font-size: 13px; color: #334155;
}
.sr b { color: var(--brand); }
.sr .sm { font-size: 11px; color: var(--text-dim); }
/* ── Field-scan nested overlay (single-value Gemini) ───────────────────── */
.fs-ov {
display: none;
position: fixed; inset: 0;
background: rgba(15,23,42,.92);
z-index: 200;
flex-direction: column; align-items: center; justify-content: center;
padding: 20px;
}
.fs-ov.open { display: flex; }
.fs-box {
background: var(--surface);
border-radius: 16px;
width: 100%; max-width: 480px;
max-height: 90vh; overflow-y: auto;
padding: 16px;
}
.fs-box h3 {
font-size: 15px; margin-bottom: 10px; color: var(--text);
display: flex; align-items: center; gap: 8px;
}
.fs-box .fs-close {
margin-left: auto;
background: none; border: none;
font-size: 22px; color: var(--text-muted);
cursor: pointer;
}
.fs-cam {
width: 100%; aspect-ratio: 4/3;
background: #000; border-radius: 12px; overflow: hidden;
margin-bottom: 10px;
}
.fs-cam video { width: 100%; height: 100%; object-fit: cover; }
.fs-btn { display: flex; gap: 8px; margin-top: 10px; }
.fs-btn button {
flex: 1; padding: 12px;
border: none; border-radius: 10px;
font-family: inherit; font-weight: 700; font-size: 14px;
cursor: pointer;
}
.fs-capture { background: var(--brand); color: #fff; }
.fs-capture:active { background: var(--brand-dark); }
.fs-cancel { background: var(--border-soft); color: var(--text-muted); }
.conf-bar {
height: 4px; background: var(--border-soft);
border-radius: 2px; margin-top: 6px; overflow: hidden;
}
.conf-bar .f { height: 100%; transition: width .3s; }
.fs-val {
font-size: 22px; font-weight: 700; color: var(--text);
margin: 10px 0 4px; text-align: center;
font-family: ui-monospace, monospace;
}
/* ── Utility ───────────────────────────────────────────────────────────── */
.dis { opacity: .5; pointer-events: none; }
.hidden { display: none !important; }
.ta-c { text-align: center; }
.ta-r { text-align: right; }
.mt-0 { margin-top: 0; } .mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; }
.mb-0 { margin-bottom: 0; } .mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; }
.pad-body { padding-bottom: 68px; min-height: 100vh; } /* reserve space for tbar */

View File

@ -0,0 +1,39 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Barrel export for the magic-link UI kit.
// const ui = require('../ui')
// ui.page({ ... })
// ui.badge('In Progress')
// ui.section('En retard', cards, 'danger')
// ─────────────────────────────────────────────────────────────────────────────
const shell = require('./shell')
const comp = require('./components')
module.exports = {
// shell
page: shell.page,
pageExpired: shell.pageExpired,
pageError: shell.pageError,
html: shell.html,
htmlHeaders: shell.htmlHeaders,
// components
esc: comp.esc,
jsonScript: comp.jsonScript,
STATUS_META: comp.STATUS_META,
statusMeta: comp.statusMeta,
badge: comp.badge,
section: comp.section,
card: comp.card,
panel: comp.panel,
emptyState: comp.emptyState,
placeholder: comp.placeholder,
button: comp.button,
statRow: comp.statRow,
tabBar: comp.tabBar,
fmtTime: comp.fmtTime,
fmtDate: comp.fmtDate,
dateLabelFr: comp.dateLabelFr,
todayFr: comp.todayFr,
montrealDate: comp.montrealDate,
}

View File

@ -0,0 +1,113 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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(&quot;' + esc(d.value) + '&quot;)">✓ 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 }
})()

View File

@ -0,0 +1,132 @@
'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// page() — HTML shell builder.
// Inlines ui/design.css + ui/client.js (+ optionally scanner.js) into every
// magic-link page so the client loads with zero extra requests and a server
// crash can't leave a page partially styled.
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
// Read assets once at module load. Changes on disk require a service restart.
const DESIGN_CSS = fs.readFileSync(path.join(__dirname, 'design.css'), 'utf8')
const CLIENT_JS = fs.readFileSync(path.join(__dirname, 'client.js'), 'utf8')
const SCANNER_JS = fs.readFileSync(path.join(__dirname, 'scanner.js'), 'utf8')
// Nested field-scan overlay markup — injected when includeScanner is true.
const SCANNER_OVERLAY_HTML = `
<div class="fs-ov" id="fsOv">
<div class="fs-box">
<h3><span id="fsIcon">📷</span><span id="fsLabel">Scanner</span><button class="fs-close" type="button"></button></h3>
<div class="fs-cam"><video id="fsVid" autoplay playsinline muted></video></div>
<canvas id="fsCnv" style="display:none"></canvas>
<div id="fsResult"></div>
<div class="fs-btn">
<button class="fs-cancel" type="button">Annuler</button>
<button class="fs-capture" type="button">📸 Capturer</button>
</div>
</div>
</div>
`
const HTML_HEADERS = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store' }
function htmlHeaders () { return HTML_HEADERS }
// Safe JSON embed — same as components.jsonScript but avoid circular dep.
function jsonEmbed (v) { return JSON.stringify(v).replace(/</g, '\\u003c').replace(/-->/g, '--\\u003e') }
// ─────────────────────────────────────────────────────────────────────────
// page(opts)
//
// title: <title> + og title
// themeColor: optional meta theme-color (default brand-dark)
// lang: default 'fr'
// head: extra HTML injected in <head> (meta, link, etc.)
// body: page body HTML (required)
// bootVars: object; each key becomes a top-level const in script,
// e.g. { T: token, TODAY: 'yyyy-mm-dd' } ⇒ const T=...; const TODAY=...
// cfg: object merged into window.UI_CFG for client.js
// { token, hub, today, base }
// script: page-specific JS (appended after client.js / scanner.js)
// includeScanner: inject scanner.js + overlay markup
// bodyClass: extra class on <body> (e.g. 'pad-body' to reserve tab-bar space)
//
// Returns a complete HTML document string.
// ─────────────────────────────────────────────────────────────────────────
function page (opts) {
const {
title = 'Targo',
themeColor = '#3f3d7a',
lang = 'fr',
head = '',
body = '',
bootVars = {},
cfg = {},
script = '',
includeScanner = false,
bodyClass = '',
} = opts
// bootVars → "const K=v;" lines; JSON-safe, no user escaping needed.
const bootLines = Object.keys(bootVars).map(k => `var ${k}=${jsonEmbed(bootVars[k])};`).join('\n')
const cfgLine = `window.UI_CFG=${jsonEmbed(cfg)};`
const scannerAssets = includeScanner ? `<script>${SCANNER_JS}</script>` : ''
const scannerMarkup = includeScanner ? SCANNER_OVERLAY_HTML : ''
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="${themeColor}">
<title>${title}</title>
<style>${DESIGN_CSS}</style>
${head}
</head>
<body${bodyClass ? ` class="${bodyClass}"` : ''}>
${body}
${scannerMarkup}
<div class="toast" id="toast"></div>
<script>${cfgLine}</script>
<script>${CLIENT_JS}</script>
${scannerAssets}
<script>
${bootLines}
${script}
</script>
</body>
</html>`
}
// ── Pre-baked error pages ────────────────────────────────────────────────
function pageExpired (message) {
const msg = message || `Ce lien n'est plus valide. Votre superviseur vous enverra un nouveau lien par SMS.`
return page({
title: 'Lien expiré',
body: `<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px">
<div style="background:#fff;border-radius:16px;padding:32px 24px;text-align:center;max-width:340px;box-shadow:0 2px 12px rgba(0,0,0,.08)">
<div style="font-size:48px;margin-bottom:12px">🔗</div>
<h1 style="font-size:18px;margin-bottom:8px">Lien expiré</h1>
<p style="font-size:14px;color:#64748b;line-height:1.5">${msg}</p>
</div>
</div>`,
})
}
function pageError (message) {
const msg = message || 'Réessayez dans quelques instants.'
return page({
title: 'Erreur',
body: `<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px">
<div style="background:#fff;border-radius:16px;padding:32px;text-align:center;max-width:340px">
<h1 style="font-size:18px;margin-bottom:8px">Erreur temporaire</h1>
<p style="font-size:13px;color:#64748b">${msg}</p>
</div>
</div>`,
})
}
module.exports = { page, pageExpired, pageError, htmlHeaders, html: htmlHeaders }