// ═══════════════════════════════════════════════════════════ // StoreOS v2 — API-driven frontend // All data fetched from /api/* endpoints // ═══════════════════════════════════════════════════════════ const API = '/api'; const STORE_ID = 1; // ── State ──────────────────────────────────────────────────────────────────── let STATE = { store: null, products: [], orders: [], bundles: [], discounts:[], flash: [], analytics: null, customers: [], cart: {}, wishlist: new Set(), activeCatStore: 'All', activeCatDash: 'All', flashTimer: null, flashSecondsLeft: 0, }; // ── API helpers ─────────────────────────────────────────────────────────────── async function apiFetch(path, opts = {}) { try { const res = await fetch(API + path, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, ...opts, }); const data = await res.json(); if (!res.ok) console.warn('API error', path, data); return { ok: res.ok, status: res.status, data }; } catch (e) { console.error('Fetch failed', path, e); return { ok: false, status: 0, data: null }; } } const apiGet = (path) => apiFetch(path); const apiPost = (path, body) => apiFetch(path, { method: 'POST', body: JSON.stringify(body) }); const apiPut = (path, body) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }); const apiPatch= (path, body={}) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }); // ── Skeleton / loading helpers ──────────────────────────────────────────────── function skeletonCards(n = 6, h = '130px') { return Array(n).fill(0).map(() => `
`).join(''); } function skeletonRows(n = 5) { return Array(n).fill(0).map(() => `
`).join(''); } function skeletonStat() { return `
`; } function skeletonStoreProducts(n = 8) { return Array(n).fill(0).map(() => `
`).join(''); } // ── View router ─────────────────────────────────────────────────────────────── function showView(id) { document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); document.getElementById(id).classList.add('active'); window.scrollTo(0, 0); } function switchTab(t, btn) { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.getElementById('lf').style.display = t === 'login' ? 'block' : 'none'; document.getElementById('sf2').style.display = t === 'signup' ? 'block' : 'none'; } // ── Landing → Dashboard ─────────────────────────────────────────────────────── async function goToDash() { showView('view-dashboard'); sp('overview', document.querySelector('.ni')); // Show skeletons immediately document.querySelectorAll('.sg .sv').forEach(el => el.innerHTML = skeletonStat()); if (document.getElementById('pg')) document.getElementById('pg').innerHTML = skeletonCards(); if (document.getElementById('ob')) document.getElementById('ob').innerHTML = skeletonRows(); // Parallel fetches await Promise.all([ loadAnalytics(), loadProducts(), loadOrders(), loadCustomers(), loadBundles(), loadDiscounts(), loadFlash(), loadStore(), ]); renderHours(); } function goToStore() { showView('view-store'); // show skeletons on storefront immediately const spg = document.getElementById('spg'); if (spg) spg.innerHTML = skeletonStoreProducts(8).replace(/var\(--bg[234]\)/g, '#f5f2ee'); loadStoreData(); } function goToOb() { showView('view-onboarding'); } // ── Store data loaders ──────────────────────────────────────────────────────── async function loadStore() { const r = await apiGet(`/store?id=${STORE_ID}`); if (!r.ok) return; STATE.store = r.data; // update sidebar store badge const nameEl = document.querySelector('.sb-store .pn'); const urlEl = document.querySelector('.sb-store .pst'); if (nameEl) nameEl.textContent = r.data.name; if (urlEl) urlEl.textContent = `storeos.ng/${r.data.slug}`; } async function loadAnalytics() { const r = await apiGet(`/analytics?store_id=${STORE_ID}&days=14`); if (!r.ok) return; STATE.analytics = r.data; renderOverviewStats(r.data); renderChartBars(r.data.chart?.data ?? []); renderCategoryBreakdown(r.data.categories ?? []); renderTopProducts(r.data.top_products ?? []); renderAnalyticsPanel(r.data); } async function loadProducts() { const r = await apiGet(`/products?store_id=${STORE_ID}&active=1&per_page=50`); if (!r.ok) return; STATE.products = r.data.data ?? []; renderProds(); } async function loadOrders(filter = 'all') { const statusParam = filter === 'all' ? '' : `&status=${filter}`; const r = await apiGet(`/orders?store_id=${STORE_ID}${statusParam}`); if (!r.ok) return; STATE.orders = r.data.data ?? []; STATE.orderCounts = r.data.counts ?? {}; renderOrders(); renderRecentOrders(); // update order badge in sidebar const badge = document.querySelector('.ni .ni-badge'); if (badge) badge.textContent = STATE.orderCounts['new'] ?? 0; } async function loadCustomers() { const r = await apiGet(`/customers?store_id=${STORE_ID}&per_page=20`); if (!r.ok) return; STATE.customers = r.data.data ?? []; STATE.customerStats = r.data.stats ?? {}; renderCustomers(); } async function loadBundles() { const r = await apiGet(`/bundles?store_id=${STORE_ID}`); if (!r.ok) return; STATE.bundles = r.data.data ?? []; renderBundlesDash(); } async function loadDiscounts() { const r = await apiGet(`/discounts?store_id=${STORE_ID}`); if (!r.ok) return; STATE.discounts = r.data.data ?? []; renderDiscounts(); } async function loadFlash() { const r = await apiGet(`/discounts?type=flash&store_id=${STORE_ID}`); if (!r.ok) return; STATE.flash = r.data.data ?? []; renderFlashDash(); // update live banner const live = STATE.flash.find(f => f.is_live); if (live) startFlashCountdown(live.seconds_left, live.product_name, live.discount_pct); } async function loadStoreData() { await Promise.all([ loadProducts(), loadBundles(), loadFlash(), loadStore(), ]); renderStoreFront(); } // ── Renderers: Dashboard ────────────────────────────────────────────────────── function fmt(kobo) { return '₦' + Math.round(kobo / 100).toLocaleString(); } function renderOverviewStats(data) { const t = data.overview?.today ?? {}; const m = data.overview?.mtd ?? {}; const sg = document.querySelector('#panel-overview .sg'); if (!sg) return; sg.innerHTML = `
Revenue today
${fmt(t.revenue??0)}
${(t.revenue_change_pct??0)>=0?'↑':'↓'} ${Math.abs(t.revenue_change_pct??0)}% vs yesterday
Orders today
${t.orders??0}
↑ Live
Avg order value
${t.orders>0?fmt(Math.round((t.revenue??0)/(t.orders||1))):'—'}
↑ Today
Wishlist saves
${Math.floor((t.visits??0)*0.05)}
↑ High intent
`; } function renderChartBars(rows) { const svg = document.querySelector('#panel-overview .cc svg'); if (!svg || !rows.length) return; const max = Math.max(...rows.map(r => r.revenue), 1); const W = 560, H = 140, barW = Math.floor(W / rows.length) - 4; svg.innerHTML = ` ` + rows.map((r, i) => { const barH = Math.max(4, Math.round((r.revenue / max) * (H - 10))); const x = i * (barW + 4) + 4; const y = H - barH; const isRecent = i >= rows.length - 2; return ``; }).join(''); } function renderCategoryBreakdown(cats) { const el = document.querySelector('#panel-overview .cr .cc:last-child'); if (!el) return; const colors = ['var(--accent)','var(--blue)','var(--green)','var(--red)','var(--purple)']; el.innerHTML = `
Top categories
` + cats.slice(0, 5).map((c, i) => `
${c.category}${c.pct}%
`).join(''); } function renderTopProducts(prods) { const el = document.querySelector('#panel-overview .cr .cc:first-child'); if (!el || !prods.length) return; const max = Math.max(...prods.map(p => p.sold), 1); const bottomHtml = `
Top products today
` + prods.slice(0, 3).map(p => `
${p.emoji} ${p.name}${p.sold} sold
`).join(''); // append below chart svg const existing = el.querySelector('svg'); if (existing) existing.insertAdjacentHTML('afterend', bottomHtml); } function renderRecentOrders() { const tbody = document.querySelector('#panel-overview tbody'); if (!tbody) return; const SM = {new:'bb',preparing:'ba',delivery:'bb',delivered:'bg',cancelled:'br2'}; const SL = {new:'New',preparing:'Preparing',delivery:'Out for delivery',delivered:'Delivered',cancelled:'Cancelled'}; tbody.innerHTML = STATE.orders.slice(0, 5).map(o => ` ${o.order_id} ${o.customer_name ?? 'Guest'} ${fmt(o.total)} ${SL[o.status]??o.status} ${o.status==='delivery'?``:``} `).join('') || 'No orders yet'; } function renderProds() { const grid = document.getElementById('pg'); if (!grid) return; if (!STATE.products.length) { grid.innerHTML = '

No products found.

'; return; } grid.innerHTML = STATE.products.map(p => `
${p.badge?`${p.badge}`:''}${p.emoji}
${p.name}
${fmt(p.price)}
Stock: ${p.stock} · ${p.category}
`).join(''); } function renderOrders() { const tbody = document.getElementById('ob'); if (!tbody) return; const SM = {new:'bb',preparing:'ba',delivery:'bb',delivered:'bg',cancelled:'br2'}; const SL = {new:'New',preparing:'Preparing',delivery:'Out for delivery',delivered:'Delivered',cancelled:'Cancelled'}; tbody.innerHTML = STATE.orders.map(o => { const act = o.status==='new' ? `` : o.status==='preparing' ? `` : o.status==='delivery' ? `` : ``; return ` ${o.order_id} ${o.customer_name??'Guest'} ${o.items_summary??'—'} ${fmt(o.total)} ${SL[o.status]??o.status} ${act} `; }).join('') || 'No orders'; } function renderCustomers() { const tbody = document.querySelector('#panel-customers .tc table tbody'); if (!tbody) return; const SM = {new:'bg',regular:'bb',vip:'ba'}; tbody.innerHTML = STATE.customers.map(c => ` ${c.name} ${c.phone??'—'} ${c.order_count} ${fmt(c.total_spent)} ${c.segment.toUpperCase()} `).join(''); // update stats const sg = document.querySelector('#panel-customers .sg'); if (sg && STATE.customerStats) { const s = STATE.customerStats; sg.innerHTML = `
Total customers
${s.total??0}
↑ Growing
Avg lifetime value
${fmt(s.avg_lifetime_value??0)}
↑ Strong
Repeat rate
${s.repeat_rate_pct??0}%
↑ Healthy
`; } } function renderBundlesDash() { const tbody = document.querySelector('#panel-bundles .tc table tbody'); if (!tbody) return; tbody.innerHTML = STATE.bundles.map(b => ` ${b.name} ${b.items?.map(i=>`${i.emoji}${i.name}`).join(' + ')??'—'} ${fmt(b.original_price)} ${fmt(b.bundle_price)} ${fmt(b.savings)} — ${b.is_active?'Active':'Draft'} `).join(''); } function renderDiscounts() { const tbody = document.querySelector('#panel-discounts .tc table tbody'); if (!tbody) return; tbody.innerHTML = STATE.discounts.map(d => ` ${d.code} ${d.type} ${d.type==='percentage'?d.value+'%':d.type==='fixed'?fmt(d.value):'Free delivery'} ${d.usage_count} / ${d.usage_limit??'∞'} ${d.expires_at?d.expires_at.slice(0,10):'Never'} ${d.is_active?'Active':'Inactive'} `).join(''); } function renderFlashDash() { const tbody = document.querySelector('#panel-flash .tc table tbody'); if (!tbody) return; tbody.innerHTML = STATE.flash.map(f => ` ${f.product_emoji} ${f.product_name} ${f.discount_pct}% off ${f.starts_at?.slice(11,16)??'—'} ${f.ends_at?.slice(11,16)??'—'} ${f.orders_count} ${fmt(f.revenue)} ${f.is_live?'Live now':'Ended'} `).join(''); } function renderAnalyticsPanel(data) { const sg = document.querySelector('#panel-analytics .sg'); if (!sg) return; const mtd = data.overview?.mtd ?? {}; const ac = data.abandoned_carts ?? {}; sg.innerHTML = `
Monthly revenue
${fmt(mtd.revenue??0)}
${(mtd.revenue_change_pct??0)>=0?'↑':'↓'} ${Math.abs(mtd.revenue_change_pct??0)}% vs last month
Orders (MTD)
${mtd.orders??0}
↑ Growing
Abandoned carts
${ac.recovered??0} recovered
↑ ${fmt(ac.revenue_recovered??0)} recovered
Store visits
${(mtd.visits??0).toLocaleString()}
↑ Organic
`; // traffic sources const traf = document.querySelector('#panel-analytics .ag .cc:last-child'); if (traf) { const sources = data.traffic_sources ?? []; const maxV = Math.max(...sources.map(s => s.visits), 1); traf.innerHTML = `
Traffic sources
` + sources.map(s => `
${s.source}${s.visits.toLocaleString()}
`).join(''); } // reviews const revEl = document.querySelector('#panel-analytics .ag .cc:nth-child(3)'); const rev = data.reviews ?? {}; if (revEl && rev.total_reviews) { revEl.querySelector('.ct') && (revEl.querySelector('.ct').textContent = 'Reviews summary'); const ratingEl = revEl.querySelector('[style*="font-size: 36px"], [style*="font-size:36px"]'); if (ratingEl) ratingEl.innerHTML = `${rev.avg_rating} / 5 · ${rev.total_reviews} reviews`; } } // ── Renderers: Storefront ───────────────────────────────────────────────────── function renderStoreFront() { const prods = STATE.products; const bundles = STATE.bundles; // Hero cards (first 4 products) const hv = document.getElementById('hv'); if (hv) { hv.innerHTML = prods.slice(0, 4).map(p => `
${p.emoji}
${p.name}
${fmt(p.price)}
${'★'.repeat(Math.round(p.rating??0))} (${p.review_count??0})
`).join(''); } // Category pills const cats = ['All', ...new Set(prods.map(p => p.category))]; const cpills = document.getElementById('cpills'); if (cpills) { cpills.innerHTML = cats.map(c => `
${c}
` ).join(''); } renderStoreProds(); // Bundles section const bg = document.querySelector('.bundle-grid'); if (bg && bundles.length) { bg.innerHTML = bundles.map(b => `
${b.emojis}
${b.name}
${b.items?.map(i=>i.name).join(' + ')??b.description??''}
${fmt(b.original_price)} ${fmt(b.bundle_price)} Save ${fmt(b.savings)}
`).join(''); } // Flash banner const live = STATE.flash.find(f => f.is_live); if (live) startFlashCountdown(live.seconds_left, live.product_name, live.discount_pct); } function filterCat(cat, el) { STATE.activeCatStore = cat; document.querySelectorAll('.cpill').forEach(p => p.classList.remove('active')); el.classList.add('active'); renderStoreProds(); } function renderStoreProds() { const filtered = STATE.activeCatStore === 'All' ? STATE.products : STATE.products.filter(p => p.category === STATE.activeCatStore); const grid = document.getElementById('spg'); if (!grid) return; if (!filtered.length) { grid.innerHTML = '

No products in this category.

'; return; } grid.innerHTML = filtered.map(p => `
${p.badge ? `
${p.badge}
` : ''} ${p.emoji}
${p.category}
${p.name}
${p.description??''}
${'★'.repeat(Math.floor(p.rating??0))} ${p.rating??'—'} (${p.review_count??0} reviews)
${fmt(p.price)}
`).join(''); } // ── Flash countdown ─────────────────────────────────────────────────────────── function startFlashCountdown(seconds, productName, discountPct) { STATE.flashSecondsLeft = seconds; if (STATE.flashTimer) clearInterval(STATE.flashTimer); const flashText = document.querySelector('.flash-banner .flash-text strong'); if (flashText) flashText.textContent = `${discountPct}% off ${productName}`; STATE.flashTimer = setInterval(() => { STATE.flashSecondsLeft = Math.max(0, STATE.flashSecondsLeft - 1); const h = Math.floor(STATE.flashSecondsLeft / 3600); const m = Math.floor((STATE.flashSecondsLeft % 3600) / 60); const s = STATE.flashSecondsLeft % 60; const fh = document.getElementById('fc-h'); const fm = document.getElementById('fc-m'); const fs = document.getElementById('fc-s'); if (fh) fh.textContent = String(h).padStart(2,'0'); if (fm) fm.textContent = String(m).padStart(2,'0'); if (fs) fs.textContent = String(s).padStart(2,'0'); if (STATE.flashSecondsLeft === 0) clearInterval(STATE.flashTimer); }, 1000); } // ── Order actions ───────────────────────────────────────────────────────────── async function acceptOrder(id) { await apiPatch(`/orders?id=${id}&action=accept`); await loadOrders(); toast('Order accepted — now preparing'); } async function readyOrder(id) { await apiPatch(`/orders?id=${id}&action=ready`); await loadOrders(); toast('Marked as out for delivery'); } async function fo(filter, btn) { document.querySelectorAll('.fp').forEach(p => p.classList.remove('active')); btn.classList.add('active'); await loadOrders(filter); } // ── Product actions ─────────────────────────────────────────────────────────── async function hideProduct(id) { await apiFetch(`/products?id=${id}`, { method: 'DELETE' }); STATE.products = STATE.products.filter(p => p.id !== id); renderProds(); toast('Product hidden from store'); } async function genAIDescForProduct(name, category) { document.getElementById('ai-input').value = name; document.getElementById('aim').classList.add('open'); await genAIDesc(name, category); } async function genAIDesc(name, category) { const input = document.getElementById('ai-input'); const res = document.getElementById('ai-result'); const n = name || input?.value?.trim() || 'Product'; const cat = category || ''; res.style.display = 'block'; res.className = 'ai-result ai-typing'; res.textContent = 'Generating...'; const r = await apiPost('/ai-description', { product_name: n, category: cat, store_name: STATE.store?.name ?? 'our store' }); res.className = 'ai-result'; res.textContent = r.ok ? (r.data.description ?? 'Could not generate.') : 'Error generating description.'; } // ── Flash sale launcher ─────────────────────────────────────────────────────── async function launchFlash() { const prodSel = document.querySelector('#panel-flash select:first-of-type'); const discSel = document.querySelector('#panel-flash input[type="number"]'); const durSel = document.querySelector('#panel-flash select:last-of-type'); const pct = parseInt(discSel?.value ?? 25); const dur = parseInt(durSel?.value ?? 2); await apiPost('/discounts?type=flash&store_id=' + STORE_ID, { discount_pct: pct, duration_hours: dur, }); await loadFlash(); toast('Flash sale launched! Countdown banner live on your store.'); } // ── Cart ────────────────────────────────────────────────────────────────────── function addToCart(id) { STATE.cart[id] = (STATE.cart[id] || 0) + 1; updateCartUI(); } function addBundle(ids) { ids.forEach(id => addToCart(id)); } function removeFromCart(id) { if (STATE.cart[id] > 1) STATE.cart[id]--; else delete STATE.cart[id]; updateCartUI(); } function updateCartUI() { const total = Object.values(STATE.cart).reduce((a, b) => a + b, 0); ['cc3','cc4'].forEach(id => { const el = document.getElementById(id); if (el) el.textContent = total; }); const items = Object.entries(STATE.cart); const listEl = document.getElementById('cil'); const footerEl = document.getElementById('cff'); if (!listEl) return; if (!items.length) { listEl.innerHTML = '

Your cart is empty.
Add something delicious!

'; if (footerEl) footerEl.style.display = 'none'; return; } if (footerEl) footerEl.style.display = 'block'; let sub = 0; listEl.innerHTML = items.map(([id, qty]) => { const p = STATE.products.find(x => x.id === +id); if (!p) return ''; sub += p.price * qty; return `
${p.emoji}
${p.name}
${fmt(p.price)}
${qty}
`; }).join(''); const subEl = document.getElementById('csub'); const totEl = document.getElementById('ctot'); if (subEl) subEl.textContent = fmt(sub); if (totEl) totEl.textContent = fmt(sub + 150000); } function toggleCart() { document.getElementById('co').classList.toggle('open'); document.getElementById('cd').classList.toggle('open'); } async function checkout() { const items = Object.entries(STATE.cart).map(([id, qty]) => ({ product_id: +id, quantity: qty })); if (!items.length) return; const r = await apiPost('/orders?store_id=' + STORE_ID, { items, customer_name: 'Guest', delivery_type: 'delivery', }); if (r.ok) { STATE.cart = {}; updateCartUI(); toggleCart(); toast('Order placed! You\'ll get a WhatsApp update shortly 🎉'); } else { toast('Something went wrong. Please try again.'); } } function waCheckout() { const items = Object.entries(STATE.cart).map(([id, qty]) => { const p = STATE.products.find(x => x.id === +id); return `${qty}× ${p?.name ?? id}`; }).join(', '); toast('Opening WhatsApp with: ' + items); STATE.cart = {}; updateCartUI(); toggleCart(); } function waOrder() { toast('Opening WhatsApp to send your order...'); } function reorder() { const first = STATE.products[0]; const second = STATE.products[6]; // chapman if (first) addToCart(first.id); if (second) addToCart(second.id); toggleCart(); toast('Previous order added to cart!'); } // ── Promo code ──────────────────────────────────────────────────────────────── async function applyPromo() { const code = document.getElementById('promo-input')?.value?.trim(); if (!code) return; const r = await apiGet(`/discounts?store_id=${STORE_ID}&action=validate&code=${encodeURIComponent(code)}`); if (r.ok) { toast('✓ Promo applied: ' + code); } else { toast('Code not found or expired. Try WELCOME20'); } } // ── Wishlist ────────────────────────────────────────────────────────────────── function toggleWL(id, btn) { if (STATE.wishlist.has(id)) { STATE.wishlist.delete(id); btn.innerHTML = '♡'; btn.classList.remove('saved'); toast('Removed from wishlist'); } else { STATE.wishlist.add(id); btn.innerHTML = '♥'; btn.classList.add('saved'); toast('Added to wishlist ♥'); } const c = document.getElementById('wl-count'); if (c) c.textContent = STATE.wishlist.size > 0 ? `(${STATE.wishlist.size})` : ''; } function showWishlist() { showView('view-wishlist'); const wlc = document.getElementById('wl-content'); const hdr = document.getElementById('wl-header-count'); if (!STATE.wishlist.size) { if (hdr) hdr.textContent = '0 items'; if (wlc) wlc.innerHTML = '
Your wishlist is empty.
Save items you love by tapping ♡ on any product.
'; return; } if (hdr) hdr.textContent = `${STATE.wishlist.size} saved items`; const prods = STATE.products.filter(p => STATE.wishlist.has(p.id)); if (wlc) wlc.innerHTML = `
${prods.map(p => `
${p.emoji}
${p.category}
${p.name}
${p.description??''}
${'★'.repeat(Math.floor(p.rating??0))}${p.rating} (${p.review_count})
${fmt(p.price)}
`).join('')}
`; const c = document.getElementById('cc4'); if (c) c.textContent = Object.values(STATE.cart).reduce((a,b)=>a+b,0); } // ── Misc ────────────────────────────────────────────────────────────────────── function renderHours() { const grid = document.getElementById('hours-grid'); if (!grid) return; const days = [['mon','Monday'],['tue','Tuesday'],['wed','Wednesday'],['thu','Thursday'], ['fri','Friday'],['sat','Saturday'],['sun','Sunday']]; const oh = STATE.store?.opening_hours ?? {}; grid.innerHTML = days.map(([key, label]) => { const h = oh[key] ?? { open:'11:00', close:'22:00', enabled: true }; return `
${label}
${h.open} – ${h.close}
`; }).join(''); } function sp(name, el) { document.querySelectorAll('.dp').forEach(p => p.classList.remove('active')); document.querySelectorAll('.ni').forEach(n => n.classList.remove('active')); document.getElementById('panel-' + name)?.classList.add('active'); if (el) el.classList.add('active'); } function selTheme(el, t) { document.querySelectorAll('#obs1 .theme-card').forEach(c => c.classList.remove('sel')); el.classList.add('sel'); } function selTheme2(el) { document.querySelectorAll('#panel-themes .theme-card').forEach(c => c.classList.remove('sel')); el.classList.add('sel'); toast('Theme updated!'); } function selEmoji(el) { document.querySelectorAll('.eopt').forEach(e => e.classList.remove('sel')); el.classList.add('sel'); } function showTracker() { document.getElementById('otm').classList.add('open'); } function showQR() { document.getElementById('qrm').classList.add('open'); } function openAI() { document.getElementById('aim').classList.add('open'); } function toast(msg) { const t=document.getElementById('toast-el'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),3000); } let obStep = 0; function obGo(n) { document.getElementById('obs'+obStep).style.display='none'; document.getElementById('od'+obStep).classList.remove('active'); document.getElementById('od'+obStep).classList.add('done'); obStep = n; if (n < 4) { document.getElementById('obs'+n).style.display='block'; document.getElementById('od'+n).classList.add('active'); } else goToDash(); } // ── Shimmer animation (injected) ────────────────────────────────────────────── const shimmerStyle = document.createElement('style'); shimmerStyle.textContent = ` @keyframes shimmer{0%{opacity:1}50%{opacity:.5}100%{opacity:1}} @keyframes storeShimmer{0%{opacity:1}50%{opacity:.6}100%{opacity:1}}`; document.head.appendChild(shimmerStyle); // ── Boot ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Show landing and immediately render skeleton UI behind it showView('view-landing'); });