import { App } from '@capacitor/app'; import { Capacitor } from '@capacitor/core'; import { Keyboard } from '@capacitor/keyboard'; import { getToken, clearToken, setToken, isUsageTrialExpired } from './auth.js'; import { apiGet, fetchAndApplyMobileConfig, getStoredMobileConfig } from './api.js'; import { deriveMobileExperience, getStoredUserProfile, setStoredUserProfile, clearStoredUserProfile } from './mobile-profile.js'; import { getNotifications, getUnreadNotificationCount, markAllNotificationsRead, markAllNotificationsReadEverywhere, NOTIFICATIONS_UPDATED_EVENT, refreshRemoteNotifications, } from './notifications.js'; import { renderDashboard } from './views/dashboard.js'; import { renderActivation } from './views/activation.js'; import { renderLogin } from './views/login.js'; import { renderProximity } from './views/proximity.js'; import { renderAssigned } from './views/assigned.js'; import { renderTreatment } from './views/treatment.js'; import { renderFtthList } from './views/ftth-list.js'; import { renderFtthReport } from './views/ftth-report.js'; import { initializeTrackingService, stopTrackingService, syncTrackingService } from './tracking.js'; import { getMobileActivationState, syncMobileActivationState } from './activation.js'; import { syncUsageTrialWithServer } from './trial.js'; const SHELL_META = { '/dashboard': { title: 'Dashboard', subtitle: 'Pilotage rapide des interventions mobiles', }, '/assigned': { title: 'Incidents', subtitle: 'Traitement terrain et suivi des tickets', backTarget: '/dashboard', }, '/nearby': { title: 'Proximité agent', subtitle: 'Clients FTTH proches et itinéraires terrain', backTarget: '/dashboard', }, '/treatment': { title: 'Traitement incident', subtitle: 'Saisie terrain et clôture opérationnelle', backTarget: '/assigned', }, '/ftth': { title: 'FTTH B2B', subtitle: 'Portefeuille terrain et interventions assignées', backTarget: '/dashboard', }, '/ftth-report': { title: 'Fiche terrain', subtitle: 'Expérience mobile premium pour Android', backTarget: '/ftth', }, }; let activeShellRoot = null; let activeUserProfile = getStoredUserProfile(); let nativeBackListenerReady = false; let notificationPollingHandle = null; let responsiveViewportReady = false; let baselineViewportHeight = 0; let pendingFieldRevealHandle = null; let pendingViewportSyncFrame = null; let pendingViewportResetBaseline = false; let lastViewportMetrics = null; let pendingKeyboardViewportRevealHandle = null; let keyboardListenersReady = false; let nativeKeyboardInset = 0; const BOOT_NETWORK_TIMEOUT_MS = 4500; function parseHash() { const raw = (location.hash || '#/activate').slice(1); const [pathPart, queryPart] = raw.split('?'); const path = pathPart || '/activate'; const params = new URLSearchParams(queryPart || ''); return { path, params }; } function isTextEntryElement(target) { return target instanceof HTMLElement && target.matches('input:not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="checkbox"]):not([type="radio"]):not([type="range"]), textarea, select, [contenteditable="true"]'); } function syncViewportMetrics({ resetBaseline = false } = {}) { const root = document.documentElement; const viewport = window.visualViewport; const visibleHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0); const viewportOffsetTop = Math.round(viewport?.offsetTop || 0); if (resetBaseline || !baselineViewportHeight || visibleHeight >= baselineViewportHeight - 48) { baselineViewportHeight = Math.max(baselineViewportHeight, visibleHeight); } const viewportKeyboardInset = Math.max(0, baselineViewportHeight - visibleHeight - viewportOffsetTop); const keyboardInset = Math.max(viewportKeyboardInset, nativeKeyboardInset); const keyboardOpen = keyboardInset > 120 && isTextEntryElement(document.activeElement); const nextMetrics = { visibleHeight: Math.max(visibleHeight, 320), keyboardInset, keyboardOpen, }; if ( !resetBaseline && lastViewportMetrics && Math.abs(lastViewportMetrics.visibleHeight - nextMetrics.visibleHeight) <= 2 && Math.abs(lastViewportMetrics.keyboardInset - nextMetrics.keyboardInset) <= 2 && lastViewportMetrics.keyboardOpen === nextMetrics.keyboardOpen ) { return; } lastViewportMetrics = nextMetrics; root.style.setProperty('--app-height', `${nextMetrics.visibleHeight}px`); root.style.setProperty('--app-keyboard-offset', `${keyboardInset}px`); document.body.classList.toggle('keyboard-open', keyboardOpen); } function queueViewportMetricsSync({ resetBaseline = false } = {}) { pendingViewportResetBaseline = pendingViewportResetBaseline || resetBaseline; if (pendingViewportSyncFrame !== null) { return; } pendingViewportSyncFrame = window.requestAnimationFrame(() => { const shouldResetBaseline = pendingViewportResetBaseline; pendingViewportSyncFrame = null; pendingViewportResetBaseline = false; syncViewportMetrics({ resetBaseline: shouldResetBaseline }); }); } function findScrollableContainer(target) { let node = target?.parentElement || null; while (node) { const style = window.getComputedStyle(node); const overflowY = style.overflowY || style.overflow; const canScroll = /(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight + 4; if (canScroll) { return node; } node = node.parentElement; } return document.scrollingElement || document.documentElement; } function getFieldRevealPadding() { const keyboardInset = lastViewportMetrics?.keyboardInset || 0; return Math.max(24, Math.min(140, Math.round(keyboardInset * 0.35) + 24)); } function revealFocusedField(target, delay = 220) { if (!isTextEntryElement(target)) { return; } if (pendingFieldRevealHandle) { window.clearTimeout(pendingFieldRevealHandle); } pendingFieldRevealHandle = window.setTimeout(() => { if (!document.contains(target)) { return; } const container = findScrollableContainer(target); const viewportHeight = Math.round(window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight || 0); const keyboardInset = lastViewportMetrics?.keyboardInset || 0; const padding = getFieldRevealPadding(); const visibleBottom = Math.max(120, viewportHeight - Math.max(keyboardInset, padding)); target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' }); const targetRect = target.getBoundingClientRect(); if (container instanceof HTMLElement && container !== document.body && container !== document.documentElement) { const containerRect = container.getBoundingClientRect(); const topLimit = containerRect.top + padding; const bottomLimit = Math.min(containerRect.bottom - padding, visibleBottom); if (targetRect.bottom > bottomLimit) { container.scrollTop += targetRect.bottom - bottomLimit; } else if (targetRect.top < topLimit) { container.scrollTop -= topLimit - targetRect.top; } return; } if (targetRect.bottom > visibleBottom) { window.scrollBy({ top: targetRect.bottom - visibleBottom, behavior: 'auto' }); } else if (targetRect.top < padding) { window.scrollBy({ top: targetRect.top - padding, behavior: 'auto' }); } }, delay); } function revealActiveFieldDuringKeyboardTransition(delay = 80) { const activeTarget = document.activeElement; if (!isTextEntryElement(activeTarget)) { return; } if (pendingKeyboardViewportRevealHandle) { window.clearTimeout(pendingKeyboardViewportRevealHandle); } pendingKeyboardViewportRevealHandle = window.setTimeout(() => { pendingKeyboardViewportRevealHandle = null; if (!lastViewportMetrics?.keyboardOpen) { return; } revealFocusedField(activeTarget, 0); }, delay); } function setupResponsiveViewportHandling() { if (responsiveViewportReady) { return; } responsiveViewportReady = true; if (!keyboardListenersReady && Capacitor.getPlatform() === 'android') { keyboardListenersReady = true; Keyboard.addListener('keyboardDidShow', (event) => { nativeKeyboardInset = Math.max(0, Math.round(event?.keyboardHeight || 0)); queueViewportMetricsSync(); revealActiveFieldDuringKeyboardTransition(0); }); Keyboard.addListener('keyboardDidHide', () => { nativeKeyboardInset = 0; queueViewportMetricsSync({ resetBaseline: true }); }); } const handleViewportChange = () => { queueViewportMetricsSync(); revealActiveFieldDuringKeyboardTransition(); }; queueViewportMetricsSync({ resetBaseline: true }); window.addEventListener('resize', handleViewportChange); window.addEventListener('orientationchange', () => { window.setTimeout(() => queueViewportMetricsSync({ resetBaseline: true }), 180); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', handleViewportChange); } document.addEventListener('focusin', (event) => { const target = event.target; if (!isTextEntryElement(target)) { return; } queueViewportMetricsSync(); revealFocusedField(target, 180); }, true); document.addEventListener('focusout', () => { if (pendingFieldRevealHandle) { window.clearTimeout(pendingFieldRevealHandle); pendingFieldRevealHandle = null; } if (pendingKeyboardViewportRevealHandle) { window.clearTimeout(pendingKeyboardViewportRevealHandle); pendingKeyboardViewportRevealHandle = null; } window.setTimeout(() => queueViewportMetricsSync(), 180); }, true); } function setStatus(root, text, kind = 'secondary') { const el = root.querySelector('[data-role="status"]'); if (!el) return; el.className = `mobile-status alert alert-${kind} py-2 mb-0`; el.textContent = text; } function getShellMeta(path, experience) { const baseMeta = SHELL_META[path] || { title: 'Insuite Technicien', subtitle: 'Console mobile terrain', }; if (path === '/ftth') { return { ...baseMeta, title: experience.moduleLabel, subtitle: experience.moduleHeroSubtitle, backTarget: '/dashboard', }; } if (path === '/ftth-report') { return { ...baseMeta, title: experience.technicianType === 'raccordement' ? 'Fiche raccordement' : 'Fiche terrain', subtitle: experience.technicianType === 'raccordement' ? 'Saisie terrain et suivi opérationnel des raccordements clients' : baseMeta.subtitle, backTarget: '/ftth', }; } return baseMeta; } function renderBottomNavigation(path, experience) { return experience.navItems.map((item) => { const isActive = item.activePaths.includes(path); return ` ${item.label} `; }).join(''); } function layoutShell(path, experience) { const meta = getShellMeta(path, experience); const showBack = Boolean(meta.backTarget); const focusedFormRoutes = new Set(['/activate', '/login', '/ftth-report']); const isFocusedFormRoute = focusedFormRoutes.has(path); const root = document.getElementById('app'); root.innerHTML = `
${showBack ? ` ` : ''} ${isFocusedFormRoute ? '' : `
`}
${meta.title}
${isFocusedFormRoute ? '' : `
${meta.subtitle}
`}
Session mobile
`; activeShellRoot = root; updateNotificationUi(root); const btn = root.querySelector('#btnLogout'); btn.addEventListener('click', () => { stopTrackingService(); clearToken(); location.hash = '#/login'; }); const btnBack = root.querySelector('#btnBack'); if (btnBack) { btnBack.addEventListener('click', () => { navigateBackFrom(path); }); } const btnNotifications = root.querySelector('#btnNotifications'); btnNotifications?.addEventListener('click', () => { toggleNotificationPanel(root); }); const btnReadNotifications = root.querySelector('#btnReadNotifications'); btnReadNotifications?.addEventListener('click', async () => { await markAllNotificationsReadEverywhere(); updateNotificationUi(root); }); root.addEventListener('click', (event) => { const panel = root.querySelector('#notificationPanel'); const trigger = root.querySelector('#btnNotifications'); if (!panel || panel.hidden) return; if (panel.contains(event.target) || trigger?.contains(event.target)) return; closeNotificationPanel(root); }); return root; } async function showBootSplash() { const cfg = getStoredMobileConfig(); const logoUrl = cfg.logo_url || '/insuite-logo.svg'; const appName = cfg.app_name || 'Insuite Technicien'; const splashBg = cfg.splash_url ? `style="background-image:url('${escapeHtml(cfg.splash_url)}');background-size:cover;background-position:center"` : ''; const root = document.getElementById('app'); root.innerHTML = `
${escapeHtml(appName)}
Fiche terrain native-like pour Android
`; await new Promise((resolve) => window.setTimeout(resolve, 1400)); } async function refreshMeLine(root) { const meLine = root.querySelector('#meLine'); if (!meLine) return; const profile = activeUserProfile || getStoredUserProfile(); if (!profile) { meLine.textContent = ''; return; } const experience = deriveMobileExperience(profile); const suffix = experience.technicianType ? ` — ${experience.moduleLabel}` : ''; meLine.textContent = `${profile.name || ''} — ${profile.email || ''}${suffix}`.trim(); } async function resolveMobileUserProfile({ force = false } = {}) { const token = getToken(); if (!token) { activeUserProfile = null; clearStoredUserProfile(); return null; } if (!force && activeUserProfile) { return activeUserProfile; } if (!force) { const cachedProfile = getStoredUserProfile(); if (cachedProfile) { activeUserProfile = cachedProfile; return cachedProfile; } } try { const res = await apiGet('/api/mobile/me'); if (res?.ok && res.user) { activeUserProfile = setStoredUserProfile(res.user); return activeUserProfile; } } catch { } activeUserProfile = null; clearStoredUserProfile(); return null; } async function renderRoute() { const { path, params } = parseHash(); const activationState = getMobileActivationState(); if (isUsageTrialExpired() && !activationState.activated) { clearToken(); if (path !== '/activate') { location.hash = '#/activate'; return; } } const token = getToken(); if (!activationState.activated && path !== '/activate') { stopTrackingService(); location.hash = '#/activate'; return; } if (activationState.activated && path === '/activate') { location.hash = token ? '#/dashboard' : '#/login'; return; } if (!token && path !== '/login' && path !== '/activate') { stopTrackingService(); location.hash = '#/login'; return; } if (token && path === '/login') { location.hash = '#/dashboard'; return; } if (path === '/activate') { const root = document.getElementById('app'); activeShellRoot = null; root.innerHTML = ''; await renderActivation(root, async () => { location.hash = '#/login'; }); return; } if (path === '/login') { const root = document.getElementById('app'); activeShellRoot = null; root.innerHTML = ''; await renderLogin(root, async (tokenValue) => { setToken(tokenValue); activeUserProfile = null; await syncTrackingService(); location.hash = '#/dashboard'; }); return; } await syncTrackingService(); const userProfile = await resolveMobileUserProfile(); if (!userProfile) { stopTrackingService(); clearToken(); location.hash = '#/login'; return; } const experience = deriveMobileExperience(userProfile); if (!experience.canAccessPath(path)) { location.hash = `#${experience.defaultRoute}`; return; } const root = layoutShell(path, experience); root.querySelector('[data-role="status"]').style.display = 'none'; await refreshMeLine(root); await refreshNotifications({ playSound: false }); const view = root.querySelector('#view'); if (path === '/dashboard') { await renderDashboard(view, { userProfile, experience }); return; } if (path === '/assigned') { await renderAssigned(view); return; } if (path === '/nearby') { await renderProximity(view); return; } if (path === '/treatment') { const incidentId = Number(params.get('id') || 0); await renderTreatment(view, { incidentId }); return; } if (path === '/ftth') { await renderFtthList(view, { userProfile, experience }); return; } if (path === '/ftth-report') { const ticketId = Number(params.get('id') || 0); await renderFtthReport(view, { ticketId, userProfile, experience }); return; } view.innerHTML = '
Route inconnue.
'; } export function startApp() { setupResponsiveViewportHandling(); initializeTrackingService(); window.addEventListener('hashchange', () => { // Nettoyer le handler d'auto-sync de la fiche terrain si on quitte la page if (window.__insuiteFtthOnlineSyncHandler) { window.removeEventListener('online', window.__insuiteFtthOnlineSyncHandler); window.__insuiteFtthOnlineSyncHandler = null; } renderRoute(); }); window.addEventListener(NOTIFICATIONS_UPDATED_EVENT, () => { if (activeShellRoot) { updateNotificationUi(activeShellRoot); } }); setupNativeBackHandler(); setupNotificationPolling(); // Fetch config serveur + splash en parallèle — la config est appliquée avant renderRoute Promise.allSettled([ showBootSplash(), withBootstrapTimeout(fetchAndApplyMobileConfig()), withBootstrapTimeout(syncUsageTrialWithServer()), ]).then(async () => { applyIconToHead(); await withBootstrapTimeout(syncMobileActivationState()); await withBootstrapTimeout(refreshNotifications({ playSound: false })); await renderRoute(); queueViewportMetricsSync({ resetBaseline: true }); }).catch(async () => { await renderRoute(); queueViewportMetricsSync({ resetBaseline: true }); }); } function withBootstrapTimeout(promise, timeoutMs = BOOT_NETWORK_TIMEOUT_MS) { return Promise.race([ promise, new Promise((resolve) => { window.setTimeout(resolve, timeoutMs); }), ]); } function updateNotificationUi(root) { const notifications = getNotifications(); const unreadCount = getUnreadNotificationCount(); const badge = root.querySelector('#notifBadge'); const list = root.querySelector('#notificationList'); if (badge) { badge.hidden = unreadCount < 1; badge.textContent = String(unreadCount); } if (!list) return; if (!notifications.length) { list.innerHTML = '
Aucune notification in-app pour le moment.
'; return; } list.innerHTML = notifications.map((item) => ` `).join(''); list.querySelectorAll('[data-route]').forEach((button) => { button.addEventListener('click', () => { markAllNotificationsRead(); markAllNotificationsReadEverywhere().catch(() => {}); const route = button.getAttribute('data-route') || '/dashboard'; closeNotificationPanel(root); location.hash = `#${route}`; }); }); } function toggleNotificationPanel(root) { const panel = root.querySelector('#notificationPanel'); if (!panel) return; panel.hidden = !panel.hidden; if (!panel.hidden) { markAllNotificationsRead(); markAllNotificationsReadEverywhere().catch(() => {}); updateNotificationUi(root); } } async function refreshNotifications({ playSound = false } = {}) { try { await refreshRemoteNotifications({ playSound }); } catch { return; } if (activeShellRoot) { updateNotificationUi(activeShellRoot); } } function setupNotificationPolling() { if (notificationPollingHandle) return; notificationPollingHandle = window.setInterval(() => { if (!isNativePlatform() && document.hidden) return; refreshNotifications({ playSound: true }); }, 30000); document.addEventListener('visibilitychange', () => { if (!document.hidden) { refreshNotifications({ playSound: false }); } }); window.addEventListener('focus', () => { refreshNotifications({ playSound: false }); }); if (isNativePlatform()) { App.addListener('appStateChange', ({ isActive }) => { if (isActive) { refreshNotifications({ playSound: false }); } }); } } function closeNotificationPanel(root) { const panel = root.querySelector('#notificationPanel'); if (!panel) return false; const wasOpen = !panel.hidden; panel.hidden = true; return wasOpen; } function navigateBackFrom(path) { const backTarget = SHELL_META[path]?.backTarget; if (backTarget) { location.hash = `#${backTarget}`; return; } location.hash = '#/dashboard'; } function applyIconToHead() { const { icon_url: iconUrl } = getStoredMobileConfig(); if (!iconUrl) return; ['icon', 'apple-touch-icon'].forEach((rel) => { let link = document.querySelector(`link[rel="${rel}"]`); if (!link) { link = document.createElement('link'); link.rel = rel; document.head.appendChild(link); } link.href = iconUrl; }); } function isNativePlatform() { return typeof Capacitor?.isNativePlatform === 'function' && Capacitor.isNativePlatform(); } async function setupNativeBackHandler() { if (nativeBackListenerReady || !isNativePlatform()) return; nativeBackListenerReady = true; await App.addListener('backButton', async () => { if (activeShellRoot && closeNotificationPanel(activeShellRoot)) { return; } const { path } = parseHash(); const backTarget = SHELL_META[path]?.backTarget; if (backTarget) { location.hash = `#${backTarget}`; return; } if (path === '/dashboard' || path === '/login') { const confirmed = window.confirm('Voulez-vous quitter l’application Insuite Technicien ?'); if (confirmed) { await App.exitApp(); } return; } location.hash = '#/dashboard'; }); } function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); }