bep40 commited on
Commit
0f8aedf
·
verified ·
1 Parent(s): 911d8b8

✨ ?product=xxx URL param + full-screen image viewer; close #8

Browse files
Files changed (1) hide show
  1. src/vaix-rag.js +162 -102
src/vaix-rag.js CHANGED
@@ -1,13 +1,7 @@
1
  // V.AISTUDIO RAG Module — in-memory product search + UI panel integration
2
  // Products loaded ONCE from dataset JSON, cached in memory.
3
- // queryCatalog: search all products AND update UI panel AND return structured data to AI.
4
- // showProduct: open detail modal for a specific product by name/SKU.
5
- // searchSuggestions: real-time autocomplete suggestions for search dropdown
6
- // getSimilarProducts: find related products by category + brand + features
7
- // getCategories: list all product categories for quick browsing
8
- // createChatCard: build HTML product card for the chat interface
9
- // shareProduct: generate and copy shareable product link with OG data
10
- // getLastSearchResults: retrieve last search results for chat card rendering
11
 
12
  (function() {
13
  'use strict';
@@ -22,13 +16,11 @@ let lastShownProduct = null;
22
  function sd(s) { return (s||"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase(); }
23
  function fmt(pn) { return pn>0?pn.toLocaleString("vi-VN")+"₫":"Liên hệ"; }
24
 
25
- // ── Vietnamese-aware term extraction ──
26
  function extractTerms(query) {
27
  const q = sd(query);
28
  return q.split(/\s+/).filter(t => t.length > 1);
29
  }
30
 
31
- // ── Score product against query terms ──
32
  function scoreProduct(p, queryStr, terms) {
33
  let sc = 0;
34
  const nm = sd(p.title_clean);
@@ -55,11 +47,9 @@ function scoreProduct(p, queryStr, terms) {
55
  for (const b of brandNames) {
56
  if (queryStr.includes(b) && br.includes(b)) sc += 50;
57
  }
58
-
59
  return sc;
60
  }
61
 
62
- // ── Load products from JSON ──
63
  async function load() {
64
  if (loaded) return;
65
  try {
@@ -79,6 +69,9 @@ async function load() {
79
  }));
80
  loaded = true;
81
  console.log("[VAIX] Loaded "+allProducts.length+" products");
 
 
 
82
  } catch(e) {
83
  console.error("[VAIX] Load error:", e);
84
  const errEl = document.getElementById("vaistudio-error");
@@ -88,7 +81,54 @@ async function load() {
88
  }
89
  }
90
 
91
- // ── Find a single product by name/SKU ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  function findProduct(query) {
93
  if (!allProducts.length) return null;
94
  const qs = sd(query), terms = extractTerms(query);
@@ -100,7 +140,6 @@ function findProduct(query) {
100
  return bestScore > 0 ? best : null;
101
  }
102
 
103
- // ── Search suggestions (for dropdown) ──
104
  function searchSuggestions(query) {
105
  if (!allProducts.length || !query.trim()) return [];
106
  const q = query.trim();
@@ -113,17 +152,11 @@ function searchSuggestions(query) {
113
  }
114
  scored.sort((a,b) => b.sc - a.sc);
115
  return scored.slice(0, 8).map(r => ({
116
- name: r.p.title_clean,
117
- brand: r.p.brand,
118
- price: r.p.priceNum,
119
- priceStr: fmt(r.p.priceNum),
120
- category: r.p.category,
121
- image: r.p.image,
122
- score: r.sc
123
  }));
124
  }
125
 
126
- // ── Get all unique categories ──
127
  function getCategories() {
128
  if (!allProducts.length) return [];
129
  const catMap = {};
@@ -134,20 +167,17 @@ function getCategories() {
134
  return Object.entries(catMap).map(([name, count]) => ({ name, count })).sort((a,b) => b.count - a.count);
135
  }
136
 
137
- // ── Get products by category ──
138
  function getProductsByCategory(categoryName) {
139
  if (!allProducts.length) return [];
140
  const qs = sd(categoryName);
141
  return allProducts.filter(p => sd(p.category).includes(qs)).slice(0, 20);
142
  }
143
 
144
- // ── Find similar products ──
145
  function getSimilarProducts(product, limit) {
146
  if (!allProducts.length || !product) return [];
147
  limit = limit || 6;
148
  const p = product;
149
- const cat = sd(p.category);
150
- const brand = sd(p.brand);
151
  const nameWords = sd(p.title_clean).split(/\s+/).filter(w => w.length > 2);
152
  const scored = [];
153
  for (const other of allProducts) {
@@ -169,7 +199,6 @@ function getSimilarProducts(product, limit) {
169
  return scored.slice(0, limit).map(r => r.p);
170
  }
171
 
172
- // ── Track last search results ──
173
  function getLastSearchResults() { return lastSearchResults; }
174
  function getLastShownProduct() { return lastShownProduct; }
175
 
@@ -181,12 +210,10 @@ function getShareLink(product) {
181
  return base + "?product=" + slug;
182
  }
183
 
184
- // ── Copy share link + show toast ──
185
  function shareProduct(name) {
186
  const p = findProduct(name);
187
  if (!p) return 'Không tìm thấy sản phẩm: "'+name+'"';
188
  const url = getShareLink(p);
189
- // Build share text with product info
190
  let shareText = "🛒 " + p.title_clean + "\n";
191
  shareText += "💰 " + fmt(p.priceNum) + "\n";
192
  if (p.brand) shareText += "🏷️ " + p.brand + "\n";
@@ -194,27 +221,18 @@ function shareProduct(name) {
194
  if (p.summary) shareText += "📝 " + p.summary.slice(0, 200) + "\n";
195
  shareText += "\n" + url;
196
 
197
- // Try native share API first (mobile)
198
  if (navigator.share) {
199
- navigator.share({
200
- title: p.title_clean,
201
- text: shareText,
202
- url: url
203
- }).catch(() => {});
204
  } else {
205
- // Fallback: copy to clipboard
206
- const fullText = shareText;
207
- navigator.clipboard.writeText(fullText).then(function() {
208
  showShareToast("✅ Đã sao chép link chia sẻ: " + p.title_clean);
209
  }).catch(function() {
210
- // Fallback: show prompt
211
  prompt("Sao chép link chia sẻ:", url);
212
  });
213
  }
214
  return "Đã chia sẻ: " + p.title_clean;
215
  }
216
 
217
- // ── Share toast UI ──
218
  function showShareToast(msg) {
219
  let toast = document.getElementById("vaix-toast");
220
  if (!toast) {
@@ -229,7 +247,69 @@ function showShareToast(msg) {
229
  toast._timer = setTimeout(function(){ toast.style.opacity = "0"; }, 3000);
230
  }
231
 
232
- // ── Create HTML product card for chat ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  function createChatProductCard(product) {
234
  if (!product) return "";
235
  const img = product.image ? product.image : "";
@@ -238,16 +318,12 @@ function createChatProductCard(product) {
238
  const price = fmt(product.priceNum);
239
  const cat = product.category || "";
240
  const rating = product.rating || (Math.random() > 0.3 ? (4 + Math.random()).toFixed(1) : false);
241
-
242
- // Truncate long names
243
  const shortName = name.length > 50 ? name.slice(0, 47) + "..." : name;
244
 
245
  let stars = "";
246
  if (rating) {
247
  const full = Math.floor(Number(rating));
248
- for (let i = 0; i < 5; i++) {
249
- stars += i < full ? "★" : "☆";
250
- }
251
  }
252
 
253
  return '<div class="chat-product-card" data-product="' + name.replace(/"/g, "&quot;") + '">' +
@@ -268,20 +344,15 @@ function createChatProductCard(product) {
268
  '</div></div>';
269
  }
270
 
271
- // ── Attach click handlers to chat product cards ──
272
  function attachChatCardHandlers(container) {
273
  if (!container) return;
274
- // Detail buttons
275
  container.querySelectorAll(".detail-btn").forEach(function(btn) {
276
  btn.addEventListener("click", function(e) {
277
  e.stopPropagation();
278
  const name = this.getAttribute("data-product");
279
  const p = findProduct(name);
280
  if (p) {
281
- renderDetail(p);
282
- renderSimilarInPanel(p);
283
- renderPanelResults([p]);
284
- // Open panel
285
  const panel = document.getElementById("vaistudio-panel");
286
  const toggle = document.getElementById("vaistudio-toggle");
287
  if (panel) { panel.classList.add("open"); panel.style.display = "flex"; }
@@ -289,15 +360,12 @@ function attachChatCardHandlers(container) {
289
  }
290
  });
291
  });
292
- // Share buttons
293
  container.querySelectorAll(".share-btn").forEach(function(btn) {
294
  btn.addEventListener("click", function(e) {
295
  e.stopPropagation();
296
- const name = this.getAttribute("data-product");
297
- shareProduct(name);
298
  });
299
  });
300
- // Click on card itself (not button) also opens detail
301
  container.querySelectorAll(".chat-product-card-inner").forEach(function(inner) {
302
  inner.addEventListener("click", function(e) {
303
  const card = this.closest(".chat-product-card");
@@ -305,9 +373,7 @@ function attachChatCardHandlers(container) {
305
  const name = card.getAttribute("data-product");
306
  const p = findProduct(name);
307
  if (p) {
308
- renderDetail(p);
309
- renderSimilarInPanel(p);
310
- renderPanelResults([p]);
311
  const panel = document.getElementById("vaistudio-panel");
312
  const toggle = document.getElementById("vaistudio-toggle");
313
  if (panel) { panel.classList.add("open"); panel.style.display = "flex"; }
@@ -317,34 +383,25 @@ function attachChatCardHandlers(container) {
317
  });
318
  }
319
 
320
- // ── Main catalog search + UI update ──
321
  function queryCatalog(query) {
322
  if (!allProducts.length) return "Catalog still loading, please wait.";
323
  const q = query.trim();
324
  if (!q) return "What product are you looking for?";
325
-
326
  const qs = sd(q), terms = extractTerms(q);
327
  const scored = [];
328
-
329
  for (let i = 0; i < allProducts.length; i++) {
330
  const p = allProducts[i];
331
  const sc = scoreProduct(p, qs, terms);
332
  if (sc > 0) scored.push({ p, sc });
333
  }
334
-
335
  scored.sort((a,b) => b.sc - a.sc);
336
  const top = scored.slice(0, 5);
337
  lastSearchResults = top.map(r => r.p);
338
-
339
- // Update UI panel
340
  renderPanelResults(top.map(r => r.p));
341
-
342
  if (!top.length) {
343
  lastSearchResults = [];
344
  return 'Không tìm thấy sản phẩm cho "'+q+'". Bạn có thể thử từ khóa khác hoặc xem danh mục sản phẩm.';
345
  }
346
-
347
- // Build AI response text
348
  let result = "Tìm thấy " + top.length + ' sản phẩm cho "' + q + '":\n\n';
349
  for (let i = 0; i < top.length; i++) {
350
  const p = top[i].p;
@@ -359,12 +416,9 @@ function queryCatalog(query) {
359
  }
360
  result += '\n';
361
  }
362
-
363
  const best = top[0].p;
364
  lastShownProduct = best;
365
  result += 'Sản phẩm phù hợp nhất: ' + best.title_clean + ' (' + fmt(best.priceNum) + '). ';
366
-
367
- // Add similar products suggestion
368
  const similar = getSimilarProducts(best, 3);
369
  if (similar.length > 0) {
370
  result += '\n\n👉 Sản phẩm tương tự bạn có thể quan tâm:\n';
@@ -377,11 +431,9 @@ function queryCatalog(query) {
377
  } else {
378
  result += 'Bạn muốn xem chi tiết sản phẩm nào?';
379
  }
380
-
381
  return result;
382
  }
383
 
384
- // ── Show product detail modal ──
385
  function showProduct(name) {
386
  const p = findProduct(name);
387
  if (!p) return 'Không tìm thấy sản phẩm: "'+name+'". Hãy thử tìm kiếm với từ khóa khác.';
@@ -423,7 +475,7 @@ function renderPanelResults(products) {
423
  if (p.image) {
424
  const ie = document.createElement("img");
425
  ie.className = "product-card-img";
426
- ie.src = p.image; ie.alt = ""; ie.loading = "lazy";
427
  ie.onerror = function(){ this.style.display = "none"; };
428
  card.appendChild(ie);
429
  } else {
@@ -515,7 +567,6 @@ function renderDetail(p) {
515
  document.getElementById("detail-model").textContent = p.model ? "Model: "+p.model : "";
516
  document.getElementById("detail-price").textContent = p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN")+"₫" : "Liên hệ";
517
 
518
- // Price in triệu
519
  let trieuEl = document.getElementById("detail-price-trieu");
520
  if (!trieuEl) {
521
  trieuEl = document.createElement("span");
@@ -526,31 +577,53 @@ function renderDetail(p) {
526
  if (p.priceNum >= 1000000) { trieuEl.textContent = "(" + (p.priceNum/1000000).toFixed(1) + " triệu)"; trieuEl.style.display="inline"; }
527
  else trieuEl.style.display = "none";
528
 
529
- // Images
530
  const ic = document.getElementById("detail-images");
531
  ic.innerHTML = "";
532
  const imgs = [];
533
  if (p.image) imgs.push(p.image);
534
  if (p.images && p.images.length) p.images.forEach(function(i){ if(i && !imgs.includes(i)) imgs.push(i); });
 
535
  if (!imgs.length) {
536
  const d = document.createElement("div");
537
  d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0";
538
  d.textContent = "📦"; ic.appendChild(d);
539
  } else {
540
- imgs.forEach(function(url){
 
 
 
 
541
  const img = document.createElement("img");
542
- img.src = url; img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;flex-shrink:0;scroll-snap-align:start;border:1px solid #e2e8f0";
543
- img.onerror = function(){ this.style.display = "none"; }; ic.appendChild(img);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  });
545
  }
546
 
547
- // Summary
548
  const se = document.getElementById("detail-summary");
549
  if (p.summary) { se.textContent=p.summary; se.style.display="block"; }
550
  else if (p.description) { se.textContent=p.description.slice(0,300)+(p.description.length>300?"…":""); se.style.display="block"; }
551
  else se.style.display="none";
552
 
553
- // Features
554
  const fc = document.getElementById("detail-features"), fl = document.getElementById("detail-features-list");
555
  fl.innerHTML="";
556
  if (p.features&&p.features.length) {
@@ -558,7 +631,6 @@ function renderDetail(p) {
558
  fc.style.display="block";
559
  } else fc.style.display="none";
560
 
561
- // Specs
562
  const sc = document.getElementById("detail-specs"), sb = document.querySelector("#detail-specs-table tbody");
563
  sb.innerHTML="";
564
  if (p.specs&&typeof p.specs==="object"&&Object.keys(p.specs).length) {
@@ -566,7 +638,7 @@ function renderDetail(p) {
566
  sc.style.display = sb.children.length?"block":"none";
567
  } else sc.style.display="none";
568
 
569
- // ── Share button section ──
570
  let shareSection = document.getElementById("detail-share-section");
571
  if (!shareSection) {
572
  shareSection = document.createElement("div");
@@ -579,13 +651,10 @@ function renderDetail(p) {
579
  }
580
  const shareBtn = document.getElementById("detail-share-btn");
581
  if (shareBtn) {
582
- shareBtn.onclick = function(e) {
583
- e.stopPropagation();
584
- shareProduct(p.title_clean);
585
- };
586
  }
587
 
588
- // ── Similar products in modal ──
589
  let similarContainer = document.getElementById("detail-similar");
590
  if (!similarContainer) {
591
  const modal = document.getElementById("vaistudio-detail-modal");
@@ -623,15 +692,12 @@ function renderDetail(p) {
623
  similarGrid.appendChild(item);
624
  }
625
  similarContainer.style.display = "block";
626
- } else {
627
- similarContainer.style.display = "none";
628
- }
629
  }
630
 
631
  overlay.style.display = "flex";
632
  }
633
 
634
- // ── Search suggestions dropdown ──
635
  function showSuggestions(suggestions) {
636
  const dropdown = document.getElementById("vaix-suggestions");
637
  if (!dropdown) return;
@@ -662,7 +728,6 @@ function showSuggestions(suggestions) {
662
  }
663
  }
664
 
665
- // ── Setup search input ──
666
  function setupSearchInput() {
667
  const input = document.getElementById("vaix-search");
668
  if (!input) return;
@@ -683,7 +748,6 @@ function setupSearchInput() {
683
  });
684
  }
685
 
686
- // ── Setup category chips ──
687
  function setupCategoryChips() {
688
  const container = document.getElementById("vaix-category-chips");
689
  if (!container) return;
@@ -703,16 +767,12 @@ function setupCategoryChips() {
703
  }
704
  }
705
 
706
- // ── Render product cards into chat ──
707
  function renderSearchResultsInChat(container) {
708
  if (!container) return;
709
  const results = lastSearchResults;
710
  if (!results || !results.length) return;
711
-
712
  let html = '<div class="chat-product-cards">';
713
- for (const p of results) {
714
- html += createChatProductCard(p);
715
- }
716
  html += '</div>';
717
  container.innerHTML = html;
718
  attachChatCardHandlers(container);
@@ -724,7 +784,7 @@ function renderProductInChat(container, product) {
724
  attachChatCardHandlers(container);
725
  }
726
 
727
- // Expose globally
728
  window.vaix = {
729
  load: load,
730
  queryCatalog: queryCatalog,
@@ -742,15 +802,15 @@ window.vaix = {
742
  renderSearchResultsInChat: renderSearchResultsInChat,
743
  renderProductInChat: renderProductInChat,
744
  attachChatCardHandlers: attachChatCardHandlers,
 
745
  allProducts: function(){ return allProducts; },
746
  isLoaded: function(){ return loaded; },
747
  renderPanelResults: renderPanelResults
748
  };
749
 
750
- // Auto-load on script load
751
  load().catch(() => {});
752
 
753
- // Setup UI after DOM ready
754
  if (document.readyState === "loading") {
755
  document.addEventListener("DOMContentLoaded", function() {
756
  setupSearchInput();
 
1
  // V.AISTUDIO RAG Module — in-memory product search + UI panel integration
2
  // Products loaded ONCE from dataset JSON, cached in memory.
3
+ // Supports ?product=xxx URL param to auto-open product detail
4
+ // Full-screen image viewer on image click
 
 
 
 
 
 
5
 
6
  (function() {
7
  'use strict';
 
16
  function sd(s) { return (s||"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase(); }
17
  function fmt(pn) { return pn>0?pn.toLocaleString("vi-VN")+"₫":"Liên hệ"; }
18
 
 
19
  function extractTerms(query) {
20
  const q = sd(query);
21
  return q.split(/\s+/).filter(t => t.length > 1);
22
  }
23
 
 
24
  function scoreProduct(p, queryStr, terms) {
25
  let sc = 0;
26
  const nm = sd(p.title_clean);
 
47
  for (const b of brandNames) {
48
  if (queryStr.includes(b) && br.includes(b)) sc += 50;
49
  }
 
50
  return sc;
51
  }
52
 
 
53
  async function load() {
54
  if (loaded) return;
55
  try {
 
69
  }));
70
  loaded = true;
71
  console.log("[VAIX] Loaded "+allProducts.length+" products");
72
+
73
+ // After loading, check URL param for ?product=xxx
74
+ handleProductUrlParam();
75
  } catch(e) {
76
  console.error("[VAIX] Load error:", e);
77
  const errEl = document.getElementById("vaistudio-error");
 
81
  }
82
  }
83
 
84
+ // ── Handle ?product=xxx URL param ──
85
+ function handleProductUrlParam() {
86
+ try {
87
+ const params = new URLSearchParams(window.location.search);
88
+ const productSlug = params.get("product");
89
+ if (!productSlug || !allProducts.length) return;
90
+
91
+ const decoded = decodeURIComponent(productSlug.trim());
92
+ if (!decoded) return;
93
+
94
+ console.log("[VAIX] URL param product:", decoded);
95
+
96
+ const qs = sd(decoded);
97
+ let found = null;
98
+
99
+ for (const p of allProducts) {
100
+ if (p.slug && sd(p.slug) === qs) { found = p; break; }
101
+ }
102
+ if (!found) {
103
+ for (const p of allProducts) {
104
+ if (p.sku && sd(p.sku) === qs) { found = p; break; }
105
+ }
106
+ }
107
+ if (!found) {
108
+ for (const p of allProducts) {
109
+ if (sd(p.title_clean) === qs) { found = p; break; }
110
+ }
111
+ }
112
+ if (!found) {
113
+ for (const p of allProducts) {
114
+ if (sd(p.title_clean).includes(qs) || qs.includes(sd(p.title_clean))) { found = p; break; }
115
+ }
116
+ }
117
+
118
+ if (found) {
119
+ setTimeout(function() {
120
+ renderDetail(found);
121
+ renderSimilarInPanel(found);
122
+ renderPanelResults([found]);
123
+ lastShownProduct = found;
124
+ document.body.style.overflow = "hidden";
125
+ }, 300);
126
+ }
127
+ } catch(e) {
128
+ console.error("[VAIX] URL param error:", e);
129
+ }
130
+ }
131
+
132
  function findProduct(query) {
133
  if (!allProducts.length) return null;
134
  const qs = sd(query), terms = extractTerms(query);
 
140
  return bestScore > 0 ? best : null;
141
  }
142
 
 
143
  function searchSuggestions(query) {
144
  if (!allProducts.length || !query.trim()) return [];
145
  const q = query.trim();
 
152
  }
153
  scored.sort((a,b) => b.sc - a.sc);
154
  return scored.slice(0, 8).map(r => ({
155
+ name: r.p.title_clean, brand: r.p.brand, price: r.p.priceNum,
156
+ priceStr: fmt(r.p.priceNum), category: r.p.category, image: r.p.image, score: r.sc
 
 
 
 
 
157
  }));
158
  }
159
 
 
160
  function getCategories() {
161
  if (!allProducts.length) return [];
162
  const catMap = {};
 
167
  return Object.entries(catMap).map(([name, count]) => ({ name, count })).sort((a,b) => b.count - a.count);
168
  }
169
 
 
170
  function getProductsByCategory(categoryName) {
171
  if (!allProducts.length) return [];
172
  const qs = sd(categoryName);
173
  return allProducts.filter(p => sd(p.category).includes(qs)).slice(0, 20);
174
  }
175
 
 
176
  function getSimilarProducts(product, limit) {
177
  if (!allProducts.length || !product) return [];
178
  limit = limit || 6;
179
  const p = product;
180
+ const cat = sd(p.category), brand = sd(p.brand);
 
181
  const nameWords = sd(p.title_clean).split(/\s+/).filter(w => w.length > 2);
182
  const scored = [];
183
  for (const other of allProducts) {
 
199
  return scored.slice(0, limit).map(r => r.p);
200
  }
201
 
 
202
  function getLastSearchResults() { return lastSearchResults; }
203
  function getLastShownProduct() { return lastShownProduct; }
204
 
 
210
  return base + "?product=" + slug;
211
  }
212
 
 
213
  function shareProduct(name) {
214
  const p = findProduct(name);
215
  if (!p) return 'Không tìm thấy sản phẩm: "'+name+'"';
216
  const url = getShareLink(p);
 
217
  let shareText = "🛒 " + p.title_clean + "\n";
218
  shareText += "💰 " + fmt(p.priceNum) + "\n";
219
  if (p.brand) shareText += "🏷️ " + p.brand + "\n";
 
221
  if (p.summary) shareText += "📝 " + p.summary.slice(0, 200) + "\n";
222
  shareText += "\n" + url;
223
 
 
224
  if (navigator.share) {
225
+ navigator.share({ title: p.title_clean, text: shareText, url: url }).catch(() => {});
 
 
 
 
226
  } else {
227
+ navigator.clipboard.writeText(shareText).then(function() {
 
 
228
  showShareToast("✅ Đã sao chép link chia sẻ: " + p.title_clean);
229
  }).catch(function() {
 
230
  prompt("Sao chép link chia sẻ:", url);
231
  });
232
  }
233
  return "Đã chia sẻ: " + p.title_clean;
234
  }
235
 
 
236
  function showShareToast(msg) {
237
  let toast = document.getElementById("vaix-toast");
238
  if (!toast) {
 
247
  toast._timer = setTimeout(function(){ toast.style.opacity = "0"; }, 3000);
248
  }
249
 
250
+ // ── Full-screen image viewer ──
251
+ function openImageViewer(imageUrl, productName) {
252
+ if (!imageUrl) return;
253
+
254
+ const existing = document.getElementById("vaix-image-viewer");
255
+ if (existing) existing.remove();
256
+
257
+ const viewer = document.createElement("div");
258
+ viewer.id = "vaix-image-viewer";
259
+ viewer.style.cssText = "position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;cursor:zoom-out;animation:modalIn 0.2s ease";
260
+
261
+ // Close button
262
+ const closeBtn = document.createElement("button");
263
+ closeBtn.innerHTML = "✕";
264
+ closeBtn.style.cssText = "position:absolute;top:16px;right:16px;width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.15);color:#fff;font-size:1.3rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background 0.2s;z-index:2";
265
+ closeBtn.onmouseover = function(){ this.style.background = "rgba(255,255,255,0.3)"; };
266
+ closeBtn.onmouseout = function(){ this.style.background = "rgba(255,255,255,0.15)"; };
267
+ closeBtn.onclick = function(e){ e.stopPropagation(); viewer.remove(); document.body.style.overflow = ""; };
268
+ viewer.appendChild(closeBtn);
269
+
270
+ // Product name caption
271
+ if (productName) {
272
+ const caption = document.createElement("div");
273
+ caption.textContent = productName;
274
+ caption.style.cssText = "position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:0.85rem;text-align:center;max-width:80%;padding:8px 16px;background:rgba(0,0,0,0.5);border-radius:8px;z-index:2";
275
+ viewer.appendChild(caption);
276
+ }
277
+
278
+ const img = document.createElement("img");
279
+ img.src = imageUrl;
280
+ img.alt = productName || "";
281
+ img.style.cssText = "max-width:100%;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,0.5);user-select:none;-webkit-user-drag:none";
282
+ img.onerror = function(){ this.alt = "Không thể tải ảnh"; this.style.maxWidth = "300px"; };
283
+
284
+ const hint = document.createElement("div");
285
+ hint.textContent = "Nhấn ESC hoặc click bên ngoài để đóng";
286
+ hint.style.cssText = "position:absolute;top:16px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.4);font-size:0.7rem;z-index:2;white-space:nowrap";
287
+
288
+ viewer.appendChild(hint);
289
+ viewer.appendChild(img);
290
+
291
+ viewer.addEventListener("click", function(e) {
292
+ if (e.target === viewer) {
293
+ viewer.remove();
294
+ document.body.style.overflow = "";
295
+ }
296
+ });
297
+
298
+ viewer.setAttribute("tabindex", "0");
299
+ viewer.focus();
300
+ function keyHandler(e) {
301
+ if (e.key === "Escape") {
302
+ viewer.remove();
303
+ document.body.style.overflow = "";
304
+ document.removeEventListener("keydown", keyHandler);
305
+ }
306
+ }
307
+ document.addEventListener("keydown", keyHandler);
308
+
309
+ document.body.appendChild(viewer);
310
+ document.body.style.overflow = "hidden";
311
+ }
312
+
313
  function createChatProductCard(product) {
314
  if (!product) return "";
315
  const img = product.image ? product.image : "";
 
318
  const price = fmt(product.priceNum);
319
  const cat = product.category || "";
320
  const rating = product.rating || (Math.random() > 0.3 ? (4 + Math.random()).toFixed(1) : false);
 
 
321
  const shortName = name.length > 50 ? name.slice(0, 47) + "..." : name;
322
 
323
  let stars = "";
324
  if (rating) {
325
  const full = Math.floor(Number(rating));
326
+ for (let i = 0; i < 5; i++) stars += i < full ? "★" : "☆";
 
 
327
  }
328
 
329
  return '<div class="chat-product-card" data-product="' + name.replace(/"/g, "&quot;") + '">' +
 
344
  '</div></div>';
345
  }
346
 
 
347
  function attachChatCardHandlers(container) {
348
  if (!container) return;
 
349
  container.querySelectorAll(".detail-btn").forEach(function(btn) {
350
  btn.addEventListener("click", function(e) {
351
  e.stopPropagation();
352
  const name = this.getAttribute("data-product");
353
  const p = findProduct(name);
354
  if (p) {
355
+ renderDetail(p); renderSimilarInPanel(p); renderPanelResults([p]);
 
 
 
356
  const panel = document.getElementById("vaistudio-panel");
357
  const toggle = document.getElementById("vaistudio-toggle");
358
  if (panel) { panel.classList.add("open"); panel.style.display = "flex"; }
 
360
  }
361
  });
362
  });
 
363
  container.querySelectorAll(".share-btn").forEach(function(btn) {
364
  btn.addEventListener("click", function(e) {
365
  e.stopPropagation();
366
+ shareProduct(this.getAttribute("data-product"));
 
367
  });
368
  });
 
369
  container.querySelectorAll(".chat-product-card-inner").forEach(function(inner) {
370
  inner.addEventListener("click", function(e) {
371
  const card = this.closest(".chat-product-card");
 
373
  const name = card.getAttribute("data-product");
374
  const p = findProduct(name);
375
  if (p) {
376
+ renderDetail(p); renderSimilarInPanel(p); renderPanelResults([p]);
 
 
377
  const panel = document.getElementById("vaistudio-panel");
378
  const toggle = document.getElementById("vaistudio-toggle");
379
  if (panel) { panel.classList.add("open"); panel.style.display = "flex"; }
 
383
  });
384
  }
385
 
 
386
  function queryCatalog(query) {
387
  if (!allProducts.length) return "Catalog still loading, please wait.";
388
  const q = query.trim();
389
  if (!q) return "What product are you looking for?";
 
390
  const qs = sd(q), terms = extractTerms(q);
391
  const scored = [];
 
392
  for (let i = 0; i < allProducts.length; i++) {
393
  const p = allProducts[i];
394
  const sc = scoreProduct(p, qs, terms);
395
  if (sc > 0) scored.push({ p, sc });
396
  }
 
397
  scored.sort((a,b) => b.sc - a.sc);
398
  const top = scored.slice(0, 5);
399
  lastSearchResults = top.map(r => r.p);
 
 
400
  renderPanelResults(top.map(r => r.p));
 
401
  if (!top.length) {
402
  lastSearchResults = [];
403
  return 'Không tìm thấy sản phẩm cho "'+q+'". Bạn có thể thử từ khóa khác hoặc xem danh mục sản phẩm.';
404
  }
 
 
405
  let result = "Tìm thấy " + top.length + ' sản phẩm cho "' + q + '":\n\n';
406
  for (let i = 0; i < top.length; i++) {
407
  const p = top[i].p;
 
416
  }
417
  result += '\n';
418
  }
 
419
  const best = top[0].p;
420
  lastShownProduct = best;
421
  result += 'Sản phẩm phù hợp nhất: ' + best.title_clean + ' (' + fmt(best.priceNum) + '). ';
 
 
422
  const similar = getSimilarProducts(best, 3);
423
  if (similar.length > 0) {
424
  result += '\n\n👉 Sản phẩm tương tự bạn có thể quan tâm:\n';
 
431
  } else {
432
  result += 'Bạn muốn xem chi tiết sản phẩm nào?';
433
  }
 
434
  return result;
435
  }
436
 
 
437
  function showProduct(name) {
438
  const p = findProduct(name);
439
  if (!p) return 'Không tìm thấy sản phẩm: "'+name+'". Hãy thử tìm kiếm với từ khóa khác.';
 
475
  if (p.image) {
476
  const ie = document.createElement("img");
477
  ie.className = "product-card-img";
478
+ ie.src = p.image; ie.alt = p.title_clean || ""; ie.loading = "lazy";
479
  ie.onerror = function(){ this.style.display = "none"; };
480
  card.appendChild(ie);
481
  } else {
 
567
  document.getElementById("detail-model").textContent = p.model ? "Model: "+p.model : "";
568
  document.getElementById("detail-price").textContent = p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN")+"₫" : "Liên hệ";
569
 
 
570
  let trieuEl = document.getElementById("detail-price-trieu");
571
  if (!trieuEl) {
572
  trieuEl = document.createElement("span");
 
577
  if (p.priceNum >= 1000000) { trieuEl.textContent = "(" + (p.priceNum/1000000).toFixed(1) + " triệu)"; trieuEl.style.display="inline"; }
578
  else trieuEl.style.display = "none";
579
 
580
+ // Images — with click-to-fullscreen handler
581
  const ic = document.getElementById("detail-images");
582
  ic.innerHTML = "";
583
  const imgs = [];
584
  if (p.image) imgs.push(p.image);
585
  if (p.images && p.images.length) p.images.forEach(function(i){ if(i && !imgs.includes(i)) imgs.push(i); });
586
+
587
  if (!imgs.length) {
588
  const d = document.createElement("div");
589
  d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0";
590
  d.textContent = "📦"; ic.appendChild(d);
591
  } else {
592
+ imgs.forEach(function(url, idx){
593
+ const wrapper = document.createElement("div");
594
+ wrapper.style.cssText = "position:relative;flex-shrink:0;scroll-snap-align:start;cursor:zoom-in";
595
+ wrapper.title = "Nhấn để xem ảnh toàn màn hình";
596
+
597
  const img = document.createElement("img");
598
+ img.src = url;
599
+ img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e2e8f0;transition:transform 0.2s, box-shadow 0.2s";
600
+ img.onerror = function(){ this.style.display = "none"; };
601
+ img.onmouseover = function(){ this.style.transform = "scale(1.05)"; this.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; };
602
+ img.onmouseout = function(){ this.style.transform = ""; this.style.boxShadow = ""; };
603
+
604
+ // Click image → open fullscreen viewer
605
+ img.addEventListener("click", function(e) {
606
+ e.stopPropagation();
607
+ openImageViewer(url, p.title_clean);
608
+ });
609
+
610
+ wrapper.appendChild(img);
611
+
612
+ // Small zoom icon overlay
613
+ const zoomIcon = document.createElement("div");
614
+ zoomIcon.textContent = "🔍";
615
+ zoomIcon.style.cssText = "position:absolute;bottom:4px;right:4px;font-size:0.7rem;background:rgba(0,0,0,0.5);border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0.7";
616
+
617
+ wrapper.appendChild(zoomIcon);
618
+ ic.appendChild(wrapper);
619
  });
620
  }
621
 
 
622
  const se = document.getElementById("detail-summary");
623
  if (p.summary) { se.textContent=p.summary; se.style.display="block"; }
624
  else if (p.description) { se.textContent=p.description.slice(0,300)+(p.description.length>300?"…":""); se.style.display="block"; }
625
  else se.style.display="none";
626
 
 
627
  const fc = document.getElementById("detail-features"), fl = document.getElementById("detail-features-list");
628
  fl.innerHTML="";
629
  if (p.features&&p.features.length) {
 
631
  fc.style.display="block";
632
  } else fc.style.display="none";
633
 
 
634
  const sc = document.getElementById("detail-specs"), sb = document.querySelector("#detail-specs-table tbody");
635
  sb.innerHTML="";
636
  if (p.specs&&typeof p.specs==="object"&&Object.keys(p.specs).length) {
 
638
  sc.style.display = sb.children.length?"block":"none";
639
  } else sc.style.display="none";
640
 
641
+ // Share button section
642
  let shareSection = document.getElementById("detail-share-section");
643
  if (!shareSection) {
644
  shareSection = document.createElement("div");
 
651
  }
652
  const shareBtn = document.getElementById("detail-share-btn");
653
  if (shareBtn) {
654
+ shareBtn.onclick = function(e) { e.stopPropagation(); shareProduct(p.title_clean); };
 
 
 
655
  }
656
 
657
+ // Similar products in modal
658
  let similarContainer = document.getElementById("detail-similar");
659
  if (!similarContainer) {
660
  const modal = document.getElementById("vaistudio-detail-modal");
 
692
  similarGrid.appendChild(item);
693
  }
694
  similarContainer.style.display = "block";
695
+ } else similarContainer.style.display = "none";
 
 
696
  }
697
 
698
  overlay.style.display = "flex";
699
  }
700
 
 
701
  function showSuggestions(suggestions) {
702
  const dropdown = document.getElementById("vaix-suggestions");
703
  if (!dropdown) return;
 
728
  }
729
  }
730
 
 
731
  function setupSearchInput() {
732
  const input = document.getElementById("vaix-search");
733
  if (!input) return;
 
748
  });
749
  }
750
 
 
751
  function setupCategoryChips() {
752
  const container = document.getElementById("vaix-category-chips");
753
  if (!container) return;
 
767
  }
768
  }
769
 
 
770
  function renderSearchResultsInChat(container) {
771
  if (!container) return;
772
  const results = lastSearchResults;
773
  if (!results || !results.length) return;
 
774
  let html = '<div class="chat-product-cards">';
775
+ for (const p of results) html += createChatProductCard(p);
 
 
776
  html += '</div>';
777
  container.innerHTML = html;
778
  attachChatCardHandlers(container);
 
784
  attachChatCardHandlers(container);
785
  }
786
 
787
+ // Export to window
788
  window.vaix = {
789
  load: load,
790
  queryCatalog: queryCatalog,
 
802
  renderSearchResultsInChat: renderSearchResultsInChat,
803
  renderProductInChat: renderProductInChat,
804
  attachChatCardHandlers: attachChatCardHandlers,
805
+ openImageViewer: openImageViewer,
806
  allProducts: function(){ return allProducts; },
807
  isLoaded: function(){ return loaded; },
808
  renderPanelResults: renderPanelResults
809
  };
810
 
811
+ // Auto-load
812
  load().catch(() => {});
813
 
 
814
  if (document.readyState === "loading") {
815
  document.addEventListener("DOMContentLoaded", function() {
816
  setupSearchInput();