514 lines
18 KiB
JavaScript
514 lines
18 KiB
JavaScript
"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 = `<div class="empty">Không có sản phẩm phù hợp.</div>`;
|
|
} else {
|
|
el.listBody.innerHTML = rows.map(rowHtml).join("");
|
|
}
|
|
el.count.textContent = `${rows.length} / ${allRows.length} sản phẩm`;
|
|
}
|
|
|
|
function rowHtml(p) {
|
|
return `
|
|
<div class="row" data-id="${p._id}">
|
|
<div class="cell cell--model" title="${esc(p.model)}">${esc(p.model)}</div>
|
|
<div class="cell cell--cond textCenter" title="${esc(p.condition)}">${esc(p.condition)}</div>
|
|
<div class="cell cell--condeb">${conditionSelect(p)}</div>
|
|
<div class="cell cell--title">
|
|
<input class="cell-input" data-id="${p._id}" data-field="title" value="${esc(p.title)}" />
|
|
</div>
|
|
<div class="cell cell--num">
|
|
<input class="cell-input cell-input--num" data-id="${p._id}" data-field="priceAud" value="${esc(p.priceAud)}" />
|
|
</div>
|
|
<div class="cell cell--num">
|
|
<input class="cell-input cell-input--num" data-id="${p._id}" data-field="priceUsd" value="${esc(p.priceUsd)}" />
|
|
</div>
|
|
<div class="cell cell--pkg">
|
|
<textarea class="cell-input cell-input--area" data-id="${p._id}" data-field="package" rows="2">${esc(p.package)}</textarea>
|
|
</div>
|
|
<div class="cell cell--actions">
|
|
<div class="action-btns">
|
|
${actionBtn("AU", p)}
|
|
${actionBtn("US", p)}
|
|
</div>
|
|
${listedLinks(p)}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// 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 ? "" : `<option value="" selected disabled>— chọn —</option>`;
|
|
html += opts
|
|
.map((o) => `<option value="${esc(o)}"${o === cur ? " selected" : ""}>${esc(o)}</option>`)
|
|
.join("");
|
|
return `<select class="cell-input cell-select" data-id="${p._id}" data-field="conditionEbay">${html}</select>`;
|
|
}
|
|
|
|
// 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 `<button class="btn btn--row ${cls}" data-region="${region}" data-id="${p._id}">
|
|
<span class="btn__label">${region}</span>
|
|
<span class="btn__spinner" aria-hidden="true"></span>
|
|
</button>`;
|
|
}
|
|
|
|
// 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 ? `<div class="listed-links">${links.join("")}</div>` : "";
|
|
}
|
|
|
|
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 `<a class="${cls}" href="${esc(url)}" target="_blank" rel="noopener" title="listingId ${esc(listingId)}">${label} ↗</a>`;
|
|
}
|
|
return `<span class="${cls}">${label}</span>`;
|
|
}
|
|
|
|
/* ----------------------------- 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: { "<id>": { 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");
|
|
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);
|
|
});
|