// Sync.jsx — reliable submission status + persistent client-side queue // ----------------------------------------------------------------------------- // This extends the existing Pow Lee sync pill instead of creating a parallel // system. It provides: // • PLCSync — one app-wide queue/status engine // • — topbar pill; distinguishes attention from normal queue // • — persistent strips, toasts, aria-live announcements, drawer mount // • IndexedDB-first queue, with localStorage fallback for older/private browsers // • stable client_submission_id / Idempotency-Key for retries // ----------------------------------------------------------------------------- (function injectSyncStyles() { if (document.getElementById('plc-sync-styles')) return; const css = ` .plc-net { display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 11px; border-radius: var(--radius-pill); border: 1px solid var(--border-default); background: var(--color-white); font: 600 12.5px/1 var(--font-body); color: var(--color-grey-700); cursor: pointer; user-select: none; white-space: nowrap; transition: background var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out); } .plc-net:hover { background: var(--color-grey-50); } .plc-net__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } .plc-net__count { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; padding: 0 5px; border-radius: 9px; font-size: 10.5px; font-weight: 800; color: #fff; margin-left: 1px; } .plc-net--ok .plc-net__dot { background: var(--color-success); box-shadow: 0 0 0 3px rgba(34,197,94,0.18); } .plc-net--off { border-color: rgba(245,158,11,0.5); background: rgba(245,158,11,0.10); color: var(--color-yellow-700); } .plc-net--off .plc-net__dot { background: var(--color-warning); } .plc-net--sync { border-color: rgba(59,130,246,0.5); background: rgba(59,130,246,0.10); color: #1d4ed8; } .plc-net--sync .plc-net__dot { background: var(--color-info); } .plc-net--attention { border-color: rgba(239,68,68,0.55); background: rgba(239,68,68,0.10); color: var(--color-danger); } .plc-net--attention .plc-net__dot { background: var(--color-danger); } .plc-net__count--off { background: var(--color-yellow-700); } .plc-net__count--sync { background: var(--color-info); } .plc-net__count--attention { background: var(--color-danger); } .plc-spin { animation: plc-net-spin 0.9s linear infinite; } @keyframes plc-net-spin { to { transform: rotate(360deg); } } @media (max-width: 768px) { .plc-net__label { display: none; } } .plc-sync-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .plc-net-pop { position: absolute; top: calc(100% + 8px); right: 0; width: 340px; background: #fff; border: 1px solid var(--border-default); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); z-index: 90; overflow: hidden; font-family: var(--font-body); } .plc-net-pop__head { padding: 16px; display: flex; gap: 12px; align-items: flex-start; } .plc-net-pop__icon { width: 38px; height: 38px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; } .plc-net-foot { padding: 12px 16px; display: flex; gap: 8px; border-top: 1px solid var(--border-default); } .plc-net-test { padding: 12px 16px; border-top: 1px solid var(--border-default); background: var(--color-grey-50); } .plc-net-test__row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; } .plc-sw { width: 38px; height: 22px; border-radius: 11px; border: none; cursor: pointer; padding: 2px; flex-shrink: 0; display: inline-flex; transition: background var(--duration-fast); } .plc-sw__knob { width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.3); transition: transform var(--duration-fast) var(--ease-out); } .plc-strip { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 980; display: flex; align-items: center; gap: 11px; padding: 11px 16px; border-radius: var(--radius-pill); box-shadow: var(--shadow-lg); font: 600 13px/1.35 var(--font-body); max-width: calc(100vw - 32px); animation: plc-strip-in 240ms var(--ease-out); } .plc-strip--off, .plc-strip--sync { background: var(--color-navy); color: #fff; } .plc-strip--attention { background: var(--color-danger); color: #fff; } .plc-strip__btn { border: none; cursor: pointer; font: 700 12px/1 var(--font-body); padding: 7px 12px; border-radius: var(--radius-pill); background: rgba(255,255,255,0.16); color: #fff; display: inline-flex; align-items: center; gap: 6px; } .plc-strip__btn:hover { background: rgba(255,255,255,0.26); } @keyframes plc-strip-in { from { transform: translate(-50%, 16px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } .plc-toasts { position: fixed; top: 24px; right: 24px; z-index: 1001; display: flex; flex-direction: column; gap: 10px; max-width: 390px; } .plc-toast { display: flex; align-items: flex-start; gap: 11px; padding: 13px 15px; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); color: #fff; font: 500 13.5px/1.45 var(--font-body); animation: plc-toast-in 240ms var(--ease-out); } .plc-toast__x { margin-left: auto; background: transparent; border: none; color: rgba(255,255,255,0.7); cursor: pointer; padding: 0; flex-shrink: 0; display: inline-flex; } .plc-toast--success { background: var(--color-success); } .plc-toast--info { background: var(--color-navy); } .plc-toast--warning, .plc-toast--error { background: var(--color-danger); } @keyframes plc-toast-in { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @media (prefers-reduced-motion: reduce) { .plc-spin, .plc-strip, .plc-toast { animation: none !important; transition: none !important; } } @media (max-width: 768px) { .plc-toasts { left: 16px; right: 16px; top: 16px; max-width: none; } .plc-net-pop { position: fixed; left: 16px; right: 16px; top: 70px; width: auto; } } `; const tag = document.createElement('style'); tag.id = 'plc-sync-styles'; tag.textContent = css; document.head.appendChild(tag); })(); const STATUS_CONFIG = { draft: { label: 'Draft', group: 'waiting', retryable: false, tone: 'gray' }, queued: { label: 'Waiting to sync', group: 'waiting', retryable: true, tone: 'warning' }, sending: { label: 'Sending', group: 'sending', retryable: false, tone: 'info' }, synchronized: { label: 'Synchronized', group: 'synced', retryable: false, tone: 'success' }, retry_scheduled: { label: 'Retry scheduled', group: 'waiting', retryable: true, tone: 'warning' }, failed_temporary: { label: 'Temporary failure', group: 'waiting', retryable: true, tone: 'warning' }, failed_permanent: { label: 'Requires attention', group: 'attention', retryable: false, tone: 'danger' }, validation_error: { label: 'Validation error', group: 'attention', retryable: false, tone: 'danger' }, authentication_required: { label: 'Sign in required', group: 'attention', retryable: false, tone: 'danger' }, conflict: { label: 'Review latest version', group: 'attention', retryable: false, tone: 'danger' }, already_processed: { label: 'Already saved', group: 'synced', retryable: false, tone: 'success' }, cancelled: { label: 'Cancelled', group: 'synced', retryable: false, tone: 'gray' }, }; const plcNowIso = () => new Date().toISOString(); const plcRelTime = (ts) => { const t = typeof ts === 'number' ? ts : Date.parse(ts || 0); const s = Math.max(0, Math.round((Date.now() - t) / 1000)); if (s < 60) return 'just now'; if (s < 3600) return Math.floor(s / 60) + ' min ago'; if (s < 86400) return Math.floor(s / 3600) + ' hr ago'; return Math.floor(s / 86400) + ' d ago'; }; const plcUuid = () => { if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); return 'cs_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 12); }; const plcStableStringify = (value) => { if (Array.isArray(value)) return '[' + value.map(plcStableStringify).join(',') + ']'; if (value && typeof value === 'object') { return '{' + Object.keys(value).sort().filter(k => !/attempt|timestamp|last_attempt/i.test(k)).map(k => JSON.stringify(k) + ':' + plcStableStringify(value[k])).join(',') + '}'; } return JSON.stringify(value); }; const plcHash = (value) => { const s = plcStableStringify(value); let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); } return (h >>> 0).toString(16); }; const PLCSync = (() => { const DB_NAME = 'plc_sync_queue_v2'; const STORE = 'submissions'; const FALLBACK_KEY = 'plc_sync_queue_v2_fallback'; const HISTORY_KEY = 'plc_sync_history_v2'; let dbPromise = null; let items = []; let history = []; let simulateOffline = false; let simulateNext = null; let flushing = false; let drawerOpen = false; let lockUntil = 0; try { history = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]') || []; } catch (e) { history = []; } const persistHistory = () => { history = history.slice(0, 25); try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); } catch (e) {} }; const emit = () => { window.dispatchEvent(new CustomEvent('plc-sync-change')); try { localStorage.setItem('plc_sync_broadcast', String(Date.now())); } catch (e) {} }; const toast = (kind, msg) => window.dispatchEvent(new CustomEvent('plc-sync-toast', { detail: { kind, msg } })); const announce = (msg) => window.dispatchEvent(new CustomEvent('plc-sync-announce', { detail: { msg } })); const isOnline = () => navigator.onLine && !simulateOffline; const openDb = () => { if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB unavailable')); if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error('IndexedDB open failed')); }); return dbPromise; }; const txStore = async (mode) => { const db = await openDb(); return db.transaction(STORE, mode).objectStore(STORE); }; const loadFallback = () => { try { return JSON.parse(localStorage.getItem(FALLBACK_KEY) || '[]') || []; } catch (e) { return []; } }; const saveFallback = (rows) => { try { localStorage.setItem(FALLBACK_KEY, JSON.stringify(rows)); } catch (e) {} }; const loadAll = async () => { try { const store = await txStore('readonly'); items = await new Promise((resolve, reject) => { const req = store.getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } catch (e) { items = loadFallback(); } emit(); return items; }; const saveItem = async (item) => { const next = items.filter(x => x.id !== item.id).concat(item).sort((a, b) => String(a.created_at_client).localeCompare(String(b.created_at_client))); items = next; try { const store = await txStore('readwrite'); await new Promise((resolve, reject) => { const req = store.put(item); req.onsuccess = resolve; req.onerror = () => reject(req.error); }); } catch (e) { saveFallback(items); } emit(); return item; }; const removeItem = async (id) => { items = items.filter(x => x.id !== id); try { const store = await txStore('readwrite'); await new Promise((resolve, reject) => { const req = store.delete(id); req.onsuccess = resolve; req.onerror = () => reject(req.error); }); } catch (e) { saveFallback(items); } emit(); }; const classify = (err, envelope) => { const status = err && (err.status || err.code); const code = String((err && err.code) || (envelope && envelope.status) || status || '').toLowerCase(); if (simulateNext) { const s = simulateNext; simulateNext = null; if (s === 'auth') return { status: 'authentication_required', code: 'AUTHENTICATION_REQUIRED', message: 'Please sign in again to submit this pending entry.' }; if (s === 'conflict') return { status: 'conflict', code: 'RECORD_VERSION_CONFLICT', message: 'This record was changed by another user. Review the latest version before continuing.' }; if (s === 'validation') return { status: 'validation_error', code: 'INVALID_INPUT', message: 'This entry requires correction before it can be submitted.' }; } if (code === 'already_processed' || (envelope && envelope.status === 'already_processed')) { return { status: 'already_processed', code: 'ALREADY_PROCESSED', message: 'This entry was already saved. No duplicate was created.' }; } if (status === 401 || code.includes('auth')) return { status: 'authentication_required', code: 'AUTHENTICATION_REQUIRED', message: 'Please sign in again to submit this pending entry.' }; if (status === 409 || code.includes('conflict')) return { status: 'conflict', code: 'RECORD_VERSION_CONFLICT', message: 'This record was changed by another user. Review the latest version before continuing.' }; if (status === 422 || code.includes('validation')) return { status: 'validation_error', code: 'INVALID_INPUT', message: (err && err.message) || 'This entry requires correction before it can be submitted.', fields: err && err.fields }; if ([408, 425, 429, 500, 502, 503, 504].includes(Number(status)) || !status) return { status: 'failed_temporary', code: 'TEMPORARY_FAILURE', message: 'The entry has not been confirmed. It remains safely queued.' }; return { status: 'failed_permanent', code: String(status || 'REQUEST_FAILED'), message: (err && err.message) || 'This entry requires attention before it can be submitted.' }; }; const backoffMs = (retryCount) => { const base = [2000, 5000, 15000, 30000][Math.min(retryCount, 3)] || 60000; return base + Math.floor(Math.random() * Math.min(base, 5000)); }; const addHistory = (item, result) => { history.unshift({ id: item.id, title: item.title, entity_type: item.entity_type, operation_type: item.operation_type, client_submission_id: item.client_submission_id, server_record_id: result && result.recordId, status: result && result.status || item.current_status, at: plcNowIso(), }); persistHistory(); }; const snapshot = () => { const attention = items.filter(x => (STATUS_CONFIG[x.current_status] || {}).group === 'attention'); const sending = items.filter(x => x.current_status === 'sending'); const waiting = items.filter(x => ['queued', 'retry_scheduled', 'failed_temporary'].includes(x.current_status)); return { online: isOnline(), rawOnline: navigator.onLine, simulateOffline, simulateNext, flushing, drawerOpen, pending: waiting.length + sending.length, attention: attention.length, sending: sending.length, waiting: waiting.length, items: items.slice(), history: history.slice(), statuses: STATUS_CONFIG, }; }; const makeItem = (opts) => { const payload = opts.payload || {}; const clientId = opts.clientSubmissionId || payload.client_submission_id || payload.clientSubmissionId || plcUuid(); const withId = Object.assign({}, payload, { client_submission_id: clientId }); return { id: clientId, client_submission_id: clientId, entity_type: opts.entityType || opts.entity_type || 'submission', operation_type: opts.operationType || opts.operation_type || 'create', record_ref: opts.recordRef || opts.record_ref || null, title: opts.title || 'Pending submission', payload: withId, payload_version: opts.payloadVersion || 1, payload_hash: plcHash(withId), created_at_client: opts.createdAt || plcNowIso(), first_attempt_at: null, last_attempt_at: null, synchronized_at: null, retry_count: 0, next_retry_at: null, current_status: 'queued', last_error_code: null, message: 'No connection. This entry is safely queued and will be submitted when the connection returns.', user_id: opts.userId || null, original_record_version: opts.originalRecordVersion || null, }; }; const enqueue = async (opts) => { const item = makeItem(opts || {}); const existing = items.find(x => x.id === item.id); if (existing && existing.payload_hash !== item.payload_hash) { const conflict = Object.assign({}, existing, { current_status: 'conflict', last_error_code: 'CLIENT_IDEMPOTENCY_CONFLICT', message: 'This retry has different data from the original submission. Review before continuing.', }); await saveItem(conflict); toast('warning', conflict.message); return conflict; } await saveItem(existing || item); announce('Submission queued safely on this device.'); return existing || item; }; const markResult = async (item, result) => { const status = result && result.status || 'synchronized'; const done = ['synchronized', 'already_processed'].includes(status); const next = Object.assign({}, item, { current_status: done ? status : (STATUS_CONFIG[status] ? status : 'synchronized'), synchronized_at: done ? plcNowIso() : item.synchronized_at, server_record_id: result && (result.recordId || result.id || result.booking_ref), message: result && result.message || (status === 'already_processed' ? 'This entry was already saved. No duplicate was created.' : 'Saved successfully.'), }); if (done) { addHistory(next, result); await removeItem(item.id); } else { await saveItem(next); } return next; }; const reportOutcome = async (meta, errOrResult) => { const item = await enqueue(meta || {}); if (!errOrResult || errOrResult.ok || errOrResult.success || errOrResult.id || errOrResult.booking_ref) { const done = await markResult(item, Object.assign({ status: 'synchronized' }, errOrResult || {})); toast('success', done.message || 'Saved successfully.'); return done; } const c = classify(errOrResult); const retryable = c.status === 'failed_temporary'; const next = Object.assign({}, item, { current_status: retryable ? 'retry_scheduled' : c.status, last_error_code: c.code, message: c.message, field_errors: c.fields || errOrResult.fields || null, retry_count: item.retry_count || 0, next_retry_at: retryable ? new Date(Date.now() + backoffMs(item.retry_count || 0)).toISOString() : null, }); await saveItem(next); toast(retryable ? 'warning' : 'error', c.message); announce(c.message); return next; }; const sendItem = async (item) => { if (!isOnline()) return; const current = items.find(x => x.id === item.id) || item; if ((STATUS_CONFIG[current.current_status] || {}).group === 'attention') return; if (current.next_retry_at && Date.parse(current.next_retry_at) > Date.now()) return; const sending = Object.assign({}, current, { current_status: 'sending', first_attempt_at: current.first_attempt_at || plcNowIso(), last_attempt_at: plcNowIso(), }); await saveItem(sending); try { let result; if (current.entity_type === 'booking' && window.PLC_API) { result = current.operation_type === 'update' ? await window.PLC_API.update('bookings', current.record_ref, current.payload, { idempotencyKey: current.client_submission_id }) : await window.PLC_API.createBooking(current.payload, { idempotencyKey: current.client_submission_id }); } else { throw Object.assign(new Error('No retry handler is available for this submission.'), { status: 400, code: 'NO_RETRY_HANDLER' }); } await markResult(sending, Object.assign({ status: 'synchronized' }, result || {})); toast('success', result && result.message || 'Saved successfully.'); return result || { ok: true }; } catch (err) { const c = classify(err); const retryable = c.status === 'failed_temporary'; const nextRetry = retryable ? new Date(Date.now() + backoffMs((sending.retry_count || 0) + 1)).toISOString() : null; await saveItem(Object.assign({}, sending, { current_status: retryable ? 'retry_scheduled' : c.status, retry_count: (sending.retry_count || 0) + 1, next_retry_at: nextRetry, last_error_code: c.code, message: c.message, field_errors: c.fields || err.fields || null, })); if (!retryable) toast('error', c.message); throw err; } }; const flush = async (opts) => { opts = opts || {}; if (flushing || !items.length || !isOnline()) return; if (Date.now() < lockUntil) return; lockUntil = Date.now() + 15000; flushing = true; emit(); try { const retryable = items.filter(x => ['queued', 'retry_scheduled', 'failed_temporary'].includes(x.current_status)); for (const item of retryable) await sendItem(item); } finally { flushing = false; lockUntil = 0; emit(); } }; const submitApi = async (opts) => { const item = await enqueue(opts || {}); if (!isOnline()) { toast('info', `No connection — “${item.title}” is safely queued and will submit automatically.`); return Promise.reject(Object.assign(new Error('Queued while offline'), { status: 0, code: 'QUEUED_OFFLINE', queued: true })); } let result; try { result = await sendItem(item); } catch (err) { const stillAfterError = items.find(x => x.id === item.id); return Promise.reject(Object.assign(err, { queued: true, syncItem: stillAfterError || item })); } const still = items.find(x => x.id === item.id); if (still) return Promise.reject(Object.assign(new Error(still.message || 'Submission was not confirmed'), { status: still.last_error_code, code: still.last_error_code, queued: true, syncItem: still })); return result || { ok: true, status: 'synchronized', clientSubmissionId: item.client_submission_id }; }; const submit = ({ title, message }) => { toast(isOnline() ? 'success' : 'info', isOnline() ? (message || `${title} submitted.`) : `No connection — “${title}” was saved locally.`); return isOnline(); }; const cancel = async (id) => { const item = items.find(x => x.id === id); if (!item) return; addHistory(Object.assign({}, item, { current_status: 'cancelled' }), { status: 'cancelled' }); await removeItem(id); toast('info', 'Pending submission cancelled.'); }; const setDrawerOpen = (v) => { drawerOpen = !!v; emit(); }; const simulate = async (kind) => { simulateNext = kind; const id = plcUuid(); await reportOutcome({ title: 'Simulated ' + kind + ' booking', entityType: 'booking', operationType: 'create', clientSubmissionId: id, payload: { client_submission_id: id, simulated: true, kind }, }, Object.assign(new Error('Simulated ' + kind), { status: kind === 'auth' ? 401 : kind === 'conflict' ? 409 : 422, code: kind })); }; window.addEventListener('online', () => { emit(); toast('info', 'Back online — checking pending submissions…'); flush(); }); window.addEventListener('offline', () => { emit(); toast('warning', "You're offline — submissions will be saved on this device."); }); window.addEventListener('focus', () => flush({ silent: true })); window.addEventListener('storage', (e) => { if (e.key === 'plc_sync_broadcast') loadAll(); }); setInterval(() => { if (items.length && isOnline()) flush({ silent: true }); }, 12000); loadAll(); return { STATUS_CONFIG, snapshot, loadAll, isOnline, enqueue, submitApi, reportOutcome, flush, submit, retry: (id) => id ? sendItem(items.find(x => x.id === id)).then(() => emit()) : flush(), retryAll: () => flush(), cancel, setDrawerOpen, simulate, setSimulateOffline: (v) => { simulateOffline = !!v; emit(); if (!simulateOffline) flush(); }, }; })(); window.PLCSync = PLCSync; function useSync() { const [s, setS] = React.useState(() => PLCSync.snapshot()); React.useEffect(() => { const h = () => setS(PLCSync.snapshot()); window.addEventListener('plc-sync-change', h); window.addEventListener('online', h); window.addEventListener('offline', h); return () => { window.removeEventListener('plc-sync-change', h); window.removeEventListener('online', h); window.removeEventListener('offline', h); }; }, []); return s; } function OfflineIndicator() { const s = useSync(); const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); let variant = 'ok', label = 'Online', Ico = null, spin = false; if (s.attention) { variant = 'attention'; label = 'Needs attention'; Ico = AlertTriangle; } else if (s.flushing || s.sending) { variant = 'sync'; label = 'Syncing…'; Ico = RefreshCw; spin = true; } else if (!s.online) { variant = 'off'; label = 'Offline'; Ico = Wifi; } else if (s.pending) { variant = 'sync'; label = 'Pending'; Ico = RefreshCw; } const headTitle = s.attention ? 'Submissions need attention' : (!s.online ? "You're offline" : (s.pending ? 'Pending submissions' : 'Connected')); const headBody = s.attention ? `${s.attention} item${s.attention > 1 ? 's need' : ' needs'} review. No data has been silently lost.` : (!s.online ? 'New submissions are saved safely on this device and will retry when the connection returns.' : (s.pending ? `${s.pending} item${s.pending > 1 ? 's are' : ' is'} waiting or sending.` : 'All confirmed submissions are synchronized.')); const Toggle = ({ on, onClick }) => ( ); return (
{open ? (
{s.attention ? : (!s.online ? : (s.pending ? : ))}
{headTitle}
{headBody}
Testing
Simulate offline PLCSync.setSimulateOffline(!s.simulateOffline)}/>
) : null}
); } function SubmissionStatus({ item }) { const cfg = STATUS_CONFIG[item.current_status] || STATUS_CONFIG.queued; const color = cfg.tone === 'danger' ? 'var(--color-danger)' : cfg.tone === 'success' ? 'var(--color-success)' : cfg.tone === 'info' ? 'var(--color-info)' : 'var(--color-warning)'; return (
{item.title} {cfg.label}
{item.message || 'Submission is being tracked.'}
{item.client_submission_id}
{cfg.retryable || item.current_status === 'authentication_required' ? : null} {item.current_status === 'authentication_required' ? : null} {item.current_status === 'conflict' ? : null} {item.current_status === 'validation_error' ? : null} {cfg.group !== 'synced' ? : null}
); } function SyncHost() { const s = useSync(); const [toasts, setToasts] = React.useState([]); const [announceMsg, setAnnounceMsg] = React.useState(''); React.useEffect(() => { const onToast = (e) => { const id = Date.now() + Math.random(); const t = { id, kind: e.detail.kind, msg: e.detail.msg }; setToasts(list => [...list, t]); setTimeout(() => setToasts(list => list.filter(x => x.id !== id)), e.detail.kind === 'warning' || e.detail.kind === 'error' ? 6000 : 3600); }; const onAnnounce = (e) => setAnnounceMsg(e.detail.msg || ''); window.addEventListener('plc-sync-toast', onToast); window.addEventListener('plc-sync-announce', onAnnounce); return () => { window.removeEventListener('plc-sync-toast', onToast); window.removeEventListener('plc-sync-announce', onAnnounce); }; }, []); const dismiss = (id) => setToasts(list => list.filter(x => x.id !== id)); let strip = null; if (s.attention) { strip =
{s.attention} submission{s.attention > 1 ? 's need' : ' needs'} attention.
; } else if (!s.online) { strip =
{s.pending ? `Offline — ${s.pending} submission${s.pending > 1 ? 's' : ''} safely queued.` : "You're offline — new submissions will be queued."}
; } else if (s.pending) { strip =
{s.flushing ? `Syncing ${s.pending} submission${s.pending > 1 ? 's' : ''}…` : `${s.pending} submission${s.pending > 1 ? 's' : ''} waiting to sync.`}
; } return (
{announceMsg}
{strip} {toasts.length ? (
{toasts.map(t => (
{t.kind === 'success' ? : t.kind === 'info' ? : } {t.msg}
))}
) : null} {window.PendingSubmissionsDrawer ? : null}
); } Object.assign(window, { STATUS_CONFIG, PLCSync, useSync, OfflineIndicator, SyncHost, SubmissionStatus, plcRelTime });