ໂຄດສຳເລັດຮູບ Node.js
ໂຄດນີ້ເອົາໄປໃຊ້ໄດ້ເລີຍ: ສະແດງສິນຄ້າທັງໝົດຂອງຮ້ານ (ພ້ອມຮູບ, ລາຄາ, ຄົງເຫຼືອ) ເປັນໜ້າເວັບ, ແລະ ຮັບ webhook ຈາກ SmartPOS ພ້ອມກວດລາຍເຊັນ. ເມື່ອລາຄາປ່ຽນ ລະບົບອັບເດດສິນຄ້າເອງ. ໃຊ້ Node.js 18 ຂຶ້ນໄປ ແລະ ບໍ່ຕ້ອງຕິດຕັ້ງແພັກເກດໃດເລີຍ.
ວິທີໃຊ້ (5 ນາທີ)
- ສ້າງ API key ໃນໜ້າ ບັນຊີ (ກົດ ສ້າງ API key) ແລ້ວສຳເນົາໄວ້.
- ດາວໂຫຼດໄຟລ໌ລຸ່ມນີ້ໃສ່ໂຟນເດີດຽວກັນ: server.mjs, package.json, env.example.
- ແລ່ນຄຳສັ່ງ:
SMARTPOS_API_KEY=spk_... node server.mjs
ແລ້ວເປີດhttp://localhost:3000: ຈະເຫັນສິນຄ້າທັງໝົດຂອງຮ້ານ ພ້ອມຮູບ ແລະ ຊ່ອງຄົ້ນຫາ. - ຖ້າຕ້ອງການຮັບ webhook: ເອົາໂຄດຂຶ້ນເຊີບເວີທີ່ມີ https (ເຊັ່ນ
https://api.your-shop.com), ໄປໜ້າ Webhook, ເພີ່ມ URLhttps://api.your-shop.com/smartpos/webhook, ສຳເນົາ secret ແລ້ວແລ່ນໃໝ່ດ້ວຍ:SMARTPOS_API_KEY=spk_... SMARTPOS_WEBHOOK_SECRET=whsec_... node server.mjs
- ກົດ ທົດສອບ ໃນໜ້າ 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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