Spaces:
Running
Running
ML Intern commited on
Commit ·
ee1a7da
1
Parent(s): 12abe10
Triệt để: web LUÔN có mã KH + link chiết khấu — /api/kh-link mint mã KH + signed link cho khách vào thẳng web; initDiscountLink fallback qua kh-link + lưu vas_signed_kh; greeting/mọi reply hiện header
Browse files- index.html +1 -1
- index.ts +60 -0
- src/app.js +62 -20
index.html
CHANGED
|
@@ -553,7 +553,7 @@
|
|
| 553 |
<script src="./src/vaix-rag.js?gc=46"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
|
| 554 |
<script src="./src/greeting-news.js?gc=22"></script> <!-- Greeting HOT news cards + source links -->
|
| 555 |
<script src="./src/order-sync.js?gc=14"></script> <!-- Order sync with V.AISTUDIO backend -->
|
| 556 |
-
<script type="module" src="./src/app.js?gc=
|
| 557 |
<script src="./src/greeting-source-cards.js?gc=14"></script> <!-- FIX: Nguồn tin cards under greeting -->
|
| 558 |
<script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
|
| 559 |
|
|
|
|
| 553 |
<script src="./src/vaix-rag.js?gc=46"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
|
| 554 |
<script src="./src/greeting-news.js?gc=22"></script> <!-- Greeting HOT news cards + source links -->
|
| 555 |
<script src="./src/order-sync.js?gc=14"></script> <!-- Order sync with V.AISTUDIO backend -->
|
| 556 |
+
<script type="module" src="./src/app.js?gc=52"></script>
|
| 557 |
<script src="./src/greeting-source-cards.js?gc=14"></script> <!-- FIX: Nguồn tin cards under greeting -->
|
| 558 |
<script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
|
| 559 |
|
index.ts
CHANGED
|
@@ -1285,6 +1285,66 @@ const server = Bun.serve({
|
|
| 1285 |
}
|
| 1286 |
},
|
| 1287 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
"/api/customers": {
|
| 1289 |
GET: async () => {
|
| 1290 |
try {
|
|
|
|
| 1285 |
}
|
| 1286 |
},
|
| 1287 |
},
|
| 1288 |
+
// ── Issue a mã KH + fresh time-limited discount link for the web visitor ──
|
| 1289 |
+
// The Zalo bot only sends ?kh links in Zalo chat. Web visitors who open the
|
| 1290 |
+
// site directly (no ?kh in URL) still need to SEE their mã KH + a valid
|
| 1291 |
+
// chiết khấu link on every greeting / non-product reply. The client sends a
|
| 1292 |
+
// stable visitor id (localStorage vas_cid) + display name; the server mints
|
| 1293 |
+
// a customer code (persisted to customers.json) and a signed link — so the
|
| 1294 |
+
// greeting always has a working link, not a dead one.
|
| 1295 |
+
"/api/kh-link": {
|
| 1296 |
+
GET: async (req: Request) => {
|
| 1297 |
+
try {
|
| 1298 |
+
const url = new URL(req.url);
|
| 1299 |
+
const cid = (url.searchParams.get("cid") || "").trim().slice(0, 40);
|
| 1300 |
+
const name = (url.searchParams.get("name") || "").trim().slice(0, 60);
|
| 1301 |
+
if (!cid) return Response.json({ ok: false, error: "missing cid" }, { status: 400 });
|
| 1302 |
+
const f = join("/app", "customers.json");
|
| 1303 |
+
let data: any = {};
|
| 1304 |
+
if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")) || {}; } catch (_e) { data = {}; } }
|
| 1305 |
+
if (!data || typeof data !== "object") data = {};
|
| 1306 |
+
let rec: any = null;
|
| 1307 |
+
for (const k in data) {
|
| 1308 |
+
const c = data[k];
|
| 1309 |
+
if (c && String(c.cid || "").trim() === cid) { rec = c; break; }
|
| 1310 |
+
}
|
| 1311 |
+
if (!rec) {
|
| 1312 |
+
// Mint a new code: initials + last 3 of cid (mirror bot's rule), fallback "X".
|
| 1313 |
+
const base = (name || "KH").replace(/[^A-Za-zÀ-ỹ\s]/g, " ").trim().split(/\s+/)
|
| 1314 |
+
.map((w: string) => w.charAt(0)).join("").toUpperCase().replace(/[^A-Z]/g, "") || "X";
|
| 1315 |
+
const ma_kh = base + cid.slice(-3).toUpperCase();
|
| 1316 |
+
rec = { cid: cid, name: name || "", ma_kh: ma_kh, created_at: new Date().toISOString() };
|
| 1317 |
+
data[ma_kh] = rec;
|
| 1318 |
+
// Persist locally (best-effort; mirrors the bot's own persistence).
|
| 1319 |
+
try { writeFileSync(f, JSON.stringify(data, null, 2), "utf-8"); } catch (_e) {}
|
| 1320 |
+
// Also push to the repo so the Zalo bot/KETOAN see it (best-effort).
|
| 1321 |
+
try {
|
| 1322 |
+
const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
|
| 1323 |
+
if (token) {
|
| 1324 |
+
const payload = [
|
| 1325 |
+
{ key: "header", value: { summary: "Add web visitor " + ma_kh + " via /api/kh-link", repo: { type: "space", id: "bep40/vai-avatar2" } } },
|
| 1326 |
+
{ key: "file", value: { path: "customers.json", content: JSON.stringify(data, null, 2) } },
|
| 1327 |
+
].map((o) => JSON.stringify(o)).join("\n") + "\n";
|
| 1328 |
+
await fetch("https://huggingface.co/api/spaces/bep40/vai-avatar2/commit/main", {
|
| 1329 |
+
method: "POST", headers: { "Content-Type": "application/x-ndjson", Authorization: "Bearer " + token },
|
| 1330 |
+
body: payload, signal: AbortSignal.timeout(15000),
|
| 1331 |
+
}).catch(() => {});
|
| 1332 |
+
}
|
| 1333 |
+
} catch (_e2) {}
|
| 1334 |
+
}
|
| 1335 |
+
const maKh = String(rec.ma_kh || "");
|
| 1336 |
+
const secret = (process.env.DISCOUNT_SECRET || "vai2026ck");
|
| 1337 |
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
| 1338 |
+
const crypto = await import("node:crypto");
|
| 1339 |
+
const tk = crypto.createHmac("sha256", secret).update(maKh + ":" + exp).digest("hex").slice(0, 24);
|
| 1340 |
+
const link = url.origin + "/?kh=" + encodeURIComponent(maKh) + "&tk=" + tk + "&exp=" + exp;
|
| 1341 |
+
const ck = rec.ck != null ? String(rec.ck) : null;
|
| 1342 |
+
return Response.json({ ok: true, kh: maKh, ck: ck, customer: rec.name || "", link: link });
|
| 1343 |
+
} catch (e: any) {
|
| 1344 |
+
return Response.json({ ok: false, error: e.message }, { status: 500 });
|
| 1345 |
+
}
|
| 1346 |
+
},
|
| 1347 |
+
},
|
| 1348 |
"/api/customers": {
|
| 1349 |
GET: async () => {
|
| 1350 |
try {
|
src/app.js
CHANGED
|
@@ -1067,34 +1067,76 @@ function cleanAiText(text) {
|
|
| 1067 |
.trim();
|
| 1068 |
}
|
| 1069 |
|
| 1070 |
-
// ──
|
| 1071 |
-
//
|
| 1072 |
-
//
|
| 1073 |
-
//
|
| 1074 |
-
//
|
| 1075 |
-
//
|
| 1076 |
-
//
|
| 1077 |
-
//
|
| 1078 |
let _discountLink = null; // { kh, url } when a verified link is active
|
| 1079 |
let _discountCustomer = null; // { ma_kh, ck, name } verified customer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1080 |
async function initDiscountLink() {
|
| 1081 |
try {
|
| 1082 |
const p = new URLSearchParams(location.search);
|
| 1083 |
const kh = (p.get("kh") || "").trim();
|
| 1084 |
const tk = (p.get("tk") || "").trim();
|
| 1085 |
const exp = (p.get("exp") || "").trim();
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
_discountCustomer = { ma_kh: maKh, ck: ck, name: custName };
|
| 1099 |
// Remember the customer for the quote/order exports (customers.js format).
|
| 1100 |
try {
|
|
|
|
| 1067 |
.trim();
|
| 1068 |
}
|
| 1069 |
|
| 1070 |
+
// ── Mã KH + time-limited discount link (Zalo bot → web, and web standalone) ──
|
| 1071 |
+
// 1) Opened FROM the Zalo bot's signed link (?kh=..&tk=..&exp=..): verify it
|
| 1072 |
+
// server-side (/api/verify-kh); expired/stale links are ignored.
|
| 1073 |
+
// 2) Opened DIRECTLY (no ?kh): mint/load a mã KH + fresh signed link via
|
| 1074 |
+
// /api/kh-link, so the greeting and every non-product reply STILL show the
|
| 1075 |
+
// customer's mã KH + a working chiết khấu link.
|
| 1076 |
+
// Both paths remember the customer (vas_selected_customer: ma_kh, ck, name,
|
| 1077 |
+
// cid) and auto-apply the customer's ck to quote + product cards.
|
| 1078 |
let _discountLink = null; // { kh, url } when a verified link is active
|
| 1079 |
let _discountCustomer = null; // { ma_kh, ck, name } verified customer
|
| 1080 |
+
let _servedLink = null; // full signed link returned by /api/kh-link
|
| 1081 |
+
let _stableCid = null; // stable visitor id (localStorage vas_cid)
|
| 1082 |
+
async function _ensureStableCid() {
|
| 1083 |
+
if (_stableCid) return _stableCid;
|
| 1084 |
+
try { _stableCid = localStorage.getItem("vas_cid"); } catch (e) {}
|
| 1085 |
+
if (!_stableCid) {
|
| 1086 |
+
_stableCid = "web" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
|
| 1087 |
+
try { localStorage.setItem("vas_cid", _stableCid); } catch (e) {}
|
| 1088 |
+
}
|
| 1089 |
+
return _stableCid;
|
| 1090 |
+
}
|
| 1091 |
async function initDiscountLink() {
|
| 1092 |
try {
|
| 1093 |
const p = new URLSearchParams(location.search);
|
| 1094 |
const kh = (p.get("kh") || "").trim();
|
| 1095 |
const tk = (p.get("tk") || "").trim();
|
| 1096 |
const exp = (p.get("exp") || "").trim();
|
| 1097 |
+
// Store the signed-link info DAILY so a returning visitor keeps a valid
|
| 1098 |
+
// link even after the URL param is gone. Fallback to /api/kh-link below.
|
| 1099 |
+
let signed = null;
|
| 1100 |
+
if (kh && tk && exp) {
|
| 1101 |
+
let resp;
|
| 1102 |
+
try {
|
| 1103 |
+
resp = await fetch("/api/verify-kh?kh=" + encodeURIComponent(kh) + "&tk=" + encodeURIComponent(tk) + "&exp=" + encodeURIComponent(exp));
|
| 1104 |
+
} catch (e) { return; }
|
| 1105 |
+
if (!resp || !resp.ok) return;
|
| 1106 |
+
const data = await resp.json();
|
| 1107 |
+
if (data && data.ok) {
|
| 1108 |
+
signed = { kh: data.kh || kh, tk: tk, exp: exp, ck: data.ck != null ? String(data.ck) : "", customer: data.customer || "" };
|
| 1109 |
+
try { localStorage.setItem("vas_signed_kh", JSON.stringify(signed)); } catch (e2) {}
|
| 1110 |
+
}
|
| 1111 |
+
}
|
| 1112 |
+
// No (valid) signed link in URL → get/mint one from the server so the web
|
| 1113 |
+
// chat ALWAYS has a mã KH + working link (visitor identity from vas_cid).
|
| 1114 |
+
if (!signed) {
|
| 1115 |
+
try { signed = JSON.parse(localStorage.getItem("vas_signed_kh") || "null"); } catch (e3) { signed = null; }
|
| 1116 |
+
if (signed && signed.exp && Number(signed.exp) < Math.floor(Date.now() / 1000)) signed = null; // expired stored link
|
| 1117 |
+
}
|
| 1118 |
+
if (!signed) {
|
| 1119 |
+
const cid = await _ensureStableCid();
|
| 1120 |
+
let name = "";
|
| 1121 |
+
try { name = (localStorage.getItem("vas_name") || "").trim(); } catch (e4) {}
|
| 1122 |
+
try {
|
| 1123 |
+
const r = await fetch("/api/kh-link?cid=" + encodeURIComponent(cid) + "&name=" + encodeURIComponent(name));
|
| 1124 |
+
if (r.ok) {
|
| 1125 |
+
const d = await r.json();
|
| 1126 |
+
if (d && d.ok && d.kh && d.link) {
|
| 1127 |
+
signed = { kh: d.kh, tk: "", exp: String(Math.floor(Date.now() / 1000) + 3600), ck: d.ck != null ? String(d.ck) : "", customer: d.customer || "" };
|
| 1128 |
+
try { localStorage.setItem("vas_signed_kh", JSON.stringify(signed)); } catch (e5) {}
|
| 1129 |
+
_servedLink = d.link;
|
| 1130 |
+
}
|
| 1131 |
+
}
|
| 1132 |
+
} catch (e6) {}
|
| 1133 |
+
}
|
| 1134 |
+
if (!signed || !signed.kh) return;
|
| 1135 |
+
const maKh = signed.kh;
|
| 1136 |
+
const ck = signed.ck != null ? String(signed.ck) : "";
|
| 1137 |
+
const custName = signed.customer || "";
|
| 1138 |
+
const linkUrl = _servedLink || (location.origin + location.pathname + "?kh=" + encodeURIComponent(maKh) + "&tk=" + encodeURIComponent(signed.tk || "") + "&exp=" + encodeURIComponent(signed.exp || ""));
|
| 1139 |
+
_discountLink = { kh: maKh, url: linkUrl };
|
| 1140 |
_discountCustomer = { ma_kh: maKh, ck: ck, name: custName };
|
| 1141 |
// Remember the customer for the quote/order exports (customers.js format).
|
| 1142 |
try {
|