commit ea63161e3a859451048e6f4611c2d5e01ba19fa9 Author: andrew.ng Date: Wed Jun 17 07:40:40 2026 +0700 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..51224e8 --- /dev/null +++ b/README.md @@ -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 đó. diff --git a/app.js b/app.js new file mode 100644 index 0000000..ffeaaa9 --- /dev/null +++ b/app.js @@ -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 = `
Không có sản phẩm phù hợp.
`; + } else { + el.listBody.innerHTML = rows.map(rowHtml).join(""); + } + el.count.textContent = `${rows.length} / ${allRows.length} sản phẩm`; +} + +function rowHtml(p) { + return ` +
+
${esc(p.model)}
+
${esc(p.condition)}
+
${conditionSelect(p)}
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ${actionBtn("AU", p)} + ${actionBtn("US", p)} +
+ ${listedLinks(p)} +
+
`; +} + +// Combobox Condition — 3 giá trị cố định, giữ giá trị gốc nếu khác. +const CONDITION_OPTIONS = ["NEW", "NEW_OTHER", "USED_EXCELLENT"]; +function conditionSelect(p) { + const cur = p.conditionEbay || ""; + const opts = CONDITION_OPTIONS.slice(); + if (cur && !opts.includes(cur)) opts.unshift(cur); + let html = cur ? "" : ``; + html += opts + .map((o) => ``) + .join(""); + return ``; +} + +// 2 nút AU/US luôn hiển thị (để update & list lại bất cứ lúc nào). +function actionBtn(region, p) { + const cls = region === "AU" ? "btn--au" : "btn--us"; + return ``; +} + +// Dấu "đã listed" hiện DƯỚI 2 nút; click dẫn tới sản phẩm trên eBay. +function listedLinks(p) { + const links = []; + if (p.listedAu) links.push(listedLink("AU", p.listedAu.listingId)); + if (p.listedUs) links.push(listedLink("US", p.listedUs.listingId)); + return links.length ? `` : ""; +} + +function listedLink(region, listingId) { + const label = `${region} ✓ listed`; + const cls = `listed-link listed-link--${region.toLowerCase()}`; + if (typeof listingId === "string" && listingId) { + const url = CFG.EBAY_ITEM_URL[region] + listingId; + return `${label} ↗`; + } + return `${label}`; +} + +/* ----------------------------- Status ---------------------------- */ + +function setStatus(msg, kind = "info") { + el.status.textContent = msg; + el.status.className = `status status--${kind}`; +} + +/* ------------------------- Load Sheet flow ----------------------- */ + +async function loadSheet() { + const raw = el.url.value; + if (!raw.trim()) { setStatus("Vui lòng nhập link Google Sheet.", "error"); return; } + + const id = extractSheetId(raw); + if (!id) { setStatus("Không nhận diện được Spreadsheet ID từ link.", "error"); return; } + + const gid = extractGid(raw); + const sheetName = el.name.value.trim(); + const csvUrl = buildCsvUrl(id, sheetName, gid); + + setBtnLoading(el.loadBtn, true); + el.url.disabled = el.name.disabled = true; + setStatus("Đang tải dữ liệu từ Google Sheet...", "info"); + + try { + const res = await fetch(csvUrl); + if (!res.ok) throw new Error(`HTTP ${res.status} — kiểm tra sheet đã share công khai chưa.`); + const text = await res.text(); + + const grid = parseCsv(text); + if (!grid.length) throw new Error("File rỗng hoặc không đọc được."); + + const { rowIdx, index, hits } = findHeaderRow(grid); + if (hits === 0) { + throw new Error("Không tìm thấy các cột Model/Title/... Kiểm tra tên cột hoặc COLUMN_MAP trong config.js."); + } + + allRows = grid.slice(rowIdx + 1) + .map((cells, i) => ({ + _id: String(i), + model: (cells[index.model] ?? "").trim(), + condition: (cells[index.condition] ?? "").trim(), + conditionEbay: "", // chọn từ combobox (tách riêng khỏi condition) + title: (cells[index.title] ?? "").trim(), + priceAud: (cells[index.priceAud] ?? "").trim(), + priceUsd: (cells[index.priceUsd] ?? "").trim(), + package: (cells[index.package] ?? "").trim(), + })) + // bỏ dòng trống hoàn toàn + .filter((p) => p.model || p.title || p.condition); + + el.toolbar.hidden = false; + el.listWrap.hidden = false; + applyListedFlags(); // đối chiếu với danh sách đã listed (nếu API đã về) + setStatus(`Đã tải ${allRows.length} sản phẩm (nhận diện ${hits} cột).`, "ok"); + } catch (err) { + setStatus(`Lỗi: ${err.message}`, "error"); + } finally { + setBtnLoading(el.loadBtn, false); + el.url.disabled = el.name.disabled = false; + } +} + +/* ----------------------------- Search ---------------------------- */ + +function applySearch() { + const q = norm(el.search.value); + if (!q) { render(allRows); return; } + const filtered = allRows.filter((p) => + norm(p.model).includes(q) || + norm(p.title).includes(q) || + norm(p.condition).includes(q) || + norm(p.package).includes(q) + ); + render(filtered); +} + +/* -------------------- Danh sách đã listed ------------------------ */ + +// Đối chiếu Model (sheet) với code (API) -> gắn cờ listedAu/listedUs cho mỗi dòng. +// Gọi lại mỗi khi 1 trong 3 nguồn xong (2 API listed + load sheet). +function applyListedFlags() { + if (!allRows.length) return; + for (const p of allRows) { + const key = norm(p.model); + p.listedAu = key && listed.AU.has(key) ? listed.AU.get(key) : null; + p.listedUs = key && listed.US.has(key) ? listed.US.get(key) : null; + } + applySearch(); // render lại view hiện tại +} + +// Gọi transferGetData để lấy danh sách đã listed cho 1 store (AU/US). +async function fetchListed(region) { + const body = { + urlAPI: CFG.GET_LIST_URL_API, + filter: { + skip: 0, + limit: 2000000, + order: "updatedAt desc", + where: { account: CFG.ACCOUNTS[region], status: CFG.LISTED_STATUS }, + }, + }; + try { + const res = await fetch(CFG.TRANSFER_GET_URL, { + method: "POST", + headers: CFG.HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const items = Array.isArray(json?.data) ? json.data : []; + const map = new Map(); + for (const it of items) { + if (it && it.code) { + map.set(norm(it.code), { listingId: it.listingId || "", id: it.ebayListingId || "" }); + } + } + listed[region] = map; + applyListedFlags(); // 1 trong 3 lần đối chiếu + } catch (err) { + console.warn(`Không lấy được danh sách đã listed (${region}):`, err.message); + } +} + +// Gọi transferGetData để lấy danh sách đã listed cho 1 store (AU/US). +async function fetchModel(model) { + const body = { + urlAPI: CFG.GET_MODEL_URL_API, + filter: { + limit: 10, + order: "updatedAt desc", + where: { _q: model }, + }, + }; + try { + const res = await fetch(CFG.TRANSFER_GET_URL, { + method: "POST", + headers: CFG.HEADERS, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const items = Array.isArray(json?.data) ? json.data : []; + const map = new Map(); + for (const it of items) { + if (it && it.code && it.code === model) return it; + } + return null; + } catch (err) { + console.warn(`Không lấy được model "${model}":`, err.message); + return null; + } +} + +/* --------------------------- API calls --------------------------- */ + +// "$380" / "380.00 AUD" -> 380 (number). Trả "" nếu rỗng. +function toNumber(v) { + const n = parseFloat(String(v).replace(/[^0-9.]/g, "")); + return Number.isFinite(n) ? n : ""; +} + +// "1x A9K-RSP440-TR\n1x ..." -> ["1x A9K-RSP440-TR", "1x ..."] +function toPackageArray(v) { + return String(v || "") + .split(/\r?\n|;/) + .map((s) => s.trim()) + .filter(Boolean); +} + +// Tạo item dữ liệu khớp cấu trúc /api/ebay-listing/data-save. +function buildItem(p, region, productModelId, listedId) { + return { + ...CFG.ITEM_DEFAULTS, + code: p.model, + shortDescription: p.title, + condition: p.condition, + conditionEbay: p.conditionEbay, + price: region === "AU" ? toNumber(p.priceAud) : toNumber(p.priceUsd), + currency: region === "AU" ? "AUD" : "USD", + package_contain: p.package ? toPackageArray(p.package) : ["1x " + p.model], + account: CFG.ACCOUNTS[region], // <-- field khác nhau giữa AU và US + productModelId, // id lấy từ API product-model, nếu có + id: listedId || "", // id listing đã có (từ fetchListed); rỗng nếu chưa listed + shippingPostagePolicy: region === "AU" ? 11753752025 : 231163007025 + }; +} + +function buildPayload(p, region, productModelId, listedId) { + return { + urlAPI: CFG.URL_API, + data: { data: [buildItem(p, region, productModelId, listedId)] }, + pageCurrent: CFG.PAGE_CURRENT, + }; +} + +// Lấy bản ghi kết quả đầu tiên từ response. +// Cấu trúc thật: { Status, Msg, data: { "": { listingId, statusCode, message, ... } } } +function extractResult(json) { + if (!json || typeof json !== "object") return null; + const d = json.data; + if (d && typeof d === "object") { + if (Array.isArray(d)) return d[0] || null; + // data là object keyed theo id -> lấy phần tử đầu, dùng key làm id nếu thiếu. + const entries = Object.entries(d); + if (entries[0]) { + const [key, val] = entries[0]; + if (val && typeof val === "object") return { ...val, id: val.id || key }; + } + } + if (json.listingId) return json; // fallback dạng phẳng + return null; +} + +async function callApi(region, id, btn) { + const product = allRows.find((p) => p._id === id); + if (!product) return; + + const rowEl = btn.closest(".row"); + + // disable + loading cho cả dòng + setRowBusy(rowEl, true); + setBtnLoading(btn, true); + setStatus(`Đang gửi "${product.model || product.title}" sang ${region}...`, "info"); + + const modelData = await fetchModel(product.model); + if (!modelData) { + setStatus(`Model "${product.model}" không tồn tại trong ERP`, "error"); + 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); +}); diff --git a/config.js b/config.js new file mode 100644 index 0000000..a8f2531 --- /dev/null +++ b/config.js @@ -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: [ ] }, 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", + ], + }, +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..541e673 --- /dev/null +++ b/index.html @@ -0,0 +1,72 @@ + + + + + + eBay Listing — Google Sheet Loader + + + +
+
+

Danh sách sản phẩm

+

Đọc Google Sheet → xem danh sách → đẩy sang AU / US

+
+ + +
+ + + +
+ +
+ + + + + + +
+ + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..8478b6e --- /dev/null +++ b/styles.css @@ -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; +}