first commit

This commit is contained in:
andrew.ng 2026-06-17 07:40:40 +07:00
commit ea63161e3a
5 changed files with 1063 additions and 0 deletions

49
README.md Normal file
View File

@ -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****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 đó.

513
app.js Normal file
View File

@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
});

83
config.js Normal file
View File

@ -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 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",
],
},
};

72
index.html Normal file
View File

@ -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>

346
styles.css Normal file
View File

@ -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;
}