/* @jsx React.createElement */ const { useState, useEffect, useRef, useMemo } = React; /* ============================================================ Yappari — APP SHELL + router ============================================================ */ const NAV = [ { id: 'overview', label: 'Overview', jp: '概要', icon: 'overview' }, { id: 'sales', label: 'Penjualan', jp: '売上', icon: 'sales' }, { id: 'stock', label: 'Stok', jp: '在庫', icon: 'stock' }, { id: 'planogram', label: 'Planogram', jp: '棚割', icon: 'planogram' }, { id: 'control', label: 'Machine Control', jp: '制御', icon: 'control' }, { id: 'signage', label: 'Signage', jp: '広告', icon: 'screen' }, { id: 'products', label: 'Produk', jp: '商品', icon: 'products' }, { id: 'events', label: 'Events', jp: 'イベント', icon: 'events' }, { id: 'settings', label: 'Pengaturan', jp: '設定', icon: 'settings' }, ]; /* tweakable typography + accent */ const TWEAK_DEFAULTS = { uiFont: 'Plus Jakarta Sans', numberStyle: 'Rapi', headWeight: 'Sedang', textScale: 100, accent: '#004080' }; const TFONTS = { 'Plus Jakarta Sans': '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', 'Be Vietnam Pro': '"Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif', 'Hanken Grotesk': '"Hanken Grotesk", ui-sans-serif, system-ui, sans-serif', 'Figtree': '"Figtree", ui-sans-serif, system-ui, sans-serif', }; const TWHEAD = { Ringan: 600, Sedang: 700, Tebal: 800 }; function useClickOutside(ref, cb) { useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) cb(); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); } function MachineSelect({ machines, value, onChange }) { const [open, setOpen] = useState(false); const ref = useRef(); useClickOutside(ref, () => setOpen(false)); const sel = machines.find(m => m.id === value); const dotColor = (s) => s === 'online' ? 'var(--st-online)' : s === 'offline' ? 'var(--st-offline)' : s === 'error' ? 'var(--st-error)' : 'var(--st-maint)'; return React.createElement('div', { className: 'mach-select', ref }, React.createElement('button', { className: 'ms-btn', onClick: () => setOpen(o => !o) }, sel ? React.createElement('span', { className: 'ms-dot', style: { background: dotColor(sel.status) } }) : React.createElement(Icon, { name: 'layers', size: 16, style: { color: 'var(--st-maint)' } }), React.createElement('span', { style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, sel ? sel.name : 'Semua mesin'), React.createElement(Icon, { name: 'chevronDown', size: 16, style: { color: 'var(--fg-3)' } })), open && React.createElement('div', { className: 'ms-menu' }, React.createElement('div', { className: 'ms-opt', onClick: () => { onChange(null); setOpen(false); }, style: value == null ? { background: 'var(--bg-sunken)' } : null }, React.createElement(Icon, { name: 'layers', size: 16, style: { color: 'var(--st-maint)' } }), React.createElement('div', null, React.createElement('div', { style: { fontWeight: 700 } }, 'Semua mesin'), React.createElement('div', { className: 'ms-loc' }, machines.length + ' unit'))), machines.map(m => React.createElement('div', { key: m.id, className: 'ms-opt', onClick: () => { onChange(m.id); setOpen(false); }, style: value === m.id ? { background: 'var(--bg-sunken)' } : null }, React.createElement('span', { className: 'ms-dot', style: { background: dotColor(m.status) } }), React.createElement('div', { style: { minWidth: 0 } }, React.createElement('div', { style: { fontWeight: 700 } }, m.name), React.createElement('div', { className: 'ms-loc', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, m.loc)))), ), ); } function Clock() { const [t, setT] = useState(new Date()); useEffect(() => { const id = setInterval(() => setT(new Date()), 1000); return () => clearInterval(id); }, []); const hh = String(t.getHours()).padStart(2, '0'); const mm = String(t.getMinutes()).padStart(2, '0'); const ss = String(t.getSeconds()).padStart(2, '0'); return React.createElement('div', { className: 'tb-clock' }, hh + ':' + mm, React.createElement('span', { className: 'secs' }, ':' + ss)); } function MachineDrawer({ m, onClose, onGo }) { const Y = window.YAPPARI; const sum = machineStockSummary(m.id); const sales = Y.SALES_BY_MACHINE[m.id]; const tempBad = m.temp != null && m.temp > 10; const stat = (label, value, color) => React.createElement('div', { className: 'card', style: { padding: 14, boxShadow: 'none', background: 'var(--bg-sunken)' } }, React.createElement('div', { className: 'field-label', style: { marginBottom: 4 } }, label), React.createElement('div', { style: { fontSize: 18, fontWeight: 800, color: color || 'var(--fg-1)' } }, value)); return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 150, display: 'flex', justifyContent: 'flex-end', background: 'rgba(0,20,38,.45)', animation: 'fadeIn .2s' }, onClick: onClose }, React.createElement('aside', { onClick: e => e.stopPropagation(), style: { width: 440, maxWidth: '92vw', background: 'var(--bg)', height: '100%', overflowY: 'auto', boxShadow: 'var(--shadow-3)', animation: 'viewIn .25s' } }, React.createElement('div', { style: { background: 'linear-gradient(150deg,#004080,#002E5C)', color: '#fff', padding: '22px 24px', position: 'relative', overflow: 'hidden' } }, React.createElement('div', { style: { position: 'absolute', right: -10, top: -10, opacity: .16 } }, React.createElement(PlumBlossom, { size: 120, color: '#fff' })), React.createElement('div', { className: 'row', style: { justifyContent: 'space-between', alignItems: 'flex-start' } }, React.createElement('div', null, React.createElement('div', { style: { fontFamily: 'var(--font-display)', fontSize: 30, letterSpacing: '.02em' } }, m.name), React.createElement('div', { className: 'row gap-1', style: { fontSize: 13, opacity: .9, marginTop: 4 } }, React.createElement(Icon, { name: 'mapPin', size: 14 }), m.loc)), React.createElement('button', { className: 'icon-btn', onClick: onClose, style: { background: 'rgba(255,255,255,.15)', border: 'none', color: '#fff' } }, React.createElement(Icon, { name: 'x', size: 18 }))), React.createElement('div', { style: { marginTop: 14 } }, React.createElement(StatusBadge, { status: m.status })), ), React.createElement('div', { style: { padding: 24 } }, React.createElement('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 } }, stat('Suhu kabin', m.temp != null ? m.temp + '°C' : '—', tempBad ? 'var(--st-error)' : 'var(--st-online)'), stat('Pintu', m.door === 'closed' ? 'Tertutup' : m.door === 'open' ? 'Terbuka' : '—', m.door === 'open' ? 'var(--st-warn)' : null), stat('Omzet hari ini', fmtRpShort(sales.totalRev)), stat('Transaksi', fmtNum(sales.totalTx)), stat('Slot habis', sum.empty, sum.empty ? 'var(--st-error)' : 'var(--st-online)'), stat('Stok rendah', sum.low, sum.low ? 'var(--st-warn)' : 'var(--st-online)')), React.createElement('div', { style: { fontSize: 12.5, color: 'var(--fg-3)', marginBottom: 16 } }, 'Serial ' + m.serial + ' · ' + m.model + ' · terlihat ' + relMins(m.lastSeen)), React.createElement('div', { className: 'col gap-3' }, React.createElement('div', { className: 'row gap-3' }, React.createElement('button', { className: 'btn btn-ghost', style: { flex: 1, justifyContent: 'center' }, onClick: () => onGo('stock', m.id) }, React.createElement(Icon, { name: 'stock', size: 16 }), 'Stok'), React.createElement('button', { className: 'btn btn-ghost', style: { flex: 1, justifyContent: 'center' }, onClick: () => onGo('planogram', m.id) }, React.createElement(Icon, { name: 'planogram', size: 16 }), 'Planogram')), React.createElement('button', { className: 'btn btn-danger', style: { justifyContent: 'center' }, onClick: () => onGo('control', m.id) }, React.createElement(Icon, { name: 'zap', size: 16 }), 'Buka Machine Control'), ), ), ), ); } function App() { const Y = window.YAPPARI; const machines = Y.MACHINES; const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [, forceRev] = useState(0); const bump = () => forceRev(v => v + 1); const [theme, setTheme] = useState(() => localStorage.getItem('yappari-theme') || 'light'); const [view, setView] = useState('overview'); const [selMachine, setSelMachine] = useState(null); const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); const [drawer, setDrawer] = useState(null); const [refreshing, setRefreshing] = useState(false); const [lastUpdate, setLastUpdate] = useState(new Date()); const [tick, setTick] = useState(0); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('yappari-theme', theme); }, [theme]); useEffect(() => { const rs = document.documentElement.style; rs.setProperty('--font-sans', TFONTS[t.uiFont] || TFONTS['Plus Jakarta Sans']); rs.fontSize = (16 * (t.textScale || 100) / 100) + 'px'; rs.setProperty('--w-head', String(TWHEAD[t.headWeight] || 700)); if (t.numberStyle === 'Tegas') { rs.setProperty('--font-num', 'var(--font-display)'); rs.setProperty('--w-num', '400'); rs.setProperty('--ls-num', '.01em'); } else { rs.setProperty('--font-num', 'var(--font-sans)'); rs.setProperty('--w-num', '700'); rs.setProperty('--ls-num', '-.01em'); } rs.setProperty('--accent', t.accent || '#004080'); }, [t]); useEffect(() => { const id = setInterval(() => setTick(t => t + 1), 5000); return () => clearInterval(id); }, []); const online = machines.filter(m => m.status === 'online').length; const offline = machines.length - online; const navMeta = NAV.find(n => n.id === view); const doRefresh = () => { setRefreshing(true); setTimeout(() => { setRefreshing(false); setLastUpdate(new Date()); }, 900); }; const relUpdate = () => { const s = Math.floor((Date.now() - lastUpdate.getTime()) / 1000); return s < 5 ? 'baru saja' : s < 60 ? s + ' detik lalu' : Math.floor(s / 60) + ' menit lalu'; }; const goView = (v) => { setView(v); setMobileOpen(false); }; const goFromDrawer = (v, mid) => { setSelMachine(mid); setView(v); setDrawer(null); }; const renderView = () => { switch (view) { case 'overview': return React.createElement(OverviewView, { machines, onOpenMachine: setDrawer }); case 'sales': return React.createElement(SalesView, { selectedMachine: selMachine, machines }); case 'stock': return React.createElement(StockView, { selectedMachine: selMachine, machines }); case 'planogram': return React.createElement(PlanogramView, { selectedMachine: selMachine, machines }); case 'control': return React.createElement(ControlView, { selectedMachine: selMachine, machines }); case 'signage': return React.createElement(SignageView, { machines, selectedMachine: selMachine }); case 'products': return React.createElement(ProductsView, null); case 'events': return React.createElement(EventsView, { machines, selectedMachine: selMachine }); case 'settings': return React.createElement(SettingsView, { machines, onChange: bump }); default: return null; } }; return React.createElement('div', { className: 'app' }, mobileOpen && React.createElement('div', { className: 'scrim-mobile show', onClick: () => setMobileOpen(false) }), // ---- SIDEBAR ---- React.createElement('aside', { className: 'sidebar' + (collapsed ? ' collapsed' : '') + (mobileOpen ? ' mobile-open' : '') }, React.createElement('div', { className: 'side-brand' }, React.createElement('img', { src: 'assets/yappari.png', alt: 'Yappari' }), React.createElement('div', { style: { minWidth: 0 } }, React.createElement('div', { className: 'wordmark' }, 'YAPPARI!'), React.createElement('div', { className: 'sub' }, 'Vending Control')), ), React.createElement('nav', { className: 'side-nav' }, NAV.map(n => React.createElement('button', { key: n.id, className: 'nav-item' + (view === n.id ? ' active' : ''), onClick: () => goView(n.id), title: n.label }, React.createElement('span', { className: 'ni-icon' }, React.createElement(Icon, { name: n.icon, size: 20 })), React.createElement('span', { className: 'ni-label' }, n.label), n.id === 'control' && React.createElement('span', { className: 'ni-badge' }, offline)), )), React.createElement('div', { className: 'side-foot' }, React.createElement('div', { className: 'side-foot-label', style: { fontSize: 11, textTransform: 'uppercase', letterSpacing: '.12em', fontWeight: 700, color: 'var(--fg-3)', marginBottom: 8 } }, 'Status armada'), React.createElement('div', { className: 'side-status' }, React.createElement('div', { className: 'ss-pill ss-on' }, React.createElement('span', { className: 'ss-num' }, online), React.createElement('span', { className: 'ss-lab' }, 'Online')), React.createElement('div', { className: 'ss-pill ss-off' }, React.createElement('span', { className: 'ss-num' }, offline), React.createElement('span', { className: 'ss-lab' }, 'Offline'))), React.createElement('button', { className: 'nav-item', style: { marginTop: 8, justifyContent: collapsed ? 'center' : 'flex-start' }, onClick: () => setCollapsed(c => !c) }, React.createElement('span', { className: 'ni-icon' }, React.createElement(Icon, { name: collapsed ? 'chevronRight' : 'chevronLeft', size: 20 })), React.createElement('span', { className: 'ni-label' }, 'Ciutkan')), React.createElement('button', { className: 'nav-item', style: { justifyContent: collapsed ? 'center' : 'flex-start', color: 'var(--st-error)' }, onClick: () => window.YAPPARI_AUTH.logout(), title: 'Keluar' }, React.createElement('span', { className: 'ni-icon' }, React.createElement(Icon, { name: 'wifiOff', size: 20 })), React.createElement('span', { className: 'ni-label' }, 'Keluar')), ), ), // ---- MAIN ---- React.createElement('div', { className: 'main' }, React.createElement('header', { className: 'topbar' }, React.createElement('button', { className: 'icon-btn menu-toggle', onClick: () => setMobileOpen(true) }, React.createElement(Icon, { name: 'menu', size: 20 })), React.createElement('div', { className: 'tb-title' }, navMeta.label, React.createElement('span', { className: 'jp' }, navMeta.jp)), React.createElement('div', { className: 'tb-spacer' }), React.createElement('div', { className: 'hide-sm' }, React.createElement(MachineSelect, { machines, value: selMachine, onChange: setSelMachine })), React.createElement('button', { className: 'icon-btn' + (refreshing ? ' spinning' : ''), onClick: doRefresh, title: 'Refresh' }, React.createElement(Icon, { name: 'refresh', size: 18 })), React.createElement('div', { className: 'col hide-sm', style: { alignItems: 'flex-end', gap: 0 } }, React.createElement(Clock, null), React.createElement('div', { className: 'tb-update' }, React.createElement('span', { className: 'dot' }), 'Update ' + relUpdate())), React.createElement('button', { className: 'icon-btn', onClick: () => setTheme(t => t === 'light' ? 'dark' : 'light'), title: 'Ganti tema' }, React.createElement(Icon, { name: theme === 'light' ? 'moon' : 'sun', size: 18 })), ), React.createElement('main', { className: 'content' }, React.createElement('div', { className: 'content-inner' }, renderView())), ), drawer && React.createElement(MachineDrawer, { m: drawer, onClose: () => setDrawer(null), onGo: goFromDrawer }), React.createElement(TweaksPanel, null, React.createElement(TweakSection, { label: 'Tipografi' }), React.createElement(TweakSelect, { label: 'Font teks', value: t.uiFont, options: ['Plus Jakarta Sans', 'Be Vietnam Pro', 'Hanken Grotesk', 'Figtree'], onChange: v => setTweak('uiFont', v) }), React.createElement(TweakRadio, { label: 'Angka besar', value: t.numberStyle, options: ['Rapi', 'Tegas'], onChange: v => setTweak('numberStyle', v) }), React.createElement(TweakRadio, { label: 'Bobot judul', value: t.headWeight, options: ['Ringan', 'Sedang', 'Tebal'], onChange: v => setTweak('headWeight', v) }), React.createElement(TweakSlider, { label: 'Ukuran teks', value: t.textScale, min: 90, max: 115, step: 5, unit: '%', onChange: v => setTweak('textScale', v) }), React.createElement(TweakSection, { label: 'Tampilan' }), React.createElement(TweakColor, { label: 'Aksen', value: t.accent, options: ['#004080', '#F04000', '#0078A8', '#1FA463'], onChange: v => setTweak('accent', v) }), React.createElement(TweakToggle, { label: 'Mode gelap', value: theme === 'dark', onChange: v => setTheme(v ? 'dark' : 'light') }), ), ); } /* ---- Boot: login (jika perlu) lalu muat data dari API ---- */ function BootScreen({ msg }) { return React.createElement('div', { style: { position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 18, background: 'var(--bg)' } }, React.createElement('img', { src: 'assets/yappari.png', alt: 'Yappari', style: { width: 72, height: 72, borderRadius: '50%', animation: 'pulse 1.4s ease-in-out infinite' } }), React.createElement('div', { style: { fontFamily: 'var(--font-display)', fontSize: 28, letterSpacing: '.04em', color: 'var(--accent)' } }, 'YAPPARI!'), React.createElement('div', { style: { fontSize: 14, color: 'var(--fg-3)' } }, msg || 'Memuat data…')); } function LoginScreen({ onDone }) { const [email, setEmail] = useState(''); const [pw, setPw] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const submit = async () => { setErr(''); if (!email) { setErr('Email wajib diisi'); return; } if (!pw) { setErr('Password wajib diisi'); return; } setBusy(true); try { await window.YAPPARI_AUTH.login(email, pw); onDone(); } catch (e) { setErr(e.message || 'Login gagal'); setBusy(false); } }; const onKey = (e) => { if (e.key === 'Enter') submit(); }; return React.createElement('div', { style: { position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(150deg,#004080,#002E5C)', padding: 20 } }, React.createElement('div', { style: { width: 380, maxWidth: '94vw', background: 'var(--bg)', borderRadius: 16, padding: '32px 28px', boxShadow: '0 20px 60px rgba(0,0,0,.3)' } }, React.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 22 } }, React.createElement('img', { src: 'assets/yappari.png', alt: 'Yappari', style: { width: 56, height: 56, borderRadius: '50%' } }), React.createElement('div', { style: { fontFamily: 'var(--font-display)', fontSize: 26, letterSpacing: '.04em', color: 'var(--accent)' } }, 'YAPPARI!'), React.createElement('div', { style: { fontSize: 13, color: 'var(--fg-3)' } }, 'Vending Control — masuk untuk lanjut')), React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 12 } }, React.createElement('input', { className: 'inp', type: 'email', placeholder: 'Email', value: email, autoFocus: true, onChange: (e) => setEmail(e.target.value), onKeyDown: onKey, style: { padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border-subtle)', background: 'var(--bg-elev)', color: 'var(--fg-1)', fontSize: 14 } }), React.createElement('input', { className: 'inp', type: 'password', placeholder: 'Password', value: pw, onChange: (e) => setPw(e.target.value), onKeyDown: onKey, style: { padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border-subtle)', background: 'var(--bg-elev)', color: 'var(--fg-1)', fontSize: 14 } }), err && React.createElement('div', { style: { fontSize: 13, color: 'var(--st-error)' } }, err), React.createElement('button', { onClick: submit, disabled: busy, style: { padding: '11px 12px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: '#fff', fontSize: 15, fontWeight: 700, cursor: 'pointer', opacity: busy ? .6 : 1 } }, busy ? 'Memproses…' : 'Masuk')))); } const _root = ReactDOM.createRoot(document.getElementById('root')); function boot() { if (!window.YAPPARI_AUTH.getToken()) { _root.render(React.createElement(LoginScreen, { onDone: boot })); return; } _root.render(React.createElement(BootScreen, { msg: 'Memuat data…' })); window.loadYappari().then(function (Y) { if (!Y) { _root.render(React.createElement(LoginScreen, { onDone: boot })); return; } if (Y._fallback) console.warn('[yappari] berjalan dengan data contoh (mock).'); _root.render(React.createElement(App)); }).catch(function (e) { console.error('[yappari] boot gagal:', e); _root.render(React.createElement(BootScreen, { msg: 'Gagal memuat data. Coba muat ulang.' })); }); } boot();