← 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; });