← 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
A .env.exampleA .gitignoreA README.mdA index.htmlA main.jsA package.jsonA poller.jsA preload.js
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 →