KLEINUILTIES-WEBAPP/js/api.js
Slim Uiltjies App 1939bf2ef4 Initial commit: Slim Uiltjies AfterCare invoice system
- Progressive Web App with Express + JSON backend
- PIN-based auth, multi-device sync
- Invoice + Statement PDF generation (branded, Grandstander + Nunito)
- Mobile + Desktop responsive layouts
- n8n backup integration
- Custom logo upload
- Settings for business info, fees, banking
- Bulk invoice generation
- Web Share API integration
2026-08-08 09:25:17 +02:00

253 lines
6.8 KiB
JavaScript

/* Slim Uiltjies — Client API wrapper
Talks to the Express server. Falls back to local-only mode if the
server is unreachable so the app still works offline.
*/
(function () {
'use strict';
const TOKEN_KEY = 'su_api_token';
const PIN_FALLBACK = '847291'; // used if server unreachable
let _token = null;
try { _token = sessionStorage.getItem(TOKEN_KEY) || null; } catch {}
let _online = true;
let _syncing = false;
let _lastSyncAt = null;
let _listeners = [];
function emit(event, data) {
for (const fn of _listeners) {
try { fn(event, data); } catch {}
}
}
function on(fn) {
_listeners.push(fn);
return () => { _listeners = _listeners.filter((f) => f !== fn); };
}
function isReady() { return !!_token; }
function setToken(t) {
_token = t;
try {
if (t) sessionStorage.setItem(TOKEN_KEY, t);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {}
}
async function auth(pin) {
try {
const r = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin }),
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));
throw new Error(data.error || 'Auth failed');
}
const { token } = await r.json();
setToken(token);
_online = true;
emit('online', true);
return { token, online: true };
} catch (e) {
// Server unreachable — allow offline mode with matching PIN
if (pin === PIN_FALLBACK) {
_online = false;
setToken('offline');
emit('online', false);
return { token: 'offline', online: false };
}
throw e;
}
}
async function health() {
try {
const r = await fetch('/api/health');
return r.ok;
} catch { return false; }
}
async function getState() {
if (!_token || _token === 'offline') {
throw new Error('Offline');
}
const r = await fetch('/api/state', {
headers: { 'X-Auth-Token': _token },
});
if (!r.ok) throw new Error('Fetch failed (' + r.status + ')');
return r.json();
}
async function pushState(state) {
if (!_token || _token === 'offline') {
throw new Error('Offline');
}
const r = await fetch('/api/state', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': _token,
},
body: JSON.stringify(state),
});
if (!r.ok) throw new Error('Push failed (' + r.status + ')');
return r.json();
}
async function logout() {
if (_token && _token !== 'offline') {
try {
await fetch('/api/logout', { method: 'POST', headers: { 'X-Auth-Token': _token } });
} catch {}
}
setToken(null);
_online = true;
}
// Pull server state and import to local IndexedDB. Returns true on success.
async function pullAndMerge() {
if (!_token || _token === 'offline') return false;
try {
const state = await getState();
// Server wins — replace local with server
await window.SUDB.importAll(state, { replace: true });
_lastSyncAt = new Date();
emit('synced', { direction: 'pull', at: _lastSyncAt });
return true;
} catch (e) {
_online = false;
emit('online', false);
return false;
}
}
// Push local state to server. Coalesces rapid calls via the syncLock flag.
let _pendingSync = null;
let _scheduledSync = null;
async function doPush() {
if (!_token || _token === 'offline') return false;
if (_syncing) return false;
_syncing = true;
emit('syncing', true);
try {
const state = await window.SUDB.exportAll();
const res = await pushState(state);
_lastSyncAt = new Date(res.updatedAt || Date.now());
_online = true;
emit('synced', { direction: 'push', at: _lastSyncAt });
// Also fire n8n backup if configured (don't await — fire-and-forget)
if (_n8nUrl) {
n8nBackup(state).catch(() => {});
}
return true;
} catch (e) {
_online = false;
emit('online', false);
return false;
} finally {
_syncing = false;
emit('syncing', false);
}
}
function schedulePush() {
if (!_token || _token === 'offline') return;
if (_scheduledSync) clearTimeout(_scheduledSync);
_scheduledSync = setTimeout(() => {
_scheduledSync = null;
doPush();
}, 500); // debounce 500ms
}
function status() {
return {
online: _online,
hasToken: !!_token,
offlineMode: _token === 'offline',
syncing: _syncing,
lastSyncAt: _lastSyncAt,
};
}
// ============================================================
// n8n backup webhook
// ============================================================
let _n8nUrl = null;
let _n8nToken = null;
let _n8nLastBackup = null;
let _n8nLastError = null;
let _n8nBackingUp = false;
function configureN8n(url, token) {
_n8nUrl = url || null;
_n8nToken = token || null;
try {
if (url) localStorage.setItem('su_n8n_url', url);
else localStorage.removeItem('su_n8n_url');
if (token) localStorage.setItem('su_n8n_token', token);
else localStorage.removeItem('su_n8n_token');
} catch {}
}
function loadN8nConfig() {
try {
_n8nUrl = localStorage.getItem('su_n8n_url') || null;
_n8nToken = localStorage.getItem('su_n8n_token') || null;
} catch {}
}
function n8nStatus() {
return {
configured: !!_n8nUrl,
url: _n8nUrl,
lastBackup: _n8nLastBackup,
lastError: _n8nLastError,
backingUp: _n8nBackingUp,
};
}
async function n8nBackup(state) {
if (!_n8nUrl) return { skipped: true };
if (_n8nBackingUp) return { skipped: true, reason: 'in-progress' };
_n8nBackingUp = true;
emit('n8n-syncing', true);
try {
const headers = { 'Content-Type': 'application/json' };
if (_n8nToken) headers['Authorization'] = `Bearer ${_n8nToken}`;
const res = await fetch(_n8nUrl, {
method: 'POST',
headers,
body: JSON.stringify({ ...state, _backupAt: new Date().toISOString() }),
});
if (!res.ok) throw new Error('HTTP ' + res.status);
_n8nLastBackup = new Date();
_n8nLastError = null;
emit('n8n-synced', { at: _n8nLastBackup });
return { ok: true, at: _n8nLastBackup };
} catch (e) {
_n8nLastError = e.message;
emit('n8n-error', { error: e.message });
return { ok: false, error: e.message };
} finally {
_n8nBackingUp = false;
emit('n8n-syncing', false);
}
}
// Load n8n config on init
loadN8nConfig();
window.SUAPI = {
auth, logout, health, getState, pushState,
pullAndMerge, schedulePush, status,
configureN8n, loadN8nConfig, n8nStatus, n8nBackup,
on, isReady,
};
})();