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.
133 lines
5.8 KiB
JavaScript
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 }
|