bep40 commited on
Commit
6680cf8
·
1 Parent(s): c46759c

Fix triệt để: Excel đơn giá (đ→d), OCR AI fallback + comma-VND, URL ảnh chain fallback, redirect to detail

Browse files
Files changed (6) hide show
  1. Dockerfile +1 -1
  2. bulk-import.ts +19 -3
  3. index.html +3 -3
  4. index.ts +90 -0
  5. src/vaix-add-product.js +10 -1
  6. 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: 1786976466 cache-bust-v9
 
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) dot-grouped thousands: 1.290.000, 5.500.000 (not part of a code like GL-400)
419
- let mt = L.match(/(?<![\d.])(\d{1,3}(?:\.\d{3})+)(?!\d)(\s*(?:đ|₫|đồng|dong|vnd))?/i);
 
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
- const aftk = after.split(/\s+/);
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=61"></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,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=9"></script>
866
- <script defer src="./src/vaix-bulk-import.js?v=2"></script>
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
- setTimeout(function () { window.closeAddProductModal(); var sb = $('addProductSubmit'); if (sb) { sb.textContent = 'Thêm SP'; sb.removeAttribute('data-confirm'); } }, 2600);
 
 
 
 
 
 
 
 
 
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
- if (p.image) { const ie = document.createElement("img"); ie.className = "product-card-img"; ie.setAttribute("data-raw", p.image); ie.src = primaryImgSrc(p.image); ie.alt = p.title_clean || ""; ie.loading = "lazy"; ie.onerror = function(){ this.style.display = "none"; const w = this.closest && this.closest(".product-card-img-wrap"); if (w) w.style.display = "none"; }; card.appendChild(ie); }
1825
- else if (p.image) { const ie = document.createElement("img"); ie.className = "product-card-img"; ie.src = p.image; ie.alt = p.title_clean || ""; ie.loading = "lazy"; ie.onerror = function(){ this.style.display = "none"; }; card.appendChild(ie); }
1826
- 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); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- let src = p.image || (Array.isArray(p.images) && p.images[0] ? p.images[0] : '') || '';
2031
- if (src) {
2032
- mainImg.setAttribute("data-raw", src);
2033
- mainImg.src = primaryImgSrc(src);
2034
- mainImg.style.display = 'block';
2035
- mainImg.onerror = function(){ this.style.display = 'none'; };
2036
- if (zoomBadge) zoomBadge.style.display = 'flex';
2037
- } else {
2038
- mainImg.style.display = 'none';
2039
- if (zoomBadge) zoomBadge.style.display = 'none';
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 = [];