// aerOS Guest v3 — production data layer. Hydrates window.G2DATA (whose mock // values come from guest2/data.jsx) from the real Django-Ninja backend, and // exposes window.guestApi for orders / concierge / service requests. // Loaded AFTER guest2/data.jsx and BEFORE guest3/app.jsx. (function () { const api = window.aerosApi; const SYM = { TRY: '₺', EUR: '€', USD: '$', GBP: '£' }; const opt = { auth: false }; async function safe(fn) { try { return await fn(); } catch (e) { return null; } } // Map a TR/EN allergen name to the guest's built-in SVG icon key (lib G2 set); // unmapped allergens (hardal/kereviz/kükürt/lupin) fall back to a generic icon. function _allergenKey(name) { const n = (name || '').toString().toLocaleLowerCase('tr'); if (/glu|buğday|bugday|wheat/.test(n)) return 'gluten'; if (/süt|sut|milk|laktoz|lactose/.test(n)) return 'milk'; if (/yumurta|egg/.test(n)) return 'egg'; if (/kabuk|crustace|shellfish|karides|crab/.test(n)) return 'shellfish'; if (/yumuşak|yumusak|mollus|midye|squid/.test(n)) return 'shellfish'; if (/balık|balik|fish/.test(n)) return 'fish'; if (/susam|sesame/.test(n)) return 'sesame'; if (/soya|soy/.test(n)) return 'soy'; if (/fıstı|fisti|peanut|\bnut|fındık|findik|ceviz|badem|antep/.test(n)) return 'nuts'; return ''; } const qp = new URLSearchParams(location.search); // URL query wins; fall back to the values config.jsx persisted from the QR // deep-link so a PWA relaunch (no query string) keeps the room/mode context. const _ctx = function (k) { const v = qp.get(k); if (v) return v; try { return localStorage.getItem('aeros-guest-' + k) || ''; } catch (e) { return ''; } }; const MODE = _ctx('mode') === 'restaurant' ? 'restaurant' : 'room'; const ROOM = _ctx('room'); const TABLE = _ctx('table'); const OUTLET = _ctx('outlet'); window.G2CTX = { mode: MODE, room: ROOM, table: TABLE, outlet: OUTLET ? Number(OUTLET) : null }; window.guestApi = { ctx: window.G2CTX, async createOrder(payload) { return api.data(await api.post('/qr_menu/create-order/', payload, opt)); }, async trackOrder(id, surname) { return api.data(await api.post('/qr_menu/track-order/', { id, surname }, opt)); }, // Concierge (room messaging). async conciergeActive(room) { const d = await safe(async () => api.data(await api.post('/hotel/concierge/active/', { room_number: room }, opt))); return d ? d.thread_id : null; }, async startConcierge(room, body, name) { return api.data(await api.post('/hotel/concierge/start/', { room_number: room, body, guest_name: name || ('Oda ' + room) }, opt)); }, async sendConcierge(threadId, body) { return api.data(await api.post('/hotel/concierge/send/', { thread_id: Number(threadId), body }, opt)); }, // Static page full content (sections / rich body) for the guest content screens. async staticPage(pageId) { return await safe(async () => api.data(await api.post('/qr_menu/get-static-page/', { page_id: Number(pageId) }, opt))); }, // Active popup event (is_popup + within date window) — shown as a modal on home. async popupEvent() { const d = await safe(async () => api.data(await api.post('/qr_menu/get-popup-event/', {}, opt))); if (!d) return null; return { id: d.id, title: d.event_name, en: d.event_name_eng || d.event_name, info: d.event_info || '', info_en: d.event_info_eng || d.event_info || '', image: d.event_img || null, date: (d.event_start_date || '').slice(0, 10) }; }, async conciergeMessages(threadId) { return (await safe(async () => api.data(await api.post('/hotel/concierge/messages/', { thread_id: Number(threadId) }, opt)))) || []; }, // Service request (call waiter / request bill / water / napkins). async serviceRequest(kind, note) { const body = { kind, note: note || null, outlet_id: window.G2CTX.outlet }; if (window.G2CTX.mode === 'room') body.room_number = window.G2CTX.room || null; else body.table_number = window.G2CTX.table || null; return api.data(await api.post('/hotel/service/request/', body, opt)); }, // Product portions + modifiers + allergens on demand (detail sheet). async productOptions(productId) { const d = await safe(async () => api.data(await api.post('/qr_menu/options/get/', { menu_det_id: productId }, opt))); if (!d) return { portions: [], mods: [], allergens: [] }; const portions = (d.portions || []).map((p) => ({ id: p.id, label: p.name, en: p.name, price: p.rest_price })); const mods = []; (d.modifier_groups || []).forEach((g) => (g.modifiers || []).forEach((m) => mods.push({ id: m.id, label: m.name, en: m.name, price: m.price_delta }))); return { portions, mods, allergens: d.allergen_ids || [] }; }, // Spa reservation (guest -> tenant spa reservations list). async bookSpa(serviceId, duration, price, payload) { const body = { service_id: serviceId, guest_surname: payload.surname || '-', room_number: window.G2CTX.room || payload.surname || '-', reservation_time: payload.when, selected_duration: duration || null, selected_price: price || null, note: payload.note || null }; return api.data(await api.post('/qr_menu/spa/reservations/create/', body, opt)); }, // Transfer/VIP request (guest -> tenant transfer reservations list). async requestTransfer(payload) { const body = { passenger: payload.surname || '-', tel: payload.tel || null, mail: payload.mail || null, route: payload.route || null, car: payload.car || '-', car_price: Number(payload.price) || 0, flight_no: payload.flight || '', round_trip: !!payload.round, remarks: payload.remarks || null, }; return api.data(await api.post('/qr_menu/transfer/vip/create/', body, opt)); }, // Web Push async pushPublicKey() { const d = await safe(async () => api.data(await api.post('/hotel/push/public-key/', {}, opt))); return d && d.public_key; }, async pushSubscribe(sub) { const k = sub.toJSON().keys || {}; return api.data(await api.post('/hotel/push/subscribe/', { endpoint: sub.endpoint, p256dh: k.p256dh, auth: k.auth, room_number: window.G2CTX.room || null, table_number: window.G2CTX.table || null }, opt)); }, }; // Push enable flow (needs a secure context: HTTPS or localhost, and on iOS the // app must be installed to the Home Screen first). function _urlB64ToUint8(b64) { const pad = '='.repeat((4 - b64.length % 4) % 4); const s = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(s); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } window.G2PUSH = { supported: (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) && (typeof window !== 'undefined' && 'PushManager' in window) && (typeof Notification !== 'undefined'), async enable() { if (!this.supported) return { ok: false, reason: 'unsupported' }; try { const perm = await Notification.requestPermission(); if (perm !== 'granted') return { ok: false, reason: 'denied' }; const reg = await navigator.serviceWorker.register('sw.js'); const key = await window.guestApi.pushPublicKey(); if (!key) return { ok: false, reason: 'nokey' }; let sub = await reg.pushManager.getSubscription(); if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: _urlB64ToUint8(key) }); await window.guestApi.pushSubscribe(sub); try { localStorage.setItem('aeros-push-on', '1'); } catch (e) {} return { ok: true }; } catch (e) { return { ok: false, reason: (location.protocol !== 'https:' && location.hostname !== 'localhost') ? 'insecure' : 'error' }; } }, }; // Brand the home-screen app (name + logo). Fire-and-forget — never blocks data hydration. let _manifestUrl = null; function applyAppIdentity(b) { try { const nm = b.name || 'aerOS Misafir'; document.title = nm; const ti = document.querySelector('meta[name="apple-mobile-web-app-title"]'); if (ti) ti.setAttribute('content', nm); const tc = document.querySelector('meta[name="theme-color"]'); if (tc && b.primary_color) tc.setAttribute('content', b.primary_color); const dir = location.href.split('#')[0].split('?')[0].replace(/[^/]*$/, ''); const buildManifest = (logo512) => { const apple = document.querySelector('link[rel="apple-touch-icon"]'); if (apple && (logo512 || b.logo)) apple.href = logo512 || b.logo; const icons = []; if (logo512) icons.push({ src: logo512, sizes: '512x512', type: 'image/png', purpose: 'any maskable' }); icons.push({ src: dir + 'icon-192.png', sizes: '192x192', type: 'image/png' }, { src: dir + 'icon-512.png', sizes: '512x512', type: 'image/png' }); const man = { name: nm, short_name: nm.slice(0, 12), start_url: location.href, scope: dir, display: 'standalone', orientation: 'portrait', background_color: '#0C0A07', theme_color: (b.primary_color || '#0C0A07'), icons }; if (_manifestUrl) { try { URL.revokeObjectURL(_manifestUrl); } catch (e) {} } _manifestUrl = URL.createObjectURL(new Blob([JSON.stringify(man)], { type: 'application/manifest+json' })); const ml = document.querySelector('link[rel="manifest"]'); if (ml) ml.href = _manifestUrl; }; if (!b.logo) { buildManifest(null); return; } // Square the logo to 512x512 off the critical path. const img = new Image(); img.onerror = () => buildManifest(null); img.onload = () => { try { const cv = document.createElement('canvas'); cv.width = 512; cv.height = 512; const cx = cv.getContext('2d'); cx.fillStyle = '#ffffff'; cx.fillRect(0, 0, 512, 512); const s = Math.min(512 / img.width, 512 / img.height); const w = img.width * s, h = img.height * s; cx.drawImage(img, (512 - w) / 2, (512 - h) / 2, w, h); buildManifest(cv.toDataURL('image/png')); } catch (e) { buildManifest(null); } }; img.src = b.logo; } catch (e) {} } // Overwrite the mock G2DATA arrays with real tenant data. window.G2HYDRATE = async function () { const D = window.G2DATA; const roomMode = window.G2CTX.mode === 'room'; // Live data only — drop the prototype's sample content so nothing mock shows. D.PRODUCTS = []; D.OUTLETS = []; D.CATS = []; D.SPA_SERVICES = []; D.ROUTES = []; D.VEHICLES = []; D.EVENTS = []; D.PAGES = []; const b = await safe(async () => api.data(await api.post('/company/get-branding/', {}, opt))); if (b) { D.BRAND = Object.assign({}, D.BRAND, { name: b.name || D.BRAND.name, sub: b.slug || D.BRAND.sub, color: b.primary_color || '#E0742F', logo: b.logo || null, currency: SYM[b.default_currency] || '₺', menuView: b.menu_view || 'gallery', wifiSsid: b.wifi_ssid || D.BRAND.wifiSsid, wifiPass: b.wifi_pass || D.BRAND.wifiPass, whatsapp: b.whatsapp || '', }); [D.TR, D.EN].forEach((dict) => { if (b.name) { dict.brand = b.name; } }); // Home-screen app identity (name + logo). Runs fire-and-forget so the // logo image decode never blocks the menu/data fetch below. applyAppIdentity(b); } const accent = (b && b.primary_color) || '#E0742F'; // Branding (above) gives the accent; the remaining lists are independent — // fetch them concurrently so guest startup is ~2 RTTs, not ~11. const post = (path) => safe(async () => api.data(await api.post(path, {}, opt))); await Promise.all([ post('/qr_menu/list-allergens/').then((al) => { if (al && al.length) { const m = {}, ik = {}; al.forEach((a) => { m[a.id] = a.allergens_name; ik[a.id] = _allergenKey(a.allergens_name); }); D.ALLERGENS = m; D.ALLERGEN_ICOKEY = ik; } }), post('/qr_menu/list-category/').then((cats) => { if (cats && cats.length) D.CATS = cats.map((c) => ({ id: c.id, name: c.category_name, en: c.category_name_eng || c.category_name })); }), post('/qr_menu/outlet/list/').then((outs) => { if (outs && outs.length) D.OUTLETS = outs.map((o) => ({ id: o.id, name: o.name, type: o.outlet_type, desc: '', en: '', color: accent, image: o.image || null })); }), post('/qr_menu/list-menu-det/').then((prods) => { if (prods && prods.length) D.PRODUCTS = prods.map((p) => ({ id: p.id, cat: p.menu_det_category_id, name: p.menu_det_name, en: p.menu_det_name_eng || p.menu_det_name, desc: p.menu_det_content || '', en_desc: p.menu_det_content_eng || p.menu_det_content || '', price: Number(roomMode ? p.menu_det_room_price : p.menu_det_rest_price) || 0, cal: p.menu_det_kcal != null ? p.menu_det_kcal : null, prep: p.menu_det_prep != null ? p.menu_det_prep : null, color: accent, image: p.menu_det_img || null, diet: [], allergens: p.allergen_ids || [], portions: [], mods: [], })); }), post('/qr_menu/spa/services/list/').then((spa) => { if (spa) D.SPA_SERVICES = spa.filter((s) => s.active !== false).map((s) => { const tiers = []; if (s.duration_min != null) tiers.push({ d: s.duration_min + ' dk', p: Number(s.price) || 0 }); if (s.duration_min_2) tiers.push({ d: s.duration_min_2 + ' dk', p: Number(s.price_2) || 0 }); if (s.duration_min_3) tiers.push({ d: s.duration_min_3 + ' dk', p: Number(s.price_3) || 0 }); return { id: s.id, name: s.name_tr, en: s.name_en || s.name_tr, color: accent, image: s.image || null, tiers }; }); }), post('/qr_menu/list-event/').then((evs) => { if (evs) D.EVENTS = evs.filter((e) => e.enabled !== false).map((e) => ({ id: e.id, title: e.event_name, en: e.event_name_eng || e.event_name, info: e.event_info || '', info_en: e.event_info_eng || e.event_info || '', date: (e.event_start_date || '').slice(0, 10), image: e.event_img || null, popup: !!e.is_popup, color: accent })); }), post('/qr_menu/transfer/route/list/').then((routes) => { if (routes) D.ROUTES = routes.filter((r) => r.enabled !== false).map((r) => ({ id: r.id, label: r.route_name + (r.route_to ? ' → ' + r.route_to : ''), en: r.route_name, price: Number(r.route_price) || 0 })); }), post('/qr_menu/transfer/car/list/').then((cars) => { if (cars) D.VEHICLES = cars.filter((c) => c.enabled !== false).map((c) => ({ id: c.id, label: c.car_name + (c.car_type ? ' · ' + c.car_type : ''), cap: c.car_capacity || 0, mult: 1 })); }), post('/qr_menu/list-static-page/').then((pages) => { if (pages) D.PAGES = pages.filter((p) => p.enabled !== false && p.show_in_sidebar !== false).map((p) => ({ id: p.id, key: p.page_key, title: p.page_name, title_en: p.page_name_eng, icon: p.page_icon || p.page_key })); }), ]); return true; }; })();