SmartPOS Developersເຂົ້າລະບົບ

ໂຄດສຳເລັດຮູບ Node.js

ໂຄດນີ້ເອົາໄປໃຊ້ໄດ້ເລີຍ: ສະແດງສິນຄ້າທັງໝົດຂອງຮ້ານ (ພ້ອມຮູບ, ລາຄາ, ຄົງເຫຼືອ) ເປັນໜ້າເວັບ, ແລະ ຮັບ webhook ຈາກ SmartPOS ພ້ອມກວດລາຍເຊັນ. ເມື່ອລາຄາປ່ຽນ ລະບົບອັບເດດສິນຄ້າເອງ. ໃຊ້ Node.js 18 ຂຶ້ນໄປ ແລະ ບໍ່ຕ້ອງຕິດຕັ້ງແພັກເກດໃດເລີຍ.

ວິທີໃຊ້ (5 ນາທີ)

  1. ສ້າງ API key ໃນໜ້າ ບັນຊີ (ກົດ ສ້າງ API key) ແລ້ວສຳເນົາໄວ້.
  2. ດາວໂຫຼດໄຟລ໌ລຸ່ມນີ້ໃສ່ໂຟນເດີດຽວກັນ: server.mjs, package.json, env.example.
  3. ແລ່ນຄຳສັ່ງ:
    SMARTPOS_API_KEY=spk_... node server.mjs
    ແລ້ວເປີດ http://localhost:3000: ຈະເຫັນສິນຄ້າທັງໝົດຂອງຮ້ານ ພ້ອມຮູບ ແລະ ຊ່ອງຄົ້ນຫາ.
  4. ຖ້າຕ້ອງການຮັບ webhook: ເອົາໂຄດຂຶ້ນເຊີບເວີທີ່ມີ https (ເຊັ່ນ https://api.your-shop.com), ໄປໜ້າ Webhook, ເພີ່ມ URL https://api.your-shop.com/smartpos/webhook, ສຳເນົາ secret ແລ້ວແລ່ນໃໝ່ດ້ວຍ:
    SMARTPOS_API_KEY=spk_... SMARTPOS_WEBHOOK_SECRET=whsec_... node server.mjs
  5. ກົດ ທົດສອບ ໃນໜ້າ Webhook: ເຊີບເວີຂອງທ່ານຈະພິມ [webhook] webhook.ping ແລະ SmartPOS ຂຶ້ນວ່າສົ່ງສຳເລັດ.

ໂຄດນີ້ເຮັດຫຍັງແດ່

ເສັ້ນທາງໜ້າທີ່
GET /ໜ້າເວັບສະແດງສິນຄ້າທັງໝົດ (ຮູບ, ຊື່, ລາຄາ, ຄົງເຫຼືອ) ພ້ອມຄົ້ນຫາ
GET /api/productsສິນຄ້າທັງໝົດເປັນ JSON ສຳລັບແອັບ ຫຼື ເວັບອື່ນຂອງທ່ານ
POST /smartpos/webhookຮັບເຫດການ, ກວດລາຍເຊັນ ແລະ ເວລາ (ກັນການປອມ ແລະ ສົ່ງຊ້ຳ), ລາຄາປ່ຽນ = ໂຫຼດສິນຄ້າໃໝ່

ດຶງສິນຄ້າທຸກໜ້າ (ໜ້າລະ 100 ລາຍການ) ຕອນເລີ່ມ ແລະ ທຸກ 10 ນາທີ. ເພີ່ມວຽກຂອງທ່ານເອງໄດ້ໃນຟັງຊັນ onEvent.

server.mjs ດາວໂຫຼດ

// SmartPOS starter (Node.js 18+, no packages to install).
// 1) Shows your shop's products (with pictures) on a web page, pulled from the SmartPOS open API.
// 2) Receives SmartPOS webhooks, checks the signature, and refreshes products when a price changes.
//
// Run:  SMARTPOS_API_KEY=spk_... SMARTPOS_WEBHOOK_SECRET=whsec_... node server.mjs
// Open: http://localhost:3000
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const API = process.env.SMARTPOS_API || "https://pos-v2.luayver.com/api/open/v1";
const KEY = process.env.SMARTPOS_API_KEY;               // made on pos-developer.luayver.com/account
const SECRET = process.env.SMARTPOS_WEBHOOK_SECRET;     // shown once when you add a webhook
const PORT = Number(process.env.PORT || 3000);
const REFRESH_MINUTES = 10;
if (!KEY) { console.error("Set SMARTPOS_API_KEY (pos-developer.luayver.com/account)"); process.exit(1); }

// ---------- products: every page of GET /products ----------
let products = [];
let loadedAt = null;

async function loadProducts() {
  const all = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`${API}/products?page=${page}&per_page=100`, { headers: { authorization: `Bearer ${KEY}` } });
    if (!res.ok) throw new Error(`SmartPOS ${res.status}: ${await res.text()}`);
    const body = await res.json();               // { data, count, total, page, per_page }
    all.push(...body.data);
    if (!body.data.length || all.length >= body.total) break;
  }
  products = all;
  loadedAt = new Date();
  console.log(`[products] ${all.length} loaded`);
}

// ---------- webhooks: Standard Webhooks signature over the raw body ----------
function verify(headers, body) {
  if (!SECRET) return false;
  const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sig = headers["webhook-signature"] || "";
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes = replay
  const expected = "v1," + createHmac("sha256", Buffer.from(SECRET.replace("whsec_", ""), "base64"))
    .update(`${id}.${ts}.${body}`).digest("base64");
  return sig.split(" ").some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

const seen = new Set(); // webhook-id already handled (a resend carries the same id)
async function onEvent(event) {
  console.log(`[webhook] ${event.type}`, JSON.stringify(event.data));
  if (event.type === "price.changed") await loadProducts();
  // sale.completed / sale.voided: write to your own database, notify, etc.
}

// ---------- web page ----------
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function page(q) {
  const list = q ? products.filter((p) => [p.name, p.sku, p.barcode].some((v) => v?.toLowerCase().includes(q.toLowerCase()))) : products;
  const cards = list.map((p) => `
    <div class="card">
      ${p.image_url ? `<img src="${esc(p.image_url)}" loading="lazy" alt="">` : `<div class="noimg">ບໍ່ມີຮູບ</div>`}
      <b>${esc(p.name)}</b>
      <span class="price">${Number(p.price).toLocaleString()} ${esc(p.currency)}</span>
      <small>ຄົງເຫຼືອ ${Number(p.stock_qty).toLocaleString()} ${esc(p.unit)}</small>
    </div>`).join("");
  return `<!doctype html><html lang="lo"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ສິນຄ້າຂອງຮ້ານ</title>
<style>body{font-family:system-ui,"Noto Sans Lao",sans-serif;margin:0;padding:16px;background:#f7f7f8;color:#111}
form{margin:0 0 16px}input{padding:8px 12px;width:280px;max-width:100%;border:1px solid #ccc;border-radius:8px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
.card{background:#fff;border:1px solid #e5e5e5;border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:4px}
.card img,.noimg{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;background:#eee;display:grid;place-items:center;color:#999}
.price{color:#1447e6;font-weight:600}small{color:#666}</style>
<h1>ສິນຄ້າ (${list.length}/${products.length})</h1>
<form><input name="q" value="${esc(q)}" placeholder="ຄົ້ນຫາ ຊື່, ລະຫັດ, ບາໂຄດ"></form>
<div class="grid">${cards}</div>
<p><small>ອັບເດດລ່າສຸດ ${loadedAt?.toLocaleString() ?? "—"}</small></p></html>`;
}

// ---------- server ----------
createServer(async (req, res) => {
  const url = new URL(req.url, "http://x");
  try {
    if (req.method === "POST" && url.pathname === "/smartpos/webhook") {
      let body = "";
      for await (const chunk of req) body += chunk;
      if (!verify(req.headers, body)) { res.writeHead(401).end("bad signature"); return; }
      res.writeHead(200).end("ok");               // answer fast; work after
      const id = req.headers["webhook-id"];
      if (seen.has(id)) return;
      seen.add(id);
      await onEvent(JSON.parse(body));
      return;
    }
    if (url.pathname === "/api/products") {
      res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(products));
      return;
    }
    if (url.pathname === "/") {
      res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page(url.searchParams.get("q") || ""));
      return;
    }
    res.writeHead(404).end("not found");
  } catch (e) {
    console.error(e);
    if (!res.headersSent) res.writeHead(500).end("error");
  }
}).listen(PORT, async () => {
  console.log(`http://localhost:${PORT}  (webhook URL: https://<your-domain>/smartpos/webhook)`);
  await loadProducts().catch((e) => console.error(e.message));
  setInterval(() => loadProducts().catch((e) => console.error(e.message)), REFRESH_MINUTES * 60_000);
});

package.json

{
  "name": "smartpos-starter",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "SmartPOS products page + webhook receiver (no dependencies)",
  "engines": { "node": ">=18" },
  "scripts": { "start": "node server.mjs" }
}

env.example

SMARTPOS_API_KEY=spk_your_key_here
SMARTPOS_WEBHOOK_SECRET=whsec_your_secret_here
PORT=3000