Spaces:
Running
Running
Fix triệt để: Excel đơn giá (đ→d), OCR AI fallback + comma-VND, URL ảnh chain fallback, redirect to detail
Browse files- Dockerfile +1 -1
- bulk-import.ts +19 -3
- index.html +3 -3
- index.ts +90 -0
- src/vaix-add-product.js +10 -1
- src/vaix-rag.js +64 -14
Dockerfile
CHANGED
|
@@ -11,4 +11,4 @@ EXPOSE 7860
|
|
| 11 |
USER bun
|
| 12 |
CMD ["bun", "index.ts"]
|
| 13 |
|
| 14 |
-
# Rebuild trigger:
|
|
|
|
| 11 |
USER bun
|
| 12 |
CMD ["bun", "index.ts"]
|
| 13 |
|
| 14 |
+
# Rebuild trigger: 1786990001 fix-excel-vnd-ocr-trien-de
|
bulk-import.ts
CHANGED
|
@@ -48,6 +48,8 @@ function _normHeader(s: string): string {
|
|
| 48 |
return String(s || "")
|
| 49 |
.normalize("NFD")
|
| 50 |
.replace(/[\u0300-\u036f]/g, "")
|
|
|
|
|
|
|
| 51 |
.toLowerCase()
|
| 52 |
.replace(/\s+/g, " ")
|
| 53 |
.trim();
|
|
@@ -415,13 +417,16 @@ function parsePdfTextLines(lines: string[], opts: any, warnings: string[]): any
|
|
| 415 |
// null when the line has no parseable price (then callers keep other strategies).
|
| 416 |
function parseRaggedRow(line: string, opts: any): any | null {
|
| 417 |
const L = String(line || "");
|
| 418 |
-
// (a)
|
| 419 |
-
|
|
|
|
| 420 |
if (!mt) {
|
| 421 |
// (b) standalone integer token of 4+ digits separated from letters
|
| 422 |
mt = L.match(/(?<![\wđ₫])(\d{4,})(?![\wđ₫])(\s*(?:đ|₫|đồng|dong|vnd))?/i);
|
| 423 |
}
|
| 424 |
if (!mt) return null;
|
|
|
|
|
|
|
| 425 |
const priceText = mt[1] + (mt[2] ? mt[2].trim() : "");
|
| 426 |
const priceStart = mt.index as number;
|
| 427 |
const full = mt[0];
|
|
@@ -429,8 +434,10 @@ function parseRaggedRow(line: string, opts: any): any | null {
|
|
| 429 |
let after = L.slice(priceStart + full.length).trim();
|
| 430 |
const out: any = { price: priceText };
|
| 431 |
// numeric qty token immediately after the price → drop (not product text)
|
| 432 |
-
|
| 433 |
if (aftk.length && /^\d{1,3}$/.test(aftk[0])) after = aftk.slice(1).join(" ").trim();
|
|
|
|
|
|
|
| 434 |
if (after && /[a-zA-ZÀ-ỹ]/.test(after)) out.brand = after;
|
| 435 |
// trailing code-like SKU at the end of the "before" segment: "EH-90", "LO-45",
|
| 436 |
// "MQ-8", "MM-6030" (letters(+dash/space)+digits, short, contains a digit).
|
|
@@ -441,6 +448,12 @@ function parseRaggedRow(line: string, opts: any): any | null {
|
|
| 441 |
} else {
|
| 442 |
out.name = before;
|
| 443 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
if (!out.name && out.sku) out.name = "";
|
| 445 |
return out;
|
| 446 |
}
|
|
@@ -657,6 +670,9 @@ function finalizeProducts(products: any[], opts: any, warnings: string[]): any {
|
|
| 657 |
if (!nm && !skuV) return;
|
| 658 |
if (nm && _isNoiseName(nm)) return; // "TỔNG CỘNG", "Cộng tiền", "STT"
|
| 659 |
if (/^(trang|page)\s*\d+$/i.test(nm)) return; // "Trang 2", "page 5" page footers
|
|
|
|
|
|
|
|
|
|
| 660 |
if (nm && /^[\d.,\sđ₫%]+$/.test(nm) && !skuV) return; // numeric-only name (a total amount)
|
| 661 |
if (nm && /[a-zA-ZÀ-ỹ]/.test(nm) === false && !skuV) return;
|
| 662 |
if (!p.name && !p.sku) return;
|
|
|
|
| 48 |
return String(s || "")
|
| 49 |
.normalize("NFD")
|
| 50 |
.replace(/[\u0300-\u036f]/g, "")
|
| 51 |
+
.replace(/đ/g, "d")
|
| 52 |
+
.replace(/Đ/g, "d")
|
| 53 |
.toLowerCase()
|
| 54 |
.replace(/\s+/g, " ")
|
| 55 |
.trim();
|
|
|
|
| 417 |
// null when the line has no parseable price (then callers keep other strategies).
|
| 418 |
function parseRaggedRow(line: string, opts: any): any | null {
|
| 419 |
const L = String(line || "");
|
| 420 |
+
// (a) grouped thousands — accept BOTH dot (1.290.000) and comma (6,290,000)
|
| 421 |
+
// VND separators, plus optional currency suffix.
|
| 422 |
+
let mt = L.match(/(?<![\d.,])(\d{1,3}(?:[.,]\d{3})+)(?!\d)(\s*(?:đ|₫|đồng|dong|vnd))?/i);
|
| 423 |
if (!mt) {
|
| 424 |
// (b) standalone integer token of 4+ digits separated from letters
|
| 425 |
mt = L.match(/(?<![\wđ₫])(\d{4,})(?![\wđ₫])(\s*(?:đ|₫|đồng|dong|vnd))?/i);
|
| 426 |
}
|
| 427 |
if (!mt) return null;
|
| 428 |
+
// Normalize the captured thousands to dots (so parseVnd parses them correctly);
|
| 429 |
+
// a pure 4-digit "6,290" without three trailing digits is NOT thousands.
|
| 430 |
const priceText = mt[1] + (mt[2] ? mt[2].trim() : "");
|
| 431 |
const priceStart = mt.index as number;
|
| 432 |
const full = mt[0];
|
|
|
|
| 434 |
let after = L.slice(priceStart + full.length).trim();
|
| 435 |
const out: any = { price: priceText };
|
| 436 |
// numeric qty token immediately after the price → drop (not product text)
|
| 437 |
+
let aftk = after.split(/\s+/);
|
| 438 |
if (aftk.length && /^\d{1,3}$/.test(aftk[0])) after = aftk.slice(1).join(" ").trim();
|
| 439 |
+
// A lone currency token (đ, d, ₫, vnd, đồng) right after the price is NOT a brand.
|
| 440 |
+
if (/^[đd₫vnd]{1,4}$/i.test(after)) after = "";
|
| 441 |
if (after && /[a-zA-ZÀ-ỹ]/.test(after)) out.brand = after;
|
| 442 |
// trailing code-like SKU at the end of the "before" segment: "EH-90", "LO-45",
|
| 443 |
// "MQ-8", "MM-6030" (letters(+dash/space)+digits, short, contains a digit).
|
|
|
|
| 448 |
} else {
|
| 449 |
out.name = before;
|
| 450 |
}
|
| 451 |
+
// Strip a leading OCR row ordinal ("1 May giat..." -> "May giat...") when the
|
| 452 |
+
// rest looks like a product name (starts with a letter). Never touch names
|
| 453 |
+
// that genuinely begin with digits.
|
| 454 |
+
if (out.name && /^\d{1,3}[.\s]\s*[A-Za-zÀ-ỹ]/.test(out.name)) {
|
| 455 |
+
out.name = out.name.replace(/^\d{1,3}[.\s]+\s*/, "").trim();
|
| 456 |
+
}
|
| 457 |
if (!out.name && out.sku) out.name = "";
|
| 458 |
return out;
|
| 459 |
}
|
|
|
|
| 670 |
if (!nm && !skuV) return;
|
| 671 |
if (nm && _isNoiseName(nm)) return; // "TỔNG CỘNG", "Cộng tiền", "STT"
|
| 672 |
if (/^(trang|page)\s*\d+$/i.test(nm)) return; // "Trang 2", "page 5" page footers
|
| 673 |
+
// Non-data rows: unit-of-measure, note/label lines, section markers.
|
| 674 |
+
if (/^(don vi(tinh)?\s*[::]?|đơn vị\s*[::]?|ghi chu\s*[::]?|ky hieu\s*[::]?|loai\s*[::]?|hang\s*[::]?)\s*.{0,40}$/i.test(nm)) return;
|
| 675 |
+
if (/^(nhom|phan nhom|danh muc|muc luc|bang gia|toan bo|san pham)$/i.test(nm)) return;
|
| 676 |
if (nm && /^[\d.,\sđ₫%]+$/.test(nm) && !skuV) return; // numeric-only name (a total amount)
|
| 677 |
if (nm && /[a-zA-ZÀ-ỹ]/.test(nm) === false && !skuV) return;
|
| 678 |
if (!p.name && !p.sku) return;
|
index.html
CHANGED
|
@@ -830,7 +830,7 @@
|
|
| 830 |
first paint — the welcome/avatar-picker modal renders immediately. -->
|
| 831 |
<script defer src="./src/cart-quote.js?gc=34"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
|
| 832 |
<script defer src="./src/customers.js?gc=18"></script> <!-- Customer list (Danh sách khách hàng) -->
|
| 833 |
-
<script defer src="./src/vaix-rag.js?gc=
|
| 834 |
<script defer src="./src/greeting-news.js?gc=41"></script> <!-- Greeting HOT news cards + source links -->
|
| 835 |
<script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
|
| 836 |
<script type="module" src="./src/app.js?gc=61"></script>
|
|
@@ -862,7 +862,7 @@
|
|
| 862 |
|
| 863 |
<!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
|
| 864 |
homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
|
| 865 |
-
<script defer src="./src/vaix-add-product.js?v=
|
| 866 |
-
<script defer src="./src/vaix-bulk-import.js?v=
|
| 867 |
</body>
|
| 868 |
</html>
|
|
|
|
| 830 |
first paint — the welcome/avatar-picker modal renders immediately. -->
|
| 831 |
<script defer src="./src/cart-quote.js?gc=34"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
|
| 832 |
<script defer src="./src/customers.js?gc=18"></script> <!-- Customer list (Danh sách khách hàng) -->
|
| 833 |
+
<script defer src="./src/vaix-rag.js?gc=62"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
|
| 834 |
<script defer src="./src/greeting-news.js?gc=41"></script> <!-- Greeting HOT news cards + source links -->
|
| 835 |
<script defer src="./src/order-sync.js?gc=17"></script> <!-- Order sync with V.AISTUDIO backend -->
|
| 836 |
<script type="module" src="./src/app.js?gc=61"></script>
|
|
|
|
| 862 |
|
| 863 |
<!-- ── Thêm SP (Add Product) module: 100% port from V.AISTUDIO, gated by the
|
| 864 |
homepage V.AISTUDIO access code (window.isVaAdmin()). ── -->
|
| 865 |
+
<script defer src="./src/vaix-add-product.js?v=10"></script>
|
| 866 |
+
<script defer src="./src/vaix-bulk-import.js?v=3"></script>
|
| 867 |
</body>
|
| 868 |
</html>
|
index.ts
CHANGED
|
@@ -423,6 +423,78 @@ async function chatOnce(model: string, messages: any[], token: string) {
|
|
| 423 |
throw err;
|
| 424 |
}
|
| 425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
// ── Server-side tool execution (restores news/web/product search in /api/chat) ──
|
| 427 |
const CHAT_TOOLS = [
|
| 428 |
{ type: "function", function: {
|
|
@@ -1669,7 +1741,25 @@ const server = Bun.serve({
|
|
| 1669 |
const body: any = await req.json().catch(() => ({}));
|
| 1670 |
const text = String(body?.text || "");
|
| 1671 |
const opts: any = { defaultCategory: String(body?.defaultCategory || "") || undefined };
|
|
|
|
| 1672 |
const res: any = await parseTextContent(text, opts);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1673 |
return Response.json(res);
|
| 1674 |
} catch (e: any) {
|
| 1675 |
return Response.json({ ok: false, products: [], ready: false, reason: String(e?.message || e) }, { status: 200 });
|
|
|
|
| 423 |
throw err;
|
| 424 |
}
|
| 425 |
|
| 426 |
+
// ── AI-assisted extraction from OCR text (PDF/ảnh đã OCR) ──────────────────
|
| 427 |
+
// The deterministic line parser (`lineRowsToProducts`) struggles with ragged
|
| 428 |
+
// tesseract OCR output. When the user uploads a PDF/ảnh and the OCR text is
|
| 429 |
+
// noisy, send the raw OCR text to Gemma (same router as chat) and ask it to
|
| 430 |
+
// return CLEAN structured products as JSON. Deterministic parse stays the
|
| 431 |
+
// cheap/fast path for well-formed input; AI is the accuracy fallback.
|
| 432 |
+
async function aiExtractProductsFromText(ocrText: string, opts: any = {}): Promise<any> {
|
| 433 |
+
const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
|
| 434 |
+
if (!token) return { ok: false, reason: "no HF token" };
|
| 435 |
+
const text = String(ocrText || "").slice(0, 24000);
|
| 436 |
+
if (!text.trim()) return { ok: false, reason: "empty text" };
|
| 437 |
+
const model = opts.aiTextModel || "google/gemma-3-12b-it";
|
| 438 |
+
const sys = [
|
| 439 |
+
"Bạn là bộ phận trích xuất sản phẩm từ văn bản OCR (quét catalogue giá thị trường hoặc PDF thiết bị nhà bếp bằng tiếng Việt).",
|
| 440 |
+
"OCR thường bị lẫn dòng, sai chỗ ngắt cột, dính chữ. Dựa vào NGỮ NGHĨA để bóc tách đúng từng sản phẩm.",
|
| 441 |
+
"Mỗi dòng/sụm dòng là MỘT sản phẩm. Tách: name (tên SP đầy đủ), sku/mã (nếu có, VD 'EH-90','GL-400','357869'), brand (thương hiệu: Heatlock, Hafele, Eurogold, Malloca, Bosch, Malloca...), price (giá số nguyên, bỏ dấu chấm phẩy, VD 6290000 cho '6.290.000đ'; nếu '5tr5' -> 5500000), description (mô tả ngắn).",
|
| 442 |
+
"BỎ các dòng không phải sản phẩm: 'TỔNG CỘNG', 'Trang 2', 'STT', 'Cộng tiền', 'Đơn vị tính', tiêu đề cột, dòng chỉ chứa số thứ tự.",
|
| 443 |
+
"Trả về DUY NHẤT một JSON array, không markdown, không text thừa. Mỗi phần tử object chỉ dùng các key: name, sku, brand, price, description.",
|
| 444 |
+
"Nếu không trích được sản phẩm nào, trả về [].",
|
| 445 |
+
];
|
| 446 |
+
const body = {
|
| 447 |
+
model,
|
| 448 |
+
messages: [
|
| 449 |
+
{ role: "system", content: sys.join("\n") },
|
| 450 |
+
{ role: "user", content: "Văn bản OCR catalogue:\n---\n" + text + "\n---\nHãy trả về JSON array các sản phẩm." },
|
| 451 |
+
],
|
| 452 |
+
max_tokens: 2048,
|
| 453 |
+
temperature: 0,
|
| 454 |
+
top_p: 0.95,
|
| 455 |
+
stream: false,
|
| 456 |
+
};
|
| 457 |
+
let resp: Response;
|
| 458 |
+
try {
|
| 459 |
+
resp = await fetch(ROUTER, {
|
| 460 |
+
method: "POST",
|
| 461 |
+
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token, "User-Agent": "vai-avatar2-extract" },
|
| 462 |
+
body: JSON.stringify(body),
|
| 463 |
+
signal: AbortSignal.timeout(60000),
|
| 464 |
+
});
|
| 465 |
+
} catch (e: any) { return { ok: false, reason: String(e?.message || e) }; }
|
| 466 |
+
if (!resp.ok) {
|
| 467 |
+
const t = await resp.text().catch(() => "");
|
| 468 |
+
return { ok: false, reason: "HTTP " + resp.status + " " + t.slice(0, 120), status: resp.status };
|
| 469 |
+
}
|
| 470 |
+
let content = "";
|
| 471 |
+
try { const d = await resp.json(); content = String(d?.choices?.[0]?.message?.content || "").trim(); }
|
| 472 |
+
catch (e: any) { return { ok: false, reason: "Bad AI response JSON" }; }
|
| 473 |
+
let arr: any = null;
|
| 474 |
+
try { arr = JSON.parse(content); }
|
| 475 |
+
catch (e1: any) {
|
| 476 |
+
const m = content.match(/\[[\s\S]*\]/);
|
| 477 |
+
if (m) { try { arr = JSON.parse(m[0]); } catch (e2: any) {} }
|
| 478 |
+
}
|
| 479 |
+
if (!Array.isArray(arr)) return { ok: false, reason: "AI returned non-array JSON" };
|
| 480 |
+
const cleaned = arr
|
| 481 |
+
.filter((p: any) => p && typeof p === "object" && (p.name || p.sku))
|
| 482 |
+
.map((p: any) => {
|
| 483 |
+
const price = Number(String(p.price ?? "").replace(/[^\d]/g, ""));
|
| 484 |
+
return {
|
| 485 |
+
name: String(p.name || "").trim(),
|
| 486 |
+
sku: String(p.sku || p.model || p.ma || "").trim(),
|
| 487 |
+
brand: String(p.brand || "").trim(),
|
| 488 |
+
price: price || undefined,
|
| 489 |
+
description: String(p.description || p.desc || "").trim(),
|
| 490 |
+
features: (Array.isArray(p.features) ? p.features : (p.features ? [p.features] : [])).map(String).map(s => s.trim()).filter(Boolean),
|
| 491 |
+
};
|
| 492 |
+
})
|
| 493 |
+
.filter((p: any) => p.name || p.sku);
|
| 494 |
+
if (!cleaned.length) return { ok: false, reason: "AI extracted 0 products" };
|
| 495 |
+
return { ok: true, products: cleaned, model };
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
// ── Server-side tool execution (restores news/web/product search in /api/chat) ──
|
| 499 |
const CHAT_TOOLS = [
|
| 500 |
{ type: "function", function: {
|
|
|
|
| 1741 |
const body: any = await req.json().catch(() => ({}));
|
| 1742 |
const text = String(body?.text || "");
|
| 1743 |
const opts: any = { defaultCategory: String(body?.defaultCategory || "") || undefined };
|
| 1744 |
+
// First try the fast deterministic parser.
|
| 1745 |
const res: any = await parseTextContent(text, opts);
|
| 1746 |
+
const detProds = (Array.isArray(res?.products) ? res.products : []);
|
| 1747 |
+
const detTrustworthy = detProds.length >= 1 && (!res.reason || /product rows/i.test(String(res.reason)));
|
| 1748 |
+
// If deterministic gave nothing (ragged OCR, merged columns), fall back
|
| 1749 |
+
// to the Gemma structured extractor for accuracy — exactly the "PDF/ảnh
|
| 1750 |
+
// trích xuất tầm bậy" case the admin reported.
|
| 1751 |
+
if (!detTrustworthy || body?.forceAI) {
|
| 1752 |
+
try {
|
| 1753 |
+
const ai = await aiExtractProductsFromText(text, opts);
|
| 1754 |
+
if (ai.ok && Array.isArray(ai.products) && ai.products.length) {
|
| 1755 |
+
const aiProds = ai.products.map((p: any) => normalizeCatalogProduct(p, opts)).filter((p: any) => p && (p.name || p.sku));
|
| 1756 |
+
const merged = detProds.length && aiProds.length ? aiProds : (aiProds.length ? aiProds : detProds);
|
| 1757 |
+
const finalRes: any = { ok: true, products: merged, ready: true, count: merged.length, aiExtracted: aiProds.length > 0, model: ai.model };
|
| 1758 |
+
if (detProds.length && !aiProds.length) finalRes.warnings = res.warnings || [];
|
| 1759 |
+
return Response.json(finalRes);
|
| 1760 |
+
}
|
| 1761 |
+
} catch (_e) { /* fall through to deterministic result */ }
|
| 1762 |
+
}
|
| 1763 |
return Response.json(res);
|
| 1764 |
} catch (e: any) {
|
| 1765 |
return Response.json({ ok: false, products: [], ready: false, reason: String(e?.message || e) }, { status: 200 });
|
src/vaix-add-product.js
CHANGED
|
@@ -1214,7 +1214,16 @@
|
|
| 1214 |
_pendingAdd = null;
|
| 1215 |
var moreBtn = $('addMoreBtn');
|
| 1216 |
if (moreBtn) moreBtn.style.display = 'inline-flex';
|
| 1217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1218 |
} catch (err) {
|
| 1219 |
status('❌ Lỗi: ' + (err && err.message || err), 'err');
|
| 1220 |
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '✅ Xác nhận thêm SP'; }
|
|
|
|
| 1214 |
_pendingAdd = null;
|
| 1215 |
var moreBtn = $('addMoreBtn');
|
| 1216 |
if (moreBtn) moreBtn.style.display = 'inline-flex';
|
| 1217 |
+
// ✅ User request: after adding, jump straight to THAT product's detail
|
| 1218 |
+
// page so they can review the new item (image + price) immediately.
|
| 1219 |
+
// `?product=<slug>` is handled by handleProductUrlParam in vaix-rag.js.
|
| 1220 |
+
var detailSlug = slugify((sku ? sku + ' ' : '') + name);
|
| 1221 |
+
setTimeout(function () {
|
| 1222 |
+
try {
|
| 1223 |
+
var toDetail = (window.location.origin + window.location.pathname).replace(/\/+$/, '') + '/?product=' + encodeURIComponent(detailSlug);
|
| 1224 |
+
window.location.href = toDetail;
|
| 1225 |
+
} catch (e2) { }
|
| 1226 |
+
}, 1400);
|
| 1227 |
} catch (err) {
|
| 1228 |
status('❌ Lỗi: ' + (err && err.message || err), 'err');
|
| 1229 |
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '✅ Xác nhận thêm SP'; }
|
src/vaix-rag.js
CHANGED
|
@@ -1821,9 +1821,38 @@ function renderPanelResults(products) {
|
|
| 1821 |
// Always store the SKU so reapplyCk() can resolve the product even when the
|
| 1822 |
// discount map is applied after render (fixes giá CK missing on other brands).
|
| 1823 |
if (p.sku) card.setAttribute("data-sku", p.sku);
|
| 1824 |
-
|
| 1825 |
-
|
| 1826 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1827 |
const info = document.createElement("div"); info.className = "product-card-info";
|
| 1828 |
const t = document.createElement("p"); t.className = "product-card-title"; t.textContent = p.title_clean || p.name;
|
| 1829 |
const b = document.createElement("p"); b.className = "product-card-brand"; b.textContent = p.brand || "";
|
|
@@ -2027,17 +2056,38 @@ function renderDetailImages(p) {
|
|
| 2027 |
const zoomBadge = document.getElementById("detail-zoom-badge");
|
| 2028 |
if (mainImg) {
|
| 2029 |
p = p || {};
|
| 2030 |
-
|
| 2031 |
-
|
| 2032 |
-
|
| 2033 |
-
|
| 2034 |
-
|
| 2035 |
-
|
| 2036 |
-
|
| 2037 |
-
|
| 2038 |
-
|
| 2039 |
-
|
| 2040 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2041 |
}
|
| 2042 |
if (!ic) return;
|
| 2043 |
let imgs = [];
|
|
|
|
| 1821 |
// Always store the SKU so reapplyCk() can resolve the product even when the
|
| 1822 |
// discount map is applied after render (fixes giá CK missing on other brands).
|
| 1823 |
if (p.sku) card.setAttribute("data-sku", p.sku);
|
| 1824 |
+
// Image chain: try p.image, then each p.images[] (in case the primary is a
|
| 1825 |
+
// broken/no-extension URL that the CDN 403s), then a plain (un-proxied)
|
| 1826 |
+
// variant. Only hide the card image once EVERY candidate in the chain fails.
|
| 1827 |
+
var _imgCandidates = (function () {
|
| 1828 |
+
var arr = [];
|
| 1829 |
+
if (p.image) arr.push(p.image);
|
| 1830 |
+
if (Array.isArray(p.images)) { for (var ii = 0; ii < p.images.length; ii++) { if (p.images[ii] && arr.indexOf(p.images[ii]) < 0) arr.push(p.images[ii]); } }
|
| 1831 |
+
return arr;
|
| 1832 |
+
})();
|
| 1833 |
+
if (_imgCandidates.length) {
|
| 1834 |
+
var ie = document.createElement("img"); ie.className = "product-card-img"; ie.alt = p.title_clean || ""; ie.loading = "lazy";
|
| 1835 |
+
var _imgIdx = 0;
|
| 1836 |
+
ie.setAttribute("data-raw", _imgCandidates[0]);
|
| 1837 |
+
ie.src = primaryImgSrc(_imgCandidates[0]);
|
| 1838 |
+
ie.onerror = function () {
|
| 1839 |
+
_imgIdx++;
|
| 1840 |
+
if (_imgIdx < _imgCandidates.length) {
|
| 1841 |
+
ie.setAttribute("data-raw", _imgCandidates[_imgIdx]);
|
| 1842 |
+
ie.src = primaryImgSrc(_imgCandidates[_imgIdx]);
|
| 1843 |
+
return;
|
| 1844 |
+
}
|
| 1845 |
+
var raw = _imgCandidates[_imgCandidates.length - 1];
|
| 1846 |
+
if (raw && ie.getAttribute("data-raw") !== "__raw") {
|
| 1847 |
+
ie.setAttribute("data-raw", "__raw");
|
| 1848 |
+
ie.src = raw;
|
| 1849 |
+
return;
|
| 1850 |
+
}
|
| 1851 |
+
this.style.display = "none";
|
| 1852 |
+
var wrp = this.closest && this.closest(".product-card-img-wrap"); if (wrp) wrp.style.display = "none";
|
| 1853 |
+
};
|
| 1854 |
+
card.appendChild(ie);
|
| 1855 |
+
} else { const pl = document.createElement("div"); pl.className = "product-card-img"; pl.style.cssText = "flex-shrink:0;background:#e2e8f0;display:flex;align-items:center;justify-content:center;font-size:1.5rem"; pl.textContent = "📦"; card.appendChild(pl); }
|
| 1856 |
const info = document.createElement("div"); info.className = "product-card-info";
|
| 1857 |
const t = document.createElement("p"); t.className = "product-card-title"; t.textContent = p.title_clean || p.name;
|
| 1858 |
const b = document.createElement("p"); b.className = "product-card-brand"; b.textContent = p.brand || "";
|
|
|
|
| 2056 |
const zoomBadge = document.getElementById("detail-zoom-badge");
|
| 2057 |
if (mainImg) {
|
| 2058 |
p = p || {};
|
| 2059 |
+
let src = p.image || (Array.isArray(p.images) && p.images[0] ? p.images[0] : '') || '';
|
| 2060 |
+
if (src) {
|
| 2061 |
+
// Image chain fallback (like card): try each candidate in p.image +
|
| 2062 |
+
// p.images[], then plain raw. Fixes the case where the primary is a
|
| 2063 |
+
// broken no-extension CDN URL that 403s even via the proxy.
|
| 2064 |
+
const _chain = [];
|
| 2065 |
+
if (p.image) _chain.push(p.image);
|
| 2066 |
+
if (Array.isArray(p.images)) { for (let _ci = 0; _ci < p.images.length; _ci++) { const _u = p.images[_ci]; if (_u && _chain.indexOf(_u) < 0) _chain.push(_u); } }
|
| 2067 |
+
var _mi = 0;
|
| 2068 |
+
mainImg.setAttribute("data-raw", _chain[0]);
|
| 2069 |
+
mainImg.src = primaryImgSrc(_chain[0]);
|
| 2070 |
+
mainImg.style.display = 'block';
|
| 2071 |
+
mainImg.onerror = function () {
|
| 2072 |
+
_mi++;
|
| 2073 |
+
if (_mi < _chain.length) {
|
| 2074 |
+
mainImg.setAttribute("data-raw", _chain[_mi]);
|
| 2075 |
+
mainImg.src = primaryImgSrc(_chain[_mi]);
|
| 2076 |
+
return;
|
| 2077 |
+
}
|
| 2078 |
+
var _raw = _chain[_chain.length - 1];
|
| 2079 |
+
if (_raw && mainImg.getAttribute("data-raw") !== "__raw") {
|
| 2080 |
+
mainImg.setAttribute("data-raw", "__raw");
|
| 2081 |
+
mainImg.src = _raw;
|
| 2082 |
+
return;
|
| 2083 |
+
}
|
| 2084 |
+
this.style.display = 'none';
|
| 2085 |
+
};
|
| 2086 |
+
if (zoomBadge) zoomBadge.style.display = 'flex';
|
| 2087 |
+
} else {
|
| 2088 |
+
mainImg.style.display = 'none';
|
| 2089 |
+
if (zoomBadge) zoomBadge.style.display = 'none';
|
| 2090 |
+
}
|
| 2091 |
}
|
| 2092 |
if (!ic) return;
|
| 2093 |
let imgs = [];
|