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