← back to Shopify Order Bell
main.js
163 lines
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; });