first commit
This commit is contained in:
commit
ea63161e3a
|
|
@ -0,0 +1,49 @@
|
|||
# eBay Listing — Google Sheet Loader
|
||||
|
||||
Web app tĩnh (HTML/CSS/JS thuần, không cần build) để:
|
||||
|
||||
1. Nhập link Google Sheet và bấm **Load**.
|
||||
2. Đọc tab chứa danh sách sản phẩm (các cột: `Model`, `Condition`, `Title`, `Giá AUD`, `Giá USD`, `Package Contain (Racks)`).
|
||||
3. Hiển thị danh sách có **scroll** và **search**.
|
||||
4. Mỗi dòng có 2 nút **AU** / **US** — bấm để gọi API sang hệ thống của bạn.
|
||||
5. Tất cả thao tác load và gọi API đều có hiệu ứng **disable + loading (spinner)**.
|
||||
|
||||
## Cấu trúc
|
||||
|
||||
| File | Vai trò |
|
||||
|--------------|---------------------------------------------------------------|
|
||||
| `index.html` | Bố cục giao diện |
|
||||
| `styles.css` | Style, spinner, trạng thái disable/loading |
|
||||
| `config.js` | **Cấu hình của bạn**: API_BASE, endpoint AU/US, header, map cột |
|
||||
| `app.js` | Logic: parse link → tải CSV → parse → render → search → call API |
|
||||
|
||||
## Cách chạy
|
||||
|
||||
Vì app gọi `fetch`, nên chạy qua HTTP (không mở trực tiếp `file://`):
|
||||
|
||||
```bash
|
||||
cd /home/andrew/listing_ebay
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
|
||||
Mở http://localhost:8000
|
||||
|
||||
## Chuẩn bị Google Sheet
|
||||
|
||||
Sheet phải được **share công khai** (Anyone with the link → Viewer) để đọc được qua
|
||||
endpoint CSV `gviz`. Dán link đầy đủ (có `/spreadsheets/d/...`) hoặc Spreadsheet ID.
|
||||
|
||||
- Nếu danh sách nằm ở tab khác tab đầu tiên: nhập **Tên tab** vào ô nhỏ, hoặc dán
|
||||
link có sẵn `#gid=...`.
|
||||
- App tự dò dòng tiêu đề trong 15 dòng đầu (sheet có thể có thông tin phía trên).
|
||||
|
||||
## Tuỳ chỉnh
|
||||
|
||||
Mở `config.js`:
|
||||
|
||||
- `API_BASE` + `ENDPOINTS.AU/US`: trỏ tới backend của bạn. Request là
|
||||
`POST` với body JSON (xem `buildPayload` trong `app.js`).
|
||||
- `HEADERS`: thêm token nếu cần.
|
||||
- `COLUMN_MAP`: nếu tên cột trong sheet khác, thêm alias tại đây.
|
||||
|
||||
> Lưu ý: nếu backend khác domain, hãy bật **CORS** ở phía server đó.
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
"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);
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Cấu hình hệ thống — chỉnh sửa cho khớp với backend của bạn.
|
||||
*
|
||||
* Khi bấm nút AU / US trên mỗi dòng, app sẽ gọi:
|
||||
* POST {API_URL}
|
||||
* body: { urlAPI, data: { data: [ <item> ] }, pageCurrent }
|
||||
*
|
||||
* AU và US chỉ khác nhau ở field `account` của item (xem ACCOUNTS).
|
||||
* Nếu API ở domain khác, hãy bật CORS ở phía server đó.
|
||||
*/
|
||||
window.APP_CONFIG = {
|
||||
// Endpoint trung chuyển (transferPostData).
|
||||
API_URL: "https://int.ipsupply.com.au/api/transferPostData",
|
||||
|
||||
// urlAPI thật phía sau và trang hiện tại (gửi kèm trong body).
|
||||
URL_API: "/api/ebay-listing/data-save",
|
||||
PAGE_CURRENT: "/ebaytools/listing-ebay",
|
||||
|
||||
// Lấy danh sách sản phẩm đã listed (gọi khi mở trang, cho cả 2 account).
|
||||
TRANSFER_GET_URL: "https://int.ipsupply.com.au/api/transferGetData",
|
||||
GET_LIST_URL_API: "/api/ebay-listing/listing-get-list",
|
||||
GET_MODEL_URL_API: "/api/product-model/list-combo-box",
|
||||
LISTED_STATUS: "Updated",
|
||||
|
||||
// Tài khoản eBay theo khu vực — đây là field khác nhau giữa AU và US.
|
||||
ACCOUNTS: {
|
||||
AU: "prology_net",
|
||||
US: "prologyinc8",
|
||||
},
|
||||
|
||||
// Header gửi kèm mỗi request.
|
||||
HEADERS: {
|
||||
"accept": "application/json, text/plain, */*",
|
||||
"content-type": "application/json",
|
||||
// Token xác thực — thay bằng token còn hiệu lực của bạn.
|
||||
"authorization":
|
||||
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2ludC5pcHN1cHBseS5jb20uYXUvYXBpL2xvZ2luIiwiaWF0IjoxNzc1MDI4MDg2LCJleHAiOjMyODg3NTYwODYsIm5iZiI6MTc3NTAyODA4NiwianRpIjoiTkx0b09iTmI5ZzhkNnJDdiIsInN1YiI6MSwicHJ2IjoiYzhlZTFmYzg5ZTc3NWVjNGM3Mzg2NjdlNWJlMTdhNTkwYjZkNDBmYyJ9.bUK9fOLPR9b6ADNkT5Uj1nyudbo-zaM2lwnN1WTYHzE",
|
||||
},
|
||||
|
||||
// Link sản phẩm eBay theo khu vực (ghép với listingId trả về).
|
||||
EBAY_ITEM_URL: {
|
||||
AU: "https://www.ebay.com.au/itm/",
|
||||
US: "https://www.ebay.com/itm/",
|
||||
},
|
||||
|
||||
// Các field cố định/mặc định của item — không có trong Google Sheet.
|
||||
// Nếu mỗi sản phẩm cần giá trị riêng (vd productModelId, productTypeId...),
|
||||
// hãy thêm cột tương ứng vào sheet + COLUMN_MAP và map trong buildItem().
|
||||
ITEM_DEFAULTS: {
|
||||
listImage: [],
|
||||
manufactorName: "Cisco",
|
||||
productTypeName: "Other Computers & Networking",
|
||||
productTypeId: 162,
|
||||
storeCategoryName: null,
|
||||
offerId: "",
|
||||
quantity: 1,
|
||||
note: "",
|
||||
status: null,
|
||||
testReports: "",
|
||||
specification: "",
|
||||
categoryPrologyId: [2043],
|
||||
itemIdRelateds: [],
|
||||
shippingPostagePolicy: 11753752025,
|
||||
},
|
||||
|
||||
// Tên các cột trong Google Sheet -> map sang field nội bộ.
|
||||
// So khớp không phân biệt hoa/thường và bỏ khoảng trắng thừa.
|
||||
// Mỗi field có thể nhận nhiều tên cột (alias).
|
||||
COLUMN_MAP: {
|
||||
model: ["Model"],
|
||||
condition: ["Condition"],
|
||||
title: ["Title"],
|
||||
priceAud: ["Giá AUD", "Gia AUD", "Price AUD", "AUD"],
|
||||
priceUsd: ["Giá USD", "Gia USD", "Price USD", "USD"],
|
||||
package: [
|
||||
"Package Contain (Racks)",
|
||||
"Package Contain\n (Racks)",
|
||||
"Package Contain",
|
||||
"Package",
|
||||
"Racks",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>eBay Listing — Google Sheet Loader</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="app__header">
|
||||
<h1>Danh sách sản phẩm</h1>
|
||||
<p class="app__subtitle">Đọc Google Sheet → xem danh sách → đẩy sang AU / US</p>
|
||||
</header>
|
||||
|
||||
<!-- Khu vực nhập & load file Google Sheet -->
|
||||
<section class="loader">
|
||||
<input
|
||||
id="sheetUrl"
|
||||
type="text"
|
||||
class="loader__input"
|
||||
placeholder="Dán link Google Sheet (đã share công khai) hoặc Spreadsheet ID..."
|
||||
autocomplete="off"
|
||||
value="https://docs.google.com/spreadsheets/d/146fVacsayB8OYUaUtaVN9l6BE0-5G2jQJS6xPtPhXzw/edit?gid=632973171#gid=632973171"
|
||||
/>
|
||||
<input
|
||||
id="sheetName"
|
||||
type="text"
|
||||
class="loader__input loader__input--small"
|
||||
placeholder="Tên tab (tuỳ chọn)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="loadBtn" class="btn btn--primary">
|
||||
<span class="btn__label">Load</span>
|
||||
<span class="btn__spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div id="status" class="status" role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Thanh tìm kiếm -->
|
||||
<section class="toolbar" id="toolbar" hidden>
|
||||
<input
|
||||
id="searchInput"
|
||||
type="search"
|
||||
class="toolbar__search"
|
||||
placeholder="Tìm theo Model, Title, Condition..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span id="resultCount" class="toolbar__count"></span>
|
||||
</section>
|
||||
|
||||
<!-- Danh sách sản phẩm (scroll) -->
|
||||
<section class="list" id="listWrap" hidden>
|
||||
<div class="list__head">
|
||||
<div class="cell cell--model textCenter">Model</div>
|
||||
<div class="cell cell--cond textCenter">Condition</div>
|
||||
<div class="cell cell--condeb textCenter">Condition eBay</div>
|
||||
<div class="cell cell--title textCenter">Title</div>
|
||||
<div class="cell cell--num textCenter">Giá AUD</div>
|
||||
<div class="cell cell--num textCenter">Giá USD</div>
|
||||
<div class="cell cell--pkg textCenter">Package Contain</div>
|
||||
<div class="cell cell--actions textCenter">Listing</div>
|
||||
</div>
|
||||
<div class="list__body" id="listBody"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--card: #ffffff;
|
||||
--border: #e2e6ee;
|
||||
--text: #1f2733;
|
||||
--muted: #6b7686;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--au: #0ea5e9;
|
||||
--us: #10b981;
|
||||
--danger: #dc2626;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.app__header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.app__subtitle {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- Loader ---- */
|
||||
.loader {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.loader__input {
|
||||
flex: 1 1 280px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.loader__input--small {
|
||||
flex: 0 1 180px;
|
||||
}
|
||||
.loader__input:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn__spinner {
|
||||
display: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
.btn.is-loading .btn__spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Status ---- */
|
||||
.status {
|
||||
margin: 14px 2px 0;
|
||||
font-size: 14px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.status--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.status--ok {
|
||||
color: #059669;
|
||||
}
|
||||
.status--info {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ---- Toolbar ---- */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
.toolbar__search {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toolbar__search:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.toolbar__count {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- List ---- */
|
||||
.list {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list__head,
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 90px 150px 1fr 90px 90px 200px 170px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.list__head {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.list__body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.row {
|
||||
border-bottom: 1px solid #f1f4f9;
|
||||
font-size: 14px;
|
||||
}
|
||||
.row:hover {
|
||||
background: #f9fbff;
|
||||
}
|
||||
.cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cell--title {
|
||||
white-space: normal;
|
||||
}
|
||||
.cell--num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell--model {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cell--actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.listed-links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
}
|
||||
.listed-link {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.listed-link--au {
|
||||
color: var(--au);
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
}
|
||||
.listed-link--us {
|
||||
color: #059669;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
.listed-link[href]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.btn--au {
|
||||
background: var(--au);
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
.btn--us {
|
||||
background: var(--us);
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
.btn--au:hover:not(:disabled) {
|
||||
filter: brightness(0.93);
|
||||
}
|
||||
.btn--us:hover:not(:disabled) {
|
||||
filter: brightness(0.93);
|
||||
}
|
||||
.btn--row.is-loading .btn__label {
|
||||
opacity: 0;
|
||||
}
|
||||
.btn--row .btn__spinner {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
border-top-color: #fff;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Input chỉnh sửa ngay trong dòng */
|
||||
.cell-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.cell-input:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.cell-input--num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cell-input--area {
|
||||
resize: vertical;
|
||||
min-height: 38px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.cell-select {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Nút dạng link (anchor) — khi đã có link sản phẩm eBay */
|
||||
.btn--link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row.is-busy {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.list__head {
|
||||
display: none;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 12px;
|
||||
}
|
||||
.cell--title {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell--actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.textCenter {
|
||||
text-align: center;
|
||||
}
|
||||
Loading…
Reference in New Issue