bep40 commited on
Commit
50d5689
·
1 Parent(s): 22442da

Danh sách chia sẻ (BIGSALE/COMBO/FLASHSALE) + Xóa admin + /api/delete-promo

Browse files
Files changed (7) hide show
  1. big_sale.js +102 -5
  2. index.html +5 -4
  3. index.ts +40 -0
  4. src/vaix-add-product.js +85 -1
  5. src/vaix-combo.js +127 -15
  6. src/vaix-flashsale.js +259 -0
  7. src/vaix-rag.js +0 -14
big_sale.js CHANGED
@@ -84,9 +84,7 @@ function buildProductCard(p) {
84
  <span class="big-sale-price-original">${formatVND(p.price)}</span>
85
  <span class="big-sale-price-new">${formatVND(p.bigsale)}</span>
86
  </div>
87
- <button type="button" class="big-sale-share-btn" onclick="window.shareBigSale('${p.code}')">
88
- <i class="fas fa-share-alt"></i> Chia sẻ link
89
- </button>
90
  </div>`;
91
  }
92
 
@@ -117,7 +115,10 @@ function createBigSaleElements() {
117
  <span class="big-sale-panel-icon">🔥</span>
118
  <span>CHƯƠNG TRÌNH BIG SALE</span>
119
  </div>
120
- <span class="big-sale-product-count">${BIG_SALE_PRODUCTS.length} sản phẩm</span>
 
 
 
121
  </div>
122
  <div class="big-sale-product-list">
123
  ${BIG_SALE_PRODUCTS.map(p => buildProductCard(p)).join('')}
@@ -494,6 +495,25 @@ function handleBigSaleUrlParam() {
494
  var params = new URLSearchParams(location.search);
495
  var code = params.get('bigsale') || '';
496
  if (!code) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  var tries = 0;
498
  var iv = setInterval(function () {
499
  tries++;
@@ -600,6 +620,81 @@ function refreshBigSalePanel() {
600
  if (countEl) countEl.textContent = BIG_SALE_PRODUCTS.length + ' sản phẩm';
601
  if (countBadge) countBadge.textContent = BIG_SALE_PRODUCTS.length + ' SP';
602
  if (wasOpen) panel.classList.add('open'); else panel.classList.remove('open');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  } catch (e) {}
604
  }
605
 
@@ -609,13 +704,15 @@ function initBigSale() {
609
  // Load admin-managed BIGSALE products from the Promo CMS backend.
610
  loadBigSaleProducts().then(function (list) {
611
  console.log('[BIG SALE] Dynamic list loaded: ' + list.length + ' products');
 
612
  handleBigSaleUrlParam();
613
  }).catch(function () {
 
614
  handleBigSaleUrlParam();
615
  });
616
  // Listen for CMS saves so the panel stays in sync.
617
  try {
618
- window.addEventListener('vai-promo-changed', function () { loadBigSaleProducts(true); });
619
  } catch (e) {}
620
  }
621
 
 
84
  <span class="big-sale-price-original">${formatVND(p.price)}</span>
85
  <span class="big-sale-price-new">${formatVND(p.bigsale)}</span>
86
  </div>
87
+ <div class="big-sale-card-actions" id="bsa-${p.code}" style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap"></div>
 
 
88
  </div>`;
89
  }
90
 
 
115
  <span class="big-sale-panel-icon">🔥</span>
116
  <span>CHƯƠNG TRÌNH BIG SALE</span>
117
  </div>
118
+ <div style="display:flex;align-items:center;gap:8px">
119
+ <span class="big-sale-product-count">${BIG_SALE_PRODUCTS.length} sản phẩm</span>
120
+ <button type="button" class="big-sale-list-share-btn" onclick="event.stopPropagation();window.shareBigSaleList()" style="padding:5px 10px;border:none;border-radius:8px;background:#f97316;color:#fff;font-size:0.68rem;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap"><i class="fas fa-share-alt"></i> Chia sẻ danh sách</button>
121
+ </div>
122
  </div>
123
  <div class="big-sale-product-list">
124
  ${BIG_SALE_PRODUCTS.map(p => buildProductCard(p)).join('')}
 
495
  var params = new URLSearchParams(location.search);
496
  var code = params.get('bigsale') || '';
497
  if (!code) return;
498
+ // ?bigsale=all → open the FULL list (not a single product).
499
+ var codeNorm = String(code).trim().toUpperCase().replace(/\s+/g, ' ');
500
+ if (codeNorm === 'ALL' || codeNorm === 'DANHSACH' || codeNorm === 'LIST') {
501
+ var triesAll = 0;
502
+ var ivAll = setInterval(function () {
503
+ triesAll++;
504
+ try {
505
+ if (document.getElementById('big-sale-floating')) {
506
+ if (!document.getElementById('big-sale-panel')) initBigSale();
507
+ dismissWelcomeForBigSale();
508
+ openBigSalePanel();
509
+ clearInterval(ivAll);
510
+ return;
511
+ }
512
+ } catch (e) {}
513
+ if (triesAll > 40) clearInterval(ivAll);
514
+ }, 500);
515
+ return;
516
+ }
517
  var tries = 0;
518
  var iv = setInterval(function () {
519
  tries++;
 
620
  if (countEl) countEl.textContent = BIG_SALE_PRODUCTS.length + ' sản phẩm';
621
  if (countBadge) countBadge.textContent = BIG_SALE_PRODUCTS.length + ' SP';
622
  if (wasOpen) panel.classList.add('open'); else panel.classList.remove('open');
623
+ injectBigSaleAdminActions();
624
+ } catch (e) {}
625
+ }
626
+
627
+ // ── List-level share (toàn bộ danh sách BIG SALE) ──
628
+ // Copies a self-hosted deep link (?bigsale=all) that opens the FULL BIG SALE
629
+ // panel — NOT a single product. Replaces the old per-product share.
630
+ window.shareBigSaleList = function () {
631
+ var base = window.location.origin + window.location.pathname;
632
+ var url = base + '?bigsale=all';
633
+ var done = function () {
634
+ if (typeof window.toast === 'function') window.toast('✓ Đã copy link chia sẻ danh sách BIG SALE');
635
+ else alert('Link chia sẻ danh sách BIG SALE:\n' + url);
636
+ };
637
+ if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
638
+ navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt('Link chia sẻ danh sách BIG SALE:', url); });
639
+ } else { window.prompt('Link chia sẻ danh sách BIG SALE:', url); }
640
+ };
641
+
642
+ // Open the full BIG SALE panel (used by ?bigsale=all deep link).
643
+ function openBigSalePanel() {
644
+ try {
645
+ var panel = document.getElementById('big-sale-panel');
646
+ var floating = document.getElementById('big-sale-floating');
647
+ if (panel) panel.classList.add('open');
648
+ if (floating) floating.classList.add('open');
649
+ } catch (e) {}
650
+ }
651
+ window.openBigSalePanel = openBigSalePanel;
652
+
653
+ // Admin-only delete for a BIG SALE item (permanent, via /api/delete-promo).
654
+ async function deleteBigSaleProduct(code) {
655
+ try {
656
+ var isAdmin = (typeof window.isVaAdmin === 'function') ? window.isVaAdmin() : false;
657
+ if (!isAdmin) { alert('🔒 Cần nhập mã V.AISTUDIO trên trang chủ.'); return; }
658
+ if (!window.confirm('Xóa sản phẩm ' + code + ' khỏi danh sách BIG SALE?')) return;
659
+ var r = await fetch('/api/delete-promo', {
660
+ method: 'POST',
661
+ headers: { 'Content-Type': 'application/json', 'x-vai-admin': '1' },
662
+ body: JSON.stringify({ type: 'bigsale', key: code })
663
+ });
664
+ var d = await r.json().catch(function () { return {}; });
665
+ if (!r.ok) { alert('❌ ' + (d.error || 'Lỗi xóa')); return; }
666
+ if (window.toast) window.toast('✓ Đã xóa khỏi BIG SALE');
667
+ loadBigSaleProducts(true);
668
+ } catch (e) { alert('❌ Lỗi xóa: ' + (e && e.message || e)); }
669
+ }
670
+ window.deleteBigSaleProduct = deleteBigSaleProduct;
671
+
672
+ // Inject admin delete buttons into every BIG SALE card (after each render).
673
+ function injectBigSaleAdminActions() {
674
+ try {
675
+ var isAdmin = (typeof window.isVaAdmin === 'function') ? window.isVaAdmin() : false;
676
+ if (!isAdmin) return;
677
+ var panel = document.getElementById('big-sale-panel');
678
+ if (!panel) return;
679
+ var norm = function (s) { return String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); };
680
+ var cards = panel.querySelectorAll('.big-sale-product');
681
+ for (var i = 0; i < cards.length; i++) {
682
+ var card = cards[i];
683
+ var code = card.getAttribute('data-code') || '';
684
+ if (!code) continue;
685
+ var actions = card.querySelector('.big-sale-card-actions');
686
+ if (!actions || actions.querySelector('.bs-del-btn')) continue;
687
+ var del = document.createElement('button');
688
+ del.type = 'button';
689
+ del.className = 'bs-del-btn';
690
+ del.innerHTML = '<i class="fas fa-trash"></i> Xóa';
691
+ del.style.cssText = "flex:1;padding:6px 8px;border:none;border-radius:8px;background:#dc2626;color:#fff;font-size:0.7rem;font-weight:700;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:5px";
692
+ del.addEventListener('click', function (e) {
693
+ e.stopPropagation();
694
+ window.deleteBigSaleProduct(code);
695
+ });
696
+ actions.appendChild(del);
697
+ }
698
  } catch (e) {}
699
  }
700
 
 
704
  // Load admin-managed BIGSALE products from the Promo CMS backend.
705
  loadBigSaleProducts().then(function (list) {
706
  console.log('[BIG SALE] Dynamic list loaded: ' + list.length + ' products');
707
+ injectBigSaleAdminActions();
708
  handleBigSaleUrlParam();
709
  }).catch(function () {
710
+ injectBigSaleAdminActions();
711
  handleBigSaleUrlParam();
712
  });
713
  // Listen for CMS saves so the panel stays in sync.
714
  try {
715
+ window.addEventListener('vai-promo-changed', function () { loadBigSaleProducts(true).then(function () { injectBigSaleAdminActions(); }); });
716
  } catch (e) {}
717
  }
718
 
index.html CHANGED
@@ -758,17 +758,18 @@
758
  first paint — the welcome/avatar-picker modal renders immediately. -->
759
  <script defer src="./src/cart-quote.js?gc=33"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
760
  <script defer src="./src/customers.js?gc=17"></script> <!-- Customer list (Danh sách khách hàng) -->
761
- <script defer src="./src/vaix-rag.js?gc=56"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
762
  <script defer src="./src/greeting-news.js?gc=40"></script> <!-- Greeting HOT news cards + source links -->
763
  <script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
764
  <script type="module" src="./src/app.js?gc=59"></script>
765
  <script defer src="./src/greeting-source-cards.js?gc=15"></script> <!-- FIX: Nguồn tin cards under greeting -->
766
  <script defer src="./src/avatar-picker.js?gc=13"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
767
  <!-- BIG SALE Floating Promotion Module -->
768
- <script defer src="./big_sale.js?v=13"></script>
769
  <!-- V.AI STUDIO Promo CMS (admin editor + BIGSALE/COMBO manager) -->
770
  <script defer src="./src/vaix-promo-cms.js?v=3"></script>
771
- <script defer src="./src/vaix-combo.js?v=3"></script>
 
772
 
773
  <script>
774
  // Wire the "Đơn hàng" buttons to the order modal
@@ -789,6 +790,6 @@
789
 
790
  <!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
791
  homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
792
- <script defer src="./src/vaix-add-product.js?v=4"></script>
793
  </body>
794
  </html>
 
758
  first paint — the welcome/avatar-picker modal renders immediately. -->
759
  <script defer src="./src/cart-quote.js?gc=33"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
760
  <script defer src="./src/customers.js?gc=17"></script> <!-- Customer list (Danh sách khách hàng) -->
761
+ <script defer src="./src/vaix-rag.js?gc=57"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
762
  <script defer src="./src/greeting-news.js?gc=40"></script> <!-- Greeting HOT news cards + source links -->
763
  <script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
764
  <script type="module" src="./src/app.js?gc=59"></script>
765
  <script defer src="./src/greeting-source-cards.js?gc=15"></script> <!-- FIX: Nguồn tin cards under greeting -->
766
  <script defer src="./src/avatar-picker.js?gc=13"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
767
  <!-- BIG SALE Floating Promotion Module -->
768
+ <script defer src="./big_sale.js?v=14"></script>
769
  <!-- V.AI STUDIO Promo CMS (admin editor + BIGSALE/COMBO manager) -->
770
  <script defer src="./src/vaix-promo-cms.js?v=3"></script>
771
+ <script defer src="./src/vaix-combo.js?v=5"></script>
772
+ <script defer src="./src/vaix-flashsale.js?v=1"></script>
773
 
774
  <script>
775
  // Wire the "Đơn hàng" buttons to the order modal
 
790
 
791
  <!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
792
  homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
793
+ <script defer src="./src/vaix-add-product.js?v=5"></script>
794
  </body>
795
  </html>
index.ts CHANGED
@@ -1675,6 +1675,46 @@ const server = Bun.serve({
1675
  }
1676
  },
1677
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1678
  "/api/promos": {
1679
  GET: async () => {
1680
  try {
 
1675
  }
1676
  },
1677
  },
1678
+ "/api/delete-promo": {
1679
+ POST: async (req: Request) => {
1680
+ // Admin-only: permanently remove a BIGSALE item (by code) or a COMBO
1681
+ // (by code/name/slug) from promos.json. Same x-vai-admin gate as
1682
+ // /api/delete-product so discount-link visitors stay locked out.
1683
+ try {
1684
+ const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
1685
+ if (!token) return Response.json({ error: "No write token on server" }, { status: 500 });
1686
+ const isAdmin = (req.headers.get("x-vai-admin") || "") === "1";
1687
+ if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 });
1688
+ const body: any = await req.json().catch(() => ({}));
1689
+ const type = String(body?.type || "").trim();
1690
+ const key = String(body?.key || "").trim();
1691
+ if (!["bigsale", "combo"].includes(type)) return Response.json({ error: "type must be bigsale|combo" }, { status: 400 });
1692
+ if (!key) return Response.json({ error: "Missing key" }, { status: 400 });
1693
+ const cur = await readPromos(token);
1694
+ const normK = (s: any) =>
1695
+ String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, "");
1696
+ const normKey = normK(key);
1697
+ let removed = 0;
1698
+ if (type === "bigsale") {
1699
+ const arr = Array.isArray(cur.bigsale) ? cur.bigsale : [];
1700
+ const next = arr.filter((it: any) => normK(it?.code) !== normKey);
1701
+ removed = arr.length - next.length;
1702
+ cur.bigsale = next;
1703
+ } else {
1704
+ const arr = Array.isArray(cur.combo) ? cur.combo : [];
1705
+ const next = arr.filter((it: any) => normK(it?.code || it?.name || it?.slug) !== normKey);
1706
+ removed = arr.length - next.length;
1707
+ cur.combo = next;
1708
+ }
1709
+ if (!removed) return Response.json({ ok: true, removed: 0 });
1710
+ await writePromos(token, cur);
1711
+ return Response.json({ ok: true, removed, type });
1712
+ } catch (e: any) {
1713
+ console.error("[delete-promo]", e?.message);
1714
+ return Response.json({ error: e.message || String(e) }, { status: 502 });
1715
+ }
1716
+ },
1717
+ },
1718
  "/api/promos": {
1719
  GET: async () => {
1720
  try {
src/vaix-add-product.js CHANGED
@@ -756,7 +756,91 @@
756
  var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
757
  if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
758
  brand = String(brand || '').replace(/^www\./i, '');
759
- return {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
760
  name: name, sku: sku, img: primaryImg, gallery: gallery, desc: desc,
761
  price: priceInfo.salePrice || priceInfo.price, pn: priceInfo.salePn || priceInfo.pn,
762
  listPrice: priceInfo.listPrice, listPn: priceInfo.listPn,
 
756
  var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
757
  if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
758
  brand = String(brand || '').replace(/^www\./i, '');
759
+ // ── dienmayxanh.com override (v2024 layout) ──
760
+ if (/dienmayxanh\.com/i.test(pageUrl || '')) {
761
+ // NAME: h1 direct text only
762
+ var h1d = doc.querySelector('h1');
763
+ if (h1d) { var nm = ''; for (var ci = 0; ci < h1d.childNodes.length; ci++) { if (h1d.childNodes[ci].nodeType === 3) nm += h1d.childNodes[ci].nodeValue; } nm = String(nm || '').trim(); if (nm) name = nm; }
764
+ // PRICES: .bs_price strong (sale) + em (list) + data attrs
765
+ var bs = doc.querySelector('.bs_price');
766
+ if (bs) {
767
+ var sEl = bs.querySelector('strong'), eEl = bs.querySelector('em');
768
+ var sT = sEl ? sEl.textContent.trim() : '';
769
+ var lT = eEl ? eEl.textContent.trim() : '';
770
+ if (sT) priceInfo.salePn = parseInt(String(sT).replace(/[^\d]/g, ''), 10);
771
+ if (lT) priceInfo.listPn = parseInt(String(lT).replace(/[^\d]/g, ''), 10);
772
+ if (!priceInfo.salePn && bs.getAttribute('data-discountorigin')) priceInfo.salePn = parseInt(String(bs.getAttribute('data-discountorigin')).replace(/[^\d]/g, ''), 10);
773
+ if (!priceInfo.listPn && bs.getAttribute('data-priceorg')) priceInfo.listPn = parseInt(String(bs.getAttribute('data-priceorg')).replace(/[^\d]/g, ''), 10);
774
+ if (priceInfo.listPn && priceInfo.salePn && priceInfo.listPn < priceInfo.salePn) { var tt = priceInfo.listPn; priceInfo.listPn = priceInfo.salePn; priceInfo.salePn = tt; }
775
+ priceInfo.pn = priceInfo.salePn || priceInfo.listPn;
776
+ priceInfo.salePrice = priceInfo.salePn ? money(priceInfo.salePn) : '';
777
+ priceInfo.price = priceInfo.salePrice || (priceInfo.pn ? money(priceInfo.pn) : '');
778
+ priceInfo.listPrice = priceInfo.listPn ? money(priceInfo.listPn) : '';
779
+ }
780
+ // GALLERY: Product/Images URLs (may have NO extension)
781
+ var gl = [];
782
+ var dimgs = doc.querySelectorAll('.thubmail-slide .owl-carousel .owl-dot img, .thubmail-slide img, #slider-detail img');
783
+ for (var dg1 = 0; dg1 < dimgs.length; dg1++) {
784
+ var dsr = dimgs[dg1].getAttribute('data-original') || dimgs[dg1].getAttribute('data-src') || dimgs[dg1].getAttribute('src') || '';
785
+ if (!dsr || dsr.indexOf('Products/Images') < 0) continue;
786
+ if (dsr.indexOf('comment') >= 0 || dsr.indexOf('/Common/') >= 0 || dsr.indexOf('/Banner/') >= 0) continue;
787
+ var du = cleanUrl(dsr, pageUrl);
788
+ if (du && gallery.indexOf(du) < 0) gallery.push(du);
789
+ }
790
+ if (gallery.length) {
791
+ var firstFolder = gallery[0].substring(0, gallery[0].lastIndexOf('/') + 1);
792
+ var allImgs2 = doc.querySelectorAll('img');
793
+ for (var dg2 = 0; dg2 < allImgs2.length; dg2++) {
794
+ var ds2 = allImgs2[dg2].getAttribute('data-original') || allImgs2[dg2].getAttribute('data-src') || allImgs2[dg2].getAttribute('src') || '';
795
+ if (!ds2 || ds2.indexOf('Products/Images') < 0 || ds2.indexOf('comment') >= 0) continue;
796
+ var da = cleanUrl(ds2, pageUrl);
797
+ if (da && da.indexOf(firstFolder) === 0 && gallery.indexOf(da) < 0) gallery.push(da);
798
+ }
799
+ gallery = gallery.filter(function (u) { return u.indexOf('-thumb') < 0; });
800
+ }
801
+ if (gallery.length) { primaryImg = gallery[0]; if (gallery.indexOf(primaryImg) < 0) gallery.unshift(primaryImg); }
802
+ // SPECS: #tab-2 .text-specifi li (aside[0]=key, aside[1]=value)
803
+ var t2b = doc.getElementById('tab-2') || doc.getElementById('tab-02');
804
+ if (t2b) {
805
+ var s2b = {};
806
+ var tlis = t2b.querySelectorAll('.text-specifi li, .parameter__list li');
807
+ for (var tl2 = 0; tl2 < tlis.length; tl2++) {
808
+ var as2 = tlis[tl2].querySelectorAll('aside');
809
+ if (as2.length >= 2) {
810
+ var k2b = as2[0].textContent.replace(/\s+/g, ' ').trim().replace(/:$/, '');
811
+ var v2b = as2[1].textContent.replace(/\s+/g, ' ').trim();
812
+ if (k2b && v2b && ['Tên sản phẩm', 'Mã số', 'Giá', 'Thương hiệu', 'Khuyến mãi'].indexOf(k2b) < 0) s2b[k2b] = v2b;
813
+ }
814
+ }
815
+ if (Object.keys(s2b).length) specs = s2b;
816
+ }
817
+ // FEATURES: paragraphs under h3 in #tab-3 (Thông tin sản phẩm)
818
+ var t3b = doc.getElementById('tab-3') || doc.getElementById('tab-03');
819
+ var f2 = [];
820
+ if (t3b) {
821
+ var p3 = t3b.querySelectorAll('p');
822
+ for (var pi2 = 0; pi2 < p3.length && f2.length < 12; pi2++) {
823
+ var txb = p3[pi2].textContent.replace(/\s+/g, ' ').trim();
824
+ if (txb.length >= 30 && txb.length <= 200 && txb.indexOf('*Hình ảnh') < 0 && txb.indexOf('minh họa') < 0) f2.push(txb);
825
+ }
826
+ }
827
+ if (f2.length) feats = f2.slice(0, 12);
828
+ // BRAND: breadcrumb last link → "Máy giặt Hitachi" → "Hitachi"
829
+ var cr2 = doc.querySelectorAll('.box_breadcrumb li a, .breadcrumb li a, .breadcrumb a');
830
+ if (cr2.length) {
831
+ var lst = cr2[cr2.length - 1].textContent.replace(/\s+/g, ' ').trim();
832
+ ['Máy giặt ', 'Máy lạnh ', 'Tủ lạnh ', 'Tivi ', 'Điện thoại ', 'Máy hút mùi ', 'Bếp ', 'Lò '].forEach(function (pr) { if (lst.indexOf(pr) === 0) lst = lst.slice(pr.length); });
833
+ lst = lst.replace(/(Inverter|Digital|Smart|Auto).*$/i, '').trim();
834
+ if (lst && lst.length <= 40 && !/ số | kg| inch/i.test(lst)) brand = lst;
835
+ }
836
+ // DESC: full #tab-3 text
837
+ if (t3b) {
838
+ var dtx = t3b.textContent.replace(/\s+/g, ' ').trim();
839
+ if (dtx.length > 60) desc = dtx.slice(0, 8000);
840
+ }
841
+ }
842
+
843
+ return {
844
  name: name, sku: sku, img: primaryImg, gallery: gallery, desc: desc,
845
  price: priceInfo.salePrice || priceInfo.price, pn: priceInfo.salePn || priceInfo.pn,
846
  listPrice: priceInfo.listPrice, listPn: priceInfo.listPn,
src/vaix-combo.js CHANGED
@@ -27,7 +27,7 @@
27
  var panel = document.createElement("div");
28
  panel.id = "combo-panel";
29
  panel.style.cssText = "position:fixed;right:18px;bottom:170px;z-index:9998;width:320px;max-height:460px;background:#fff;border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,0.2);overflow:hidden;display:none;flex-direction:column;font-family:inherit;border:1px solid #bfdbfe";
30
- panel.innerHTML = '<div style="padding:12px 14px;background:linear-gradient(135deg,#eff6ff,#dbeafe);display:flex;align-items:center;justify-content:space-between"><span style="font-weight:800;color:#1d4ed8;font-size:0.85rem">🧩 COMBO KHUYẾN MÃI</span><button class="combo-close" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#1e3a8a">×</button></div><div class="combo-list" style="flex:1;overflow:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px"></div>';
31
 
32
  document.body.appendChild(btn);
33
  document.body.appendChild(panel);
@@ -175,38 +175,149 @@
175
  '<div style="font-size:0.7rem;color:#64748b;margin-bottom:4px">' + itemsHtml + '</div>' +
176
  '<div style="display:flex;align-items:center;justify-content:space-between;gap:6px">' +
177
  '<span style="font-weight:700;color:#1d4ed8;font-size:0.8rem">' + fmt(priceNum) + '</span>' +
178
- '<button type="button" class="combo-share-btn" onclick="window.shareCombo(' + idx + ', event, this)" style="padding:5px 10px;border:none;border-radius:7px;background:#1e40af;color:#fff;font-size:0.68rem;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap"><i class="fas fa-share-alt"></i> Chia sẻ</button>' +
179
  '</div>' +
180
  '</div>';
181
  }).join("");
 
182
  } catch(e) { console.warn("[COMBO] refresh:", e); }
183
  }
184
  // Per-combo "Chia sẻ link" (SEO). Copy a self-hosted deep link that opens the
185
  // COMBO KHUYẾN MÃI panel. Exposed globally because combo cards are HTML strings.
186
- window.shareCombo = function (idx, ev, btn) {
 
 
 
187
  try {
188
  if (ev) { ev.stopPropagation(); }
189
- var combos = loadCombos() || [];
190
- var c = combos[idx];
191
- if (!c) return;
192
- var slug = String(c.slug || c.name || "combo-" + (idx + 1))
193
- .toLowerCase()
194
- .replace(/[^a-z0-9]+/g, "-")
195
- .replace(/^-+|-+$/g, "");
196
  var base = window.location.origin + window.location.pathname;
197
- var url = base + "?combo=" + encodeURIComponent(slug);
198
  var done = function () {
199
- if (typeof window.toast === "function") window.toast("\u2713 \u0110\u00e3 copy link chia s\u1ebb COMBO (SEO)");
200
- else alert("Link chia s\u1ebb COMBO:\n" + url);
201
  };
202
  if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
203
- navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt("Link chia s\u1ebb COMBO:", url); });
204
- } else { window.prompt("Link chia s\u1ebb COMBO:", url); }
205
  } catch (err) {
206
  alert("L\u1ed7i chia s\u1ebb: " + (err && err.message || err));
207
  }
208
  };
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  function boot() {
211
  // Retry createComboElements until vaixPromo is ready (can load after CMS module)
212
  var _retry = 0;
@@ -214,6 +325,7 @@
214
  _retry++;
215
  try {
216
  createComboElements();
 
217
  } catch(e) {
218
  if (_retry < 30) { setTimeout(tryCreate, 500); return; }
219
  }
 
27
  var panel = document.createElement("div");
28
  panel.id = "combo-panel";
29
  panel.style.cssText = "position:fixed;right:18px;bottom:170px;z-index:9998;width:320px;max-height:460px;background:#fff;border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,0.2);overflow:hidden;display:none;flex-direction:column;font-family:inherit;border:1px solid #bfdbfe";
30
+ panel.innerHTML = '<div style="padding:12px 14px;background:linear-gradient(135deg,#eff6ff,#dbeafe);display:flex;align-items:center;justify-content:space-between;gap:8px"><span style="font-weight:800;color:#1d4ed8;font-size:0.85rem">🧩 COMBO KHUYẾN MÃI</span><div style="display:flex;align-items:center;gap:6px"><button type="button" class="combo-list-share-btn" onclick="window.shareComboList(event)" style="padding:5px 9px;border:none;border-radius:8px;background:#1d4ed8;color:#fff;font-size:0.64rem;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap"><i class="fas fa-share-alt"></i> Chia sẻ danh sách</button><button class="combo-close" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#1e3a8a">×</button></div></div><div class="combo-list" style="flex:1;overflow:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px"></div>';
31
 
32
  document.body.appendChild(btn);
33
  document.body.appendChild(panel);
 
175
  '<div style="font-size:0.7rem;color:#64748b;margin-bottom:4px">' + itemsHtml + '</div>' +
176
  '<div style="display:flex;align-items:center;justify-content:space-between;gap:6px">' +
177
  '<span style="font-weight:700;color:#1d4ed8;font-size:0.8rem">' + fmt(priceNum) + '</span>' +
178
+ '<span class="combo-card-actions" style="display:flex;gap:4px"></span>' +
179
  '</div>' +
180
  '</div>';
181
  }).join("");
182
+ injectComboAdminActions();
183
  } catch(e) { console.warn("[COMBO] refresh:", e); }
184
  }
185
  // Per-combo "Chia sẻ link" (SEO). Copy a self-hosted deep link that opens the
186
  // COMBO KHUYẾN MÃI panel. Exposed globally because combo cards are HTML strings.
187
+
188
+ // List-level share (toàn bộ danh sách COMBO). Copy a self-hosted deep link
189
+ // (?combo=all) that opens the FULL COMBO panel — NOT a single combo.
190
+ window.shareComboList = function (ev) {
191
  try {
192
  if (ev) { ev.stopPropagation(); }
 
 
 
 
 
 
 
193
  var base = window.location.origin + window.location.pathname;
194
+ var url = base + "?combo=all";
195
  var done = function () {
196
+ if (typeof window.toast === "function") window.toast("\u2713 \u0110\u00e3 copy link chia s\u1ebb danh s\u00e1ch COMBO");
197
+ else alert("Link chia s\u1ebb danh s\u00e1ch COMBO:\n" + url);
198
  };
199
  if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
200
+ navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt("Link chia s\u1ebb danh s\u00e1ch COMBO:", url); });
201
+ } else { window.prompt("Link chia s\u1ebb danh s\u00e1ch COMBO:", url); }
202
  } catch (err) {
203
  alert("L\u1ed7i chia s\u1ebb: " + (err && err.message || err));
204
  }
205
  };
206
 
207
+ // Admin-only: permanently delete a COMBO from promos.json (/api/delete-promo).
208
+ window.deleteCombo = async function (idx, ev) {
209
+ try {
210
+ if (ev) { ev.stopPropagation(); }
211
+ var isAdmin = (typeof window.isVaAdmin === "function") ? window.isVaAdmin() : false;
212
+ if (!isAdmin) { alert("\uD83D\uDD12 C\u1ea7n nh\u1eadp m\u00e3 V.AISTUDIO tr\u00ean trang ch\u1ee7."); return; }
213
+ var combos = loadCombos() || [];
214
+ var c = combos[idx];
215
+ if (!c) return;
216
+ if (!window.confirm("X\u00f3a COMBO \"" + (c.name || "COMBO") + "\" kh\u1ecfi danh s\u00e1ch?")) return;
217
+ var key = String(c.code || c.slug || c.name || "combo-" + (idx + 1));
218
+ var r = await fetch("/api/delete-promo", {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/json", "x-vai-admin": "1" },
221
+ body: JSON.stringify({ type: "combo", key: key })
222
+ });
223
+ var d = await r.json().catch(function () { return {}; });
224
+ if (!r.ok) { alert("\u274c " + (d.error || "L\u1ed7i x\u00f3a")); return; }
225
+ if (window.toast) window.toast("\u2713 \u0110\u00e3 x\u00f3a COMBO");
226
+ // refresh from server
227
+ if (window.vaixPromo && typeof window.vaixPromo.loadPromos === "function") {
228
+ window.vaixPromo.loadPromos().then(function () { refreshPanel(); }).catch(function () { refreshPanel(); });
229
+ } else { refreshPanel(); }
230
+ } catch (err) {
231
+ alert("\u274c L\u1ed7i x\u00f3a: " + (err && err.message || err));
232
+ }
233
+ };
234
+ // Inject admin delete buttons into combo cards (after each render).
235
+ function injectComboAdminActions() {
236
+ try {
237
+ var isAdmin = (typeof window.isVaAdmin === "function") ? window.isVaAdmin() : false;
238
+ if (!isAdmin) return;
239
+ var panel = document.getElementById("combo-panel");
240
+ if (!panel) return;
241
+ var cards = panel.querySelectorAll(".c-card");
242
+ for (var i = 0; i < cards.length; i++) {
243
+ var card = cards[i];
244
+ var idx = parseInt(card.getAttribute("data-ci"), 10);
245
+ if (isNaN(idx) || idx < 0) continue;
246
+ var actions = card.querySelector(".combo-card-actions");
247
+ if (!actions || actions.querySelector(".combo-del-btn")) continue;
248
+ var del = document.createElement("button");
249
+ del.type = "button";
250
+ del.className = "combo-del-btn";
251
+ del.innerHTML = '<i class="fas fa-trash"></i> X\u00f3a';
252
+ del.style.cssText = "padding:4px 8px;border:none;border-radius:7px;background:#dc2626;color:#fff;font-size:0.62rem;font-weight:700;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:4px;white-space:nowrap";
253
+ del.addEventListener("click", function (e) { window.deleteCombo(idx, e); });
254
+ actions.appendChild(del);
255
+ }
256
+ } catch (e) { console.warn("[COMBO] inject:", e); }
257
+ }
258
+ // Open the FULL combo panel (used by ?combo=all deep link).
259
+ window.openComboPanel = function () {
260
+ try {
261
+ var panel = document.getElementById("combo-panel");
262
+ var btn = document.getElementById("combo-floating");
263
+ if (panel) panel.style.display = "flex";
264
+ if (btn) btn.style.display = "";
265
+ if (panel) {
266
+ var list = panel.querySelector(".combo-list");
267
+ if (list) list.innerHTML = "";
268
+ refreshPanel();
269
+ }
270
+ } catch (e) {}
271
+ };
272
+
273
+ // ── ?combo= deep link: "all" opens the full list; a slug opens that COMBO ──
274
+ function handleComboUrlParam() {
275
+ try {
276
+ var params = new URLSearchParams(location.search);
277
+ var val = params.get("combo") || "";
278
+ if (!val) return;
279
+ var valNorm = String(val).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
280
+ var tries = 0;
281
+ var iv = setInterval(function () {
282
+ tries++;
283
+ try {
284
+ if (!document.getElementById("combo-floating")) {
285
+ if (tries > 30) { clearInterval(iv); return; }
286
+ return;
287
+ }
288
+ var panel = document.getElementById("combo-panel");
289
+ if (!panel) { clearInterval(iv); return; }
290
+ panel.style.display = "flex";
291
+ var combos = loadCombos() || [];
292
+ if (valNorm && valNorm !== "all") {
293
+ // Try to highlight/scroll to the requested combo card and open it.
294
+ var idx = -1;
295
+ for (var i = 0; i < combos.length; i++) {
296
+ var c = combos[i];
297
+ var slug = String(c.slug || c.code || c.name || "combo-" + (i + 1))
298
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
299
+ if (slug === valNorm || String(c.code || "").toLowerCase() === val) { idx = i; break; }
300
+ }
301
+ refreshPanel();
302
+ if (idx >= 0) {
303
+ var cards = panel.querySelectorAll(".c-card");
304
+ if (cards[idx]) {
305
+ cards[idx].scrollIntoView({ behavior: "smooth", block: "center" });
306
+ cards[idx].style.border = "2px solid #f59e0b";
307
+ setTimeout(function () { if (cards[idx]) cards[idx].style.border = ""; }, 2500);
308
+ }
309
+ }
310
+ } else {
311
+ refreshPanel();
312
+ }
313
+ clearInterval(iv);
314
+ } catch (e) {
315
+ if (tries > 30) clearInterval(iv);
316
+ }
317
+ }, 400);
318
+ } catch (e) { /* never block */ }
319
+ }
320
+
321
  function boot() {
322
  // Retry createComboElements until vaixPromo is ready (can load after CMS module)
323
  var _retry = 0;
 
325
  _retry++;
326
  try {
327
  createComboElements();
328
+ handleComboUrlParam();
329
  } catch(e) {
330
  if (_retry < 30) { setTimeout(tryCreate, 500); return; }
331
  }
src/vaix-flashsale.js ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // V.AI STUDIO FLASHSALE floating panel v1.0
2
+ // Nút FLASHSALE đặt NGAY CẠNH nút COMBO (bottom:240px, combo ở 170px).
3
+ // Danh sách FLASHSALE = mọi sản phẩm khuyến mãi CÓ ĐỦ 2 GIÁ:
4
+ // - Từ catalog (vaix.allProducts): listPn (niêm yết) + salePn (khuyến mãi), listPn > salePn
5
+ // - Từ BIG SALE (window.BIG_SALE_PRODUCTS): price (niêm yết) + bigsale (giá KM)
6
+ // Chia sẻ cả danh sách qua ?flashsale=all (self-hosted, mở đúng panel này).
7
+ (function () {
8
+ if (window.__vaixFlashsaleLoaded) return;
9
+ window.__vaixFlashsaleLoaded = true;
10
+ console.log("[FLASHSALE] Module loaded");
11
+
12
+ function fmt(n) {
13
+ n = Number(n) || 0;
14
+ return n.toLocaleString("vi-VN") + " ₫";
15
+ }
16
+
17
+ function catalogProductsRaw() {
18
+ try {
19
+ if (window.vaix && typeof window.vaix.allProducts === "function") {
20
+ var arr = window.vaix.allProducts();
21
+ return Array.isArray(arr) ? arr : [];
22
+ }
23
+ } catch (e) {}
24
+ return [];
25
+ }
26
+
27
+ // Catalog products that have BOTH listPn and salePn, list > sale.
28
+ function catalogBuild() {
29
+ var out = [];
30
+ var arr = catalogProductsRaw();
31
+ for (var i = 0; i < arr.length; i++) {
32
+ var p = arr[i];
33
+ var listN = Number(p.listPn) || (p.listPrice ? parseInt(String(p.listPrice).replace(/[^\d]/g, ""), 10) : 0) || 0;
34
+ var saleN = Number(p.salePn) || (p.salePrice ? parseInt(String(p.salePrice).replace(/[^\d]/g, ""), 10) : 0) || Number(p.priceNum) || 0;
35
+ if (!listN || !saleN || listN <= saleN) continue;
36
+ if (p._deleted || p.deleted) continue;
37
+ out.push({
38
+ name: p.title_clean || p.name || p.title || "",
39
+ code: p.sku || p.model || p.code || "",
40
+ image: p.image || (p.images && p.images[0]) || "",
41
+ listPn: listN, salePn: saleN,
42
+ discount: saleN ? Math.round((listN - saleN) / listN * 100) + "%" : "",
43
+ slug: p.slug || "", catalog: true
44
+ });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // FLASHSALE = products having BOTH list price and sale price (list > sale).
50
+ function buildFlashProducts() {
51
+ var out = [];
52
+ var seen = {};
53
+ function push(p) {
54
+ if (!p) return;
55
+ var key = String(p.code || p.sku || p.model || p.name || "");
56
+ if (!key) return;
57
+ key = key.toUpperCase().replace(/\s+/g, " ");
58
+ if (seen[key]) return;
59
+ seen[key] = 1;
60
+ out.push(p);
61
+ }
62
+ // 1) Catalog products with both prices.
63
+ var cat = catalogBuild();
64
+ for (var i = 0; i < cat.length; i++) push(cat[i]);
65
+ // 2) BIG SALE products (they always have list price + bigsale discount price).
66
+ try {
67
+ var bs = (window.BIG_SALE_PRODUCTS || []);
68
+ for (var j = 0; j < bs.length; j++) {
69
+ var b = bs[j];
70
+ var listN = parseInt(String(b.price || "0").replace(/\./g, ""), 10) || 0;
71
+ var saleN = parseInt(String(b.bigsale || "0").replace(/\./g, ""), 10) || 0;
72
+ if (listN <= 0 || saleN <= 0 || listN <= saleN) continue;
73
+ push({
74
+ name: b.name, code: b.code, image: b.image || "", listPn: listN, salePn: saleN,
75
+ discount: b.discount || "", _fromBigSale: true
76
+ });
77
+ }
78
+ } catch (e) {}
79
+ return out;
80
+ }
81
+
82
+ function createElements() {
83
+ if (document.getElementById("flashsale-floating")) return;
84
+ var btn = document.createElement("button");
85
+ btn.id = "flashsale-floating";
86
+ btn.style.cssText = "position:fixed;right:18px;bottom:240px;z-index:9999;border:none;cursor:pointer;display:flex;align-items:center;gap:6px;background:#fff;border-radius:30px;padding:8px 14px;box-shadow:0 4px 14px rgba(0,0,0,0.15);font-family:inherit;font-size:0.75rem;font-weight:700;color:#c2410c;border:1px solid #fed7aa;transition:all 0.25s";
87
+ btn.innerHTML = '<span style="background:#ea580c;color:#fff;font-weight:800;font-size:0.65rem;padding:2px 8px;border-radius:20px">FLASHSALE</span><span class="flashsale-count" style="font-size:0.72rem;color:#9a3412"> 0</span><span style="margin-left:2px;font-size:0.65rem;color:#9a3412">▼</span>';
88
+
89
+ var panel = document.createElement("div");
90
+ panel.id = "flashsale-panel";
91
+ panel.style.cssText = "position:fixed;right:18px;bottom:240px;z-index:9998;width:320px;max-height:460px;background:#fff;border-radius:16px;box-shadow:0 16px 48px rgba(0,0,0,0.2);overflow:hidden;display:none;flex-direction:column;font-family:inherit;border:1px solid #fed7aa";
92
+ panel.innerHTML = '<div style="padding:12px 14px;background:linear-gradient(135deg,#fff7ed,#ffedd5);display:flex;align-items:center;justify-content:space-between;gap:8px"><span style="font-weight:800;color:#c2410c;font-size:0.85rem">⚡ FLASHSALE 2 GIÁ</span><div style="display:flex;align-items:center;gap:6px"><button type="button" class="flashsale-list-share-btn" onclick="window.shareFlashsaleList(event)" style="padding:5px 9px;border:none;border-radius:8px;background:#ea580c;color:#fff;font-size:0.64rem;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap"><i class="fas fa-share-alt"></i> Chia sẻ danh sách</button><button class="flashsale-close" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#9a3412">×</button></div></div><div class="flashsale-list" style="flex:1;overflow:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px"></div>';
93
+
94
+ document.body.appendChild(btn);
95
+ document.body.appendChild(panel);
96
+
97
+ var isOpen = false;
98
+ btn.addEventListener("click", function (e) {
99
+ e.stopPropagation();
100
+ isOpen = !isOpen;
101
+ panel.style.display = isOpen ? "flex" : "none";
102
+ if (isOpen) refreshPanel();
103
+ });
104
+ var closeBtn = panel.querySelector(".flashsale-close");
105
+ if (closeBtn) closeBtn.onclick = function () { panel.style.display = "none"; isOpen = false; };
106
+ document.addEventListener("click", function (e) {
107
+ if (isOpen && !panel.contains(e.target) && !btn.contains(e.target)) {
108
+ panel.style.display = "none"; isOpen = false;
109
+ }
110
+ });
111
+
112
+ panel.addEventListener("click", function (e) {
113
+ var card = e.target && e.target.closest ? e.target.closest(".fs-card") : null;
114
+ if (!card) return;
115
+ e.stopPropagation();
116
+ var code = card.getAttribute("data-code") || "";
117
+ if (!code) return;
118
+ var p = buildFlashProducts().find(function (x) {
119
+ return String(x.code || "").trim().toUpperCase() === code.toUpperCase();
120
+ });
121
+ if (p) openDetail(p);
122
+ });
123
+ }
124
+
125
+ function openDetail(p) {
126
+ var overlay = document.getElementById("vaistudio-detail-overlay");
127
+ var modal = document.getElementById("vaistudio-detail-modal");
128
+ if (!overlay || !modal) return;
129
+ var titleEl = document.getElementById("detail-title");
130
+ var modelEl = document.getElementById("detail-model");
131
+ var priceEl = document.getElementById("detail-price");
132
+ var imagesEl = document.getElementById("detail-images");
133
+ try {
134
+ if (titleEl) titleEl.textContent = p.name || "";
135
+ if (modelEl) modelEl.textContent = "Mã: " + (p.code || "");
136
+ if (priceEl) {
137
+ priceEl.innerHTML = '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
138
+ '<span style="color:#c2410c;font-size:1.3rem;font-weight:800">' + fmt(p.salePn) + "</span>" +
139
+ '<span style="font-size:0.8rem;color:#64748b;text-decoration:line-through">' + fmt(p.listPn) + "</span>" +
140
+ (p.discount ? '<span style="font-size:0.85rem;background:#fef3c7;color:#92400e;padding:4px 10px;border-radius:8px;font-weight:700">-' + p.discount + "</span>" : "") +
141
+ "</div>";
142
+ }
143
+ if (imagesEl) {
144
+ var gal = p.image ? [p.image] : [];
145
+ imagesEl.innerHTML = gal.length
146
+ ? 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("")
147
+ : '<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>';
148
+ }
149
+ if (overlay) overlay.classList.add("show");
150
+ if (modal) modal.classList.add("show");
151
+ } catch (e) {}
152
+ }
153
+
154
+ function refreshPanel() {
155
+ try {
156
+ var panel = document.getElementById("flashsale-panel");
157
+ var btn = document.getElementById("flashsale-floating");
158
+ if (!panel || !btn) return;
159
+ var list = panel.querySelector(".flashsale-list");
160
+ if (!list) return;
161
+ var prods = buildFlashProducts();
162
+ var cEl = btn.querySelector(".flashsale-count");
163
+ if (cEl) cEl.textContent = " " + prods.length;
164
+ if (!prods.length) {
165
+ list.innerHTML = '<div style="font-size:0.75rem;color:#94a3b8;text-align:center;padding:20px 0">Chưa có sản phẩm 2 giá nào.</div>';
166
+ return;
167
+ }
168
+ list.innerHTML = prods.map(function (p, i) {
169
+ var img = p.image ? '<img src="' + p.image + '" alt="" onerror="this.style.display=&#39;none&#39;" style="width:40px;height:40px;border-radius:8px;object-fit:cover;background:#f1f5f9;margin-right:6px;flex-shrink:0">' : "";
170
+ return '<div class="fs-card" data-code="' + (p.code || "") + '" style="cursor:pointer;padding:8px 10px;background:#fffaf5;border:1px solid #fed7aa;border-radius:10px;display:flex;align-items:center;transition:border-color 0.2s">' +
171
+ img +
172
+ '<div style="flex:1;min-width:0">' +
173
+ '<div style="font-weight:700;font-size:0.74rem;color:#431407;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (p.name || "") + '</div>' +
174
+ '<div style="font-size:0.64rem;color:#9a3412;margin-top:2px">' +
175
+ '<span style="text-decoration:line-through;color:#94a3b8">' + (p.listPn ? fmt(p.listPn) : "") + '</span> → <b style="color:#c2410c">' + (p.salePn ? fmt(p.salePn) : "") + '</b>' +
176
+ '</div>' +
177
+ '</div>' +
178
+ '</div>';
179
+ }).join("");
180
+ } catch (e) {
181
+ console.warn("[FLASHSALE] refresh:", e);
182
+ }
183
+ }
184
+
185
+ // Chia sẻ TOÀN BỘ danh sách (?flashsale=all).
186
+ window.shareFlashsaleList = function (ev) {
187
+ try {
188
+ if (ev) { ev.stopPropagation(); }
189
+ var base = window.location.origin + window.location.pathname;
190
+ var url = base + "?flashsale=all";
191
+ var done = function () {
192
+ if (typeof window.toast === "function") window.toast("✓ Đã copy link chia sẻ danh sách FLASHSALE");
193
+ else alert("Link chia sẻ danh sách FLASHSALE:\n" + url);
194
+ };
195
+ if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
196
+ navigator.clipboard.writeText(url).then(done).catch(function () { window.prompt("Link chia sẻ danh sách FLASHSALE:", url); });
197
+ } else { window.prompt("Link chia sẻ danh sách FLASHSALE:", url); }
198
+ } catch (err) {
199
+ alert("Lỗi chia sẻ: " + (err && err.message || err));
200
+ }
201
+ };
202
+ // Mở full panel khi vào link ?flashsale=all.
203
+ window.openFlashsalePanel = function () {
204
+ try {
205
+ var panel = document.getElementById("flashsale-panel");
206
+ var btn = document.getElementById("flashsale-floating");
207
+ if (panel) panel.style.display = "flex";
208
+ if (btn) btn.style.display = "";
209
+ if (panel) refreshPanel();
210
+ } catch (e) {}
211
+ };
212
+
213
+ function handleUrlParam() {
214
+ try {
215
+ var params = new URLSearchParams(location.search);
216
+ var v = params.get("flashsale") || "";
217
+ if (!v) return;
218
+ var tries = 0;
219
+ var iv = setInterval(function () {
220
+ tries++;
221
+ try {
222
+ if (document.getElementById("flashsale-floating")) {
223
+ window.openFlashsalePanel();
224
+ clearInterval(iv);
225
+ } else if (tries > 40) clearInterval(iv);
226
+ } catch (e) { if (tries > 40) clearInterval(iv); }
227
+ }, 400);
228
+ } catch (e) {}
229
+ }
230
+
231
+ function boot() {
232
+ var _retry = 0;
233
+ (function tryCreate() {
234
+ _retry++;
235
+ try {
236
+ createElements();
237
+ refreshPanel();
238
+ handleUrlParam();
239
+ // Recompute when the catalog finishes loading or promo data changes.
240
+ if (window.vaix && typeof window.vaix.load === "function") {
241
+ window.vaix.load().then(function () { refreshPanel(); }).catch(function () {});
242
+ }
243
+ window.addEventListener("vai-promo-changed", function () { setTimeout(refreshPanel, 200); });
244
+ window.addEventListener("vai-catalog-loaded", function () { refreshPanel(); });
245
+ } catch (e) {
246
+ if (_retry < 30) setTimeout(tryCreate, 500);
247
+ }
248
+ })();
249
+ }
250
+
251
+ // Quick alias so the module still works even before vaix finishes loading.
252
+ window.addEventListener("DOMContentLoaded", function () {
253
+ try {
254
+ var d = document.getElementById("flashsale-floating");
255
+ if (!d) boot();
256
+ } catch (e) {}
257
+ });
258
+ if (document.readyState !== "loading") boot();
259
+ })();
src/vaix-rag.js CHANGED
@@ -1772,20 +1772,6 @@ function renderPanelResults(products) {
1772
  const adminBtns = document.createElement("div");
1773
  adminBtns.className = "product-card-admin";
1774
  adminBtns.style.cssText = "display:flex;gap:6px;margin-top:8px;flex-wrap:wrap";
1775
- // Share link button (SEO-friendly ?product=<slug> URL, copied to clipboard).
1776
- const shareBtn = document.createElement("button");
1777
- shareBtn.type = "button";
1778
- shareBtn.className = "product-card-btn share-btn";
1779
- shareBtn.style.cssText = "flex:1;padding:7px 8px;border:none;border-radius:8px;background:#1e40af;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:5px;font-family:inherit";
1780
- shareBtn.innerHTML = '<i class="fas fa-bullhorn"></i> Chia sẻ';
1781
- shareBtn.addEventListener("click", function(e) {
1782
- e.stopPropagation();
1783
- const url = shareableProductUrl(p);
1784
- if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
1785
- navigator.clipboard.writeText(url).then(function(){ toast('✓ Đã copy link chia sẻ (SEO)'); }).catch(function(){ window.prompt('Link chia sẻ:', url); });
1786
- } else { window.prompt('Link chia sẻ:', url); }
1787
- });
1788
- adminBtns.appendChild(shareBtn);
1789
  // Delete button (admin only) — appears on admin-added products and
1790
  // permanently removes them from the shared dataset.
1791
  if (isAddedProduct) {
 
1772
  const adminBtns = document.createElement("div");
1773
  adminBtns.className = "product-card-admin";
1774
  adminBtns.style.cssText = "display:flex;gap:6px;margin-top:8px;flex-wrap:wrap";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1775
  // Delete button (admin only) — appears on admin-added products and
1776
  // permanently removes them from the shared dataset.
1777
  if (isAddedProduct) {