gigafibre-fsm/services/targo-hub/lib/ui/shell.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

133 lines
5.8 KiB
JavaScript

'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 }