Spaces:
Running
Running
Fix customer persistence (POST writes local file + GET reads live repo), fix + Thêm row, add customer-list CSS contrast, add role-based visibility (admin 479b... sees all customers/orders; others only own orders, no customer-list button)
Browse files- index.html +21 -4
- index.ts +25 -4
- src/cart-quote.js +31 -2
- src/customers.js +70 -2
- src/order-sync.js +37 -2
index.html
CHANGED
|
@@ -523,19 +523,36 @@
|
|
| 523 |
.source-card-askai{display:flex;align-items:center;gap:6px;margin:8px 4px 2px;padding:7px 12px;background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;border:none;border-radius:10px;font-size:.72rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;width:fit-content}
|
| 524 |
.source-card-askai:hover{opacity:.9;transform:translateY(-1px)}
|
| 525 |
|
| 526 |
-
/* ──
|
| 527 |
#order-btn, #vaistudio-order-btn { display: flex !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
</style>
|
| 529 |
|
| 530 |
<!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
|
| 531 |
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
|
| 532 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
| 533 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 534 |
-
<script src="./src/cart-quote.js?gc=
|
| 535 |
-
<script src="./src/customers.js?gc=
|
| 536 |
<script src="./src/vaix-rag.js?gc=45"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
|
| 537 |
<script src="./src/greeting-news.js?gc=21"></script> <!-- Greeting HOT news cards + source links -->
|
| 538 |
-
<script src="./src/order-sync.js?gc=
|
| 539 |
<script type="module" src="./src/app.js?gc=50"></script>
|
| 540 |
<script src="./src/greeting-source-cards.js?gc=14"></script> <!-- FIX: Nguồn tin cards under greeting -->
|
| 541 |
<script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
|
|
|
|
| 523 |
.source-card-askai{display:flex;align-items:center;gap:6px;margin:8px 4px 2px;padding:7px 12px;background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;border:none;border-radius:10px;font-size:.72rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;width:fit-content}
|
| 524 |
.source-card-askai:hover{opacity:.9;transform:translateY(-1px)}
|
| 525 |
|
| 526 |
+
/* ── V.AI STUDIO order buttons ── */
|
| 527 |
#order-btn, #vaistudio-order-btn { display: flex !important; }
|
| 528 |
+
|
| 529 |
+
/* ── Customer list (Danh sách khách hàng) row styling ──
|
| 530 |
+
Rows use semantic classes injected by customers.js. Style them with
|
| 531 |
+
full contrast so they read clearly against the white modal background. */
|
| 532 |
+
#cus-list .cus-row{display:flex;align-items:center;gap:12px;padding:12px 10px;border:1px solid #e2e8f0;border-radius:12px;margin-bottom:8px;background:#f8fafc;transition:background .15s}
|
| 533 |
+
#cus-list .cus-row:hover{background:#eef6ff;border-color:#b8d4e8}
|
| 534 |
+
#cus-list .cus-row-main{flex:1;min-width:0}
|
| 535 |
+
#cus-list .cus-row-name{font-size:0.86rem;font-weight:700;color:#1e293b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 536 |
+
#cus-list .cus-row-meta{font-size:0.7rem;color:#64748b;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 537 |
+
#cus-list .cus-row-actions{display:flex;gap:6px;flex-shrink:0}
|
| 538 |
+
#cus-list .cus-select-btn{padding:7px 14px;border:none;border-radius:8px;background:#0077b6;color:#fff;font-size:0.74rem;font-weight:700;cursor:pointer}
|
| 539 |
+
#cus-list .cus-select-btn:hover{background:#005f94}
|
| 540 |
+
#cus-list .cus-edit-btn{padding:7px 12px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#334155;font-size:0.78rem;cursor:pointer}
|
| 541 |
+
#cus-list .cus-edit-btn:hover{border-color:#0077b6;color:#0077b6}
|
| 542 |
+
/* Multi-value rows: give each input row a clear background */
|
| 543 |
+
#cus-detail-modal .cus-mv-row{display:flex;gap:8px;margin-bottom:6px;align-items:center}
|
| 544 |
+
#cus-detail-modal .cus-mv-row input{background:#fff !important}
|
| 545 |
</style>
|
| 546 |
|
| 547 |
<!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
|
| 548 |
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
|
| 549 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
| 550 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
| 551 |
+
<script src="./src/cart-quote.js?gc=18"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
|
| 552 |
+
<script src="./src/customers.js?gc=4"></script> <!-- Customer list (Danh sách khách hàng) -->
|
| 553 |
<script src="./src/vaix-rag.js?gc=45"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
|
| 554 |
<script src="./src/greeting-news.js?gc=21"></script> <!-- Greeting HOT news cards + source links -->
|
| 555 |
+
<script src="./src/order-sync.js?gc=13"></script> <!-- Order sync with V.AISTUDIO backend -->
|
| 556 |
<script type="module" src="./src/app.js?gc=50"></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 -->
|
index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
* S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access).
|
| 8 |
* Text-only chat: /api/chat — uses victor's S2S backend in text-only mode.
|
| 9 |
*/
|
| 10 |
-
import { readFileSync } from "fs";
|
| 11 |
// Read index.html directly as text to avoid Bun HTMLBundle object
|
| 12 |
const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8");
|
| 13 |
import { readdir } from "fs/promises";
|
|
@@ -1232,10 +1232,26 @@ const server = Bun.serve({
|
|
| 1232 |
GET: async () => {
|
| 1233 |
try {
|
| 1234 |
const f = join("/app", "customers.json");
|
| 1235 |
-
if (!existsSync(f)) return Response.json({});
|
| 1236 |
-
const raw = readFileSync(f, "utf-8");
|
| 1237 |
let data: any = {};
|
| 1238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
return new Response(JSON.stringify(data), {
|
| 1240 |
headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }
|
| 1241 |
});
|
|
@@ -1284,6 +1300,11 @@ const server = Bun.serve({
|
|
| 1284 |
const errText = await commit.text().catch(() => "");
|
| 1285 |
return Response.json({ error: "Commit failed: HTTP " + commit.status + " " + errText.slice(0, 300) }, { status: 502 });
|
| 1286 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1287 |
return Response.json({ ok: true, updated: changed, customers: data });
|
| 1288 |
} catch (e: any) {
|
| 1289 |
return Response.json({ error: e.message }, { status: 500 });
|
|
|
|
| 7 |
* S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access).
|
| 8 |
* Text-only chat: /api/chat — uses victor's S2S backend in text-only mode.
|
| 9 |
*/
|
| 10 |
+
import { readFileSync, writeFileSync } from "fs";
|
| 11 |
// Read index.html directly as text to avoid Bun HTMLBundle object
|
| 12 |
const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8");
|
| 13 |
import { readdir } from "fs/promises";
|
|
|
|
| 1232 |
GET: async () => {
|
| 1233 |
try {
|
| 1234 |
const f = join("/app", "customers.json");
|
|
|
|
|
|
|
| 1235 |
let data: any = {};
|
| 1236 |
+
// Prefer LIVE repo HEAD so edits from any writer (Zalo bot upload, new
|
| 1237 |
+
// admin saves) appear immediately without waiting for a rebuild.
|
| 1238 |
+
try {
|
| 1239 |
+
const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
|
| 1240 |
+
const r = await fetch("https://huggingface.co/spaces/bep40/vai-avatar2/raw/main/customers.json", {
|
| 1241 |
+
headers: token ? { Authorization: "Bearer " + token } : {},
|
| 1242 |
+
signal: AbortSignal.timeout(6000),
|
| 1243 |
+
});
|
| 1244 |
+
if (r.ok) {
|
| 1245 |
+
const raw = await r.text();
|
| 1246 |
+
if (raw && raw.trim()) { try { data = JSON.parse(raw); } catch (_e) {} }
|
| 1247 |
+
}
|
| 1248 |
+
} catch (_e) {}
|
| 1249 |
+
// Fallback: local file (fast path, also fine after a local POST write).
|
| 1250 |
+
if (!data || typeof data !== "object" || !Object.keys(data).length) {
|
| 1251 |
+
if (existsSync(f)) {
|
| 1252 |
+
try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_e) { data = {}; }
|
| 1253 |
+
}
|
| 1254 |
+
}
|
| 1255 |
return new Response(JSON.stringify(data), {
|
| 1256 |
headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }
|
| 1257 |
});
|
|
|
|
| 1300 |
const errText = await commit.text().catch(() => "");
|
| 1301 |
return Response.json({ error: "Commit failed: HTTP " + commit.status + " " + errText.slice(0, 300) }, { status: 502 });
|
| 1302 |
}
|
| 1303 |
+
// Persist locally too — without this, the live GET keeps returning the
|
| 1304 |
+
// stale /app/customers.json until the Space rebuilds (rebuilds are slow
|
| 1305 |
+
// and a write shouldn't depend on one). Keep local file in sync so
|
| 1306 |
+
// subsequent GET /api/customers reflect the save immediately.
|
| 1307 |
+
try { writeFileSync(f, content); } catch (_e) {}
|
| 1308 |
return Response.json({ ok: true, updated: changed, customers: data });
|
| 1309 |
} catch (e: any) {
|
| 1310 |
return Response.json({ error: e.message }, { status: 500 });
|
src/cart-quote.js
CHANGED
|
@@ -577,6 +577,15 @@ window.saveOrder = function() {
|
|
| 577 |
['ma_kh','cid','zalo_name','zalo_id','shipping_address','company','company_address','tax_code','representative','recipient','ck'].forEach(function (k) {
|
| 578 |
if (_cust[k] != null) order[k] = _cust[k];
|
| 579 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
var idx = orders.findIndex(function(o) { return o.code === code; });
|
| 581 |
if (idx >= 0) orders[idx] = order; else orders.unshift(order);
|
| 582 |
_lss(ORDER_KEY, orders);
|
|
@@ -594,13 +603,33 @@ window.deleteOrder = function(code) {
|
|
| 594 |
window.openOrderModal = function() {
|
| 595 |
var old = document.getElementById('vai-order-modal');
|
| 596 |
if (old) old.remove();
|
| 597 |
-
var
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
var ov = document.createElement('div');
|
| 599 |
ov.id = 'vai-order-modal';
|
| 600 |
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:5000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto';
|
| 601 |
ov.onclick = function(e){ if (e.target === ov) ov.remove(); };
|
| 602 |
ov.innerHTML = '<div style="background:#fff;border-radius:16px;max-width:720px;width:100%;max-height:85vh;overflow:hidden;display:flex;flex-direction:column">'
|
| 603 |
-
+ '<div style="padding:14px 18px;background:#003f62;display:flex;align-items:center"><div style="font-size:16px;font-weight:800;color:#fff;flex:1"><i class="fas fa-receipt"></i> Đơn hàng (' + orders.length + ')</div><button id="vai-ord-close" style="background:none;border:none;color:#fff;font-size:22px;cursor:pointer">✕</button></div>'
|
| 604 |
+ '<div style="padding:10px 18px;border-bottom:1px solid #e2e8f0"><input id="vai-ord-q" placeholder="Tìm mã đơn, tên KH, SĐT..." style="width:100%;padding:9px;border:2px solid #e2e8f0;border-radius:8px;font-size:12px;box-sizing:border-box"></div>'
|
| 605 |
+ '<div id="vai-ord-r" style="flex:1;overflow-y:auto;padding:10px 18px"></div>'
|
| 606 |
+ '</div>';
|
|
|
|
| 577 |
['ma_kh','cid','zalo_name','zalo_id','shipping_address','company','company_address','tax_code','representative','recipient','ck'].forEach(function (k) {
|
| 578 |
if (_cust[k] != null) order[k] = _cust[k];
|
| 579 |
});
|
| 580 |
+
// Ensure ownership fields are set so role-based order filtering works.
|
| 581 |
+
if (!order.cid && window.currentCid) { try { order.cid = window.currentCid() || ''; } catch (e) {} }
|
| 582 |
+
if (!order.ma_kh) {
|
| 583 |
+
try {
|
| 584 |
+
var sel2 = JSON.parse(localStorage.getItem('vas_selected_customer') || 'null');
|
| 585 |
+
if (sel2 && sel2.ma_kh) order.ma_kh = sel2.ma_kh;
|
| 586 |
+
else { var p2 = new URLSearchParams(location.search); if (p2.get('kh')) order.ma_kh = p2.get('kh'); }
|
| 587 |
+
} catch (e) {}
|
| 588 |
+
}
|
| 589 |
var idx = orders.findIndex(function(o) { return o.code === code; });
|
| 590 |
if (idx >= 0) orders[idx] = order; else orders.unshift(order);
|
| 591 |
_lss(ORDER_KEY, orders);
|
|
|
|
| 603 |
window.openOrderModal = function() {
|
| 604 |
var old = document.getElementById('vai-order-modal');
|
| 605 |
if (old) old.remove();
|
| 606 |
+
var allOrders = getAllOrders();
|
| 607 |
+
// Role-based order visibility: admin sees ALL orders; any other visitor sees
|
| 608 |
+
// only orders belonging to their own customer (matched by cid or ma_kh, or
|
| 609 |
+
// orders created in this same browser session with no owner).
|
| 610 |
+
var myCid = (window.currentCid ? window.currentCid() : '') || '';
|
| 611 |
+
var isAdmin = (window.isAdmin ? window.isAdmin() : (myCid === '479b1cfad6a83ff666b9'));
|
| 612 |
+
var myKh = '';
|
| 613 |
+
try {
|
| 614 |
+
var sel = JSON.parse(localStorage.getItem('vas_selected_customer') || 'null');
|
| 615 |
+
if (sel && sel.ma_kh) myKh = String(sel.ma_kh);
|
| 616 |
+
else { var khp = new URLSearchParams(location.search).get('kh'); if (khp) myKh = khp; }
|
| 617 |
+
} catch (e) {}
|
| 618 |
+
function ownsOrder(o) {
|
| 619 |
+
if (isAdmin) return true;
|
| 620 |
+
if (o.cid && myCid && String(o.cid) === String(myCid)) return true;
|
| 621 |
+
if (o.ma_kh && myKh && String(o.ma_kh).toUpperCase() === String(myKh).toUpperCase()) return true;
|
| 622 |
+
// Orders created in THIS browser without an owner are the user's own.
|
| 623 |
+
if (!o.cid && !o.ma_kh && o.source !== 'vaistudio') return true;
|
| 624 |
+
return false;
|
| 625 |
+
}
|
| 626 |
+
var orders = allOrders.filter(ownsOrder);
|
| 627 |
var ov = document.createElement('div');
|
| 628 |
ov.id = 'vai-order-modal';
|
| 629 |
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:5000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto';
|
| 630 |
ov.onclick = function(e){ if (e.target === ov) ov.remove(); };
|
| 631 |
ov.innerHTML = '<div style="background:#fff;border-radius:16px;max-width:720px;width:100%;max-height:85vh;overflow:hidden;display:flex;flex-direction:column">'
|
| 632 |
+
+ '<div style="padding:14px 18px;background:#003f62;display:flex;align-items:center"><div style="font-size:16px;font-weight:800;color:#fff;flex:1"><i class="fas fa-receipt"></i> Đơn hàng (' + orders.length + (isAdmin ? ')' : ' của tôi)') + '</div><button id="vai-ord-close" style="background:none;border:none;color:#fff;font-size:22px;cursor:pointer">✕</button></div>'
|
| 633 |
+ '<div style="padding:10px 18px;border-bottom:1px solid #e2e8f0"><input id="vai-ord-q" placeholder="Tìm mã đơn, tên KH, SĐT..." style="width:100%;padding:9px;border:2px solid #e2e8f0;border-radius:8px;font-size:12px;box-sizing:border-box"></div>'
|
| 634 |
+ '<div id="vai-ord-r" style="flex:1;overflow-y:auto;padding:10px 18px"></div>'
|
| 635 |
+ '</div>';
|
src/customers.js
CHANGED
|
@@ -105,6 +105,57 @@
|
|
| 105 |
} catch (e) {}
|
| 106 |
})();
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
// ── Discount link (?kh=MA_KH) auto-apply on load ──
|
| 109 |
// The Zalo bot appends a link https://<space>/?kh=<ma_kh> next to the
|
| 110 |
// customer code. When the user opens it, look up that customer's ck in
|
|
@@ -294,13 +345,23 @@
|
|
| 294 |
var add = e.target.closest('.cus-mv-add');
|
| 295 |
if (add) {
|
| 296 |
var field = add.getAttribute('data-field');
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
var row = document.createElement('div');
|
| 299 |
row.className = 'cus-mv-row';
|
| 300 |
row.setAttribute('data-field', field);
|
| 301 |
row.innerHTML = '<input class="cus-mv-input" placeholder="' + esc(field) + '" style="flex:1;padding:8px 10px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none">'
|
| 302 |
+ '<button class="cus-mv-del" style="background:#fee2e2;border:none;color:#dc2626;padding:6px 10px;border-radius:6px;cursor:pointer">−</button>';
|
| 303 |
list.appendChild(row);
|
|
|
|
|
|
|
|
|
|
| 304 |
return;
|
| 305 |
}
|
| 306 |
var del = e.target.closest('.cus-mv-del');
|
|
@@ -352,7 +413,14 @@
|
|
| 352 |
});
|
| 353 |
_lss('vas_quote_unlocked', '1'); // ensure save path unlocked
|
| 354 |
try {
|
| 355 |
-
await saveCustomers([record], ACCESS_CODE);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
toast('✅ Đã lưu khách hàng');
|
| 357 |
ov.remove();
|
| 358 |
renderCustomerList(); // refresh list
|
|
|
|
| 105 |
} catch (e) {}
|
| 106 |
})();
|
| 107 |
|
| 108 |
+
// ── Current visitor identity & role ──
|
| 109 |
+
// Admin = the V.AI STUDIO Zalo owner (cid 479b1cfad6a83ff666b9). Admin sees
|
| 110 |
+
// the customer-list button (all customers) and ALL orders. Any other visitor
|
| 111 |
+
// (e.g. a customer via a ?kh= discount link) must NOT see the customer-list
|
| 112 |
+
// button and must only see their OWN orders.
|
| 113 |
+
var ADMIN_CID = '479b1cfad6a83ff666b9';
|
| 114 |
+
function currentCid() {
|
| 115 |
+
try {
|
| 116 |
+
var p = new URLSearchParams(location.search);
|
| 117 |
+
var kh = p.get('kh');
|
| 118 |
+
if (kh) {
|
| 119 |
+
// ?kh=<ma_kh> — find the matching customer's cid.
|
| 120 |
+
var cust = window.__customers || {};
|
| 121 |
+
for (var k in cust) {
|
| 122 |
+
if (cust.hasOwnProperty(k) && String(cust[k].ma_kh || '').trim().toUpperCase() === String(kh).trim().toUpperCase()) {
|
| 123 |
+
return k;
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
} catch (e) {}
|
| 128 |
+
// Fall back to the selected customer (may be set by admin picking a customer).
|
| 129 |
+
try {
|
| 130 |
+
var sel = JSON.parse(localStorage.getItem('vas_selected_customer') || 'null');
|
| 131 |
+
if (sel && sel.cid) return sel.cid;
|
| 132 |
+
} catch (e) {}
|
| 133 |
+
return '';
|
| 134 |
+
}
|
| 135 |
+
window.currentCid = currentCid;
|
| 136 |
+
// Admin = V.AI STUDIO owner (cid matches) OR the user has unlocked editing
|
| 137 |
+
// with the V.AISTUDIO access code (admin-only signal). Keeping both lets the
|
| 138 |
+
// admin see the customer list even when opening the Space directly (no ?kh).
|
| 139 |
+
window.isAdmin = function () {
|
| 140 |
+
if (currentCid() === ADMIN_CID) return true;
|
| 141 |
+
try { return localStorage.getItem('vas_quote_unlocked') === '1'; } catch (e) { return false; }
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
// ── Role-based UI visibility ──
|
| 145 |
+
// Admin: sees customer-list button. Non-admin: hides it so customers cannot
|
| 146 |
+
// browse/manage the shared customer directory.
|
| 147 |
+
function applyRoleVisibility() {
|
| 148 |
+
var admin = (window.isAdmin ? window.isAdmin() : false);
|
| 149 |
+
var custBtns = document.querySelectorAll('#customer-btn, #vaistudio-customer-btn');
|
| 150 |
+
custBtns.forEach(function (b) { b.style.display = admin ? '' : 'none'; });
|
| 151 |
+
}
|
| 152 |
+
window.applyRoleVisibility = applyRoleVisibility;
|
| 153 |
+
// Re-apply after the catalog/buttons are ready and after discount-link load.
|
| 154 |
+
if (document.readyState !== 'loading') { setTimeout(applyRoleVisibility, 400); }
|
| 155 |
+
if (typeof window.addEventListener === 'function') {
|
| 156 |
+
window.addEventListener('load', function () { setTimeout(applyRoleVisibility, 800); });
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
// ── Discount link (?kh=MA_KH) auto-apply on load ──
|
| 160 |
// The Zalo bot appends a link https://<space>/?kh=<ma_kh> next to the
|
| 161 |
// customer code. When the user opens it, look up that customer's ck in
|
|
|
|
| 345 |
var add = e.target.closest('.cus-mv-add');
|
| 346 |
if (add) {
|
| 347 |
var field = add.getAttribute('data-field');
|
| 348 |
+
// FIND the correct list: the add button sits in the header-row <div>,
|
| 349 |
+
// whose PARENT (the field wrapper) contains the .cus-mv-list. Using
|
| 350 |
+
// closest('div') returns the header row, whose querySelector on the
|
| 351 |
+
// sibling list returns null -> row was never appended. Fix below.
|
| 352 |
+
var headerRow = add.closest('div');
|
| 353 |
+
var wrap = (headerRow && headerRow.parentNode) ? headerRow.parentNode : null;
|
| 354 |
+
var list = wrap ? wrap.querySelector('.cus-mv-list') : null;
|
| 355 |
+
if (!list) { toast('Không tìm thấy vùng nhập'); return; }
|
| 356 |
var row = document.createElement('div');
|
| 357 |
row.className = 'cus-mv-row';
|
| 358 |
row.setAttribute('data-field', field);
|
| 359 |
row.innerHTML = '<input class="cus-mv-input" placeholder="' + esc(field) + '" style="flex:1;padding:8px 10px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:13px;outline:none">'
|
| 360 |
+ '<button class="cus-mv-del" style="background:#fee2e2;border:none;color:#dc2626;padding:6px 10px;border-radius:6px;cursor:pointer">−</button>';
|
| 361 |
list.appendChild(row);
|
| 362 |
+
// Focus the newly added input for immediate data entry.
|
| 363 |
+
var ni = row.querySelector('.cus-mv-input');
|
| 364 |
+
if (ni) ni.focus();
|
| 365 |
return;
|
| 366 |
}
|
| 367 |
var del = e.target.closest('.cus-mv-del');
|
|
|
|
| 413 |
});
|
| 414 |
_lss('vas_quote_unlocked', '1'); // ensure save path unlocked
|
| 415 |
try {
|
| 416 |
+
var resp = await saveCustomers([record], ACCESS_CODE);
|
| 417 |
+
// Update the in-memory cache with the server's authoritative data so the
|
| 418 |
+
// list reflects the just-saved values immediately (avoids stale re-open).
|
| 419 |
+
if (resp && resp.customers && typeof resp.customers === 'object') {
|
| 420 |
+
window.__customers = resp.customers;
|
| 421 |
+
} else {
|
| 422 |
+
try { window.__customers = await fetchCustomers(); } catch (_e) {}
|
| 423 |
+
}
|
| 424 |
toast('✅ Đã lưu khách hàng');
|
| 425 |
ov.remove();
|
| 426 |
renderCustomerList(); // refresh list
|
src/order-sync.js
CHANGED
|
@@ -53,7 +53,10 @@
|
|
| 53 |
remaining: order.remaining || 0,
|
| 54 |
status: order.status || 'pending',
|
| 55 |
savedAt: order.savedAt || new Date().toISOString(),
|
| 56 |
-
source: 'avatar2'
|
|
|
|
|
|
|
|
|
|
| 57 |
};
|
| 58 |
}
|
| 59 |
|
|
@@ -100,11 +103,38 @@
|
|
| 100 |
grandTotal: so.grandTotal || so.total || 0,
|
| 101 |
status: so.status || 'pending',
|
| 102 |
savedAt: so.savedAt || '',
|
| 103 |
-
source: so.source || 'vaistudio'
|
|
|
|
|
|
|
| 104 |
};
|
| 105 |
}
|
| 106 |
|
| 107 |
// ---- merge server orders into local malloca_orders (V.AISTUDIO-style) ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
function syncFromServer() {
|
| 109 |
try {
|
| 110 |
var ac = new AbortController();
|
|
@@ -122,6 +152,11 @@
|
|
| 122 |
serverOrders.forEach(function (so) {
|
| 123 |
var code = so.ma_don || so.code || '';
|
| 124 |
if (!code) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
var existingIdx = local.findIndex(function (o) { return String(o.code) === String(code); });
|
| 126 |
if (existingIdx >= 0 && !so.overwrite && !so.savedAt && so.source !== 'avatar2') return;
|
| 127 |
var order = toLocalOrder(so);
|
|
|
|
| 53 |
remaining: order.remaining || 0,
|
| 54 |
status: order.status || 'pending',
|
| 55 |
savedAt: order.savedAt || new Date().toISOString(),
|
| 56 |
+
source: 'avatar2',
|
| 57 |
+
// ownership for privacy filtering
|
| 58 |
+
cid: order.cid || '',
|
| 59 |
+
ma_kh: order.ma_kh || ''
|
| 60 |
};
|
| 61 |
}
|
| 62 |
|
|
|
|
| 103 |
grandTotal: so.grandTotal || so.total || 0,
|
| 104 |
status: so.status || 'pending',
|
| 105 |
savedAt: so.savedAt || '',
|
| 106 |
+
source: so.source || 'vaistudio',
|
| 107 |
+
cid: so.cid || '',
|
| 108 |
+
ma_kh: so.ma_kh || ''
|
| 109 |
};
|
| 110 |
}
|
| 111 |
|
| 112 |
// ---- merge server orders into local malloca_orders (V.AISTUDIO-style) ----
|
| 113 |
+
// PRIVACY: a non-admin visitor must only ever see/sync their OWN orders.
|
| 114 |
+
// Admin (cid 479b1cfad6a83ff666b9) syncs the full list. Filter server orders
|
| 115 |
+
// by the current visitor's cid / ma_kh before merging, so other customers'
|
| 116 |
+
// orders never land in this browser's list.
|
| 117 |
+
function currentIdentity() {
|
| 118 |
+
var cid = '', kh = '';
|
| 119 |
+
try {
|
| 120 |
+
if (window.currentCid) cid = window.currentCid() || '';
|
| 121 |
+
var sel = JSON.parse(localStorage.getItem('vas_selected_customer') || 'null');
|
| 122 |
+
if (sel && sel.ma_kh) kh = String(sel.ma_kh);
|
| 123 |
+
else { var p = new URLSearchParams(location.search); if (p.get('kh')) kh = p.get('kh'); }
|
| 124 |
+
} catch (e) {}
|
| 125 |
+
return { cid: cid, kh: kh };
|
| 126 |
+
}
|
| 127 |
+
function orderBelongsToMe(o, strict) {
|
| 128 |
+
try { if (window.isAdmin && window.isAdmin()) return true; } catch (e) {}
|
| 129 |
+
var me = currentIdentity();
|
| 130 |
+
if (o.cid && me.cid && String(o.cid) === String(me.cid)) return true;
|
| 131 |
+
if (o.ma_kh && me.kh && String(o.ma_kh).toUpperCase() === String(me.kh).toUpperCase()) return true;
|
| 132 |
+
// Ownerless orders: allowed only for browser-local display (strict=false),
|
| 133 |
+
// never pulled from the server for a non-admin (strict=true) — those can't
|
| 134 |
+
// be attributed and would leak other people's data.
|
| 135 |
+
if (!strict && !o.cid && !o.ma_kh) return true;
|
| 136 |
+
return false;
|
| 137 |
+
}
|
| 138 |
function syncFromServer() {
|
| 139 |
try {
|
| 140 |
var ac = new AbortController();
|
|
|
|
| 152 |
serverOrders.forEach(function (so) {
|
| 153 |
var code = so.ma_don || so.code || '';
|
| 154 |
if (!code) return;
|
| 155 |
+
// Privacy: for non-admin only pull orders that match THIS visitor's
|
| 156 |
+
// identity (strict). Ownerless server orders are never synced to a
|
| 157 |
+
// non-admin browser.
|
| 158 |
+
var oId = { cid: so.cid || so.zalo_id || '', ma_kh: so.ma_kh || '' };
|
| 159 |
+
if (!orderBelongsToMe(oId, true)) return;
|
| 160 |
var existingIdx = local.findIndex(function (o) { return String(o.code) === String(code); });
|
| 161 |
if (existingIdx >= 0 && !so.overwrite && !so.savedAt && so.source !== 'avatar2') return;
|
| 162 |
var order = toLocalOrder(so);
|