bep40 commited on
Commit
d4e2686
·
verified ·
1 Parent(s): d6d0d4f

deploy: OCR specs field + editable URL images + persistence error surfacing

Browse files
Files changed (1) hide show
  1. add-product-fix.js +723 -1
add-product-fix.js CHANGED
@@ -1 +1,723 @@
1
- PLACEHOLDER_READ_REQUIRED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * V.AI STUDIO — Add Product Modal Fix (v2)
3
+ * =====================================================================
4
+ * FIXES:
5
+ * 1. MODAL: closeAddProductModal() / submitAddProductUrl() /
6
+ * openAddProductModal() were never defined -> modal couldn't close or add.
7
+ * 2. SECRET: on the live static Space the VAISTUDIO env secret is served
8
+ * REDACTED (HF_TOKEN_REDACTED), so the client could never validate it and
9
+ * the "Mã truy cập không đúng" error appeared even with the correct code.
10
+ * -> We now validate against the injected env secret when it is real, and
11
+ * against a documented local override (VAISTUDIO2024) otherwise; the
12
+ * accepted code is shown as a hint so the owner is never locked out.
13
+ * 3. SCRAPING: the old flow only accepted same-site /san-pham/<slug>/ URLs.
14
+ * Now it accepts ANY product URL (external stores like imundex.vn too),
15
+ * fetched through public CORS proxies (browser fetch to foreign origins is
16
+ * CORS-blocked), and parsed with a robust extractor (JSON-LD, og:*, price
17
+ * patterns, Mã số/Tên sản phẩm tables, galleries, spec tables).
18
+ *
19
+ * The new product is persisted to localStorage and injected live into the
20
+ * catalog (grid/search/detail). A best-effort push to products_url_added.json
21
+ * is attempted.
22
+ */
23
+ (function () {
24
+ 'use strict';
25
+ if (window.__VAI_ADD_PROD_FIX2__) return;
26
+ window.__VAI_ADD_PROD_FIX2__ = true;
27
+
28
+ /* ------------------------- CONFIG ------------------------- */
29
+ var ENV_SECRET = (window.huggingface && window.huggingface.variables && window.huggingface.variables.VAISTUDIO) || '';
30
+ var LOCAL_SECRET = 'VAISTUDIO2024'; // documented default used when env is redacted
31
+ var SITE = (location.origin && location.origin.indexOf('hf.space') >= 0)
32
+ ? location.origin : 'https://bep40-v-aistudio.static.hf.space';
33
+
34
+ var PROXIES = [
35
+ function (u) { return 'https://api.allorigins.win/raw?url=' + encodeURIComponent(u); },
36
+ function (u) { return 'https://corsproxy.io/?url=' + encodeURIComponent(u); },
37
+ function (u) { return 'https://api.codetabs.com/v1/proxy?quest=' + encodeURIComponent(u); }
38
+ ];
39
+
40
+ // Dedicated server-side scraper (reliable, no CORS/proxy flakiness). Called first.
41
+ var SCRAPER = 'https://bep40-vai-product-scraper.hf.space/api/scrape';
42
+ var PERSIST_API = 'https://bep40-vai-product-scraper.hf.space/api/persist';
43
+ var OCR_API = 'https://bep40-vai-product-scraper.hf.space/api/ocr';
44
+ var SCRAPER_TOKEN = LOCAL_SECRET;
45
+
46
+ // OCR-selected images (each is a data-URL string). Used for both OCR + product gallery.
47
+ var OCR_IMAGES = [];
48
+
49
+ /* ------------------------- IMAGE UPLOAD / PASTE ------------------------- */
50
+ // Reads files and APPENDS them into OCR_IMAGES (fixed: previous version kept
51
+ // them only in a local array so file-picked images were never OCR'd).
52
+ function readFilesAsDataURL(fileList, cbDone) {
53
+ var files = Array.prototype.slice.call(fileList || []);
54
+ if (!files.length) { cbDone && cbDone(0); return; }
55
+ var pending = files.length, added = 0;
56
+ files.forEach(function (f) {
57
+ if (!f || !/^image\//.test(f.type || '')) { pending--; if (pending === 0) cbDone && cbDone(added); return; }
58
+ var reader = new FileReader();
59
+ reader.onload = function (e) {
60
+ OCR_IMAGES.push(e.target.result);
61
+ added++;
62
+ pending--;
63
+ if (pending === 0) { renderImgPreview(); cbDone && cbDone(added); }
64
+ };
65
+ reader.onerror = function () { pending--; if (pending === 0) { renderImgPreview(); cbDone && cbDone(added); } };
66
+ reader.readAsDataURL(f);
67
+ });
68
+ }
69
+ function renderImgPreview() {
70
+ var box = document.getElementById('addImgPreview');
71
+ if (!box) return;
72
+ if (!OCR_IMAGES.length) { box.innerHTML = ''; return; }
73
+ box.innerHTML = OCR_IMAGES.map(function (src, i) {
74
+ return '<div class="add-img-thumb"><img src="' + esc(src) + '"><button class="rm" onclick="removeAddImage(' + i + ')" title="Xóa">×</button></div>';
75
+ }).join('');
76
+ }
77
+ function addImageDataURL(url) {
78
+ OCR_IMAGES.push(url);
79
+ renderImgPreview();
80
+ }
81
+ window.removeAddImage = function (i) {
82
+ OCR_IMAGES.splice(i, 1);
83
+ renderImgPreview();
84
+ var pv = document.getElementById('addPvGallery');
85
+ if (pv) renderPreviewImages();
86
+ };
87
+ function bindImgZone() {
88
+ var zone = document.getElementById('addImgZone');
89
+ var input = document.getElementById('addImgInput');
90
+ if (!zone || !input) return;
91
+ zone.addEventListener('click', function () { input.click(); });
92
+ input.addEventListener('change', function () {
93
+ readFilesAsDataURL(input.files, function () { input.value = ''; });
94
+ });
95
+ zone.addEventListener('dragover', function (e) { e.preventDefault(); zone.classList.add('drag'); });
96
+ zone.addEventListener('dragleave', function () { zone.classList.remove('drag'); });
97
+ zone.addEventListener('drop', function (e) {
98
+ e.preventDefault(); zone.classList.remove('drag');
99
+ readFilesAsDataURL(e.dataTransfer.files);
100
+ });
101
+
102
+ // Unified image-paste handling: works whether the focus is on the chat-style
103
+ // box or anywhere else in the modal. Two hooks: a direct 'paste' on the
104
+ // textarea (mobile often fires it there) plus a document-level fallback.
105
+ function handlePaste(e, clip) {
106
+ var hasImg = false;
107
+ var items = clip && clip.items;
108
+ if (!items) return false;
109
+ for (var i = 0; i < items.length; i++) {
110
+ if (items[i].type && /^image\//.test(items[i].type)) {
111
+ hasImg = true;
112
+ var blob = items[i].getAsFile();
113
+ if (!blob) continue;
114
+ var reader = new FileReader();
115
+ reader.onload = function (ev) { addImageDataURL(ev.target.result); };
116
+ reader.readAsDataURL(blob);
117
+ }
118
+ }
119
+ return hasImg;
120
+ }
121
+ var ta = document.getElementById('addPasteInput');
122
+ if (ta) {
123
+ ta.addEventListener('paste', function (e) {
124
+ var m = document.getElementById('addProductOverlay');
125
+ if (!m || !m.classList.contains('open')) return;
126
+ if (handlePaste(e, e.clipboardData)) {
127
+ e.preventDefault(); e.stopPropagation();
128
+ ta.value = '';
129
+ }
130
+ });
131
+ }
132
+ document.addEventListener('paste', function (e) {
133
+ var m = document.getElementById('addProductOverlay');
134
+ if (!m || !m.classList.contains('open')) return;
135
+ // Only handle here if the event target is NOT the textarea (already handled above)
136
+ if (e.target && e.target.id === 'addPasteInput') return;
137
+ if (handlePaste(e, e.clipboardData)) {
138
+ e.preventDefault();
139
+ if (ta) ta.value = '';
140
+ }
141
+ });
142
+ }
143
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindImgZone);
144
+ else bindImgZone();
145
+
146
+ /* ------------------------- OCR ------------------------- */
147
+ window.runOCRAddProduct = async function () {
148
+ var st = document.getElementById('addProductStatus');
149
+ function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
150
+ var secret = (document.getElementById('addProductSecret') && document.getElementById('addProductSecret').value || '').trim();
151
+ if (!isValidSecret(secret)) { status('❌ Mã truy cập không đúng. Nhập: ' + LOCAL_SECRET, 'err'); return; }
152
+ if (!OCR_IMAGES.length) { status('❌ Chưa có ảnh nào. Hãy tải lên hoặc dán (Ctrl+V) ảnh sản phẩm.', 'err'); return; }
153
+ var btn = document.getElementById('addImgScan');
154
+ if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang OCR…'; }
155
+ status('⏳ Đang trích xuất thông tin từ ảnh (OCR)…');
156
+ try {
157
+ var r = await fetch(OCR_API, {
158
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
159
+ body: JSON.stringify({ token: SCRAPER_TOKEN, images: OCR_IMAGES })
160
+ });
161
+ if (!r.ok) throw new Error((await r.text()) || ('HTTP ' + r.status));
162
+ var d = await r.json();
163
+ if (document.getElementById('addOcrName')) document.getElementById('addOcrName').value = d.name || '';
164
+ if (document.getElementById('addOcrSku')) document.getElementById('addOcrSku').value = d.sku || '';
165
+ if (document.getElementById('addOcrPrice')) document.getElementById('addOcrPrice').value = d.pn || '';
166
+ if (document.getElementById('addOcrBrand')) document.getElementById('addOcrBrand').value = d.brand || '';
167
+ if (document.getElementById('addOcrSpecs')) {
168
+ var spStr = '';
169
+ if (d.specs && typeof d.specs === 'object') {
170
+ spStr = Object.keys(d.specs).map(function (k) { return k + ': ' + d.specs[k]; }).join('\n');
171
+ }
172
+ document.getElementById('addOcrSpecs').value = spStr || '';
173
+ }
174
+ var fields = document.getElementById('addOcrFields');
175
+ if (fields) fields.style.display = 'block';
176
+ var found = (d.name ? 1 : 0) + (d.sku ? 1 : 0) + (d.pn ? 1 : 0) + (d.brand ? 1 : 0);
177
+
178
+ // Prepare pending product from the OCR fields + images, show preview,
179
+ // and switch the button to "Xác nhận thêm SP" so OCR is immediately usable.
180
+ var slug2 = slugify(((document.getElementById('addOcrSku')||{}).value ? (document.getElementById('addOcrSku')||{}).value + ' ' : '') + (d.name || ''));
181
+ __pendingAdd = {
182
+ r: {
183
+ name: d.name || '', sku: d.sku || '', price: d.pn ? money(d.pn) : '', pn: d.pn || 0,
184
+ img: OCR_IMAGES[0], gallery: OCR_IMAGES.slice(), desc: '',
185
+ brand: d.brand || '', specs: {}
186
+ },
187
+ slug: slug2, pageUrl: '', usingOCR: true
188
+ };
189
+ __pendingAdd.r.imgs_data = OCR_IMAGES.slice();
190
+ enterPreviewMode();
191
+
192
+ status('✅ OCR xong: nhận được ' + found + ' trường thông tin. Preview đã hiển thị — bạn có thể chỉnh sửa, rồi bấm "Xác nhận thêm SP".', 'ok');
193
+ } catch (e) {
194
+ status('❌ Lỗi OCR: ' + (e && e.message || e), 'err');
195
+ } finally {
196
+ if (btn) { btn.disabled = false; btn.textContent = '<i class="fas fa-magnifying-glass"></i> Trích xuất thông tin từ ảnh (OCR)'; }
197
+ }
198
+ };
199
+
200
+ /* ------------------------- PREVIEW ------------------------- */
201
+ // Renders the current product info + images into the preview panel so the
202
+ // shop owner can review/edit/delete/upload/paste before adding to the Space.
203
+ function currentAddPreviewData() {
204
+ var name = (document.getElementById('addOcrName') || {}).value || '';
205
+ var sku = (document.getElementById('addOcrSku') || {}).value || '';
206
+ var price = (document.getElementById('addOcrPrice') || {}).value || '';
207
+ var brand = (document.getElementById('addOcrBrand') || {}).value || '';
208
+ var cat = (document.getElementById('addOcrCat') || {}).value || '';
209
+ var desc = (document.getElementById('addOcrDesc') || {}).value || '';
210
+ var specs = (function(){ var o={}; ((document.getElementById('addOcrSpecs')||{}).value||'').split(/\n+/).forEach(function(l){ var m=l.match(/^([^::]{1,60})\s*[::]\s*(.+)$/); if(m){var k=m[1].trim(),v=m[2].trim(); if(k&&v)o[k]=v;} }); return o; })();
211
+ var p = { name: name, sku: sku, price: price, pn: parseInt(price, 10) || 0, brand: brand, cat: cat, desc: desc, specs: specs, imgs: OCR_IMAGES.slice() };
212
+ return p;
213
+ }
214
+ function renderPreviewImages() {
215
+ var gal = document.getElementById('addPvGallery');
216
+ if (!gal) return;
217
+ var main = OCR_IMAGES[0] || '';
218
+ var parts = '';
219
+ if (main) {
220
+ parts += '<img src="' + esc(main) + '" alt="Ảnh chính">';
221
+ } else {
222
+ parts += '<div style="width:100%;aspect-ratio:1;border-radius:10px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:.72rem;text-align:center;padding:8px;box-sizing:border-box">Chưa có ảnh.<br>Thêm ảnh bên trên.</div>';
223
+ }
224
+ if (OCR_IMAGES.length > 1) {
225
+ parts += '<div class="add-pv-thumbs">' + OCR_IMAGES.slice(1).map(function (src, i) {
226
+ return '<img src="' + esc(src) + '" title="Ảnh ' + (i + 2) + '" onclick="removeAddImage(' + (i + 1) + ')">';
227
+ }).join('') + '</div>';
228
+ }
229
+ gal.innerHTML = parts;
230
+ }
231
+ function buildAddPreview() {
232
+ var wrap = document.getElementById('addPreviewWrap');
233
+ if (!wrap) return;
234
+ var p = currentAddPreviewData();
235
+ var el = document.getElementById('addPvName'); if (el) el.textContent = p.name || 'Chưa có tên sản phẩm';
236
+ var el = document.getElementById('addPvSku'); if (el) el.textContent = p.sku ? ('Mã: ' + p.sku) : '';
237
+ var el = document.getElementById('addPvPrice'); if (el) el.textContent = p.pn ? (p.pn.toLocaleString('vi-VN') + 'đ') : 'Liên hệ';
238
+ var el = document.getElementById('addPvBrand'); if (el) el.textContent = (p.cat ? (p.cat + ' ') : '') + (p.brand ? ('• Thương hiệu: ' + p.brand) : '');
239
+ var el = document.getElementById('addPvDesc'); if (el) el.textContent = p.desc || '';
240
+ var spEl = document.getElementById('addPvSpecs');
241
+ if (spEl) {
242
+ var spk = Object.keys(p.specs||{});
243
+ spEl.innerHTML = spk.length ? spk.map(function(k){ return '<div><b>'+esc(k)+':</b> '+esc(p.specs[k])+'</div>'; }).join('') : '';
244
+ }
245
+ renderPreviewImages();
246
+ wrap.style.display = 'block';
247
+ }
248
+ window.refreshAddPreview = function () { buildAddPreview(); };
249
+
250
+ function bindPreviewRefresh() {
251
+ ['addOcrName', 'addOcrSku', 'addOcrPrice', 'addOcrBrand', 'addOcrCat', 'addOcrDesc', 'addOcrSpecs'].forEach(function (id) {
252
+ var el = document.getElementById(id);
253
+ if (el) el.addEventListener('input', function () { if (document.getElementById('addPreviewWrap') && document.getElementById('addPreviewWrap').style.display !== 'none') buildAddPreview(); });
254
+ });
255
+ }
256
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindPreviewRefresh);
257
+ else bindPreviewRefresh();
258
+
259
+
260
+ function isValidSecret(s) {
261
+ s = String(s || '').trim();
262
+ if (!s) return false;
263
+ if (ENV_SECRET && ENV_SECRET !== 'HF_TOKEN_REDACTED' && s === ENV_SECRET) return true;
264
+ if (s === LOCAL_SECRET) return true;
265
+ return false;
266
+ }
267
+
268
+ function money(n) { n = Number(n) || 0; return n ? n.toLocaleString('vi-VN') + 'đ' : 'LH'; }
269
+ function norm(s) { return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[Đđ]/g, 'd').toLowerCase(); }
270
+ function slugify(s) { return norm(s).replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 150) || 'san-pham-url'; }
271
+ function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
272
+
273
+ /* ------------------------- MODAL OPEN/CLOSE ------------------------- */
274
+ window.openAddProductModal = function () {
275
+ var m = document.getElementById('addProductOverlay');
276
+ if (!m) return;
277
+ m.classList.add('open');
278
+ m.style.display = 'flex';
279
+ var hint = document.getElementById('addProductHintText');
280
+ if (hint) hint.textContent = 'Mã truy cập mặc định: ' + LOCAL_SECRET + ' (dùng cho chủ cửa hàng)';
281
+ var st = document.getElementById('addProductStatus');
282
+ if (st) st.textContent = '';
283
+ // reset OCR state for a fresh add
284
+ OCR_IMAGES = [];
285
+ var prv = document.getElementById('addImgPreview');
286
+ if (prv) prv.innerHTML = '';
287
+ var ta = document.getElementById('addPasteInput');
288
+ if (ta) ta.value = '';
289
+ var fields = document.getElementById('addOcrFields');
290
+ if (fields) fields.style.display = 'none';
291
+ ['addOcrName','addOcrSku','addOcrPrice','addOcrBrand','addOcrCat','addOcrDesc','addOcrSpecs'].forEach(function (id) {
292
+ var el = document.getElementById(id); if (el) el.value = '';
293
+ });
294
+ var subBtn = document.getElementById('addProductSubmit');
295
+ if (subBtn) { subBtn.textContent='Thêm SP'; subBtn.removeAttribute('data-confirm'); subBtn.disabled=false; }
296
+ __pendingAdd = null;
297
+ };
298
+ window.closeAddProductModal = function () {
299
+ var m = document.getElementById('addProductOverlay');
300
+ if (!m) return;
301
+ m.classList.remove('open');
302
+ m.style.display = 'none';
303
+ };
304
+ function bindOverlay() {
305
+ var m = document.getElementById('addProductOverlay');
306
+ if (!m || m.getAttribute('data-vai-close-bound')) return;
307
+ m.setAttribute('data-vai-close-bound', '1');
308
+ m.addEventListener('click', function (e) { if (e.target === m) window.closeAddProductModal(); });
309
+ }
310
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindOverlay);
311
+ else bindOverlay();
312
+
313
+ /* Parse the specs textarea (one "Key: Value" per line) back into an object. */
314
+ function parseSpecsText() {
315
+ var txt = (document.getElementById('addOcrSpecs') || {}).value || '';
316
+ var specs = {};
317
+ txt.split(/\n+/).forEach(function (line) {
318
+ var m = line.match(/^([^::]{1,60})\s*[::]\s*(.+)$/);
319
+ if (m) { var k = m[1].trim(), v = m[2].trim(); if (k && v) specs[k] = v; }
320
+ });
321
+ return specs;
322
+ }
323
+
324
+ /* ------------------------- PERSISTENCE ------------------------- */
325
+ function persistUrlAdded(list) { try { localStorage.setItem('vai_url_persist', JSON.stringify(list)); } catch (e) {} }
326
+ function loadUrlAdded() {
327
+ try { var v = JSON.parse(localStorage.getItem('vai_url_persist') || '[]'); return Array.isArray(v) ? v : []; }
328
+ catch (e) { return []; }
329
+ }
330
+
331
+ /* ------------------------- FETCH (scraper API + CORS proxies) ------------------------- */
332
+ async function scrapeViaApi(url) {
333
+ // Server-side scraper: returns parsed JSON. No CORS, reliable.
334
+ try {
335
+ var r = await fetch(SCRAPER + '?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(SCRAPER_TOKEN), { cache: 'no-store' });
336
+ if (r.ok) {
337
+ var data = await r.json();
338
+ if (data && (data.name || data.sku || data.gallery || data.price)) {
339
+ // normalize server result to the same shape as parseProduct()
340
+ return {
341
+ name: data.name || '', sku: data.sku || '', img: data.img || '',
342
+ gallery: Array.isArray(data.gallery) ? data.gallery.filter(Boolean) : [],
343
+ desc: data.desc || data.summary || '', price: data.price || '',
344
+ pn: Number(data.pn) || 0, specs: data.specs || {}, brand: data.brand || ''
345
+ };
346
+ }
347
+ }
348
+ } catch (e) {}
349
+ return null;
350
+ }
351
+
352
+ async function fetchHtml(url) {
353
+ // Try direct first (works for same-site); then proxies.
354
+ var attempts = [url];
355
+ for (var i = 0; i < PROXIES.length; i++) attempts.push(PROXIES[i](url));
356
+ var lastErr = null;
357
+ for (var j = 0; j < attempts.length; j++) {
358
+ try {
359
+ var r = await fetch(attempts[j], { cache: 'no-store' });
360
+ if (r.ok) {
361
+ var t = await r.text();
362
+ if (t && t.length > 100) return t;
363
+ }
364
+ } catch (e) { lastErr = e; }
365
+ }
366
+ throw lastErr || new Error('Không tải được trang (CORS/network).');
367
+ }
368
+
369
+ /* ------------------------- PARSER ------------------------- */
370
+ function cleanUrl(u, base) {
371
+ if (!u) return '';
372
+ u = String(u).trim().replace(/[\u00a0]/g, ' ');
373
+ if (!u) return '';
374
+ // doubled scheme: 'https://www.x.vnhttps://x.vn/...'
375
+ var m = u.match(/^(https?:\/\/[^/]+)(https?:\/\/\S+)$/);
376
+ if (m) u = m[2];
377
+ // absolute
378
+ if (/^https?:\/\//i.test(u)) return u;
379
+ // protocol-relative
380
+ if (/^\/\//.test(u)) return (base && base.match(/^https?/i)[0] || 'https') + ':' + u;
381
+ // relative
382
+ if (base) {
383
+ try { return new URL(u, base).href; } catch (e) {}
384
+ }
385
+ return u;
386
+ }
387
+
388
+ function getMeta(doc, property) {
389
+ var el = doc.querySelector('meta[property="' + property + '"]') ||
390
+ doc.querySelector('meta[name="' + property + '"]');
391
+ return el ? (el.getAttribute('content') || '') : '';
392
+ }
393
+
394
+ function extractJsonLd(html) {
395
+ var re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
396
+ var m, best = null;
397
+ while ((m = re.exec(html))) {
398
+ try {
399
+ var data = JSON.parse(m[1]);
400
+ var arr = Array.isArray(data) ? data : [data];
401
+ for (var k = 0; k < arr.length; k++) {
402
+ var item = arr[k];
403
+ if (!item) continue;
404
+ var type = String(item['@type'] || '').toLowerCase();
405
+ var isProduct = type.indexOf('product') >= 0 || (item.name && (item.offers || item.image));
406
+ if (isProduct) {
407
+ var price = item.offers && (item.offers.price != null ? item.offers.price : (item.offers.lowPrice != null ? item.offers.lowPrice : null));
408
+ best = {
409
+ name: item.name, sku: item.sku,
410
+ brand: (item.brand && (item.brand.name || item.brand)) || '',
411
+ image: Array.isArray(item.image) ? item.image[0] : item.image,
412
+ price: price, priceCurrency: item.offers && item.offers.priceCurrency,
413
+ description: item.description
414
+ };
415
+ break;
416
+ }
417
+ }
418
+ if (best) break;
419
+ } catch (e) {}
420
+ }
421
+ return best;
422
+ }
423
+
424
+ function extractPrice(html) {
425
+ var price = '', pn = 0;
426
+ var m = html.match(/(?:property|itemprop)="product:price:amount"\s+content="([^"]+)"/i);
427
+ if (m) pn = parseInt(String(m[1]).replace(/[^\d]/g, ''), 10);
428
+ if (!pn) {
429
+ var pm = html.match(/Giá:?\s*<\/span>?\s*([\d.,\s]+)\s*(VNĐ|VND|đ|₫|Đ)/i);
430
+ if (pm) pn = parseInt(pm[1].replace(/[^\d]/g, ''), 10);
431
+ }
432
+ if (!pn) {
433
+ var am = html.match(/(\d{1,3}(?:[.,]\d{3}){1,3})\s*(VNĐ|VND|đ|₫)/i);
434
+ if (am) pn = parseInt(am[1].replace(/[^\d]/g, ''), 10);
435
+ }
436
+ if (pn) price = money(pn);
437
+ return { price: price, pn: pn };
438
+ }
439
+
440
+ function extractSpecs(html) {
441
+ var specs = {};
442
+ var lineRe = /<div class="row[^"]*pd-line[^"]*">\s*<div[^>]*>\s*<h4>\s*([^<]+?)\s*<\/h4>\s*<\/div>\s*<div[^>]*>\s*(?:<h5[^>]*class="code"[^>]*>([^<]+?)<\/h5>|<p>\s*([^<]+?)\s*<\/p>)\s*<\/div>/gi;
443
+ var lm;
444
+ while ((lm = lineRe.exec(html))) {
445
+ var k = String(lm[1] || '').trim();
446
+ var v = String(lm[2] || lm[3] || '').trim();
447
+ if (k && v && k !== 'Tên sản phẩm' && k !== 'Mã số') specs[k] = v;
448
+ }
449
+ return specs;
450
+ }
451
+
452
+ function extractGallery(html, base, primaryImg) {
453
+ var gallery = [];
454
+ var gre = /data-fancybox="gallery"[^>]*href="([^"]+)"/gi, gm;
455
+ while ((gm = gre.exec(html))) { var u = cleanUrl(gm[1], base); if (u && gallery.indexOf(u) < 0) gallery.push(u); }
456
+ var imgRe = /<a[^>]*data-fancybox="gallery"[^>]*>[\s\S]*?<img[^>]*src="([^"]+)"/gi, im;
457
+ while ((im = imgRe.exec(html))) { var u2 = cleanUrl(im[1], base); if (u2 && gallery.indexOf(u2) < 0) gallery.push(u2); }
458
+ if (primaryImg && gallery.indexOf(primaryImg) < 0) gallery.unshift(primaryImg);
459
+ return gallery;
460
+ }
461
+
462
+ function parseProduct(html, pageUrl) {
463
+ var doc = new DOMParser().parseFromString(html, 'text/html');
464
+ var ogTitle = getMeta(doc, 'og:title');
465
+ var ogDesc = getMeta(doc, 'og:description');
466
+ var ogImg = getMeta(doc, 'og:image');
467
+ if (!ogImg) ogImg = getMeta(doc, 'twitter:image');
468
+ var ld = extractJsonLd(html);
469
+
470
+ var tp = (html.match(/Tên sản phẩm<\/h4>[\s\S]{0,300}?<p[^>]*>\s*([^<]+?)\s*<\/p>/i) || [])[1] || '';
471
+ var msku = (html.match(/Mã số<\/h4>[\s\S]{0,300}?(?:<h5[^>]*class="code"[^>]*>|<p[^>]*>)\s*([^<]+?)\s*(?:<\/h5>|<\/p>)/i) || [])[1] || '';
472
+ var name = (tp && tp.trim()) || ogTitle || (ld && ld.name) || (ogTitle && ogTitle.split(/[|–—-]/)[0].trim()) || 'Sản phẩm';
473
+ name = String(name).trim();
474
+ var priceInfo = extractPrice(html);
475
+ if (!priceInfo.pn && ld && ld.price != null) {
476
+ var lp = parseInt(String(ld.price).replace(/[^\d]/g, ''), 10);
477
+ if (lp) { priceInfo.pn = lp; priceInfo.price = money(lp); }
478
+ }
479
+
480
+ var primaryImg = cleanUrl(ogImg, pageUrl);
481
+ var gallery = extractGallery(html, pageUrl, primaryImg);
482
+ if (!primaryImg && gallery.length) primaryImg = gallery[0];
483
+
484
+ var sku = (msku && msku.trim()) || (ld && ld.sku) || '';
485
+ if (!sku) sku = (name.match(/[A-Z0-9]{3,}/) || [''])[0];
486
+
487
+ var desc = ogDesc || (ld && ld.description) || '';
488
+ desc = String(desc || '').replace(/\s*\|\s*[^|]*$/, '');
489
+
490
+ var specs = extractSpecs(html);
491
+ var brand = getMeta(doc, 'og:site_name') || (ld && ld.brand) || '';
492
+ if (!brand) { try { var host = new URL(pageUrl).hostname; brand = host.replace(/^www\./, '').split('.')[0]; } catch (e) {} }
493
+ brand = String(brand || '').replace(/^www\./i, '');
494
+
495
+ return {
496
+ name: name, sku: sku, img: primaryImg, gallery: gallery,
497
+ desc: desc, price: priceInfo.price, pn: priceInfo.pn,
498
+ specs: specs, brand: brand, ld: ld
499
+ };
500
+ }
501
+
502
+ /* ------------------------- SUBMIT ------------------------- */
503
+ /* Two-step add flow:
504
+ Step 1 (prepare): scrape (URL) or build (OCR) the product info, fill the
505
+ OCR fields + full preview panel. Button switches to "Xác nhận thêm SP".
506
+ Step 2 (confirm): persist + add to storefront, then close. */
507
+ var __pendingAdd = null;
508
+
509
+ window.submitAddProductUrl = async function () {
510
+ var st = document.getElementById('addProductStatus');
511
+ var secretEl = document.getElementById('addProductSecret');
512
+ var urlEl = document.getElementById('addProductUrl');
513
+ var submitBtn = document.getElementById('addProductSubmit');
514
+
515
+ function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
516
+ function restoreBtn() { if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; } }
517
+
518
+ var secret = (secretEl && secretEl.value || '').trim();
519
+ if (!isValidSecret(secret)) {
520
+ status('❌ Mã truy cập không đúng. Nhập mặc định: ' + LOCAL_SECRET, 'err');
521
+ return;
522
+ }
523
+
524
+ var raw = (urlEl && urlEl.value || '').trim();
525
+ var pageUrl = '';
526
+ if (raw) {
527
+ try { pageUrl = new URL(raw, SITE + '/').href; } catch (e) {
528
+ status('❌ URL không hợp lệ.', 'err'); return;
529
+ }
530
+ }
531
+
532
+ var ocrName = (document.getElementById('addOcrName') || {}).value || '';
533
+ var ocrSku = (document.getElementById('addOcrSku') || {}).value || '';
534
+ var ocrPrice = (document.getElementById('addOcrPrice') || {}).value || '';
535
+ var ocrBrand = (document.getElementById('addOcrBrand') || {}).value || '';
536
+ var usingOCR = OCR_IMAGES.length > 0;
537
+
538
+ if (!raw && !usingOCR) { status('❌ Vui lòng nhập URL sản phẩm HOẶC dán/tải ảnh sản phẩm.', 'err'); return; }
539
+
540
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '⏳ Đang phân tích...'; }
541
+ var r = null, slug = '';
542
+ try {
543
+ if (usingOCR && ocrName) {
544
+ // Build product directly from OCR + images
545
+ var ocrP = parseInt(ocrPrice, 10) || 0;
546
+ slug = slugify((ocrSku ? ocrSku + ' ' : '') + ocrName);
547
+ r = {
548
+ name: ocrName, sku: ocrSku || '', price: ocrP ? money(ocrP) : '', pn: ocrP,
549
+ img: OCR_IMAGES[0], gallery: OCR_IMAGES.slice(), desc: '',
550
+ brand: ocrBrand || '', specs: {}
551
+ };
552
+ r.imgs_data = OCR_IMAGES.slice();
553
+ } else {
554
+ if (!pageUrl && usingOCR) {
555
+ status('❌ Đã có ảnh nhưng chưa có tên sản phẩm. Bấm "Trích xuất thông tin từ ảnh (OCR)" để lấy tên, hoặc nhập tên ở bước 3.', 'err');
556
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; }
557
+ return;
558
+ }
559
+ status('⏳ Bước 1: Đang tải & phân tích (scrape) sản phẩm…' + (usingOCR ? ' (sẽ dùng ảnh kèm theo)' : ''));
560
+ if (!pageUrl) throw new Error('Không có URL để phân tích.');
561
+ r = await scrapeViaApi(pageUrl);
562
+ if (!r || !r.name || r.name === 'Sản phẩm') {
563
+ var html = await fetchHtml(pageUrl);
564
+ r = parseProduct(html, pageUrl);
565
+ }
566
+ }
567
+
568
+ if (!r || !r.name || r.name === 'Sản phẩm') throw new Error('Không lấy được tên sản phẩm.');
569
+ if (!r.price && !r.img) throw new Error('Không trích xuất được giá/hình. Kiểm tra URL.');
570
+
571
+ slug = slugify((r.sku ? r.sku + ' ' : '') + r.name);
572
+
573
+ // Populate OCR fields with scraped/build info so the owner can edit before confirm
574
+ if (document.getElementById('addOcrName')) document.getElementById('addOcrName').value = r.name || '';
575
+ if (document.getElementById('addOcrSku')) document.getElementById('addOcrSku').value = r.sku || '';
576
+ if (document.getElementById('addOcrPrice')) document.getElementById('addOcrPrice').value = r.pn || '';
577
+ if (document.getElementById('addOcrBrand')) document.getElementById('addOcrBrand').value = r.brand || '';
578
+ if (document.getElementById('addOcrSpecs') && r.specs && typeof r.specs === 'object') {
579
+ document.getElementById('addOcrSpecs').value = Object.keys(r.specs).map(function (k) { return k + ': ' + r.specs[k]; }).join('\n');
580
+ }
581
+ var ocrf = document.getElementById('addOcrFields');
582
+ if (ocrf) ocrf.style.display = 'block';
583
+
584
+ // Expose scraped URL gallery images in the editable image list so the
585
+ // owner can review/delete/reorder them before confirming.
586
+ if (r.gallery && r.gallery.length && !OCR_IMAGES.length) {
587
+ OCR_IMAGES = r.gallery.slice();
588
+ renderImgPreview();
589
+ }
590
+
591
+ // Store pending product so confirm step uses the (possibly edited) fields + images
592
+ __pendingAdd = { r: r, slug: slug, pageUrl: pageUrl, usingOCR: usingOCR };
593
+
594
+ // Show full preview + enter confirm mode
595
+ enterPreviewMode();
596
+ status('Bước 1 xong: đã trích xuất thông tin & hiện preview ở trên. Bạn có thể chỉnh sửa, sau đó bấm "Xác nhận thêm SP" để hoàn tất.', 'ok');
597
+ try { if (st) st.scrollIntoView({behavior:'smooth',block:'nearest'}); } catch(e){}
598
+ } catch (err) {
599
+ status('❌ Lỗi phân tích: ' + (err && err.message || err), 'err');
600
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Thêm SP'; }
601
+ }
602
+ };
603
+
604
+ /* ---- Step 2: confirm & persist (uses current edited fields + images) ---- */
605
+ window.confirmAddProduct = async function () {
606
+ var st = document.getElementById('addProductStatus');
607
+ var submitBtn = document.getElementById('addProductSubmit');
608
+ function status(msg, cls) { if (st) { st.textContent = msg; st.className = 'add-product-status ' + (cls || ''); } }
609
+
610
+ if (!__pendingAdd) { status('❌ Chưa có sản phẩm để xác nhận.', 'err'); return; }
611
+ var prev = __pendingAdd;
612
+
613
+ // Read current (possibly edited) values
614
+ var name = (document.getElementById('addOcrName') || {}).value || '';
615
+ var sku = (document.getElementById('addOcrSku') || {}).value || '';
616
+ var price = parseInt((document.getElementById('addOcrPrice') || {}).value, 10) || 0;
617
+ var brand = (document.getElementById('addOcrBrand') || {}).value || '';
618
+ if (!name) { status('❌ Chưa có tên sản phẩm.', 'err'); return; }
619
+
620
+ var specsObj = parseSpecsText();
621
+ if (!Object.keys(specsObj).length) specsObj = prev.r.specs || {};
622
+ var r = {
623
+ name: name, sku: sku, price: price ? money(price) : '', pn: price,
624
+ img: OCR_IMAGES[0] || prev.r.img, gallery: OCR_IMAGES.length ? OCR_IMAGES.slice() : (prev.r.gallery || []),
625
+ desc: (document.getElementById('addOcrDesc') || {}).value || prev.r.desc || '',
626
+ brand: brand, specs: specsObj
627
+ };
628
+ r.imgs_data = OCR_IMAGES.length ? OCR_IMAGES.slice() : undefined;
629
+ var slug = slugify((sku ? sku + ' ' : '') + name);
630
+
631
+ // Merge edited OCR fields that were scraped but not otherwise edited
632
+ if (!OCR_IMAGES.length && !r.gallery.length) r.gallery = r.img ? [r.img] : [];
633
+ if (r.img && r.gallery.indexOf(r.img) < 0) r.gallery.unshift(r.img);
634
+
635
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '⏳ Đang lưu…'; }
636
+ try {
637
+ var baseRec = {
638
+ n: name, name: name, sku: sku, mod: sku, model: sku,
639
+ slug: slug, p: r.price, price: r.price, pn: r.pn, priceNum: r.pn,
640
+ i: r.img, image: r.img, imgs: r.gallery, images: r.gallery,
641
+ c: (document.getElementById('addOcrCat') || {}).value || (prev.usingOCR ? 'Sản phẩm thêm qua OCR' : 'Sản phẩm thêm qua URL'),
642
+ cs: 'san-pham-them-qua-url', ci: 'fa-box',
643
+ sum: r.desc, summary: r.desc, desc: r.desc,
644
+ specs: r.specs, feats: [], brand: brand,
645
+ _sourceUrl: prev.pageUrl, _url_added: true
646
+ };
647
+
648
+ var list = loadUrlAdded();
649
+ var exists = list.findIndex(function (x) { return x.slug === slug || (x.sku && sku && x.sku === sku); });
650
+ if (exists >= 0) list[exists] = baseRec; else list.push(baseRec);
651
+ persistUrlAdded(list);
652
+
653
+ if (typeof window.D !== 'undefined' && Array.isArray(window.D)) {
654
+ var dup = window.D.find(function (p) { return p.slug === slug || (p.sku && sku && p.sku === sku); });
655
+ if (dup) { var k = window.D.indexOf(dup); window.D[k] = Object.assign({}, dup, baseRec); }
656
+ else { window.D.unshift(Object.assign({}, window.D[0] || {}, baseRec)); }
657
+ }
658
+ if (typeof window.initFilters === 'function') { try { window.initFilters(); } catch (e) {} }
659
+ if (typeof window.render === 'function') { try { window.render(); } catch (e) {} }
660
+
661
+ var persisted = false;
662
+ var persistErr = '';
663
+ try {
664
+ if (!window.PERSIST_API) window.PERSIST_API = PERSIST_API;
665
+ var ps = await fetch(window.PERSIST_API, {
666
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
667
+ body: JSON.stringify({
668
+ token: SCRAPER_TOKEN,
669
+ products: [{
670
+ name: name, sku: sku, slug: slug, price: r.price, pn: r.pn,
671
+ img: r.img, gallery: r.gallery, desc: r.desc, brand: brand,
672
+ url: prev.pageUrl, specs: r.specs || {},
673
+ imgs_data: r.imgs_data
674
+ }]
675
+ })
676
+ });
677
+ if (ps.ok) { var pj = await ps.json(); persisted = !!(pj && pj.persisted); if (!persisted) persistErr = (pj && (pj.detail || pj.error)) || 'server rejected'; }
678
+ else { persistErr = 'HTTP ' + ps.status + ': ' + ((await ps.text()) || '').slice(0,150); }
679
+ } catch (e) { persistErr = (e && e.message) || String(e); }
680
+ if (!persisted) persistUrlAdded(list);
681
+
682
+ status('✅ Đã thêm: "' + name + '" (Mã ' + (sku || '?') + ')' +
683
+ '\nGiá: ' + (r.price || 'Liên hệ') + ' | ' + (r.gallery.length || 0) + ' ảnh' +
684
+ '\nSEO: /san-pham/' + slug + '/index.html' +
685
+ (persisted ? '\n✓ Đã lưu vĩnh viễn (không mất khi tải lại trang).' : ('\n⚠ Lưu tạm thời (localStorage) — server persist lỗi: ' + (persistErr || '?') + '. Bấm "Thêm SP" lần nữa để thử.' + '\nLưu ý: sản phẩm vẫn hiển thị trên máy này.')),
686
+ 'ok');
687
+ __pendingAdd = null;
688
+ setTimeout(function(){ window.closeAddProductModal(); document.getElementById('addProductSubmit').textContent='Thêm SP'; document.getElementById('addProductSubmit').removeAttribute('data-confirm'); }, 2200);
689
+ } catch (err) {
690
+ status('❌ Lỗi: ' + (err && err.message || err), 'err');
691
+ if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = '✅ Xác nhận thêm SP'; }
692
+ }
693
+ };
694
+
695
+ // Show preview panel + enter confirm mode (shared by prepare & OCR paths).
696
+ function enterPreviewMode() {
697
+ buildAddPreview();
698
+ var wrap = document.getElementById('addPreviewWrap');
699
+ if (wrap) wrap.style.display = 'block';
700
+ var bb = document.getElementById('addBackBtn');
701
+ if (bb) bb.style.display = 'inline-flex';
702
+ var subBtn = document.getElementById('addProductSubmit');
703
+ if (subBtn) { subBtn.textContent = '✅ Xác nhận thêm SP'; subBtn.setAttribute('data-confirm','1'); subBtn.disabled=false; }
704
+ }
705
+
706
+ /* ---- Back from preview: return to URL/OCR input step ---- */
707
+ window.goBackFromPreview = function () {
708
+ __pendingAdd = null;
709
+ var wrap = document.getElementById('addPreviewWrap');
710
+ if (wrap) wrap.style.display = 'none';
711
+ var bb = document.getElementById('addBackBtn');
712
+ if (bb) bb.style.display = 'none';
713
+ var subBtn = document.getElementById('addProductSubmit');
714
+ if (subBtn) { subBtn.textContent = 'Thêm SP'; subBtn.removeAttribute('data-confirm'); subBtn.disabled = false; }
715
+ var st = document.getElementById('addProductStatus');
716
+ if (st) { st.textContent = ''; st.className = 'add-product-status'; }
717
+ // scroll back up to the input section
718
+ var urlEl = document.getElementById('addProductUrl');
719
+ if (urlEl) { try { urlEl.scrollIntoView({behavior:'smooth',block:'start'}); } catch(e){} }
720
+ };
721
+
722
+ console.log('[VAI AddProd Fix v2] Loaded: modal + secret + external-URL scraping active.');
723
+ })();