/** * V.AI AVATAR — Danh sách khách hàng (Customer list) module * -------------------------------------------------------- * Reads customers from GET /api/customers (backed by customers.json in the * Space repo, populated by the Zalo bot bep40/vaistudio-zalo-bot). * * Customer record fields (keyed by Zalo cid): * name, ma_kh, cid, created_at * + optional multi-value fields: zalo_name, zalo_id, shipping_address, * company, company_address, tax_code, representative, recipient, ck * Each multi-value field may hold an ARRAY of values (multiple rows). * * Features: * - Customer list modal (opens from #customer-btn) * - V.AISTUDIO access code unlocks editing + savings * - Per-customer ck % auto-applies to quote discounts * - Multi-value fields with +/- rows * - Save via POST /api/customers * - applyCustomerToQuote(c, cid) → writes vas_selected_customer localStorage * + fills qcName/qcAddr and auto-applies the customer ck */ (function () { 'use strict'; var ACCESS_CODE = 'V.AISTUDIO'; var CUSTOMER_KEY = 'vas_selected_customer'; // { cid, name, ma_kh, ck, ... merged fields } // Multi-value editable fields — each may hold an array of strings. // Editable multi-value fields. NOTE: zalo_name / zalo_id are EXCLUDED on // purpose — they are filled from the Zalo bot and locked (never editable). var MULTI_FIELDS = [ { key: 'shipping_address', label: 'Địa chỉ giao hàng' }, { key: 'company', label: 'Công ty' }, { key: 'company_address', label: 'Địa chỉ công ty' }, { key: 'tax_code', label: 'Mã số thuế' }, { key: 'representative', label: 'Người đại diện' }, { key: 'recipient', label: 'Người nhận' } ]; function _ls(key, def) { try { return JSON.parse(localStorage.getItem(key) || def); } catch (e) { return JSON.parse(def); } } function _lss(key, val) { localStorage.setItem(key, JSON.stringify(val)); } function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } // ── Per-brand Chiết khấu (CK) parsing ── // ck format: "35" (global all brands) OR "Malloca 35, Grob 20, Eurogold 15" // (per-brand percentages separated by commas). Both may mix. function normalizeBrand(b) { return String(b || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd').replace(/\s+/g, ' ').trim(); } function parseCk(str) { var map = {}; // normalized brand -> pct var all = null; // global % applied to every brand var s = String(str || '').trim(); if (s) { s.split(/[,;\n]+/).forEach(function (part) { var p = part.trim(); if (!p) return; var gm = p.match(/^(\d+(?:[.,]\d+)?)\s*%?$/); // global if (gm) { var g = parseFloat(gm[1].replace(',', '.')); if (g > 0 && g <= 90) all = g; return; } var bm = p.match(/^([A-Za-zÀ-ỹ\s-]+?)\s*(\d+(?:[.,]\d+)?)\s*%?$/); // brand + pct if (bm) { var bp = parseFloat(bm[2].replace(',', '.')); if (bp > 0 && bp <= 90) map[normalizeBrand(bm[1])] = bp; } }); } map.__all = all; var hasBrand = false; for (var k in map) { if (map.hasOwnProperty(k) && k !== '__all') { hasBrand = true; break; } } return { map: map, all: all, hasBrand: hasBrand }; } // Return the CK % that applies to a product brand (or null if none). function ckPercentFor(brandVal) { var map = window.__activeCkMap || {}; if (!map || typeof map !== 'object') return null; var name = normalizeBrand(brandVal); if (!name) return (map.__all != null) ? map.__all : null; if (map[name] != null) return map[name]; for (var k in map) { if (map.hasOwnProperty(k) && k !== '__all' && (name.indexOf(k) !== -1)) return map[k]; } return (map.__all != null) ? map.__all : null; } // ── EcoKitchen exclusion ── // PRODUCT POLICY: products on malloca.com/ecokitchen (the EcoKitchen line) // NEVER receive any chiết khấu (CK %) — even for customers who already have a // saved CK rate. This overrides BOTH the per-brand CK and the global CK. // A product is EcoKitchen if "ecokitchen" appears in its normalized // name / model / sku / slug. (EcoKitchen is a Malloca sub-collection, so its // items usually have brand=Malloca; many also carry "ECO" in the model, e.g. // MOV656ECO. Matching on "ecokitchen" is precise: real EcoKitchen titles all // contain it, and it excludes false positives like "SETDECOR7546" whose model // merely happens to contain "ECO".) function isEcoKitchen(p) { if (!p) return false; var hay = normalizeBrand( String(p.name || p.title_clean || '') + ' ' + String(p.model || '') + ' ' + String(p.sku || '') + ' ' + String(p.slug || '') ); return hay.indexOf('ecokitchen') !== -1; } window.isEcoKitchen = isEcoKitchen; // ── Product-aware CK resolution ── // Returns the CK % that applies to a product, or null if none applies. // Resolution order: // 0) EcoKitchen line items → ALWAYS null (EcoKitchen products never get CK) // 1) exact brand match in the CK map // 2) brand keyword found anywhere in the product name/model // 3) global CK % (__all) as last resort // The given map uses normalized brand keys (e.g. "malloca"). function ckPercentForProduct(p, map) { map = map || window.__activeCkMap || {}; if (!map || typeof map !== 'object') return null; // POLICY: BIGSALE / COMBO promo products never get CK % on discount links — // their price is already the discounted BIG SALE price. try { if (p && (p.noCk === true || p.promo === 'bigsale' || p.promo === 'combo' || String(p.name || '').indexOf('BIG SALE') !== -1 || String(p.name || '').indexOf('COMBO') !== -1)) return null; } catch (e) {} // POLICY: EcoKitchen products are excluded from CK entirely. if (isEcoKitchen(p)) return null; var brand = String((p && p.brand) || ''); // 1) direct brand match var direct = ckPercentFor(brand); if (direct != null) return direct; // 2) brand keyword in name/model (e.g. "Malloca" appears in many titles) var hay = normalizeBrand((p && (p.name || p.title_clean || '')) + ' ' + (p && (p.model || p.sku || ''))); for (var k in map) { if (map.hasOwnProperty(k) && k !== '__all' && map[k] != null && k && hay.indexOf(k) !== -1) { return map[k]; } } // 3) global fallback return (map.__all != null) ? map.__all : null; } window.ckPercentForProduct = ckPercentForProduct; // Discounted (giá CK) price for a product, or null if no CK applies. function ckPriceFor(priceNum, brandVal) { var pct = ckPercentFor(brandVal); if (pct == null) return null; return Math.round(priceNum * (1 - pct / 100)); } // Set the active CK from a parsed ck string, then re-render price displays. function setActiveCkFromString(ckStr) { var parsed = parseCk(ckStr); window.__activeCkMap = parsed.map; // Persist the active customer discount for reloads. try { localStorage.setItem('vas_active_ck', JSON.stringify(parsed.map)); } catch (e) {} if (window.vaix && window.vaix.reapplyCk) { try { window.vaix.reapplyCk(); } catch (e) {} } return parsed; } window.parseCk = parseCk; window.ckPercentFor = ckPercentFor; window.ckPriceFor = ckPriceFor; window.setActiveCkFromString = setActiveCkFromString; // Re-hydrate active CK from localStorage on module load. (function () { try { var saved = JSON.parse(localStorage.getItem('vas_active_ck') || 'null'); if (saved && typeof saved === 'object') window.__activeCkMap = saved; } catch (e) {} // NORMAL mode (no ?kh discount link): do NOT auto-apply any chiết khấu on // load — clear the active CK map so product cards / prices start clean // ("ko áp dụng ck tự động"). CK is only applied after the user enters the // V.AISTUDIO access code and selects a customer. Discount-link mode is // untouched (it drives CK from the server-verified link). try { if (!new URLSearchParams(location.search).get('kh')) { window.__activeCkMap = null; try { localStorage.removeItem('vas_active_ck'); } catch (e2) {} } } catch (e3) {} })(); // ── Current visitor identity & role ── // Admin = the V.AI STUDIO Zalo owner (cid 479b1cfad6a83ff666b9). Admin sees // the customer-list button (all customers) and ALL orders. Any other visitor // (e.g. a customer via a ?kh= discount link) must NOT see the customer-list // button and must only see their OWN orders. var ADMIN_CID = '479b1cfad6a83ff666b9'; function currentCid() { try { var p = new URLSearchParams(location.search); var kh = p.get('kh'); if (kh) { // ?kh= — find the matching customer's cid. var cust = window.__customers || {}; for (var k in cust) { if (cust.hasOwnProperty(k) && String(cust[k].ma_kh || '').trim().toUpperCase() === String(kh).trim().toUpperCase()) { return k; } } } } catch (e) {} // Fall back to the selected customer (may be set by admin picking a customer, // or persisted by initDiscountLink from a stored Zalo link). Resolve via cid // first, then via ma_kh against the customer directory — this lets the admin // who previously entered through their ?kh=VAS6b9 link keep full admin rights // on SUBSEQUENT direct opens (no link in URL), while still acting as a normal // customer whenever an actual ?kh link is present (enteredViaLink). try { var sel = JSON.parse(localStorage.getItem('vas_selected_customer') || 'null'); if (sel && sel.cid) return sel.cid; if (sel && sel.ma_kh) { var cust2 = window.__customers || {}; var mk = String(sel.ma_kh).trim().toUpperCase(); for (var k2 in cust2) { if (cust2.hasOwnProperty(k2) && String(cust2[k2].ma_kh || '').trim().toUpperCase() === mk) return k2; } } } catch (e) {} return ''; } window.currentCid = currentCid; // Are we viewing the app through a discount link (?kh= in the URL)? // When YES, EVERYONE (including the admin's own account) behaves exactly like // a customer: they see ONLY their own orders + own customer record, and mã // KH / chiết khấu are strictly read-only. The admin gains full directory + // editing rights ONLY when opening the app directly (no ?kh link). window.enteredViaLink = function () { try { // A link that was verified as EXPIRED/INVALID no longer counts as a valid // link-view — the app reverts to homepage-like state (no customer binding). if (window.__linkExpired === true) return false; return !!new URLSearchParams(location.search).get('kh'); } catch (e) { return false; } }; // TWO distinct modes: // • DISCOUNT-LINK mode (?kh= in URL): the visitor (admin or customer) is // treated exactly like that customer — own orders/customer only, mã KH + // chiết khấu strictly read-only. No access-code unlock, no editing. // • NORMAL mode (no ?kh): the original "trạng thái ban đầu". Entering the // V.AISTUDIO access code (vas_quote_unlocked='1') unlocks the full // customer directory, editing, and CK-into-input apply. Before that code // is entered the directory is not shown and nothing is editable. window.isAdmin = function () { if (window.enteredViaLink && window.enteredViaLink()) return false; try { return localStorage.getItem('vas_quote_unlocked') === '1'; } catch (e) { return false; } }; // Can the current visitor edit CK values (quote modal / order detail)? // Only after the V.AISTUDIO access code is entered in normal mode (and never // through a discount link). window.canEditCk = function () { return window.isAdmin(); }; // ── Role-based UI visibility ── // The customer-list button stays VISIBLE for everyone; the modal itself shows // the full directory for the admin but ONLY the visitor's own record for // non-admins (read-only). Hiding the button completely would break the // requirement that users can open it and see their own customer info. function applyRoleVisibility() { var custBtns = document.querySelectorAll('#customer-btn, #vaistudio-customer-btn'); custBtns.forEach(function (b) { b.style.display = ''; }); } window.applyRoleVisibility = applyRoleVisibility; // Re-apply after the catalog/buttons are ready and after discount-link load. if (document.readyState !== 'loading') { setTimeout(applyRoleVisibility, 400); } if (typeof window.addEventListener === 'function') { window.addEventListener('load', function () { setTimeout(applyRoleVisibility, 800); }); } // ── Discount link (?kh=MA_KH&tk=..&exp=..) auto-apply on load ── // The Zalo bot appends a TIME-LIMITED link https:///?kh=&tk=&exp= // next to the customer code (valid 5 minutes, rotated on every bot message). // The link is validated server-side (/api/verify-kh): if expired/invalid, NO // discount is applied and the user is told to message the bot for a fresh link. window.applyDiscountLink = async function (maKh) { if (!maKh) return null; maKh = String(maKh).trim(); var tk = '', exp = ''; try { var p = new URLSearchParams(location.search); tk = p.get('tk') || ''; exp = p.get('exp') || ''; } catch (e) {} // Server-side validation of the signed token + 5-min expiry. if (tk && exp) { try { var vr = await fetch('/api/verify-kh?kh=' + encodeURIComponent(maKh) + '&tk=' + encodeURIComponent(tk) + '&exp=' + encodeURIComponent(exp)); var vd = await vr.json().catch(function () { return {}; }); if (vd && vd.ok === false) { // Expired / invalid link: revert to homepage-like state (no CK, no // customer binding), same as initDiscountLink's handling. try { window.__linkExpired = true; } catch (_e0) {} try { localStorage.removeItem('vas_signed_kh'); } catch (_e1) {} try { localStorage.removeItem('vas_selected_customer'); } catch (_e2) {} try { localStorage.removeItem('vas_active_ck'); } catch (_e3) {} try { window.__activeCkMap = null; } catch (_e4) {} if (window.vaix && window.vaix.reapplyCk) { try { window.vaix.reapplyCk(); } catch (_e5) {} } if (vd.expired) toast('🔗 Link chiết khấu đã hết hạn (hiệu lực 5 phút). Anh/chị nhắn tin cho bot để nhận link mới nhé!'); else toast('🔗 Link chiết khấu không hợp lệ. Anh/chị nhắn tin cho bot để nhận link mới!'); return null; } if (vd && vd.ok === true && vd.ck != null) { // Apply the ck from the server-validated record directly. var pseudo = { cid: vd.kh || maKh, ma_kh: vd.kh || maKh, name: vd.customer || maKh, ck: String(vd.ck) }; window.applyCustomerToQuote(pseudo, pseudo.cid); return pseudo; } } catch (e) {} } // Fallback (no token): legacy link — still resolve via customers.json. if (!window.__customers) { try { window.__customers = await fetchCustomers(); } catch (e) { window.__customers = {}; } } var found = null; var keys = Object.keys(window.__customers || {}); for (var i = 0; i < keys.length; i++) { var c = window.__customers[keys[i]]; if (c && String(c.ma_kh || '').trim().toUpperCase() === maKh.toUpperCase()) { found = c; break; } } if (!found) { toast('❌ Không tìm thấy mã khách hàng: ' + maKh); return null; } window.applyCustomerToQuote(found, found.cid); return found; }; window.handleDiscountLinkParam = function () { try { var p = new URLSearchParams(location.search); var kh = p.get('kh'); if (kh) { window.applyDiscountLink(kh); return true; } } catch (e) {} return false; }; // Wire the discount-link handler after the catalog loads (so product cards // exist / reapplyCk works) — run on load and re-run after vaix is ready. if (document.readyState !== 'loading') { window.handleDiscountLinkParam(); } if (typeof window.addEventListener === 'function') { window.addEventListener('load', function () { setTimeout(window.handleDiscountLinkParam, 500); }); try { if (window.ensureBigSaleButton) setTimeout(window.ensureBigSaleButton, 900); } catch(_e){}; } function toast(msg) { if (window.showToast) { window.showToast(msg); return; } try { alert(msg); } catch (e) {} } async function fetchCustomers() { var res = await fetch('/api/customers', { headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); // object keyed by cid } async function saveCustomers(records, accessCode) { var res = await fetch('/api/customers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accessCode: accessCode, customers: records }) }); var data = await res.json().catch(function () { return {}; }); if (!res.ok) throw new Error((data && data.error) || ('HTTP ' + res.status)); return data; } // ── Apply selected customer into the quote ── // Writes vas_selected_customer + fills qcName/qcAddr + auto-applies ck. window.applyCustomerToQuote = function (c, cid) { if (!c) return; var record = cid ? c : c; // c already the record var name = record.name || (Array.isArray(record.zalo_name) ? record.zalo_name[0] : record.zalo_name) || ''; var addr = Array.isArray(record.shipping_address) ? record.shipping_address[0] : (record.shipping_address || ''); var stored = { cid: record.cid || cid || '', name: name, ma_kh: record.ma_kh || '', ck: record.ck != null ? String(record.ck) : '' }; // Carry all multi-value + scalar fields so exports can use them. MULTI_FIELDS.forEach(function (f) { var v = record[f.key]; if (v != null) stored[f.key] = v; }); ['zalo_name', 'zalo_id', 'company', 'tax_code', 'representative', 'recipient', 'company_address'].forEach(function (k) { if (record[k] != null) stored[k] = record[k]; }); _lss(CUSTOMER_KEY, stored); // Fill quote form fields if present. var qcName = document.getElementById('qcName'); var qcAddr = document.getElementById('qcAddr'); if (qcName && name) qcName.value = name; if (qcAddr && addr) qcAddr.value = addr; // Regenerate the order code (mã đơn hàng) from the newly selected // customer's name — genOrderCode() builds "DH". try { if (qcName && name && window.CART_QUOTE && window.CART_QUOTE.genOrderCode) { var qcCompany = document.getElementById('qcCompany'); if (qcCompany) qcCompany.value = window.CART_QUOTE.genOrderCode(); } } catch (e) {} // Auto-apply per-customer ck (global % OR per-brand % like "Malloca 35, Grob 20"). if (record.ck != null && String(record.ck).trim() !== '') { var parsed = parseCk(record.ck); // Global % (single number): apply to ALL quote lines. if (parsed.all != null && parsed.all > 0 && window.__applyCkPercent) { window.__applyCkPercent(parsed.all); } // Per-brand %: apply to matching quote lines by brand. if (parsed.hasBrand && window.__applyCkByBrand) { window.__applyCkByBrand(parsed.map); } setActiveCkFromString(record.ck); // also drives the product-card "giá CK" display if (window.vaix && window.vaix.reapplyCk) { try { window.vaix.reapplyCk(); } catch (e) {} } } toast('✅ Đã chọn khách hàng ' + (name || '')); return stored; }; // ── Render one customer row in the list ── // `allowEdit` is true ONLY for the admin (identity-based). Non-admin visitors // see their own row with the ✎ edit button hidden (thông tin KH tuyệt đối // không sửa được). function customerRow(cid, c, allowEdit) { var name = c.name || (Array.isArray(c.zalo_name) ? c.zalo_name[0] : c.zalo_name) || ('KH ' + (c.ma_kh || cid)); var ck = c.ck != null ? (' • CK ' + c.ck) : ''; var ma = c.ma_kh ? (' • ' + c.ma_kh) : ''; var editBtn = allowEdit ? '' : ''; // Admin-only checkbox for bulk CK editing (homepage, after access code). var chk = allowEdit ? '' : ''; return '
' + (chk) + '
' + '
' + esc(name) + '
' + '
' + esc(cid.slice(0, 8)) + ma + ck + '
' + '
' + '
' + '' + editBtn + '
' + '
'; } // Collect the cids currently ticked in the (admin) bulk-CK checkboxes. function selectedBulkCids() { var out = []; (window.__cusModal && window.__cusModal.querySelectorAll ? window.__cusModal.querySelectorAll('.cus-bulk-chk:checked') : []).forEach(function (chk) { var cid = chk.getAttribute('data-cid'); if (cid) out.push(cid); }); return out; } window.selectedBulkCids = selectedBulkCids; // ── Detail editor modal (V.AISTUDIO access-code gated in normal mode) ── function openCustomerDetail(cid, c) { var old = document.getElementById('cus-detail-modal'); if (old) old.remove(); // Editing (mã KH + chiết khấu) requires the V.AISTUDIO access code in normal // mode (vas_quote_unlocked='1'). In DISCOUNT-LINK mode (enteredViaLink) it // is ALWAYS read-only — mã KH + chiết khấu tuyệt đối không sửa được. var viaLink = (window.enteredViaLink ? window.enteredViaLink() : false); var unlocked = false; try { unlocked = localStorage.getItem('vas_quote_unlocked') === '1'; } catch (e) {} var allowEdit = !viaLink && unlocked; var ov = document.createElement('div'); ov.id = 'cus-detail-modal'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:110060;display:flex;align-items:flex-start;justify-content:center;padding:24px 12px;overflow-y:auto'; ov.onclick = function (e) { if (e.target === ov) ov.remove(); }; var statusRow = ''; if (viaLink) { statusRow = '
🔒 Thông tin khách hàng qua link chiết khấu — chỉ xem, không chỉnh sửa được.
'; } else if (!allowEdit) { statusRow = '
' + '
🔒 Nhập mã truy cập V.AISTUDIO để xem danh sách đầy đủ và áp chiết khấu.
' + '
' + '' + '' + '
' + '
'; } // Build multi-value editors var fieldReadonly = allowEdit ? '' : ' disabled readonly'; var fieldsHtml = ''; MULTI_FIELDS.forEach(function (f) { var vals = c[f.key]; if (!Array.isArray(vals)) vals = vals != null ? [vals] : []; if (!vals.length) vals = ['']; var rows = vals.map(function (v, i) { return '
' + '' + (allowEdit ? '' : '') + '
'; }).join(''); fieldsHtml += '
' + '
' + '' + (allowEdit ? '' : '') + '
' + '
' + rows + '
' + '
'; }); ov.innerHTML = '
' + '
' + '' + esc(c.name || c.ma_kh || cid) + '' + '
' + statusRow // Read-only Zalo info (from Zalo bot — never editable) + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
' + fieldsHtml + (allowEdit ? '
' : '') + '
'; document.body.appendChild(ov); if (window.VAI_modalTop) window.VAI_modalTop(ov); // Multi-value add/del handlers ov.addEventListener('click', function (e) { var add = e.target.closest('.cus-mv-add'); if (add) { var field = add.getAttribute('data-field'); // FIND the correct list: the add button sits in the header-row
, // whose PARENT (the field wrapper) contains the .cus-mv-list. Using // closest('div') returns the header row, whose querySelector on the // sibling list returns null -> row was never appended. Fix below. var headerRow = add.closest('div'); var wrap = (headerRow && headerRow.parentNode) ? headerRow.parentNode : null; var list = wrap ? wrap.querySelector('.cus-mv-list') : null; if (!list) { toast('Không tìm thấy vùng nhập'); return; } var row = document.createElement('div'); row.className = 'cus-mv-row'; row.setAttribute('data-field', field); row.innerHTML = '' + ''; list.appendChild(row); // Focus the newly added input for immediate data entry. var ni = row.querySelector('.cus-mv-input'); if (ni) ni.focus(); return; } var del = e.target.closest('.cus-mv-del'); if (del) { var p = del.closest('.cus-mv-row'); if (p && p.parentNode) p.parentNode.removeChild(p); return; } }); // Unlock (normal mode only — V.AISTUDIO access code). Sets vas_quote_unlocked // then re-opens so editing + full list are enabled. In link mode there is no // unlock button (allowEdit=false, and no access box is rendered). var unlockBtn = document.getElementById('cus-unlock'); if (unlockBtn) { unlockBtn.onclick = function () { // 🔒 Discount-link visitors can never unlock editing. if (window.enteredViaLink && window.enteredViaLink()) { toast('🔒 Không thể mở khóa qua link chiết khấu'); return; } var code = (document.getElementById('cus-access').value || '').trim(); if (code === ACCESS_CODE || code.toUpperCase() === ACCESS_CODE) { try { localStorage.setItem('vas_quote_unlocked', '1'); } catch (e) {} toast('🔓 Đã mở khóa V.AISTUDIO'); ov.remove(); renderCustomerList(); // Re-open for the *current* customer so editing is enabled immediately. openCustomerDetail(cid, c); } else { toast('❌ Sai mã truy cập'); } }; } // Save (enabled only after V.AISTUDIO unlock in normal mode). Non-unlocked or // link-mode visitors have no editable Lưu button (allowEdit=false). var saveBtn = document.getElementById('cus-save'); if (saveBtn && !allowEdit) { saveBtn.disabled = true; document.querySelectorAll('#cus-detail-modal input').forEach(function (i) { if (i.id !== 'cus-access') i.disabled = true; }); } if (saveBtn) { saveBtn.onclick = async function () { var record = Object.assign({}, c); record.cid = cid; record.ma_kh = document.getElementById('cus-ma').value.trim(); record.ck = document.getElementById('cus-ck').value.trim(); MULTI_FIELDS.forEach(function (f) { var vals = []; ov.querySelectorAll('.cus-mv-row[data-field="' + f.key + '"] .cus-mv-input').forEach(function (i) { var v = i.value.trim(); if (v) vals.push(v); }); if (vals.length) record[f.key] = vals; else delete record[f.key]; }); try { var resp = await saveCustomers([record], ACCESS_CODE); // Update the in-memory cache with the server's authoritative data so the // list reflects the just-saved values immediately (avoids stale re-open). if (resp && resp.customers && typeof resp.customers === 'object') { window.__customers = resp.customers; } else { try { window.__customers = await fetchCustomers(); } catch (_e) {} } toast('✅ Đã lưu khách hàng'); ov.remove(); renderCustomerList(); // refresh list } catch (e) { toast('❌ Lưu thất bại: ' + e.message); } }; } // end if (saveBtn) // ── "Gửi qua Zalo" on the customer DETAIL modal: saves the current CK + all // filled fields to the dataset, then DMs this customer a message containing // every field that has content (mã KH, CK, Zalo name, company, address, // representative, recipient, tax code...). Only shown for admin after unlock. var sendZaloBtn = document.getElementById('cus-send-zalo'); if (sendZaloBtn) { sendZaloBtn.onclick = async function () { var record = Object.assign({}, c); record.cid = cid; record.ma_kh = document.getElementById('cus-ma').value.trim(); record.ck = document.getElementById('cus-ck').value.trim(); MULTI_FIELDS.forEach(function (f) { var vals = []; ov.querySelectorAll('.cus-mv-row[data-field="' + f.key + '"] .cus-mv-input').forEach(function (i) { var v = i.value.trim(); if (v) vals.push(v); }); if (vals.length) record[f.key] = vals; else delete record[f.key]; }); // Resolve the real Zalo cid to send to. var zcid = customerCidForSend(record); if (!zcid) { toast('❌ Khách hàng này chưa có Zalo ID để gửi.'); return; } try { // Persist first (so the Zalo message reflects the just-saved CK). var resp = await saveCustomers([record], ACCESS_CODE); if (resp && resp.customers) window.__customers = resp.customers; else { try { window.__customers = await fetchCustomers(); } catch (_e) {} } var msg = buildCustomerSendMessage(record); toast('📨 Đang gửi qua Zalo cho ' + (record.name || record.ma_kh || '') + '...'); var r = await fetch('https://bep40-vaistudio-zalo-bot.hf.space/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: zcid, message: msg }) }); var d = await r.json().catch(function () { return {}; }); if (r.ok && d && d.ok) toast('✅ Đã lưu & gửi qua Zalo.'); else toast('✅ Đã lưu, nhưng gửi qua Zalo thất bại.'); renderCustomerList(); } catch (e) { toast('❌ Lỗi: ' + e.message); } }; } } // ── Bulk CK editing on the customer list (homepage, after access code) ── // The admin selects multiple customers, types a CK% (global "35" or per-brand // "Malloca 35, Grob 20") and hits "Áp cho đã chọn". Saves every selected // customer via POST /api/customers (V.AISTUDIO-gated, dataset-backed). window.bulkApplyCk = async function () { var cids = selectedBulkCids(); if (!cids.length) { toast('⚠️ Chưa chọn khách hàng nào.'); return; } if (!(window.canEditCk ? window.canEditCk() : false)) { toast('🔒 Cần nhập mã truy cập để chỉnh CK hàng loạt.'); return; } var ckEl = document.getElementById('cus-bulk-ck'); var ckVal = (ckEl && ckEl.value || '').trim(); if (!ckVal) { toast('⚠️ Nhập giá trị chiết khấu (VD: 35 hoặc Malloca 35, Grob 20).'); ckEl && ckEl.focus(); return; } // Basic validation so junk like "abc" doesn't reach the server. var parsed = parseCk(ckVal); if (parsed.all == null && !parsed.hasBrand) { toast('❌ CK không hợp lệ. VD: 35 hoặc Malloca 35, Grob 20'); return; } var records = cids.map(function (cid) { var c = (window.__customers || {})[cid] || {}; var rec = {}; Object.keys(c).forEach(function (k) { rec[k] = c[k]; }); rec.cid = cid; rec.ck = ckVal; return rec; }); try { toast('💾 Đang lưu CK cho ' + records.length + ' khách...'); var resp = await saveCustomers(records, ACCESS_CODE); if (resp && resp.customers) window.__customers = resp.customers; else { try { window.__customers = await fetchCustomers(); } catch (_e) {} } toast('✅ Đã cập nhật chiết khấu cho ' + records.length + ' khách hàng.'); if (window.__cusModal) window.__cusModal.querySelectorAll('.cus-bulk-chk:checked').forEach(function (chk) { chk.checked = false; }); var n = document.getElementById('cus-bulk-count'); if (n) n.textContent = 0; renderCustomerList(); } catch (e) { toast('❌ Lưu CK thất bại: ' + e.message); } }; // ── Send the edited CK / customer info to each selected customer via the Zalo // bot (/send). Content = the fields that have content on the customer's detail // record (mã KH, Zalo name, company, address, representative, recipient, tax // code, shipping address, CK...) — so each customer receives their own updated // policy. The bot prepends the "📋 Mã KH + 🔗 Chiết khấu" header automatically. function buildCustomerSendMessage(c) { var lines = []; var name = c.name || (Array.isArray(c.zalo_name) ? c.zalo_name[0] : c.zalo_name) || ''; var ma = c.ma_kh || ''; if (name) lines.push('👋 Chào ' + name + '!'); if (ma) lines.push('📋 Mã khách hàng: ' + ma); if (c.ck) lines.push('💳 Chiết khấu cập nhật: ' + c.ck + '%'); lines.push(''); // Fields that HAVE content — same set the customer detail modal shows. [ ['Công ty', c.company], ['Người đại diện', c.representative], ['Người nhận', c.recipient], ['Địa chỉ giao hàng', c.shipping_address], ['Địa chỉ công ty', c.company_address], ['Mã số thuế', c.tax_code] ].forEach(function (pair) { var label = pair[0], v = pair[1]; if (v == null) return; var arr = Array.isArray(v) ? v.filter(Boolean) : [String(v).trim()]; arr.forEach(function (item) { if (item) lines.push('• ' + label + ': ' + item); }); }); lines.push(''); lines.push('Thông tin chiết khấu mới nhất của anh/chị đã được cập nhật. Truy cập web để xem báo giá kèm chiết khấu nhé!'); return lines.join('\n'); } // Resolve a customer to a sendable Zalo cid (the customer record's own cid). // Zalo chat ids are long NUMERIC strings (e.g. "479b1..." is hex, but real Zalo // OA user cids are numeric ~13-24 digits; the web/zalo customers use those). // Ketoan-directory rows are keyed by ma_kh with an EMPTY cid — those can't be // DMed until the real Zalo cid is present. Skip (return ''). function customerCidForSend(cust) { if (!cust) return ''; var cidv = cust.cid || ''; var s = String(cidv).trim(); if (s && s.length >= 10) { // Accept both numeric Zalo ids AND the known admin/hex format; but reject // ma_kh-lookalike keys (letters like VASCAS...) with no numeric core that // are really directory keys, not Zalo chat ids. if (/^\d+$/.test(s)) return s; // Known OA/hex cids used in this system (admin 479b1cfad6a83ff666b9 etc.). if (/^[0-9a-f]{8,}$/i.test(s) && /^[a-f0-9]+$/i.test(s)) return s; return ''; } return ''; } window.bulkSendViaZalo = async function () { var cids = selectedBulkCids(); if (!cids.length) { toast('⚠️ Chưa chọn khách hàng nào.'); return; } if (!(window.canEditCk ? window.canEditCk() : false)) { toast('🔒 Cần nhập mã truy cập.'); return; } var sent = 0, skipped = 0; for (var i = 0; i < cids.length; i++) { var cid = cids[i]; var cust = (window.__customers || {})[cid] || {}; var zcid = customerCidForSend(cust); if (!zcid) { skipped++; continue; } var msg = buildCustomerSendMessage(cust); try { var r = await fetch('https://bep40-vaistudio-zalo-bot.hf.space/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: zcid, message: msg }) }); var d = await r.json().catch(function () { return {}; }); if (r.ok && d && d.ok) sent++; else skipped++; } catch (e) { skipped++; } } toast('✅ Đã gửi qua Zalo cho ' + sent + ' khách' + (skipped ? ', bỏ qua ' + skipped + ' (thiếu Zalo ID)' : '')); }; function renderCustomerList() { var box = document.getElementById('cus-list'); if (!box) return; var customers = window.__customers || {}; // Non-admin visitors see ONLY their own customer record (read-only). If a // non-admin has no resolved identity (no ?kh, no selected customer), they // see NOTHING — never the shared customer directory. var admin = (window.isAdmin ? window.isAdmin() : false); var ownCid = (window.currentCid ? window.currentCid() : '') || ''; var keys = Object.keys(customers); if (admin) { // admin: full directory — DEDUPE by ma_kh: the dataset can hold the same // customer twice (one keyed by Zalo cid in vaistudio_customers.json, one // keyed by ma_kh merged from ketoán khachhang.json). Keep the cid-keyed // record (has Zalo ID) and drop the ma_kh-only duplicate. var seenMk = {}; var deduped = []; for (var i = 0; i < keys.length; i++) { var cid0 = keys[i]; var rec0 = customers[cid0]; if (!rec0 || typeof rec0 !== 'object') continue; var mk0 = String(rec0.ma_kh || '').trim().toUpperCase(); if (!mk0) { deduped.push(cid0); continue; } if (seenMk[mk0]) { // Duplicate ma_kh — keep the one WITH a cid (Zalo ID). var prev = customers[seenMk[mk0]]; if (!(prev && prev.cid) && rec0.cid) deduped[deduped.indexOf(seenMk[mk0])] = cid0; continue; } seenMk[mk0] = cid0; deduped.push(cid0); } keys = deduped; } else if (ownCid) { keys = keys.filter(function (cid) { return String(cid) === String(ownCid); }); } else { keys = []; } var countEl = document.getElementById('cus-count'); if (countEl) countEl.textContent = keys.length; if (!keys.length) { box.innerHTML = '
' + (admin ? 'Chưa có khách hàng. Dữ liệu được đồng bộ từ Zalo bot.' : 'Chưa có thông tin khách hàng của bạn. Mở web từ link chiết khấu trên Zalo để đồng bộ.') + '
'; return; } box.innerHTML = keys.map(function (cid) { return customerRow(cid, customers[cid], admin); }).join(''); } async function openCustomerModal() { var old = document.getElementById('cus-modal'); if (old) old.remove(); var ov = document.createElement('div'); ov.id = 'cus-modal'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:110055;display:flex;align-items:flex-start;justify-content:center;padding:24px 12px;overflow-y:auto'; ov.onclick = function (e) { if (e.target === ov) ov.remove(); }; ov.innerHTML = '
' + '
' + '
Danh sách khách hàng (0)
' + '
' + '
' + '
' + '' + '
' + '
'; document.body.appendChild(ov); window.__cusModal = ov; if (window.VAI_modalTop) window.VAI_modalTop(ov); document.getElementById('cus-close').onclick = function () { ov.remove(); window.__cusModal = null; }; // Access gate for NORMAL mode (no ?kh link): the full customer directory is // revealed only after the V.AISTUDIO access code is entered. In // DISCOUNT-LINK mode there is no gate (that customer's own record is shown, // read-only). var viaLink = (window.enteredViaLink ? window.enteredViaLink() : false); var unlocked = false; try { unlocked = localStorage.getItem('vas_quote_unlocked') === '1'; } catch (e) {} var gateEl = document.getElementById('cus-gate'); if (!viaLink && !unlocked && gateEl) { gateEl.innerHTML = '
' + '
🔒 Nhập mã truy cập V.AISTUDIO để xem danh sách khách hàng
' + '
' + '' + '' + '
'; var gu = document.getElementById('cus-gate-unlock'); if (gu) gu.onclick = function () { if (window.enteredViaLink && window.enteredViaLink()) { toast('🔒 Không thể mở khóa qua link chiết khấu'); return; } var code = (document.getElementById('cus-gate-access').value || '').trim(); if (code === ACCESS_CODE || code.toUpperCase() === ACCESS_CODE) { try { localStorage.setItem('vas_quote_unlocked', '1'); } catch (e) {} toast('🔓 Đã mở khóa V.AISTUDIO'); ov.remove(); openCustomerModal(); } else { toast('❌ Sai mã truy cập'); } }; var box = document.getElementById('cus-list'); if (box) box.innerHTML = '
Nhập mã truy cập để xem danh sách khách hàng.
'; return; } // Wire list events ov.addEventListener('click', function (e) { var select = e.target.closest('.cus-select-btn'); if (select) { var cid = select.getAttribute('data-cid'); window.applyCustomerToQuote(window.__customers[cid], cid); return; } var edit = e.target.closest('.cus-edit-btn'); if (edit) { var cid2 = edit.getAttribute('data-cid'); openCustomerDetail(cid2, window.__customers[cid2]); return; } }); // ── Live bulk-CK counter: as the admin ticks/unticks checkboxes, show how // many are selected (requires admin identity — checked in openCustomerModal). ov.addEventListener('change', function (e) { if (e.target && e.target.classList && e.target.classList.contains('cus-bulk-chk')) { var n = document.getElementById('cus-bulk-count'); if (n) n.textContent = selectedBulkCids().length; } }); // Bulk bar visible only for the admin (after V.AISTUDIO unlock in normal mode). var bulkBar = document.getElementById('cus-bulkbar'); if (bulkBar) bulkBar.style.display = (window.isAdmin ? window.isAdmin() : false) ? 'flex' : 'none'; var bapply = document.getElementById('cus-bulk-apply'); if (bapply) bapply.onclick = function () { if (window.bulkApplyCk) window.bulkApplyCk(); }; var bsend = document.getElementById('cus-bulk-send'); if (bsend) bsend.onclick = function () { if (window.bulkSendViaZalo) window.bulkSendViaZalo(); }; // Search filter (non-admin sees only their own row, so the filter is no-op) document.getElementById('cus-q').oninput = function () { var q = (this.value || '').toLowerCase(); var keys = Object.keys(window.__customers || {}); if (!q) { renderCustomerList(); return; } var box = document.getElementById('cus-list'); var matched = keys.filter(function (cid) { var c = window.__customers[cid]; return (c.name || '').toLowerCase().indexOf(q) !== -1 || (c.ma_kh || '').toLowerCase().indexOf(q) !== -1 || (c.zalo_name ? JSON.stringify(c.zalo_name).toLowerCase().indexOf(q) !== -1 : false); }); var admin = (window.isAdmin ? window.isAdmin() : false); box.innerHTML = matched.length ? matched.map(function (cid) { return customerRow(cid, window.__customers[cid], admin); }).join('') : '
Không tìm thấy
'; }; // Load try { var data = await fetchCustomers(); window.__customers = data || {}; var admin = (window.isAdmin ? window.isAdmin() : false); var ownCid = (window.currentCid ? window.currentCid() : '') || ''; var keysAll = Object.keys(data || {}); if (!admin && ownCid) keysAll = keysAll.filter(function (cid) { return String(cid) === String(ownCid); }); var countEl = document.getElementById('cus-count'); if (countEl) countEl.textContent = keysAll.length; renderCustomerList(); // In DISCOUNT-LINK mode (?kh=...), open the resolved customer's DETAIL // immediately so the visitor sees their full info (read-only) right away, // instead of a bare list row they would have to click. The list modal stays // visible underneath; the detail modal closes back onto it. if (viaLink && ownCid && window.__customers[ownCid]) { openCustomerDetail(ownCid, window.__customers[ownCid]); } } catch (e) { var box = document.getElementById('cus-list'); if (box) box.innerHTML = '
Lỗi tải khách hàng: ' + esc(e.message) + '
'; } } window.openCustomerModal = openCustomerModal; // Wire the #customer-btn button(s) function wire() { var btn = document.getElementById('customer-btn'); if (btn) { btn.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openCustomerModal(); }; } var btn2 = document.getElementById('vaistudio-customer-btn'); if (btn2) { btn2.onclick = function (e) { e.preventDefault(); e.stopPropagation(); openCustomerModal(); }; } } wire(); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire); window.addEventListener('load', wire); })();