gigafibre-fsm/services/targo-hub/lib/ui/client.js
louispaulb 169426a6d8 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.
2026-04-22 22:47:19 -04:00

132 lines
6.0 KiB
JavaScript

// ─────────────────────────────────────────────────────────────────────────────
// 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
})()