"use strict"; const CFG = window.APP_CONFIG; /* ----------------------------- State ----------------------------- */ let allRows = []; // toàn bộ sản phẩm đã parse // Danh sách đã listed theo store: Map(code chuẩn hoá -> listingId). const listed = { AU: new Map(), US: new Map() }; /* --------------------------- DOM refs ---------------------------- */ const el = { url: document.getElementById("sheetUrl"), name: document.getElementById("sheetName"), loadBtn: document.getElementById("loadBtn"), status: document.getElementById("status"), toolbar: document.getElementById("toolbar"), search: document.getElementById("searchInput"), count: document.getElementById("resultCount"), listWrap: document.getElementById("listWrap"), listBody: document.getElementById("listBody"), }; /* ------------------------- Helpers: URL -------------------------- */ // Lấy spreadsheet ID từ link đầy đủ hoặc chấp nhận ID trần. function extractSheetId(input) { const s = input.trim(); const m = s.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); if (m) return m[1]; // gviz export link const m2 = s.match(/[?&]key=([a-zA-Z0-9-_]+)/); if (m2) return m2[1]; // nếu người dùng dán thẳng ID if (/^[a-zA-Z0-9-_]{20,}$/.test(s)) return s; return null; } // Lấy gid (id của tab) từ link nếu có. function extractGid(input) { const m = input.match(/[#&?]gid=(\d+)/); return m ? m[1] : null; } // Tạo URL xuất CSV (hỗ trợ CORS cho sheet công khai). // Ưu tiên endpoint /export vì nó trả về NGUYÊN lưới (đủ mọi cột). // gviz/tq tự suy đoán vùng bảng nên hay cắt mất cột (vd Giá AUD/USD). function buildCsvUrl(id, sheetName, gid) { // export không nhận tên tab -> chỉ dùng gviz khi chỉ có tên tab mà không có gid. if (sheetName && !gid) { return `https://docs.google.com/spreadsheets/d/${id}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(sheetName)}`; } let url = `https://docs.google.com/spreadsheets/d/${id}/export?format=csv`; if (gid) url += `&gid=${gid}`; return url; } /* ------------------------- CSV parsing --------------------------- */ // Parser CSV nhỏ gọn, xử lý dấu phẩy/xuống dòng/quote bên trong ô. function parseCsv(text) { const rows = []; let row = []; let field = ""; let inQuotes = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; } else field += c; } else { if (c === '"') inQuotes = true; else if (c === ",") { row.push(field); field = ""; } else if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; } else if (c === "\r") { /* bỏ qua */ } else field += c; } } if (field.length > 0 || row.length > 0) { row.push(field); rows.push(row); } return rows; } const norm = (s) => String(s || "").trim().toLowerCase(); // Map header -> chỉ số cột theo COLUMN_MAP (có alias). function buildHeaderIndex(headerRow) { const index = {}; const headers = headerRow.map(norm); for (const [field, aliases] of Object.entries(CFG.COLUMN_MAP)) { for (const alias of aliases) { const pos = headers.indexOf(norm(alias)); if (pos !== -1) { index[field] = pos; break; } } } return index; } // Tìm dòng header trong N dòng đầu (sheet có thể có nhiều thông tin phía trên). function findHeaderRow(rows) { const limit = Math.min(rows.length, 15); let best = { rowIdx: -1, index: {}, hits: 0 }; for (let i = 0; i < limit; i++) { const index = buildHeaderIndex(rows[i]); const hits = Object.keys(index).length; if (hits > best.hits) best = { rowIdx: i, index, hits }; } return best; } /* --------------------------- Rendering --------------------------- */ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) ); } function render(rows) { if (!rows.length) { el.listBody.innerHTML = `
Không có sản phẩm phù hợp.
`; } else { el.listBody.innerHTML = rows.map(rowHtml).join(""); } el.count.textContent = `${rows.length} / ${allRows.length} sản phẩm`; } function rowHtml(p) { return `
${esc(p.model)}
${esc(p.condition)}
${conditionSelect(p)}
${actionBtn("AU", p)} ${actionBtn("US", p)}
${listedLinks(p)}
`; } // Combobox Condition — 3 giá trị cố định, giữ giá trị gốc nếu khác. const CONDITION_OPTIONS = ["NEW", "NEW_OTHER", "USED_EXCELLENT"]; function conditionSelect(p) { const cur = p.conditionEbay || ""; const opts = CONDITION_OPTIONS.slice(); if (cur && !opts.includes(cur)) opts.unshift(cur); let html = cur ? "" : ``; html += opts .map((o) => ``) .join(""); return ``; } // 2 nút AU/US luôn hiển thị (để update & list lại bất cứ lúc nào). function actionBtn(region, p) { const cls = region === "AU" ? "btn--au" : "btn--us"; return ``; } // Dấu "đã listed" hiện DƯỚI 2 nút; click dẫn tới sản phẩm trên eBay. function listedLinks(p) { const links = []; if (p.listedAu) links.push(listedLink("AU", p.listedAu.listingId)); if (p.listedUs) links.push(listedLink("US", p.listedUs.listingId)); return links.length ? `` : ""; } function listedLink(region, listingId) { const label = `${region} ✓ listed`; const cls = `listed-link listed-link--${region.toLowerCase()}`; if (typeof listingId === "string" && listingId) { const url = CFG.EBAY_ITEM_URL[region] + listingId; return `${label} ↗`; } return `${label}`; } /* ----------------------------- Status ---------------------------- */ function setStatus(msg, kind = "info") { el.status.textContent = msg; el.status.className = `status status--${kind}`; } /* ------------------------- Load Sheet flow ----------------------- */ async function loadSheet() { const raw = el.url.value; if (!raw.trim()) { setStatus("Vui lòng nhập link Google Sheet.", "error"); return; } const id = extractSheetId(raw); if (!id) { setStatus("Không nhận diện được Spreadsheet ID từ link.", "error"); return; } const gid = extractGid(raw); const sheetName = el.name.value.trim(); const csvUrl = buildCsvUrl(id, sheetName, gid); setBtnLoading(el.loadBtn, true); el.url.disabled = el.name.disabled = true; setStatus("Đang tải dữ liệu từ Google Sheet...", "info"); try { const res = await fetch(csvUrl); if (!res.ok) throw new Error(`HTTP ${res.status} — kiểm tra sheet đã share công khai chưa.`); const text = await res.text(); const grid = parseCsv(text); if (!grid.length) throw new Error("File rỗng hoặc không đọc được."); const { rowIdx, index, hits } = findHeaderRow(grid); if (hits === 0) { throw new Error("Không tìm thấy các cột Model/Title/... Kiểm tra tên cột hoặc COLUMN_MAP trong config.js."); } allRows = grid.slice(rowIdx + 1) .map((cells, i) => ({ _id: String(i), model: (cells[index.model] ?? "").trim(), condition: (cells[index.condition] ?? "").trim(), conditionEbay: "", // chọn từ combobox (tách riêng khỏi condition) title: (cells[index.title] ?? "").trim(), priceAud: (cells[index.priceAud] ?? "").trim(), priceUsd: (cells[index.priceUsd] ?? "").trim(), package: (cells[index.package] ?? "").trim(), })) // bỏ dòng trống hoàn toàn .filter((p) => p.model || p.title || p.condition); el.toolbar.hidden = false; el.listWrap.hidden = false; applyListedFlags(); // đối chiếu với danh sách đã listed (nếu API đã về) setStatus(`Đã tải ${allRows.length} sản phẩm (nhận diện ${hits} cột).`, "ok"); } catch (err) { setStatus(`Lỗi: ${err.message}`, "error"); } finally { setBtnLoading(el.loadBtn, false); el.url.disabled = el.name.disabled = false; } } /* ----------------------------- Search ---------------------------- */ function applySearch() { const q = norm(el.search.value); if (!q) { render(allRows); return; } const filtered = allRows.filter((p) => norm(p.model).includes(q) || norm(p.title).includes(q) || norm(p.condition).includes(q) || norm(p.package).includes(q) ); render(filtered); } /* -------------------- Danh sách đã listed ------------------------ */ // Đối chiếu Model (sheet) với code (API) -> gắn cờ listedAu/listedUs cho mỗi dòng. // Gọi lại mỗi khi 1 trong 3 nguồn xong (2 API listed + load sheet). function applyListedFlags() { if (!allRows.length) return; for (const p of allRows) { const key = norm(p.model); p.listedAu = key && listed.AU.has(key) ? listed.AU.get(key) : null; p.listedUs = key && listed.US.has(key) ? listed.US.get(key) : null; } applySearch(); // render lại view hiện tại } // Gọi transferGetData để lấy danh sách đã listed cho 1 store (AU/US). async function fetchListed(region) { const body = { urlAPI: CFG.GET_LIST_URL_API, filter: { skip: 0, limit: 2000000, order: "updatedAt desc", where: { account: CFG.ACCOUNTS[region], status: CFG.LISTED_STATUS }, }, }; try { const res = await fetch(CFG.TRANSFER_GET_URL, { method: "POST", headers: CFG.HEADERS, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const items = Array.isArray(json?.data) ? json.data : []; const map = new Map(); for (const it of items) { if (it && it.code) { map.set(norm(it.code), { listingId: it.listingId || "", id: it.ebayListingId || "" }); } } listed[region] = map; applyListedFlags(); // 1 trong 3 lần đối chiếu } catch (err) { console.warn(`Không lấy được danh sách đã listed (${region}):`, err.message); } } // Gọi transferGetData để lấy danh sách đã listed cho 1 store (AU/US). async function fetchModel(model) { const body = { urlAPI: CFG.GET_MODEL_URL_API, filter: { limit: 10, order: "updatedAt desc", where: { _q: model }, }, }; try { const res = await fetch(CFG.TRANSFER_GET_URL, { method: "POST", headers: CFG.HEADERS, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const items = Array.isArray(json?.data) ? json.data : []; const map = new Map(); for (const it of items) { if (it && it.code && it.code === model) return it; } return null; } catch (err) { console.warn(`Không lấy được model "${model}":`, err.message); return null; } } /* --------------------------- API calls --------------------------- */ // "$380" / "380.00 AUD" -> 380 (number). Trả "" nếu rỗng. function toNumber(v) { const n = parseFloat(String(v).replace(/[^0-9.]/g, "")); return Number.isFinite(n) ? n : ""; } // "1x A9K-RSP440-TR\n1x ..." -> ["1x A9K-RSP440-TR", "1x ..."] function toPackageArray(v) { return String(v || "") .split(/\r?\n|;/) .map((s) => s.trim()) .filter(Boolean); } // Tạo item dữ liệu khớp cấu trúc /api/ebay-listing/data-save. function buildItem(p, region, productModelId, listedId) { return { ...CFG.ITEM_DEFAULTS, code: p.model, shortDescription: p.title, condition: p.condition, conditionEbay: p.conditionEbay, price: region === "AU" ? toNumber(p.priceAud) : toNumber(p.priceUsd), currency: region === "AU" ? "AUD" : "USD", package_contain: p.package ? toPackageArray(p.package) : ["1x " + p.model], account: CFG.ACCOUNTS[region], // <-- field khác nhau giữa AU và US productModelId, // id lấy từ API product-model, nếu có id: listedId || "", // id listing đã có (từ fetchListed); rỗng nếu chưa listed shippingPostagePolicy: region === "AU" ? 11753752025 : 231163007025 }; } function buildPayload(p, region, productModelId, listedId) { return { urlAPI: CFG.URL_API, data: { data: [buildItem(p, region, productModelId, listedId)] }, pageCurrent: CFG.PAGE_CURRENT, }; } // Lấy bản ghi kết quả đầu tiên từ response. // Cấu trúc thật: { Status, Msg, data: { "": { listingId, statusCode, message, ... } } } function extractResult(json) { if (!json || typeof json !== "object") return null; const d = json.data; if (d && typeof d === "object") { if (Array.isArray(d)) return d[0] || null; // data là object keyed theo id -> lấy phần tử đầu, dùng key làm id nếu thiếu. const entries = Object.entries(d); if (entries[0]) { const [key, val] = entries[0]; if (val && typeof val === "object") return { ...val, id: val.id || key }; } } if (json.listingId) return json; // fallback dạng phẳng return null; } async function callApi(region, id, btn) { const product = allRows.find((p) => p._id === id); if (!product) return; const rowEl = btn.closest(".row"); // disable + loading cho cả dòng setRowBusy(rowEl, true); setBtnLoading(btn, true); setStatus(`Đang gửi "${product.model || product.title}" sang ${region}...`, "info"); const modelData = await fetchModel(product.model); if (!modelData) { setStatus(`Model "${product.model}" không tồn tại trong ERP`, "error"); // Quan trọng: nhả trạng thái loading/disable trước khi return, // nếu không nút AU/US và cả dòng sẽ kẹt spinner vĩnh viễn. setBtnLoading(btn, false); setRowBusy(rowEl, false); return; } // id listing đã có của store này (nếu model đã listed); rỗng nếu chưa. const listedRec = region === "AU" ? product.listedAu : product.listedUs; const listedId = listedRec?.id || ""; try { const res = await fetch(CFG.API_URL, { method: "POST", headers: CFG.HEADERS, body: JSON.stringify(buildPayload(product, region, modelData.id, listedId)), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json().catch(() => null); const result = extractResult(json); const listingId = result?.listingId; if (listingId) { const url = CFG.EBAY_ITEM_URL[region] + listingId; // Đánh dấu listed NGAY (không cần reload để lấy danh sách mới). const rec = { listingId, id: result.id || listedId || "" }; listed[region].set(norm(product.model), rec); if (region === "AU") product.listedAu = rec; else product.listedUs = rec; applySearch(); // render lại để hiện badge ✓ // Mở tab mới dẫn tới sản phẩm. window.open(url, "_blank", "noopener"); setStatus(`✓ ${region} thành công — listingId ${listingId}`, "ok"); } else { const msg = result?.message || json?.Msg || "không có listingId"; setStatus(`✓ Đã gửi ${region} nhưng ${msg}`, "ok"); } } catch (err) { setStatus(`Lỗi gửi ${region}: ${err.message}`, "error"); } finally { setBtnLoading(btn, false); setRowBusy(rowEl, false); } } /* ----------------------- UI state helpers ------------------------ */ function setBtnLoading(btn, loading) { btn.classList.toggle("is-loading", loading); btn.disabled = loading; } function setRowBusy(rowEl, busy) { if (!rowEl) return; rowEl.classList.toggle("is-busy", busy); rowEl.querySelectorAll(".btn--row").forEach((b) => (b.disabled = busy)); } /* ----------------------------- Events ---------------------------- */ el.loadBtn.addEventListener("click", loadSheet); el.url.addEventListener("keydown", (e) => { if (e.key === "Enter") loadSheet(); }); el.search.addEventListener("input", applySearch); // Ghi giá trị chỉnh sửa (Title/Giá AUD/Giá USD/Package) thẳng vào dữ liệu. // Không re-render để giữ con trỏ; lần gọi API sau dùng đúng giá trị mới. el.listBody.addEventListener("input", (e) => { const inp = e.target.closest(".cell-input"); if (!inp) return; const p = allRows.find((x) => x._id === inp.dataset.id); if (p) p[inp.dataset.field] = inp.value; }); /* ------------------------- Khởi động ----------------------------- */ // Lấy danh sách đã listed cho cả 2 store ngay khi mở trang. // Mỗi API về sẽ tự đối chiếu lại (kể cả khi sheet được load trước/sau). fetchListed("AU"); fetchListed("US"); // Event delegation cho các nút AU/US (vì dòng được render động). el.listBody.addEventListener("click", (e) => { const btn = e.target.closest(".btn--row"); if (!btn) return; // Là link (đã có sẵn link sản phẩm) -> để trình duyệt mở tab bình thường. if (btn.tagName === "A") return; if (btn.disabled) return; callApi(btn.dataset.region, btn.dataset.id, btn); });