← back to Shopify Order Bell

index.html

159 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Shopify Order Bell</title>
<style>
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#0b0d10;color:#e6ecf2;font:14px/1.5 ui-monospace,Menlo,monospace}
header{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid #1c2430;background:#0e1116;position:sticky;top:0}
header .k{color:#7cf;font-weight:700;letter-spacing:2px;text-transform:uppercase;font-size:11px}
header h1{font-size:15px;margin:0;font-weight:600}
header .store{color:#8a93a0;font-size:11px;margin-left:auto}
.wrap{padding:16px;max-width:520px;margin:0 auto}
.card{background:#131820;border:1px solid #1c2430;border-radius:12px;padding:14px;margin-bottom:12px}
.card h2{margin:0 0 6px;font-size:13px;letter-spacing:1px;text-transform:uppercase;color:#7cf}
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.btn{appearance:none;border:1px solid #243044;background:#1a2332;color:#e6ecf2;padding:8px 12px;border-radius:8px;cursor:pointer;font:600 12px/1 ui-monospace}
.btn:hover{border-color:#3a5578}
.btn.primary{background:#2a5bd7;border-color:#2a5bd7}
.btn.ghost{background:transparent}
.dot{width:8px;height:8px;border-radius:50%;background:#2ecc71;box-shadow:0 0 8px #2ecc71;display:inline-block;vertical-align:middle}
.dot.err{background:#ff4757;box-shadow:0 0 8px #ff4757}
.dot.idle{background:#8a93a0;box-shadow:none}
.mono{font:12px/1.4 ui-monospace,Menlo,monospace;white-space:pre-wrap;word-break:break-all;color:#b9c4d3}
.bell{font-size:42px;text-align:center;margin:6px 0 2px;filter:drop-shadow(0 0 12px rgba(124,204,255,0.5))}
.bell.ring{animation:ring 0.6s ease}
@keyframes ring{0%{transform:rotate(0)}15%{transform:rotate(14deg)}30%{transform:rotate(-12deg)}45%{transform:rotate(10deg)}60%{transform:rotate(-8deg)}75%{transform:rotate(4deg)}100%{transform:rotate(0)}}
.orders{max-height:240px;overflow:auto}
.orders .o{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px solid #1c2430;font-size:12px}
.orders .o:last-child{border:0}
.badge{font-size:10px;padding:2px 6px;border-radius:99px;background:#1a2332;border:1px solid #243044;color:#8a93a0}
small{color:#8a93a0}
</style>
</head>
<body>
<header>
  <span class="k">🔔 Bell</span>
  <h1>Shopify Order Bell</h1>
  <span class="store" id="store">—</span>
</header>
<div class="wrap">
  <div class="card" style="text-align:center">
    <div class="bell" id="bell">🔔</div>
    <div style="display:flex;gap:8px;justify-content:center;margin-top:8px">
      <button class="btn primary" id="testBtn">🔔 Test Bell</button>
      <button class="btn" id="pollBtn">↻ Poll now</button>
    </div>
    <div style="margin-top:8px"><span class="dot idle" id="dot"></span> <small id="status">Starting…</small></div>
    <div class="mono" id="meta" style="margin-top:6px"></div>
  </div>

  <div class="card">
    <h2>Last orders</h2>
    <div class="orders" id="orders"><small>Waiting for first poll…</small></div>
  </div>

  <div class="card">
    <h2>Settings</h2>
    <div class="mono" id="cfg"></div>
    <div class="row" style="margin-top:8px">
      <button class="btn ghost" id="resetBtn" title="Forget last order ID and re-seed from Shopify (no retro bell)">Reset seed</button>
      <label style="display:flex;gap:6px;align-items:center"><input type="range" id="vol" min="0" max="1" step="0.05" value="0.9" style="width:120px"><small>volume</small></label>
      <label style="display:flex;gap:6px;align-items:center"><input type="number" id="repeats" min="1" max="6" value="3" style="width:48px"> <small>repeats</small></label>
    </div>
    <small>Polls <code id="pollEvery">15s</code> · Token: <span id="tok">—</span> · Plays bell + system notification + dock bounce on new order. First run seeds silently (no retro bell).</small>
  </div>
</div>

<script>
const bellEl = document.getElementById('bell');
const statusEl = document.getElementById('status');
const dotEl = document.getElementById('dot');
const metaEl = document.getElementById('meta');
const ordersEl = document.getElementById('orders');
const cfgEl = document.getElementById('cfg');
const tokEl = document.getElementById('tok');
const pollEveryEl = document.getElementById('pollEvery');
let volEl = document.getElementById('vol');
let repeatsEl = document.getElementById('repeats');

let audioCtx;
function getCtx(){ if(!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)(); if(audioCtx.state==='suspended') audioCtx.resume(); return audioCtx; }

// synthesized bell: two decaying sines + high shimmer — no file needed, works under CSP
function playBell(volume=0.9, repeats=3){
  const ctx = getCtx();
  let t0 = ctx.currentTime + 0.02;
  for(let r=0;r<repeats;r++){
    const t = t0 + r*0.42;
    // main partials
    [{f: 740, g: 1.0, d: 1.6},{f: 1480, g: 0.45, d: 1.1},{f: 2220, g: 0.22, d: 0.8}].forEach(p=>{
      const o = ctx.createOscillator(); o.type='sine'; o.frequency.value=p.f;
      const g = ctx.createGain(); g.gain.setValueAtTime(0, t);
      g.gain.linearRampToValueAtTime(volume*p.g*0.9, t+0.008);
      g.gain.exponentialRampToValueAtTime(0.001, t+p.d);
      o.connect(g).connect(ctx.destination); o.start(t); o.stop(t+p.d+0.05);
    });
    // click transient
    const click = ctx.createOscillator(); click.frequency.value=3200;
    const cg = ctx.createGain(); cg.gain.setValueAtTime(0, t); cg.gain.linearRampToValueAtTime(volume*0.18, t+0.001); cg.gain.exponentialRampToValueAtTime(0.001, t+0.06);
    click.connect(cg).connect(ctx.destination); click.start(t); click.stop(t+0.07);
  }
  bellEl.classList.remove('ring'); void bellEl.offsetWidth; bellEl.classList.add('ring');
}

function fmtOrders(orders){
  if(!orders || !orders.length) return '<small>No orders</small>';
  return orders.slice(0,5).map(o=>{
    const name = o.name || ('#'+o.id);
    const price = o.total_price ? `${o.total_price} ${o.currency}` : '';
    const when = o.created_at ? new Date(o.created_at).toLocaleString() : '';
    const items = o.line_items ? o.line_items.length+' items' : '';
    return `<div class="o"><span><b>${name}</b> <span class="badge">${price}</span> <small>${items}</small></span><small>${when}</small></div>`;
  }).join('');
}

async function init(){
  const cfg = await window.bellAPI.getConfig();
  document.getElementById('store').textContent = cfg.store;
  pollEveryEl.textContent = cfg.pollSeconds + 's';
  tokEl.textContent = cfg.hasToken ? '✓ ready' : '✗ missing (set SHOPIFY_ORDERS_TOKEN)';
  tokEl.style.color = cfg.hasToken ? '#2ecc71' : '#ff4757';
  cfgEl.textContent = `store: ${cfg.store}\nlastOrderId: ${cfg.lastOrderId || '(none — will seed on first poll)'}\nseeded: ${cfg.seeded}\n`;
  metaEl.textContent = `last check: —`;
}

init();

document.getElementById('testBtn').addEventListener('click', ()=>{ playBell(parseFloat(volEl.value), parseInt(repeatsEl.value,10)); window.bellAPI.testBell(); });
document.getElementById('pollBtn').addEventListener('click', async ()=>{
  statusEl.textContent = 'Polling…'; dotEl.className='dot idle';
  await window.bellAPI.pollNow();
});

document.getElementById('resetBtn').addEventListener('click', async ()=>{ await window.bellAPI.resetSeed(); statusEl.textContent='Re-seeded — watching for new orders'; });

window.bellAPI.onBell(({order,title,body})=>{
  playBell(parseFloat(volEl.value), parseInt(repeatsEl.value,10));
  statusEl.textContent = `${title} — ${body}`;
  dotEl.className='dot';
  // also flash meta
  metaEl.textContent = `🔔 ${new Date().toLocaleString()} — ${body}`;
});

window.bellAPI.onStatus(({at,msg,lastId,orders,error})=>{
  statusEl.textContent = msg;
  dotEl.className = error ? 'dot err' : 'dot';
  metaEl.textContent = `last check: ${new Date(at).toLocaleString()} · lastId: ${lastId || '—'}`;
  if(orders) ordersEl.innerHTML = fmtOrders(orders);
});

// allow clicking the bell to test
bellEl.addEventListener('click', ()=> playBell(parseFloat(volEl.value), parseInt(repeatsEl.value,10)));
// prime audio on first interaction (autoplay policy)
document.addEventListener('click', ()=>{ try{ getCtx(); }catch{} }, {once:true});
</script>
</body>
</html>