bep40 commited on
Commit
5e139ab
·
1 Parent(s): b36a254

Fix root cause mất ảnh khi thêm 2+ SP qua URL: server upsert chỉ match l link khi cả 2 không rỗng (tránh ghi đè SP trước); client gửi l/link/url; restore Hitachi 357869 record bị ghi đè. Fix treo modal FLASHSALE (.show CSS đè inline display:none). Hiển thị đủ ảnh detail BIGSALE/FLASHSALE/COMBO qua proxy primaryImgSrc. Cải thiện parser WooCommerce/bossvn (JSON-LD @graph , giá, gallery, specs, brand).

Browse files
.gitignore CHANGED
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
32
 
33
  # Finder (MacOS) folder config
34
  .DS_Store
 
 
32
 
33
  # Finder (MacOS) folder config
34
  .DS_Store
35
+ env/
index.html CHANGED
@@ -837,7 +837,7 @@
837
  first paint — the welcome/avatar-picker modal renders immediately. -->
838
  <script defer src="./src/cart-quote.js?gc=37"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
839
  <script defer src="./src/customers.js?gc=19"></script> <!-- Customer list (Danh sách khách hàng) -->
840
- <script defer src="./src/vaix-rag.js?gc=66"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
841
  <script defer src="./src/greeting-news.js?gc=42"></script> <!-- Greeting HOT news cards + source links -->
842
  <script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
843
  <script type="module" src="./src/app.js?gc=61"></script>
@@ -847,8 +847,8 @@
847
  <script defer src="./big_sale.js?v=15"></script>
848
  <!-- V.AI STUDIO Promo CMS (admin editor + BIGSALE/COMBO manager) -->
849
  <script defer src="./src/vaix-promo-cms.js?v=4"></script>
850
- <script defer src="./src/vaix-combo.js?v=5"></script>
851
- <script defer src="./src/vaix-flashsale.js?v=3"></script>
852
 
853
  <script>
854
  // Wire the "Đơn hàng" buttons to the order modal
@@ -869,7 +869,7 @@
869
 
870
  <!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
871
  homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
872
- <script defer src="./src/vaix-add-product.js?v=17"></script>
873
  <script defer src="./src/vaix-bulk-import.js?v=10"></script>
874
  </body>
875
  </html>
 
837
  first paint — the welcome/avatar-picker modal renders immediately. -->
838
  <script defer src="./src/cart-quote.js?gc=37"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
839
  <script defer src="./src/customers.js?gc=19"></script> <!-- Customer list (Danh sách khách hàng) -->
840
+ <script defer src="./src/vaix-rag.js?gc=67"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
841
  <script defer src="./src/greeting-news.js?gc=42"></script> <!-- Greeting HOT news cards + source links -->
842
  <script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
843
  <script type="module" src="./src/app.js?gc=61"></script>
 
847
  <script defer src="./big_sale.js?v=15"></script>
848
  <!-- V.AI STUDIO Promo CMS (admin editor + BIGSALE/COMBO manager) -->
849
  <script defer src="./src/vaix-promo-cms.js?v=4"></script>
850
+ <script defer src="./src/vaix-combo.js?v=6"></script>
851
+ <script defer src="./src/vaix-flashsale.js?v=4"></script>
852
 
853
  <script>
854
  // Wire the "Đơn hàng" buttons to the order modal
 
869
 
870
  <!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
871
  homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
872
+ <script defer src="./src/vaix-add-product.js?v=18"></script>
873
  <script defer src="./src/vaix-bulk-import.js?v=10"></script>
874
  </body>
875
  </html>
index.ts CHANGED
@@ -1665,11 +1665,21 @@ const server = Bun.serve({
1665
  if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; }
1666
  } catch (_e) {}
1667
  // Upsert by slug or by link, mirroring zalobot add_product_from_url().
1668
- const key = (rec.slug || rec.l).toLowerCase();
 
 
 
 
 
 
1669
  let replaced = false;
1670
  for (let i = 0; i < list.length; i++) {
1671
  const x = list[i];
1672
- if ((x && x.l === rec.l) || ((x && (x.slug || "").toLowerCase()) === key)) {
 
 
 
 
1673
  list[i] = rec; replaced = true; break;
1674
  }
1675
  }
 
1665
  if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; }
1666
  } catch (_e) {}
1667
  // Upsert by slug or by link, mirroring zalobot add_product_from_url().
1668
+ // CRITICAL FIX: only match an existing record on `x.l === rec.l` when
1669
+ // BOTH links are non-empty. Previously an empty rec.l ("") matched the
1670
+ // first record with an empty l in the list, so adding a SECOND product
1671
+ // via URL silently overwrote the FIRST product's full record (images,
1672
+ // description, specs all wiped → product kept only a name in promos).
1673
+ const key = (rec.slug || "").toLowerCase();
1674
+ const keyL = (rec.l || "").toLowerCase();
1675
  let replaced = false;
1676
  for (let i = 0; i < list.length; i++) {
1677
  const x = list[i];
1678
+ const xSlug = (x && (x.slug || "")).toLowerCase();
1679
+ const xLink = (x && (x.l || "")).toLowerCase();
1680
+ const slugMatch = !!key && xSlug === key;
1681
+ const linkMatch = !!keyL && !!xLink && xLink === keyL;
1682
+ if (slugMatch || linkMatch) {
1683
  list[i] = rec; replaced = true; break;
1684
  }
1685
  }
src/vaix-add-product.js CHANGED
@@ -800,24 +800,34 @@
800
  function extractJsonLd(html) {
801
  var re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
802
  var m, best = null;
 
 
 
803
  while ((m = re.exec(html))) {
804
  try {
805
  var data = JSON.parse(m[1]);
806
- var arr = Array.isArray(data) ? data : [data];
807
- for (var k = 0; k < arr.length; k++) {
808
- var item = arr[k];
809
- if (!item) continue;
810
- var typ = String(item['@type'] || '').toLowerCase();
811
- var isProduct = typ.indexOf('product') >= 0 || (item.name && (item.offers || item.image));
812
- if (isProduct) {
813
- var price = item.offers && (item.offers.price != null ? item.offers.price : (item.offers.lowPrice != null ? item.offers.lowPrice : null));
814
- best = { name: item.name, sku: item.sku, brand: (item.brand && (item.brand.name || item.brand)) || '', image: Array.isArray(item.image) ? item.image[0] : item.image, price: price, priceCurrency: item.offers && item.offers.priceCurrency, description: item.description };
815
- break;
816
  }
817
- }
818
- if (best) break;
819
  } catch (e) {}
820
  }
 
 
 
 
 
 
 
 
 
 
 
821
  return best;
822
  }
823
  function extractPrices(html, ld) {
@@ -1009,6 +1019,86 @@
1009
  var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
1010
  if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
1011
  brand = String(brand || '').replace(/^www\./i, '');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1012
  // ── dienmayxanh.com override (v2024 layout) ──
1013
  if (/dienmayxanh\.com/i.test(pageUrl || '')) {
1014
  // NAME: h1 direct text only
@@ -1311,6 +1401,7 @@
1311
  cs: 'san-pham-them-moi', ci: 'fa-box',
1312
  sum: r.desc, summary: r.desc, desc: r.desc,
1313
  specs: r.specs, feats: featsArr, brand: brand,
 
1314
  _sourceUrl: prev.pageUrl, _url_added: true
1315
  };
1316
  // 1) local durable list + live catalog injection (always works)
 
800
  function extractJsonLd(html) {
801
  var re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
802
  var m, best = null;
803
+ // Collect every JSON-LD node (handles @graph wrappers, nested arrays, and
804
+ // single objects) into a flat list, then pick the first Product.
805
+ var nodes = [];
806
  while ((m = re.exec(html))) {
807
  try {
808
  var data = JSON.parse(m[1]);
809
+ (function collect(node) {
810
+ if (!node) return;
811
+ if (Array.isArray(node)) { node.forEach(collect); return; }
812
+ if (typeof node === 'object') {
813
+ nodes.push(node);
814
+ if (Array.isArray(node['@graph'])) node['@graph'].forEach(collect);
815
+ if (Array.isArray(node.itemListElement)) node.itemListElement.forEach(collect);
 
 
 
816
  }
817
+ })(data);
 
818
  } catch (e) {}
819
  }
820
+ for (var k = 0; k < nodes.length; k++) {
821
+ var item = nodes[k];
822
+ if (!item) continue;
823
+ var typ = String(item['@type'] || '').toLowerCase();
824
+ var isProduct = typ.indexOf('product') >= 0 || (item.name && (item.offers || item.image));
825
+ if (isProduct) {
826
+ var price = item.offers && (item.offers.price != null ? item.offers.price : (item.offers.lowPrice != null ? item.offers.lowPrice : null));
827
+ best = { name: item.name, sku: item.sku, brand: (item.brand && (item.brand.name || item.brand)) || '', image: Array.isArray(item.image) ? item.image[0] : item.image, price: price, priceCurrency: item.offers && item.offers.priceCurrency, description: item.description, category: item.category || '' };
828
+ break;
829
+ }
830
+ }
831
  return best;
832
  }
833
  function extractPrices(html, ld) {
 
1019
  var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
1020
  if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
1021
  brand = String(brand || '').replace(/^www\./i, '');
1022
+ // ── WooCommerce generic override (bossvn.vn, thegioididong-style shops) ──
1023
+ if (/woocommerce|bossvn\.vn|wp-content\/uploads/i.test(html) || /(bossvn|giadungninhbinhweb)/i.test(pageUrl || '')) {
1024
+ // BRAND: prefer the WordPress site hostname ("bossvn") over og:site_name's
1025
+ // template string ("Mẫu web công ty gia dụng - NinhBinhWeb").
1026
+ try {
1027
+ var wHost = (pageUrl || '').replace(/^https?:\/\//i, '').replace(/^www\./i, '').split(/[/:]/)[0] || '';
1028
+ var wHostBrand = wHost.replace(/\..+$/, '');
1029
+ if (/(web|mau|cong ty|gia dung|template|blog|demo)/i.test(String(brand)) || /https?:|www\./i.test(String(brand))) {
1030
+ brand = wHostBrand ? (wHostBrand.charAt(0).toUpperCase() + wHostBrand.slice(1)) : brand;
1031
+ }
1032
+ } catch (_wb) {}
1033
+ // NAME: prefer the WooCommerce <h1 class="product_title"> (strips the site
1034
+ // suffix " - Thiết bị nhà bếp Boss").
1035
+ var wH1 = doc.querySelector('h1.product_title, h1.product_title.entry-title, div.summary.entry-summary h1');
1036
+ if (wH1) {
1037
+ var wNm = (wH1.textContent || '').replace(/\s+/g, ' ').trim();
1038
+ if (wNm && wNm !== 'Sản phẩm') name = wNm.split(/\s*[-–—|]\s*/)[0].trim();
1039
+ }
1040
+ // PRICE: WooCommerce sale price (.price.product-page-price .woocommerce-Price-amount bdi).
1041
+ // Use sale first, then the plain .price block; prefers JSON-LD list price when present.
1042
+ var wSaleEl = doc.querySelector('p.price.product-page-price .woocommerce-Price-amount bdi, .price .woocommerce-Price-amount bdi');
1043
+ var wAmounts = [];
1044
+ if (wSaleEl) wAmounts.push(wSaleEl.textContent);
1045
+ doc.querySelectorAll('p.price .woocommerce-Price-amount bdi, .summary .price .woocommerce-Price-amount bdi').forEach(function (b) { wAmounts.push(b.textContent); });
1046
+ var wNums = [];
1047
+ for (var wAi = 0; wAi < wAmounts.length; wAi++) { var wN = parseInt(String(wAmounts[wAi]).replace(/[^\d]/g, ''), 10); if (wN && wN > 1000) wNums.push(wN); }
1048
+ if (wNums.length) {
1049
+ // Multiple prices: the LOWER is almost always the sale (đang KM), HIGHER is list.
1050
+ var wMin = Math.min.apply(null, wNums), wMax = Math.max.apply(null, wNums);
1051
+ priceInfo.salePn = wMin;
1052
+ priceInfo.listPn = (wMax !== wMin) ? wMax : 0;
1053
+ priceInfo.pn = wMin;
1054
+ priceInfo.salePrice = money(wMin);
1055
+ priceInfo.price = money(wMin);
1056
+ priceInfo.listPrice = wMax !== wMin ? money(wMax) : '';
1057
+ }
1058
+ // SKU: WooCommerce button data-product_sku / variation, else data-product_id,
1059
+ // else match "Mã BN7546" / "Mã số" text on the page.
1060
+ var wSkuEl = doc.querySelector('[data-product_sku][data-product_sku!=""]');
1061
+ if (wSkuEl && wSkuEl.getAttribute('data-product_sku')) sku = wSkuEl.getAttribute('data-product_sku');
1062
+ if (!sku) { var wPid = doc.querySelector('[data-product_id]'); if (wPid && wPid.getAttribute('data-product_id') && !wPid.getAttribute('data-product_sku')) sku = wPid.getAttribute('data-product_id'); }
1063
+ if (!sku) { var wMsku = html.match(/Mã\s+(BN\s*[\w-]+|[\w]{3,})/i); if (wMsku) sku = String(wMsku[1]).replace(/\s+/g, '').toUpperCase(); }
1064
+ // GALLERY: WooCommerce main image + gallery thumbs (data-src/src), all webp/jpg/png under /wp-content/uploads/.
1065
+ var wGal = [];
1066
+ var wMainImg = doc.querySelector('div.woocommerce-product-gallery__wrapper img, figure.woocommerce-product-gallery__wrapper img, .woocommerce-product-gallery img.wp-post-image');
1067
+ var wPrimary = '';
1068
+ if (wMainImg) { wPrimary = cleanUrl(wMainImg.getAttribute('src') || wMainImg.getAttribute('data-src') || '', pageUrl); }
1069
+ if (wPrimary && wPrimary.indexOf('wp-content/uploads') >= 0) { if (wGal.indexOf(wPrimary) < 0) wGal.push(wPrimary); }
1070
+ doc.querySelectorAll('div.woocommerce-product-gallery__wrapper img, figure.woocommerce-product-gallery__wrapper img').forEach(function (im) {
1071
+ var s = im.getAttribute('data-src') || im.getAttribute('src') || '';
1072
+ var cu = cleanUrl(s, pageUrl);
1073
+ if (cu && cu.indexOf('wp-content/uploads') >= 0 && wGal.indexOf(cu) < 0) wGal.push(cu);
1074
+ });
1075
+ // Only adopt the WooCommerce gallery if it found at least one product image.
1076
+ if (wGal.length) { gallery = wGal; if (wPrimary && gallery.indexOf(wPrimary) < 0) gallery.unshift(wPrimary); primaryImg = wPrimary || gallery[0]; }
1077
+ // DESCRIPTION: WooCommerce tab "description" panel text.
1078
+ var wDescEl = doc.querySelector('.woocommerce-Tabs-panel--description, #tab-description, #tab-description .panel');
1079
+ if (wDescEl) {
1080
+ var wDt = (wDescEl.textContent || '').replace(/\s+/g, ' ').trim();
1081
+ if (wDt.length > 60) desc = wDt.slice(0, 8000);
1082
+ }
1083
+ // SPECS: WooCommerce "additional_information" table.
1084
+ var wAdd = doc.querySelector('.woocommerce-Tabs-panel--additional_information table.shop_attributes, #tab-additional_information table.shop_attributes, table.shop_attributes');
1085
+ if (wAdd) {
1086
+ var wSp = {};
1087
+ wAdd.querySelectorAll('tr').forEach(function (tr) {
1088
+ var tds = tr.querySelectorAll('th, td');
1089
+ if (tds.length >= 2) {
1090
+ var wk = (tds[0].textContent || '').replace(/\s+/g, ' ').trim().replace(/:$/, '');
1091
+ var wv = (tds[1].textContent || '').replace(/\s+/g, ' ').trim();
1092
+ if (wk && wv && ['Mã sản phẩm', 'Thương hiệu', 'Giá'].indexOf(wk) < 0 && !wSp[wk]) wSp[wk] = wv;
1093
+ }
1094
+ });
1095
+ if (Object.keys(wSp).length) specs = wSp;
1096
+ }
1097
+ // CATEGORY: JSON-LD category, else breadcrumb.
1098
+ if (ld && ld.category && String(ld.category).trim() && String(ld.category).trim().length <= 80) {
1099
+ // prefer breadcrumb "Chậu rửa bát" as it's cleaner than JSON-LD "Chậu rửa bát" (same here)
1100
+ }
1101
+ }
1102
  // ── dienmayxanh.com override (v2024 layout) ──
1103
  if (/dienmayxanh\.com/i.test(pageUrl || '')) {
1104
  // NAME: h1 direct text only
 
1401
  cs: 'san-pham-them-moi', ci: 'fa-box',
1402
  sum: r.desc, summary: r.desc, desc: r.desc,
1403
  specs: r.specs, feats: featsArr, brand: brand,
1404
+ l: prev.pageUrl || '', link: prev.pageUrl || '', url: prev.pageUrl || '',
1405
  _sourceUrl: prev.pageUrl, _url_added: true
1406
  };
1407
  // 1) local durable list + live catalog injection (always works)
src/vaix-combo.js CHANGED
@@ -69,11 +69,14 @@
69
  var pEl = document.getElementById("detail-price");
70
  if (pEl) pEl.innerHTML = '<span style="color:#1d4ed8;font-size:1.3rem;font-weight:800">' + fmt(priceNum) + '</span>';
71
  var ie = document.getElementById("detail-images");
 
 
 
72
  if (ie && c.items && c.items.length) {
73
  ie.innerHTML = c.items.map(function(it) {
74
  var _iu = it.image || "";
75
  if (!_iu) { try { var _pr = window.vaix && window.vaix.findProduct ? window.vaix.findProduct(String(it.sku || "")) : null; _iu = (_pr && _pr.image) || ""; } catch (e4) {} }
76
- return '<div style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;gap:4px"><img src="' + (_iu || "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/placeholder.png") + '" onerror="this.style.display=\'none\'" style="width:70px;height:70px;border-radius:10px;object-fit:cover;background:#f1f5f9"><div style="font-size:0.62rem;color:#475569;text-align:center;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (it.name || "") + '</div></div>';
77
  }).join("");
78
  } else if (ie) {
79
  ie.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;padding:16px;background:linear-gradient(135deg,#eff6ff,#dbeafe);border-radius:12px;font-size:2rem">🧩</div>';
 
69
  var pEl = document.getElementById("detail-price");
70
  if (pEl) pEl.innerHTML = '<span style="color:#1d4ed8;font-size:1.3rem;font-weight:800">' + fmt(priceNum) + '</span>';
71
  var ie = document.getElementById("detail-images");
72
+ // Route item images through the same-origin proxy (like the main list) so
73
+ // external CDN photos always render on the combo detail modal.
74
+ var _px = (typeof window.primaryImgSrc === 'function') ? window.primaryImgSrc : function(u){ return u; };
75
  if (ie && c.items && c.items.length) {
76
  ie.innerHTML = c.items.map(function(it) {
77
  var _iu = it.image || "";
78
  if (!_iu) { try { var _pr = window.vaix && window.vaix.findProduct ? window.vaix.findProduct(String(it.sku || "")) : null; _iu = (_pr && _pr.image) || ""; } catch (e4) {} }
79
+ return '<div style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;gap:4px"><img src="' + (_iu ? _px(_iu) : "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/placeholder.png") + '" onerror="this.style.display=\'none\'" style="width:70px;height:70px;border-radius:10px;object-fit:cover;background:#f1f5f9"><div style="font-size:0.62rem;color:#475569;text-align:center;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (it.name || "") + '</div></div>';
80
  }).join("");
81
  } else if (ie) {
82
  ie.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;width:100%;padding:16px;background:linear-gradient(135deg,#eff6ff,#dbeafe);border-radius:12px;font-size:2rem">🧩</div>';
src/vaix-flashsale.js CHANGED
@@ -43,7 +43,7 @@
43
  image: p.image || (p.images && p.images[0]) || "",
44
  listPn: listN, salePn: saleN,
45
  discountNum: disc, discount: disc + "%",
46
- slug: p.slug || "", catalog: true
47
  });
48
  }
49
  return out;
@@ -129,6 +129,26 @@
129
  }
130
 
131
  function openDetail(p) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  var overlay = document.getElementById("vaistudio-detail-overlay");
133
  var modal = document.getElementById("vaistudio-detail-modal");
134
  if (!overlay || !modal) return;
@@ -147,13 +167,15 @@
147
  "</div>";
148
  }
149
  if (imagesEl) {
150
- var gal = p.image ? [p.image] : [];
151
- imagesEl.innerHTML = gal.length
152
- ? gal.map(function (u) { return '<img src="' + u + '" alt="' + (p.code || "") + '" style="flex:1;min-width:120px;height:120px;border-radius:10px;object-fit:cover;border:1px solid #e2e8f0;background:#f1f5f9" onerror="this.style.display=\'none\'">'; }).join("")
 
 
153
  : '<div style="flex:1;min-width:120px;height:120px;background:linear-gradient(135deg,#fff7ed,#ffedd5);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:2.5rem">⚡</div>';
154
  }
155
- if (overlay) overlay.classList.add("show");
156
- if (modal) modal.classList.add("show");
157
  if (window.VAI_modalTop) window.VAI_modalTop(overlay);
158
  } catch (e) {}
159
  }
 
43
  image: p.image || (p.images && p.images[0]) || "",
44
  listPn: listN, salePn: saleN,
45
  discountNum: disc, discount: disc + "%",
46
+ slug: p.slug || "", catalog: true, _full: p
47
  });
48
  }
49
  return out;
 
129
  }
130
 
131
  function openDetail(p) {
132
+ // Prefer the shared catalog detail renderer (full gallery, description,
133
+ // specs, features — identical to the "danh sách SP chính" modal) when the
134
+ // flashsale item came from the catalog (carries the full product in _full).
135
+ try {
136
+ if (p && p._full && typeof window.vaix === 'object' && window.vaix && typeof window.vaix.renderDetail === 'function') {
137
+ var _full = p._full;
138
+ // Honor listPn/salePn from the flashsale build in case price fields on
139
+ // the record differ, so the detail keeps the 2-giá price row.
140
+ var _p = Object.assign({}, _full);
141
+ if (_p.listPn === undefined || _p.salePn === undefined) {
142
+ _p.listPn = p.listPn; _p.salePn = p.salePn; _p.priceMode = 'both';
143
+ }
144
+ _p._flashsaleShow = true;
145
+ window.vaix.renderDetail(_p);
146
+ var ov = document.getElementById("vaistudio-detail-overlay");
147
+ if (ov) { ov.classList.remove('show'); ov.style.display = 'flex'; }
148
+ if (window.VAI_modalTop && ov) window.VAI_modalTop(ov);
149
+ return;
150
+ }
151
+ } catch (e) {}
152
  var overlay = document.getElementById("vaistudio-detail-overlay");
153
  var modal = document.getElementById("vaistudio-detail-modal");
154
  if (!overlay || !modal) return;
 
167
  "</div>";
168
  }
169
  if (imagesEl) {
170
+ // Full gallery, routed through the same-origin proxy like the main list.
171
+ var gal = (p.image ? [p.image] : []).concat(Array.isArray(p._full && p._full.images) ? p._full.images : (Array.isArray(p.images) ? p.images : []));
172
+ var uni = []; for (var gi = 0; gi < gal.length; gi++) { if (gal[gi] && uni.indexOf(gal[gi]) < 0) uni.push(gal[gi]); }
173
+ imagesEl.innerHTML = uni.length
174
+ ? uni.map(function (u) { var pu = (typeof window.primaryImgSrc === 'function' ? window.primaryImgSrc(u) : u); return '<img src="' + pu + '" alt="' + (p.code || "") + '" style="flex:1;min-width:120px;height:120px;border-radius:10px;object-fit:cover;border:1px solid #e2e8f0;background:#f1f5f9" onerror="this.style.display=\'none\'">'; }).join("")
175
  : '<div style="flex:1;min-width:120px;height:120px;background:linear-gradient(135deg,#fff7ed,#ffedd5);border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:2.5rem">⚡</div>';
176
  }
177
+ if (overlay) { overlay.classList.remove("show"); overlay.style.display = "flex"; }
178
+ if (modal) { modal.classList.remove("show"); modal.style.display = "block"; }
179
  if (window.VAI_modalTop) window.VAI_modalTop(overlay);
180
  } catch (e) {}
181
  }
src/vaix-rag.js CHANGED
@@ -23,6 +23,12 @@ function primaryImgSrc(u) {
23
  // Robust error handling for product <img>: on failure, hide the image (and an
24
  // optional wrapping element) so an empty/broken box never shows. Guarantees no
25
  // broken-image icon.
 
 
 
 
 
 
26
  function bindImgHideOnError(img, wrap) {
27
  if (!img) return;
28
  if (img.getAttribute("data-hide-bound")) return;
@@ -2182,7 +2188,7 @@ let src = p.image || (Array.isArray(p.images) && p.images[0] ? p.images[0] : '')
2182
  }
2183
  ic.innerHTML = "";
2184
  if (!imgs.length) { const d = document.createElement("div"); d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; d.textContent = "📦"; ic.appendChild(d); }
2185
- else { imgs.forEach(function(url, idx){ const wrapper = document.createElement("div"); wrapper.style.cssText = "position:relative;flex-shrink:0;scroll-snap-align:start;cursor:zoom-in"; wrapper.title = "Nhấn để xem ảnh toàn màn hình"; const img = document.createElement("img"); img.src = url; img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e2e8f0;transition:transform 0.2s, box-shadow 0.2s"; img.onerror = function(){ this.style.display = "none"; }; img.onmouseover = function(){ this.style.transform = "scale(1.05)"; this.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }; img.onmouseout = function(){ this.style.transform = ""; this.style.boxShadow = ""; }; img.addEventListener("click", function(e){ e.stopPropagation(); var mImg = document.getElementById("detail-main-img"); if (mImg && url && url !== mImg.src) mImg.src = url; openImageViewer(url, p.title_clean, imgs); }); wrapper.appendChild(img); const zoomIcon = document.createElement("div"); zoomIcon.textContent = "🔍"; zoomIcon.style.cssText = "position:absolute;bottom:4px;right:4px;font-size:0.7rem;background:rgba(0,0,0,0.5);border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0.7"; wrapper.appendChild(zoomIcon); ic.appendChild(wrapper); }); }
2186
  }
2187
 
2188
  // Global handler: open the fullscreen image viewer from the detail modal's large
 
23
  // Robust error handling for product <img>: on failure, hide the image (and an
24
  // optional wrapping element) so an empty/broken box never shows. Guarantees no
25
  // broken-image icon.
26
+ // Expose the proxy helpers globally so BIGSALE/FLASHSALE/COMBO panels and other
27
+ // modules can route every external CDN image through the same-origin proxy
28
+ // (identical behaviour to the main product list — fixes images disappearing on
29
+ // promo detail modals).
30
+ window.primaryImgSrc = primaryImgSrc;
31
+ window.proxyImgUrl = proxyImgUrl;
32
  function bindImgHideOnError(img, wrap) {
33
  if (!img) return;
34
  if (img.getAttribute("data-hide-bound")) return;
 
2188
  }
2189
  ic.innerHTML = "";
2190
  if (!imgs.length) { const d = document.createElement("div"); d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; d.textContent = "📦"; ic.appendChild(d); }
2191
+ else { imgs.forEach(function(url, idx){ const wrapper = document.createElement("div"); wrapper.style.cssText = "position:relative;flex-shrink:0;scroll-snap-align:start;cursor:zoom-in"; wrapper.title = "Nhấn để xem ảnh toàn màn hình"; const img = document.createElement("img"); img.src = primaryImgSrc(url); img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e2e8f0;transition:transform 0.2s, box-shadow 0.2s"; img.onerror = function(){ this.style.display = "none"; }; img.onmouseover = function(){ this.style.transform = "scale(1.05)"; this.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }; img.onmouseout = function(){ this.style.transform = ""; this.style.boxShadow = ""; }; img.addEventListener("click", function(e){ e.stopPropagation(); var mImg = document.getElementById("detail-main-img"); if (mImg && url && url !== mImg.src) mImg.src = primaryImgSrc(url); openImageViewer(url, p.title_clean, imgs); }); wrapper.appendChild(img); const zoomIcon = document.createElement("div"); zoomIcon.textContent = "🔍"; zoomIcon.style.cssText = "position:absolute;bottom:4px;right:4px;font-size:0.7rem;background:rgba(0,0,0,0.5);border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0.7"; wrapper.appendChild(zoomIcon); ic.appendChild(wrapper); }); }
2192
  }
2193
 
2194
  // Global handler: open the fullscreen image viewer from the detail modal's large
src/version.js CHANGED
@@ -1,3 +1,3 @@
1
  // V.AI AVATAR — version banner (used for cache-busting / diagnostics)
2
- window.VAI_AVATAR_VERSION = "v31-export-fix-live";
3
  window.VAI_AVATAR_CACHE = "v27-gc-" + Date.now();
 
1
  // V.AI AVATAR — version banner (used for cache-busting / diagnostics)
2
+ window.VAI_AVATAR_VERSION = "v32-promo-fix-live";
3
  window.VAI_AVATAR_CACHE = "v27-gc-" + Date.now();