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