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.
132 lines
6.0 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''')
|
|
}
|
|
|
|
// ── 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
|
|
})()
|