Spaces:
Running
Running
bep40 commited on
Commit ·
f350419
1
Parent(s): 7c94cd2
Thêm SP: hiện đủ sp đã thêm vào đầu panel (URL+OCR); nút Xóa vĩnh viễn (/api/delete-product) cho sp đã thêm; nút chia sẻ link SEO trên card, BIGSALE & COMBO KHUYẾN MÃI
Browse files- big_sale.js +23 -0
- index.html +11 -11
- index.ts +71 -0
- src/vaix-combo.js +31 -2
- src/vaix-rag.js +117 -2
big_sale.js
CHANGED
|
@@ -84,6 +84,9 @@ 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 |
</div>`;
|
| 88 |
}
|
| 89 |
|
|
@@ -323,6 +326,26 @@ function bigSaleShareUrl(product) {
|
|
| 323 |
return base + '?bigsale=' + encodeURIComponent((product && product.code) || '');
|
| 324 |
}
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
// Create/re-ensure the share + add-to-cart + quote buttons in the BIG SALE
|
| 327 |
// detail modal. These mirror the catalog modal's detail-cart-section /
|
| 328 |
// detail-share-section so behaviour (addToCart / openQuotation / shareProduct /
|
|
|
|
| 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 |
|
|
|
|
| 326 |
return base + '?bigsale=' + encodeURIComponent((product && product.code) || '');
|
| 327 |
}
|
| 328 |
|
| 329 |
+
// Per-card "Chia sẻ link" (SEO share). Copies the self-hosted SEO link to the
|
| 330 |
+
// clipboard and shows a toast. Exposed on window because the list cards are
|
| 331 |
+
// built as HTML strings with inline onclick.
|
| 332 |
+
window.shareBigSale = function (code) {
|
| 333 |
+
try {
|
| 334 |
+
var url = bigSaleShareUrl({ code: code });
|
| 335 |
+
var copy = function (u) {
|
| 336 |
+
if (window.navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
| 337 |
+
navigator.clipboard.writeText(u).then(function(){
|
| 338 |
+
if (typeof window.toast === 'function') window.toast('✓ Đã copy link chia sẻ BIG SALE');
|
| 339 |
+
else alert('Link chia sẻ BIG SALE (SEO):\n' + u);
|
| 340 |
+
}).catch(function(){ window.prompt('Link chia sẻ BIG SALE:', u); });
|
| 341 |
+
} else { window.prompt('Link chia sẻ BIG SALE:', u); }
|
| 342 |
+
};
|
| 343 |
+
copy(url);
|
| 344 |
+
} catch (err) {
|
| 345 |
+
alert('Lỗi chia sẻ: ' + (err && err.message || err));
|
| 346 |
+
}
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
// Create/re-ensure the share + add-to-cart + quote buttons in the BIG SALE
|
| 350 |
// detail modal. These mirror the catalog modal's detail-cart-section /
|
| 351 |
// detail-share-section so behaviour (addToCart / openQuotation / shareProduct /
|
index.html
CHANGED
|
@@ -758,17 +758,17 @@
|
|
| 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 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
|
| 773 |
<script>
|
| 774 |
// Wire the "Đơn hàng" buttons to the order modal
|
|
|
|
| 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
|
index.ts
CHANGED
|
@@ -1604,6 +1604,77 @@ const server = Bun.serve({
|
|
| 1604 |
}
|
| 1605 |
},
|
| 1606 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1607 |
"/api/promos": {
|
| 1608 |
GET: async () => {
|
| 1609 |
try {
|
|
|
|
| 1604 |
}
|
| 1605 |
},
|
| 1606 |
},
|
| 1607 |
+
"/api/delete-product": {
|
| 1608 |
+
POST: async (req: Request) => {
|
| 1609 |
+
// Admin-only: permanently remove a product the admin added (via URL/OCR).
|
| 1610 |
+
try {
|
| 1611 |
+
const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
|
| 1612 |
+
if (!token) return Response.json({ error: "No write token on server" }, { status: 500 });
|
| 1613 |
+
const isAdmin = (req.headers.get("x-vai-admin") || "") === "1";
|
| 1614 |
+
if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 });
|
| 1615 |
+
const body: any = await req.json().catch(() => ({}));
|
| 1616 |
+
const sku = String(body?.sku || "").trim();
|
| 1617 |
+
const slug = String(body?.slug || "").trim();
|
| 1618 |
+
if (!sku && !slug) return Response.json({ error: "Missing sku/slug" }, { status: 400 });
|
| 1619 |
+
const CAT_DATASET = "bep40/grob-products-updated";
|
| 1620 |
+
const CAT_FILE = "products_url_added.json";
|
| 1621 |
+
|
| 1622 |
+
// 1) Remove from products_url_added.json (the permanent shared file).
|
| 1623 |
+
let list: any[] = [];
|
| 1624 |
+
let hadAdded = false;
|
| 1625 |
+
try {
|
| 1626 |
+
const ra = await fetch("https://huggingface.co/datasets/" + CAT_DATASET + "/resolve/main/" + CAT_FILE, { signal: AbortSignal.timeout(10000) });
|
| 1627 |
+
if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; }
|
| 1628 |
+
} catch (_e) {}
|
| 1629 |
+
const SKU = sku.toUpperCase();
|
| 1630 |
+
const prevCount = list.length;
|
| 1631 |
+
list = list.filter((x) => {
|
| 1632 |
+
if (!x) return false;
|
| 1633 |
+
const mSku = String(x.sku || "").toUpperCase();
|
| 1634 |
+
const mSlug = String(x.slug || "").toLowerCase();
|
| 1635 |
+
return !((SKU && mSku === SKU) || (slug && mSlug === slug.toLowerCase()));
|
| 1636 |
+
});
|
| 1637 |
+
hadAdded = list.length !== prevCount;
|
| 1638 |
+
if (hadAdded) {
|
| 1639 |
+
const content = JSON.stringify(list);
|
| 1640 |
+
const payload = [
|
| 1641 |
+
{ key: "header", value: { summary: "Delete added product from avatar2 admin", repo: { type: "dataset", id: CAT_DATASET } } },
|
| 1642 |
+
{ key: "file", value: { path: CAT_FILE, content } },
|
| 1643 |
+
].map((s) => JSON.stringify(s)).join("\n");
|
| 1644 |
+
const commit = await fetch("https://huggingface.co/api/datasets/" + CAT_DATASET + "/commit/main", {
|
| 1645 |
+
method: "POST",
|
| 1646 |
+
headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" },
|
| 1647 |
+
body: payload, signal: AbortSignal.timeout(20000),
|
| 1648 |
+
});
|
| 1649 |
+
if (!commit.ok) return Response.json({ error: "Dataset commit failed (" + commit.status + ")" }, { status: 502 });
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
// 2) Mirror-remove from promos.json productEdits so the panel updates fast.
|
| 1653 |
+
let removedFromPromos = false;
|
| 1654 |
+
try {
|
| 1655 |
+
const cur = await readPromos(token);
|
| 1656 |
+
if (cur.productEdits && typeof cur.productEdits === "object") {
|
| 1657 |
+
let changed = false;
|
| 1658 |
+
if (sku && cur.productEdits[sku] != null) { delete cur.productEdits[sku]; changed = true; }
|
| 1659 |
+
if (slug) {
|
| 1660 |
+
for (const k of Object.keys(cur.productEdits)) {
|
| 1661 |
+
const ed = cur.productEdits[k];
|
| 1662 |
+
if (ed && (ed.slug === slug || String(ed.model || ed.sku || k).toUpperCase() === SKU)) {
|
| 1663 |
+
delete cur.productEdits[k]; changed = true;
|
| 1664 |
+
}
|
| 1665 |
+
}
|
| 1666 |
+
}
|
| 1667 |
+
if (changed) { await writePromos(token, cur); removedFromPromos = true; }
|
| 1668 |
+
}
|
| 1669 |
+
} catch (e2: any) { console.error("[delete-product] promos mirror failed", e2?.message); }
|
| 1670 |
+
|
| 1671 |
+
return Response.json({ ok: true, removed: hadAdded, removedFromPromos, file: CAT_DATASET + "/" + CAT_FILE });
|
| 1672 |
+
} catch (e: any) {
|
| 1673 |
+
console.error("[delete-product]", e?.message);
|
| 1674 |
+
return Response.json({ error: e.message || String(e) }, { status: 502 });
|
| 1675 |
+
}
|
| 1676 |
+
},
|
| 1677 |
+
},
|
| 1678 |
"/api/promos": {
|
| 1679 |
GET: async () => {
|
| 1680 |
try {
|
src/vaix-combo.js
CHANGED
|
@@ -173,12 +173,41 @@
|
|
| 173 |
'<div style="font-weight:700;font-size:0.8rem;color:#1e293b;margin-bottom:3px">' + (c.name || "COMBO") + '</div>' +
|
| 174 |
'<div style="display:flex;gap:4px;margin-bottom:4px">' + thumbs + '</div>' +
|
| 175 |
'<div style="font-size:0.7rem;color:#64748b;margin-bottom:4px">' + itemsHtml + '</div>' +
|
| 176 |
-
'<div style="
|
|
|
|
|
|
|
|
|
|
| 177 |
'</div>';
|
| 178 |
}).join("");
|
| 179 |
} catch(e) { console.warn("[COMBO] refresh:", e); }
|
| 180 |
}
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
// Retry createComboElements until vaixPromo is ready (can load after CMS module)
|
| 183 |
var _retry = 0;
|
| 184 |
(function tryCreate() {
|
|
|
|
| 173 |
'<div style="font-weight:700;font-size:0.8rem;color:#1e293b;margin-bottom:3px">' + (c.name || "COMBO") + '</div>' +
|
| 174 |
'<div style="display:flex;gap:4px;margin-bottom:4px">' + thumbs + '</div>' +
|
| 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;
|
| 213 |
(function tryCreate() {
|
src/vaix-rag.js
CHANGED
|
@@ -1645,6 +1645,72 @@ function openAskAIForProduct(product) {
|
|
| 1645 |
// UI RENDERING
|
| 1646 |
// ─────────────────────────────────────────────
|
| 1647 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1648 |
function renderPanelResults(products) {
|
| 1649 |
const panel = document.getElementById("vaistudio-panel");
|
| 1650 |
const toggle = document.getElementById("vaistudio-toggle");
|
|
@@ -1699,6 +1765,43 @@ function renderPanelResults(products) {
|
|
| 1699 |
});
|
| 1700 |
card.appendChild(cartBtn);
|
| 1701 |
card.appendChild(info);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1702 |
card.addEventListener("click", function(e){ e.stopPropagation(); renderDetail(p); renderSimilarInPanel(p); });
|
| 1703 |
productsEl.appendChild(card);
|
| 1704 |
}
|
|
@@ -1718,9 +1821,18 @@ function renderDiversePanel(count) {
|
|
| 1718 |
if (!allProducts.length) { renderPanelResults([]); return; }
|
| 1719 |
// Durable "Thêm SP" products (productEdits overlay) always surface first.
|
| 1720 |
const added = (window.vaixPromo && typeof window.vaixPromo.getAddedProducts === 'function') ? window.vaixPromo.getAddedProducts() : [];
|
|
|
|
|
|
|
|
|
|
| 1721 |
const base = allProducts;
|
| 1722 |
-
|
| 1723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1724 |
return;
|
| 1725 |
}
|
| 1726 |
const picks = getDiverseProducts(n);
|
|
@@ -2526,6 +2638,9 @@ window.vaix = {
|
|
| 2526 |
showComboResultsInPanel: showComboResultsInPanel,
|
| 2527 |
// Guard: returns true if ?product=xxx URL param has already rendered the panel
|
| 2528 |
hasProductUrlHandled: function(){ return productUrlHandled; },
|
|
|
|
|
|
|
|
|
|
| 2529 |
// Promo CMS hooks
|
| 2530 |
renderDetail: renderDetail,
|
| 2531 |
applyPromoEdits: applyPromoEdits
|
|
|
|
| 1645 |
// UI RENDERING
|
| 1646 |
// ─────────────────────────────────────────────
|
| 1647 |
|
| 1648 |
+
function toast(msg) {
|
| 1649 |
+
try {
|
| 1650 |
+
var t = document.getElementById('vaix-toast');
|
| 1651 |
+
if (!t) { t = document.createElement('div'); t.id = 'vaix-toast'; t.style.cssText = "position:fixed;bottom:120px;left:50%;transform:translateX(-50%);background:rgba(30,41,59,0.95);color:#fff;padding:11px 20px;border-radius:10px;font-size:.82rem;z-index:110200;box-shadow:0 6px 20px rgba(0,0,0,.3);font-family:inherit;max-width:92vw;text-align:center;transition:opacity .3s"; document.body.appendChild(t); }
|
| 1652 |
+
t.textContent = msg;
|
| 1653 |
+
t.style.opacity = '1';
|
| 1654 |
+
clearTimeout(t._ht);
|
| 1655 |
+
t._ht = setTimeout(function(){ t.style.opacity = '0'; }, 2600);
|
| 1656 |
+
} catch (e) {}
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
// SEO-friendly share link: canonical product URL (slug-based).
|
| 1660 |
+
function shareableProductUrl(p) {
|
| 1661 |
+
const base = (window.location.origin && window.location.origin.indexOf('hf.space') >= 0) ? window.location.origin : 'https://bep40-vai-avatar2.hf.space';
|
| 1662 |
+
const slug = p.slug || (p.sku ? String(p.sku).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') : '') || 'san-pham';
|
| 1663 |
+
// Keep the map share link friendlier: encode the slug into a vanity query.
|
| 1664 |
+
return base + '/?product=' + encodeURIComponent(slug);
|
| 1665 |
+
}
|
| 1666 |
+
|
| 1667 |
+
// Admin delete: permanently removes an added product from the shared dataset,
|
| 1668 |
+
// localStorage list, productEdits, and the live catalog/allProducts.
|
| 1669 |
+
function deleteProductAction(p, onDone) {
|
| 1670 |
+
try {
|
| 1671 |
+
if (!(typeof window.isVaAdmin === 'function') || !window.isVaAdmin()) { toast('🔒 Cần nhập mã V.AISTUDIO trên trang chủ.'); return; }
|
| 1672 |
+
const sku = p.sku || p.model || '';
|
| 1673 |
+
const slug = p.slug || '';
|
| 1674 |
+
const name = p.title_clean || p.name || 'sản phẩm này';
|
| 1675 |
+
if (!window.confirm('⚠️ Xóa vĩnh viễn "' + name + '" khỏi danh sách sản phẩm (không thể khôi phục)?')) return;
|
| 1676 |
+
// 1) Remove from the permanent shared dataset (products_url_added.json).
|
| 1677 |
+
return fetch('/api/delete-product', {
|
| 1678 |
+
method: 'POST',
|
| 1679 |
+
headers: { 'Content-Type': 'application/json', 'x-vai-admin': '1' },
|
| 1680 |
+
body: JSON.stringify({ sku: sku, slug: slug })
|
| 1681 |
+
}).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
|
| 1682 |
+
if (res && res.ok) {
|
| 1683 |
+
// 2) Remove from local durable list.
|
| 1684 |
+
try { if (window.vaixPromo && typeof window.vaixPromo.removeAddedProduct === 'function') window.vaixPromo.removeAddedProduct(sku); } catch (e1) {}
|
| 1685 |
+
if (window.localStorage) {
|
| 1686 |
+
try {
|
| 1687 |
+
const list = JSON.parse(window.localStorage.getItem('vai_padded_products') || '[]');
|
| 1688 |
+
const n2 = Array.isArray(list) ? list.filter(function(x){ return !(x && sku && String(x.sku).toUpperCase() === String(sku).toUpperCase()); }) : [];
|
| 1689 |
+
window.localStorage.setItem('vai_padded_products', JSON.stringify(n2));
|
| 1690 |
+
} catch (e2) {}
|
| 1691 |
+
}
|
| 1692 |
+
// 3) Remove from the live in-memory catalog so it disappears immediately.
|
| 1693 |
+
try {
|
| 1694 |
+
const arr = (window.vaix && window.vaix.allProducts) ? window.vaix.allProducts() : null;
|
| 1695 |
+
if (Array.isArray(arr) && arr.length) {
|
| 1696 |
+
const idx = arr.findIndex(function(x){ return x && x.sku && sku && String(x.sku).toUpperCase() === String(sku).toUpperCase(); });
|
| 1697 |
+
if (idx >= 0) { arr.splice(idx, 1); }
|
| 1698 |
+
if (window.vaix.resetDiversePool) { try { window.vaix.resetDiversePool(); } catch (e3) {} }
|
| 1699 |
+
}
|
| 1700 |
+
} catch (e4) {}
|
| 1701 |
+
toast('🗑️ Đã xóa vĩnh viễn "' + name + '" khỏi dữ liệu.');
|
| 1702 |
+
if (onDone) onDone();
|
| 1703 |
+
} else {
|
| 1704 |
+
toast('❌ Xóa lỗi: ' + ((res && res.error) || 'không thể xóa khỏi dataset'));
|
| 1705 |
+
}
|
| 1706 |
+
}).catch(function(e){
|
| 1707 |
+
toast('❌ Lỗi khi xóa: ' + (e && e.message || e));
|
| 1708 |
+
});
|
| 1709 |
+
} catch (err) {
|
| 1710 |
+
toast('❌ Lỗi xóa: ' + (err && err.message || err));
|
| 1711 |
+
}
|
| 1712 |
+
}
|
| 1713 |
+
|
| 1714 |
function renderPanelResults(products) {
|
| 1715 |
const panel = document.getElementById("vaistudio-panel");
|
| 1716 |
const toggle = document.getElementById("vaistudio-toggle");
|
|
|
|
| 1765 |
});
|
| 1766 |
card.appendChild(cartBtn);
|
| 1767 |
card.appendChild(info);
|
| 1768 |
+
// ---- Admin actions (only after V.AISTUDIO homepage code) ----
|
| 1769 |
+
const isAdmin = (typeof window.isVaAdmin === 'function') ? window.isVaAdmin() : false;
|
| 1770 |
+
const isAddedProduct = !!(p._addedBy === 'products_url_added' || p._addedBy === 'vai-add-product' || (window.vaixPromo && window.vaixPromo.isAddedProduct ? window.vaixPromo.isAddedProduct(p) : false));
|
| 1771 |
+
if (isAdmin) {
|
| 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) {
|
| 1792 |
+
const delBtn = document.createElement("button");
|
| 1793 |
+
delBtn.type = "button";
|
| 1794 |
+
delBtn.className = "product-card-btn del-btn";
|
| 1795 |
+
delBtn.style.cssText = "padding:7px 10px;border:none;border-radius:8px;background:#dc2626;color:#fff;font-size:.72rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:5px;font-family:inherit";
|
| 1796 |
+
delBtn.innerHTML = '<i class="fas fa-trash"></i> Xóa';
|
| 1797 |
+
delBtn.addEventListener("click", function(e) {
|
| 1798 |
+
e.stopPropagation();
|
| 1799 |
+
deleteProductAction(p, function(){ productsEl.removeChild(card); if (countEl) countEl.textContent = (productsEl.children.length) + ' kết quả'; });
|
| 1800 |
+
});
|
| 1801 |
+
adminBtns.appendChild(delBtn);
|
| 1802 |
+
}
|
| 1803 |
+
card.appendChild(adminBtns);
|
| 1804 |
+
}
|
| 1805 |
card.addEventListener("click", function(e){ e.stopPropagation(); renderDetail(p); renderSimilarInPanel(p); });
|
| 1806 |
productsEl.appendChild(card);
|
| 1807 |
}
|
|
|
|
| 1821 |
if (!allProducts.length) { renderPanelResults([]); return; }
|
| 1822 |
// Durable "Thêm SP" products (productEdits overlay) always surface first.
|
| 1823 |
const added = (window.vaixPromo && typeof window.vaixPromo.getAddedProducts === 'function') ? window.vaixPromo.getAddedProducts() : [];
|
| 1824 |
+
// Also surface products added via URL/OCR (kept in products_url_added.json, already
|
| 1825 |
+
// merged into allProducts by load()). Their home is _addedBy === 'products_url_added'.
|
| 1826 |
+
const addedByUrl = allProducts.filter(function (p) { return p && p._addedBy === 'products_url_added'; });
|
| 1827 |
const base = allProducts;
|
| 1828 |
+
// Merge + dedup by sku so a product only appears once at the top.
|
| 1829 |
+
const topAdded = [];
|
| 1830 |
+
const seenTop = new Set();
|
| 1831 |
+
const pushTop = function (p) { if (!p) return; const k = String((p.sku || p.model || '')).toUpperCase(); if (!seenTop.has(k)) { seenTop.add(k); topAdded.push(p); } };
|
| 1832 |
+
added.forEach(pushTop);
|
| 1833 |
+
addedByUrl.forEach(pushTop);
|
| 1834 |
+
if (topAdded.length) {
|
| 1835 |
+
renderPanelResults(topAdded.concat(base.slice(0, Math.max(0, n - topAdded.length))));
|
| 1836 |
return;
|
| 1837 |
}
|
| 1838 |
const picks = getDiverseProducts(n);
|
|
|
|
| 2638 |
showComboResultsInPanel: showComboResultsInPanel,
|
| 2639 |
// Guard: returns true if ?product=xxx URL param has already rendered the panel
|
| 2640 |
hasProductUrlHandled: function(){ return productUrlHandled; },
|
| 2641 |
+
// Share / admin-delete helpers (also used by renderPanelResults cards)
|
| 2642 |
+
shareableProductUrl: shareableProductUrl,
|
| 2643 |
+
deleteProductAction: deleteProductAction,
|
| 2644 |
// Promo CMS hooks
|
| 2645 |
renderDetail: renderDetail,
|
| 2646 |
applyPromoEdits: applyPromoEdits
|