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:
parent
1d23aa7814
commit
169426a6d8
File diff suppressed because it is too large
Load Diff
131
services/targo-hub/lib/ui/client.js
Normal file
131
services/targo-hub/lib/ui/client.js
Normal 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, '&').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
|
||||||
|
})()
|
||||||
134
services/targo-hub/lib/ui/components.js
Normal file
134
services/targo-hub/lib/ui/components.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
364
services/targo-hub/lib/ui/design.css
Normal file
364
services/targo-hub/lib/ui/design.css
Normal 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 */
|
||||||
39
services/targo-hub/lib/ui/index.js
Normal file
39
services/targo-hub/lib/ui/index.js
Normal 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,
|
||||||
|
}
|
||||||
113
services/targo-hub/lib/ui/scanner.js
Normal file
113
services/targo-hub/lib/ui/scanner.js
Normal 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("' + esc(d.value) + '")">✓ 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 }
|
||||||
|
})()
|
||||||
132
services/targo-hub/lib/ui/shell.js
Normal file
132
services/targo-hub/lib/ui/shell.js
Normal 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 }
|
||||||
Loading…
Reference in New Issue
Block a user