// 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 }; })();