[object Object]

← back to Shopify Order Bell

initial scaffold: Shopify Order Bell — desktop alarm on new order

485f9968ced67cc184c7190a3a2d1f473cff01c2 · 2026-09-01 14:42:11 -0700 · Steve Abrams

Files touched

Diff

commit 485f9968ced67cc184c7190a3a2d1f473cff01c2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 14:42:11 2026 -0700

    initial scaffold: Shopify Order Bell — desktop alarm on new order
---
 .env.example |   9 ++++
 .gitignore   |   7 +++
 README.md    |  50 ++++++++++++++++++
 index.html   | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 main.js      | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json |  18 +++++++
 poller.js    |  45 +++++++++++++++++
 preload.js   |   9 ++++
 8 files changed, 458 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..184f65a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,9 @@
+# Copy to .env — routed via secrets-manager (SHOPIFY_ORDERS_TOKEN)
+SHOPIFY_STORE=designer-laboratory-sandbox.myshopify.com
+SHOPIFY_ORDERS_TOKEN=shpat_...
+# Poll interval seconds (default 15)
+POLL_SECONDS=15
+# Bell volume 0.0 - 1.0
+BELL_VOLUME=0.9
+# Repeat bell N times per order (default 3)
+BELL_REPEATS=3
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8ac1cde
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env
+dist/
+build/
+.DS_Store
+*.log
+tmp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..346f2eb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,50 @@
+# Shopify Order Bell 🔔
+
+Desktop alarm that rings a bell (+ system notification + dock bounce) whenever a **new Shopify order** lands on `designer-laboratory-sandbox.myshopify.com`.
+
+Two ways to run:
+
+### 1) Desktop app (Electron)
+
+```sh
+cd ~/Projects/shopify-order-bell
+npm install          # installs electron (once, ~1 min)
+npm start            # opens the bell window
+```
+
+* Shows last orders, live status, **🔔 Test Bell** button, polls every `POLL_SECONDS` (default 15s).
+* First run **seeds silently** from the latest order — no retro bell. Only *new* orders after that ring.
+* Tray icon → Show / Test Bell / Poll now / Quit. System Notification + dock bounce on each new order.
+* Bell is synthesized with Web Audio (no file needed) — volume + repeats sliders in the UI.
+
+### 2) Headless poller (server/overnight)
+
+```sh
+node poller.js
+# or: POLL_SECONDS=10 node poller.js
+```
+
+Loops forever, `afplay`s Glass/Ping + `osascript` notification on new orders. State in `.last-order.json`.
+
+### Config
+
+Env is loaded from `./.env` (if present) then `~/Projects/secrets-manager/.env`:
+
+```
+SHOPIFY_STORE=designer-laboratory-sandbox.myshopify.com
+SHOPIFY_ORDERS_TOKEN=shpat_...   # must have read_orders scope (this one works; SHOPIFY_ADMIN_TOKEN does not)
+POLL_SECONDS=15
+```
+
+`SHOPIFY_ORDERS_TOKEN` already in secrets-manager and has `read_orders` — verified `GET /admin/api/2024-04/orders.json` returns orders (latest id 6130103812147). `SHOPIFY_FULL_ACCESS_TOKEN` also works.
+
+### Reset
+
+* In-app: **Reset seed** button forgets `lastOrderId` and re-seeds without ringing.
+* CLI: `rm .last-order.json` or `rm ~/Library/Application\ Support/shopify-order-bell/last-order.json` (Electron).
+
+### Dev
+
+```sh
+npm run dev   # with DevTools
+```
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..4dd2a28
--- /dev/null
+++ b/index.html
@@ -0,0 +1,158 @@
+<!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>
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..d3ba25c
--- /dev/null
+++ b/main.js
@@ -0,0 +1,162 @@
+const { app, BrowserWindow, ipcMain, Notification, Tray, Menu, nativeImage } = require('electron');
+const path = require('path');
+const fs = require('fs');
+const https = require('https');
+
+// load .env + secrets-manager fallback
+function loadEnv() {
+  const candidates = [path.join(__dirname, '.env'), path.join(process.env.HOME, 'Projects/secrets-manager/.env')];
+  for (const p of candidates) {
+    try {
+      const txt = fs.readFileSync(p, 'utf8');
+      for (const line of txt.split('\n')) {
+        const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+        if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
+      }
+    } catch {}
+  }
+}
+loadEnv();
+
+const STORE = process.env.SHOPIFY_STORE || process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ORDERS_TOKEN || process.env.SHOPIFY_FULL_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN || '';
+const POLL_MS = parseInt(process.env.POLL_SECONDS || '15', 10) * 1000;
+const STATE_FILE = path.join(app.getPath ? app.getPath('userData') : __dirname, 'last-order.json');
+
+let win, tray, pollTimer;
+let lastOrderId = null;
+let isDev = process.argv.includes('--dev');
+
+// try to load last seen id from previous session
+try {
+  if (fs.existsSync(STATE_FILE)) lastOrderId = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')).lastOrderId || null;
+} catch {}
+// on first run with no state, seed from Shopify so we don't bell on old orders
+let seeded = lastOrderId !== null;
+
+function fetchOrders(limit = 5) {
+  return new Promise((resolve, reject) => {
+    if (!TOKEN) return reject(new Error('Missing SHOPIFY_ORDERS_TOKEN (set in .env or secrets-manager)'));
+    const opts = {
+      hostname: STORE,
+      path: `/admin/api/2024-04/orders.json?limit=${limit}&status=any&order=created_at%20desc`,
+      method: 'GET',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    };
+    const req = https.request(opts, (res) => {
+      let data = '';
+      res.on('data', d => data += d);
+      res.on('end', () => {
+        if (res.statusCode !== 200) return reject(new Error(`Shopify ${res.statusCode}: ${data.slice(0, 300)}`));
+        try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
+      });
+    });
+    req.on('error', reject);
+    req.setTimeout(10000, () => { req.destroy(new Error('timeout')); });
+    req.end();
+  });
+}
+
+function saveState(id) {
+  try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify({ lastOrderId: id, at: new Date().toISOString() })); } catch {}
+}
+
+function notifyOrder(order) {
+  const title = '🔔 New Shopify Order!';
+  const body = `#${order.name || order.id} — ${order.total_price} ${order.currency} · ${order.customer ? (order.customer.first_name + ' ' + (order.customer.last_name || '')) : order.email || 'guest'} · ${order.line_items ? order.line_items.length + ' items' : ''}`;
+  // system notification
+  try {
+    if (Notification.isSupported()) new Notification({ title, body }).show();
+  } catch {}
+  // in-window bell
+  if (win) win.webContents.send('bell', { order, title, body });
+  // also bounce dock
+  try { if (process.platform === 'darwin') app.dock.bounce('informational'); } catch {}
+}
+
+async function pollOnce() {
+  try {
+    const data = await fetchOrders(5);
+    const orders = data.orders || [];
+    if (!orders.length) {
+      if (win) win.webContents.send('status', { at: new Date().toISOString(), msg: 'No orders yet', lastId: lastOrderId });
+      return;
+    }
+    const newest = orders[0];
+    if (!seeded) {
+      // first run: seed without ringing
+      lastOrderId = newest.id;
+      seeded = true;
+      saveState(lastOrderId);
+      if (win) win.webContents.send('status', { at: new Date().toISOString(), msg: `Seeded at #${newest.name} — watching for new orders`, lastId: lastOrderId, orders });
+      return;
+    }
+    // find any orders newer than lastOrderId (ids are monotonic)
+    const newOnes = [];
+    for (const o of orders) {
+      if (String(o.id) === String(lastOrderId)) break;
+      newOnes.push(o);
+    }
+    if (newOnes.length) {
+      // ring for each new order (newest last so we save newest correctly)
+      newOnes.reverse().forEach(notifyOrder);
+      lastOrderId = newest.id;
+      saveState(lastOrderId);
+    }
+    if (win) win.webContents.send('status', { at: new Date().toISOString(), msg: newOnes.length ? `🔔 ${newOnes.length} new order(s)!` : 'Watching…', lastId: lastOrderId, orders });
+  } catch (e) {
+    if (win) win.webContents.send('status', { at: new Date().toISOString(), msg: 'Error: ' + String(e.message).slice(0, 120), lastId: lastOrderId, error: true });
+  }
+}
+
+function createWindow() {
+  win = new BrowserWindow({
+    width: 520, height: 640,
+    backgroundColor: '#0b0d10',
+    icon: path.join(__dirname, 'assets', 'icon.png'),
+    webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false },
+    title: 'Shopify Order Bell — ' + STORE,
+  });
+  win.loadFile(path.join(__dirname, 'index.html'));
+  // open devtools in dev mode
+  if (isDev) win.webContents.openDevTools({ mode: 'detach' });
+}
+
+function createTray() {
+  try {
+    const iconPath = path.join(__dirname, 'assets', 'icon.png');
+    let img = fs.existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
+    if (img.isEmpty()) img = nativeImage.createFromDataURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=');
+    tray = new Tray(img.resize({ width: 16, height: 16 }));
+    const menu = Menu.buildFromTemplate([
+      { label: 'Show', click: () => win && win.show() },
+      { label: 'Test Bell', click: () => win && win.webContents.send('bell', { order: { name: 'TEST', total_price: '0.00', currency: 'USD' }, title: 'Test Bell', body: '🔔 Ding!' }) },
+      { label: 'Poll now', click: () => pollOnce() },
+      { type: 'separator' },
+      { label: 'Quit', click: () => app.quit() },
+    ]);
+    tray.setToolTip('Shopify Order Bell');
+    tray.setContextMenu(menu);
+    tray.on('click', () => win && win.show());
+  } catch {}
+}
+
+app.whenReady().then(() => {
+  createWindow();
+  createTray();
+  // start polling
+  pollOnce();
+  pollTimer = setInterval(pollOnce, POLL_MS);
+  app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
+});
+
+app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
+app.on('before-quit', () => { if (pollTimer) clearInterval(pollTimer); });
+
+ipcMain.handle('poll-now', async () => { await pollOnce(); return { lastOrderId, at: new Date().toISOString() }; });
+ipcMain.handle('test-bell', async () => {
+  if (win) win.webContents.send('bell', { order: { name: 'TEST', total_price: '0.00', currency: 'USD' }, title: 'Test Bell', body: '🔔 Ding dong!' });
+  return true;
+});
+ipcMain.handle('get-config', () => ({ store: STORE, pollSeconds: POLL_MS / 1000, hasToken: !!TOKEN, lastOrderId, seeded }));
+ipcMain.handle('reset-seed', async () => { lastOrderId = null; seeded = false; try { fs.unlinkSync(STATE_FILE); } catch {} await pollOnce(); return true; });
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..822a2d4
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "shopify-order-bell",
+  "version": "1.0.0",
+  "description": "Desktop bell that rings when a new Shopify order arrives — designer-laboratory-sandbox.myshopify.com",
+  "main": "main.js",
+  "scripts": {
+    "start": "electron .",
+    "poll": "node poller.js",
+    "test-sound": "node -e \"require('./main.js')\"",
+    "dev": "electron . --dev"
+  },
+  "author": "steve@designerwallcoverings.com",
+  "license": "MIT",
+  "dependencies": {},
+  "devDependencies": {
+    "electron": "^30.0.0"
+  }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..1a24ada
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+// Headless poller — same logic as the desktop app, but CLI-only.
+// Useful for overnight / server use: `node poller.js` loops forever and `afplay`s on new orders.
+const fs = require('fs'), path = require('path'), https = require('https');
+const { exec } = require('child_process');
+function loadEnv(){
+  for (const p of [path.join(__dirname,'.env'), path.join(process.env.HOME,'Projects/secrets-manager/.env')]){
+    try{ const t=fs.readFileSync(p,'utf8'); for(const l of t.split('\n')){ const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m && process.env[m[1]]===undefined) process.env[m[1]]=m[2].replace(/^["']|["']$/g,''); }}catch{}
+  }
+}
+loadEnv();
+const STORE = process.env.SHOPIFY_STORE || process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ORDERS_TOKEN || process.env.SHOPIFY_FULL_ACCESS_TOKEN || '';
+const POLL_MS = parseInt(process.env.POLL_SECONDS||'15',10)*1000;
+const STATE = path.join(__dirname,'.last-order.json');
+let lastId=null; let seeded=false;
+try{ if(fs.existsSync(STATE)) lastId=JSON.parse(fs.readFileSync(STATE,'utf8')).lastId; seeded=lastId!=null; }catch{}
+function fetchOrders(){
+  return new Promise((res,rej)=>{
+    if(!TOKEN) return rej(new Error('Missing SHOPIFY_ORDERS_TOKEN'));
+    const req=https.request({hostname:STORE,path:'/admin/api/2024-04/orders.json?limit=5&status=any&order=created_at%20desc',headers:{'X-Shopify-Access-Token':TOKEN}},r=>{
+      let d=''; r.on('data',c=>d+=c); r.on('end',()=> r.statusCode===200 ? res(JSON.parse(d)) : rej(new Error(`Shopify ${r.statusCode}: ${d.slice(0,300)}`)));
+    }); req.on('error',rej); req.setTimeout(10000,()=>req.destroy(new Error('timeout'))); req.end();
+  });
+}
+function ring(order){
+  const msg=`🔔 NEW ORDER #${order.name} ${order.total_price} ${order.currency} — ${order.email||''}`;
+  console.log(new Date().toISOString(), msg);
+  // macOS bell: Glass + spoken cue
+  exec('afplay /System/Library/Sounds/Glass.aiff 2>/dev/null; afplay /System/Library/Sounds/Ping.aiff 2>/dev/null; echo "\\a"');
+  try{ exec(`osascript -e 'display notification "${msg.replace(/"/g,'\\"').slice(0,120)}" with title "Shopify Order"'`);}catch{}
+}
+async function poll(){
+  try{
+    const {orders}=(await fetchOrders()); if(!orders.length){ console.log(new Date().toISOString(),'No orders'); return; }
+    const newest=orders[0];
+    if(!seeded){ lastId=newest.id; seeded=true; fs.writeFileSync(STATE,JSON.stringify({lastId,at:new Date().toISOString()})); console.log(new Date().toISOString(),`Seeded at #${newest.name} — watching for new orders`); return; }
+    const news=[]; for(const o of orders){ if(String(o.id)===String(lastId)) break; news.push(o); }
+    if(news.length){ news.reverse().forEach(ring); lastId=newest.id; fs.writeFileSync(STATE,JSON.stringify({lastId,at:new Date().toISOString()})); }
+    else console.log(new Date().toISOString(),`Watching — last #${orders[0].name}`);
+  }catch(e){ console.error(new Date().toISOString(),'poll error',e.message); }
+}
+console.log(`Shopify Order Bell — poller — store ${STORE} every ${POLL_MS/1000}s`);
+if(!TOKEN) { console.error('Set SHOPIFY_ORDERS_TOKEN in .env or secrets-manager'); process.exit(1); }
+poll(); setInterval(poll,POLL_MS);
diff --git a/preload.js b/preload.js
new file mode 100644
index 0000000..efcbaeb
--- /dev/null
+++ b/preload.js
@@ -0,0 +1,9 @@
+const { contextBridge, ipcRenderer } = require('electron');
+contextBridge.exposeInMainWorld('bellAPI', {
+  onBell: (cb) => ipcRenderer.on('bell', (e, data) => cb(data)),
+  onStatus: (cb) => ipcRenderer.on('status', (e, data) => cb(data)),
+  pollNow: () => ipcRenderer.invoke('poll-now'),
+  testBell: () => ipcRenderer.invoke('test-bell'),
+  getConfig: () => ipcRenderer.invoke('get-config'),
+  resetSeed: () => ipcRenderer.invoke('reset-seed'),
+});

(oldest)  ·  back to Shopify Order Bell  ·  add start-bell.sh + verify poller seeds at #32985 2339762 →