- 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
253 lines
6.8 KiB
JavaScript
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,
|
|
};
|
|
})();
|