// aerOS — shared API client (all surfaces).
// Loaded before any data.jsx. Exposes window.aerosApi: a thin fetch wrapper that
// attaches the bearer token (admin/superadmin) and X-Tenant slug (guest/tenant)
// to every call, matching the django-ninja backend's auth + multi-tenancy.
(function () {
// API base: override via or localStorage; else
// prod = same origin (Caddy proxies /api/*), dev = backend on :8000.
function resolveBase() {
const meta = document.querySelector('meta[name="aeros-api-base"]');
if (meta && meta.content) return meta.content.replace(/\/$/, '');
const ls = localStorage.getItem('aeros-api-base');
if (ls) return ls.replace(/\/$/, '');
// Production: the SPA and API share an origin, so a relative base sends calls
// to https:///api/... Dev (http localhost): backend runs on :8000.
const host = location.hostname;
const isLocal = host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1';
if (location.protocol === 'https:' || !isLocal) return '';
return location.protocol + '//' + host + ':8000';
}
const BASE = resolveBase();
// Per-surface token so the superadmin / tenant-admin / guest sessions don't
// clobber each other's auth in the same browser (shared localStorage).
function surface() {
if (window.AEROS_SURFACE) return window.AEROS_SURFACE;
const p = decodeURIComponent(location.pathname).toLowerCase();
if (p.includes('admin.html') || p.includes('tenant')) return 'ta'; // tenant panel
if (p.includes('superadmin') || p.includes('/admin')) return 'sa'; // platform console
if (p.includes('guest')) return 'guest';
return 'app';
}
const SURFACE = surface();
const tokenKey = 'aeros-token-' + SURFACE;
// Surface-scoped so a tenant-admin login (which calls setTenant(org_slug)) on
// the same browser/origin cannot clobber the guest's tenant — they shared one
// 'aeros-tenant' key before, so logging into Radisson admin made the guest menu
// resolve to Radisson too.
const tenantKey = 'aeros-tenant-' + SURFACE;
// SSO handoff: the central login (app.html) redirects to the tenant subdomain
// with #t=. Capture it into this origin's storage, then clean the URL.
(function () {
const m = /[#&]t=([^&]+)/.exec(location.hash || '');
if (m) {
try { localStorage.setItem(tokenKey, decodeURIComponent(m[1])); } catch (e) {}
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
}
})();
// Persist guest context (tenant + room/table/mode/outlet) from the QR/deep-link
// query so it survives a PWA relaunch — the manifest start_url drops the query
// string — and overrides any stale value left by a previous tenant on this
// device. Runs only when the query actually carries a value.
(function () {
try {
const q = new URLSearchParams(location.search);
const t = q.get('tenant');
if (t) localStorage.setItem(tenantKey, t);
['room', 'table', 'mode', 'outlet'].forEach(function (k) {
const v = q.get(k);
if (v) localStorage.setItem('aeros-guest-' + k, v);
});
} catch (e) {}
})();
function getToken() { return localStorage.getItem(tokenKey) || ''; }
function setToken(t) { t ? localStorage.setItem(tokenKey, t) : localStorage.removeItem(tokenKey); }
// Tenant resolution order: ?tenant= (explicit/dev) -> stored -> subdomain.
// Prod: .qrmenu.boranyazilim.com -> the first label is the tenant.
function _subdomainTenant() {
const parts = (location.hostname || '').toLowerCase().split('.');
const reserved = ['www', 'app', 'admin', 'qrmenu', 'localhost', 'staging', '127', '0', 'aeros'];
if (reserved.indexOf(parts[0]) !== -1) return '';
// .localhost (local dev — Chromium resolves *.localhost to 127.0.0.1)
if (parts.length === 2 && parts[1] === 'localhost') return parts[0];
// .qrmenu.boranyazilim.com (prod, wildcard subdomain)
if (parts.length >= 3) return parts[0];
return '';
}
function getTenant() {
return new URLSearchParams(location.search).get('tenant') ||
localStorage.getItem(tenantKey) || _subdomainTenant() || '';
}
function setTenant(s) { s ? localStorage.setItem(tenantKey, s) : localStorage.removeItem(tenantKey); }
async function request(path, { method = 'POST', body, auth = true } = {}) {
const headers = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (auth && getToken()) headers['Authorization'] = 'Bearer ' + getToken();
const ten = getTenant();
if (ten) headers['X-Tenant'] = ten;
let res;
try {
res = await fetch(BASE + '/api' + path, {
method, headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
const err = new Error('network'); err.network = true; throw err;
}
let data = null;
try { data = await res.json(); } catch (e) { /* empty body */ }
if (!res.ok) {
const err = new Error((data && (data.msg_txt || data.detail)) || ('HTTP ' + res.status));
err.status = res.status; err.data = data; throw err;
}
return data;
}
window.aerosApi = {
BASE,
get: (p, o = {}) => request(p, { ...o, method: 'GET' }),
post: (p, b, o = {}) => request(p, { ...o, method: 'POST', body: b }),
getToken, setToken, getTenant, setTenant,
// unwrap the backend's {success, data} envelope
data: (resp) => (resp && resp.data !== undefined ? resp.data : resp),
};
// Image intake: downscale + recompress in-browser before sending base64.
// Phone photos are 2-8MB; raw base64 of those blows past reverse-proxy body
// limits (Caddy/nginx) and bloats the DB TextField. We cap the longest edge
// and emit JPEG so a typical upload lands well under ~300KB.
// format: 'jpeg' (default, opaque, smallest) or 'png' (keeps transparency — use for logos).
function fileToResizedDataURL(file, { maxEdge = 1280, quality = 0.82, format = 'jpeg' } = {}) {
return new Promise((resolve, reject) => {
if (!file) { resolve(null); return; }
const reader = new FileReader();
reader.onerror = () => reject(new Error('read_failed'));
reader.onload = () => {
const img = new Image();
img.onerror = () => resolve(reader.result); // not a decodable image — fall back to raw
img.onload = () => {
const w = img.width, h = img.height;
const scale = Math.min(1, maxEdge / Math.max(w, h));
// SVG/GIF or already-small: keep original (canvas would rasterize/animate-flatten).
if (scale >= 1 && /image\/(svg|gif)/.test(file.type || '')) { resolve(reader.result); return; }
const cw = Math.round(w * scale), ch = Math.round(h * scale);
const canvas = document.createElement('canvas');
canvas.width = cw; canvas.height = ch;
const ctx = canvas.getContext('2d');
const png = format === 'png';
if (!png) { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, cw, ch); } // matte for opaque JPEG
ctx.drawImage(img, 0, 0, cw, ch);
try { resolve(png ? canvas.toDataURL('image/png') : canvas.toDataURL('image/jpeg', quality)); }
catch (e) { resolve(reader.result); }
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
window.aerosImage = { fromFile: fileToResizedDataURL };
})();