edit and re publist

This commit is contained in:
Admin 2025-08-16 10:33:28 +07:00
parent b669984033
commit cecda51e10
40 changed files with 1730 additions and 682 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 B

View File

@ -7,7 +7,6 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"dev:build": "vite build --watch"
},
"dependencies": {

View File

@ -13,6 +13,50 @@ class ProductApiService {
throw err;
}
}
finistPublist = async (
item: IItem,
values: { error?: string; published: boolean; message?: string }
) => {
const { data } = await axios({
url: "products/publist-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
updatePublist = async (
item: IItem,
values: {
error?: string;
published: boolean;
message?: string;
publist_id?: string;
}
) => {
const { data } = await axios({
url: "products/update-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
finistDelete = async (
item: IItem,
values: { error?: string; published: boolean }
) => {
const { data } = await axios({
url: "products/delete-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
}
export const productApi = new ProductApiService();

View File

@ -11,7 +11,6 @@ chrome.runtime.onConnect.addListener((port) => {
});
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
return new Promise<void>((resolve) => {
chrome.tabs.create(
@ -33,7 +32,7 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
const onConnectListener = (port: chrome.runtime.Port) => {
if (port.sender?.tab?.id === tabId) {
port.postMessage({ type: "publist-event", payload: data });
port.postMessage({ type: "PUBLIST_EVENT", payload: data });
chrome.runtime.onConnect.removeListener(onConnectListener);
}
};
@ -57,6 +56,75 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
});
}
async function handleEdits(
data: { prev: IItem; data: IItem },
timeoutMs = 5 * 60 * 1000
) {
return new Promise((resolve, reject) => {
(async () => {
try {
if (!data.prev?.publist_id) {
const response = await sendMessageToSellingTab<any>(
{ type: "GET_PUBLIST_ID", data },
30000
);
console.log({ response });
data.prev.publist_id = response.publist_id;
}
chrome.tabs.create(
{
url: `https://www.facebook.com/marketplace/edit/?listing_id=${data.prev.publist_id}`,
active: true,
},
(tab) => {
if (!tab?.id) resolve(data);
const tabId = tab.id;
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
chrome.runtime.onConnect.removeListener(onConnectListener);
chrome.tabs.onRemoved.removeListener(onTabClosed);
clearTimeout(timeoutId);
resolve(data);
ensureMarketplaceSellingTab();
};
const onConnectListener = (port: chrome.runtime.Port) => {
if (port.sender?.tab?.id === tabId) {
port.postMessage({ type: "EDIT_EVENT", payload: data });
chrome.runtime.onConnect.removeListener(onConnectListener);
}
};
chrome.runtime.onConnect.addListener(onConnectListener);
const onTabClosed = (closedTabId: number) => {
if (closedTabId === tabId) {
cleanup();
}
};
chrome.tabs.onRemoved.addListener(onTabClosed);
// Timeout tùy chỉnh
const timeoutId = setTimeout(() => {
console.warn(
`Tab ${tabId} timeout (${timeoutMs}ms) - auto resolve`
);
if (tabId) chrome.tabs.remove(tabId);
cleanup();
}, timeoutMs);
}
);
} catch (err) {
reject(err);
}
})();
});
}
const handleListenPublists = () => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/publist-stream`
@ -64,7 +132,7 @@ const handleListenPublists = () => {
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("New event:", data);
console.log("[PUBLIST] New event:", data);
queue.add(() => handlePublish(data, 30000));
};
@ -73,6 +141,45 @@ const handleListenPublists = () => {
};
};
function sendMessageToSellingTab<T = any>(
message: any,
timeoutMs = 10000
): Promise<T> {
return new Promise((resolve, reject) => {
chrome.tabs.query(
{ url: "*://www.facebook.com/marketplace/you/selling*" },
(tabs) => {
if (tabs.length > 0 && tabs[0].id !== undefined) {
const tabId = tabs[0].id;
const listener = (msg: any) => {
if (msg?.type === "GET_PUBLIST_ID_DONE") {
cleanup();
resolve(msg);
}
};
const cleanup = () => {
chrome.runtime.onMessage.removeListener(listener);
clearTimeout(timeoutId);
};
chrome.runtime.onMessage.addListener(listener);
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error("Timeout chờ phản hồi từ content script"));
}, timeoutMs);
chrome.tabs.sendMessage(tabId, message);
} else {
reject(new Error("Không tìm thấy tab Selling"));
}
}
);
});
}
const handleListenDeletes = () => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/delete-stream`
@ -122,6 +229,22 @@ const handleListenDeletes = () => {
console.log("[SSE] Listening for delete-stream events...");
};
const handleListenEdits = () => {
const evtSource = new EventSource(
`${import.meta.env.VITE_API_URL}/products/edit-stream`
);
evtSource.onmessage = (event) => {
const data = JSON.parse(event.data) as any;
console.log("[EDIT] New event:", data);
queue.add(() => handleEdits(data, 30000));
};
evtSource.onerror = (err) => {
console.error("EventSource failed:", err);
};
};
function ensureMarketplaceSellingTab() {
const targetUrl = "https://www.facebook.com/marketplace/you/selling";
@ -144,6 +267,7 @@ function ensureMarketplaceSellingTab() {
const init = async () => {
handleListenPublists();
handleListenDeletes();
handleListenEdits();
ensureMarketplaceSellingTab();
};

View File

@ -1,521 +1,115 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// content.ts
import { productApi } from "./api/product-api.service";
import { delay } from "./features/app";
import axios from "./lib/axios";
import { thiefService } from "./services/thief.service";
const selectors = {
file__image_input: 'input[type="file"]',
title_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[2]/div[1]/div[2]/div/div/div[5]/div/div/div/label/div/input",
price_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[6]/div/div/div/label/div/input",
brand_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/input",
brand_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[2]/div/div/div/label/div/input",
description_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[3]/div/div/div/label/div/div/textarea",
description_input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[3]/div/div/div/label/div/div/textarea",
sku_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[6]/div/div/div[1]/label/div/input",
sku_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[6]/div/div/div[1]/label/div/input",
category_select: {
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div/span/div",
},
condition_select: {
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[8]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div[1]/div",
},
tags_input: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
plus_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]",
},
location_select: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
container_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
},
next_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
publish_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
products:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div/div/span/div/div",
products_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div[2]/div",
};
const uploadImages = async (item: IItem) => {
// Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer();
for (const image of item.images) {
const base64 = await thiefService.imageUrlToBase64(image);
console.log("Base64:", image.slice(0, 50) + "...");
const file = thiefService.base64ToFile(
base64,
item.sku,
thiefService.getImageExtension(image) || "jpg"
);
dt.items.add(file);
}
// Tìm input file của Facebook
const input: HTMLInputElement | null = document.querySelector(
selectors.file__image_input
);
if (input) {
// Gán file vào input
input.files = dt.files;
// Gửi event change
input.dispatchEvent(new Event("change", { bubbles: true }));
} else {
console.error("Không tìm thấy input[type='file']");
}
};
const chooseSelect = async (
value: string,
xpaths: { wraper: string; container: string }
) => {
const el = await thiefService.getElementByXPath(xpaths.wraper);
if (!el) throw new Error("Wrapper xpath not found");
thiefService.scrollToElement(el);
thiefService.clickByPoint(el);
await delay(200);
const container = await thiefService.getElementByXPath(xpaths.container);
if (!container) throw new Error("Container xpath not found");
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find((child) =>
child.textContent
?.trim()
.toLocaleLowerCase()
.replace(//g, "-")
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
const chooseLocation = async (
value: string,
{
input,
...xpaths
}: {
wraper: string;
container: string;
container_fallback?: string;
input: string;
input_fallback?: string;
}
) => {
await thiefService.writeToInput(value, input, xpaths.input_fallback);
await delay(200);
const container = await thiefService.getElementByXPath(xpaths.container, {
xpathFallback: xpaths.container_fallback,
});
if (!container) throw new Error("Container xpath not found");
thiefService.scrollToElement(container);
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find((child) =>
child.textContent
?.trim()
.toLocaleLowerCase()
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
const writeTags = async (
tags: string[],
xpaths: { input: string; input_falback?: string; plus_btn: string }
) => {
const input = await thiefService.getElementByXPath(xpaths.input, {
xpathFallback: xpaths?.input_falback,
});
if (!input) throw new Error("Input is not found");
thiefService.scrollToElement(input);
await delay(200);
for (const tag of tags) {
await thiefService.writeToInput(tag, xpaths.input, xpaths?.input_falback);
await delay(200);
thiefService.pressEnter(input);
}
};
const finistPublist = async (
item: IItem,
values: { error?: string; published: boolean }
) => {
const { data } = await axios({
url: "products/publist-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
const finistDelete = async (
item: IItem,
values: { error?: string; published: boolean }
) => {
const { data } = await axios({
url: "products/delete-finish/" + item.id,
method: "POST",
data: values,
});
return data;
};
const clickNext = async () => {
const btn = await thiefService.getElementByXPath(selectors.next_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
const clickPublist = async () => {
const btn = await thiefService.getElementByXPath(selectors.publish_btn);
if (!btn) throw new Error("Publist button is not found");
thiefService.clickByPoint(btn);
};
/**
* B1. Upload images
* B2. Write title
* B3. Write price
* B4. Select category
* B5. Select condition
*
*/
const handle = async (item: IItem) => {
console.log({ item });
await delay(1000);
// B1. Upload images
await uploadImages(item);
await delay(200);
// B2. Write title
thiefService.writeToInput(item.title, selectors.title_input);
await delay(200);
// B3. Write price
thiefService.writeToInput(String(item.price), selectors.price_input);
await delay(200);
// B4. Select category
await chooseSelect(item.category, selectors.category_select);
await delay(200);
// B5. Select condition
await chooseSelect(item.condition, selectors.condition_select);
if (item.brand) {
await delay(200);
// B6. Write brand
await thiefService.writeToInput(
item.brand,
selectors.brand_input,
selectors.brand_input_fallback
);
}
await delay(200);
// B7. Write description
await thiefService.writeToInput(
item.description,
selectors.description_input,
selectors.description_input_falback
);
await delay(200);
await writeTags(item.tags, selectors.tags_input);
await delay(200);
// B8. Write sku
await thiefService.writeToInput(
item.sku,
selectors.sku_input,
selectors.sku_input_fallback
);
if (item?.location) {
await delay(200);
await chooseLocation(item.location, selectors.location_select);
}
await delay(200);
await clickNext();
await delay(200);
await clickPublist();
return true;
};
// function extractListings(productsEl: HTMLElement) {
// const children = Array.from(productsEl.children);
// return children.map((child) => {
// // Lấy title (thường là span hoặc div có dir="auto")
// const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
// const title = titleEl?.textContent?.trim() || "";
// // Lấy giá (span có attribute dir="auto")
// const priceEl = Array.from(child.querySelectorAll('span[dir="auto"]')).find(
// (el) =>
// /\d/.test(el.textContent || "") && /[AU$]/.test(el.textContent || "")
// );
// // Tách lấy số, ví dụ: "AU$20" -> "20"
// const priceMatch = priceEl?.textContent?.match(/\d+(?:\.\d+)?/);
// const price = priceMatch ? parseFloat(priceMatch[0]) : 0;
// return { title, price, el: productsEl };
// });
// }
// const getProducts = async () => {
// let products = await thiefService.getElementByXPath(selectors.products);
// if (!products) {
// products = await thiefService.getElementByXPath(
// selectors.products_fallback
// );
// }
// if (!products) return [];
// return extractListings(products) as ISyncItem[];
// };
const getProducts = async () => {
const products1 = await thiefService.getElementByXPath(selectors.products);
const products2 = await thiefService.getElementByXPath(
selectors.products_fallback
);
// Gom 2 cái vào một mảng, bỏ null
const allProductsEls = [products1, products2].filter(
Boolean
) as HTMLElement[];
if (allProductsEls.length === 0) return [];
// Nối tất cả kết quả extractListings từ mỗi element
return allProductsEls.flatMap((el) => extractListings(el)) as ISyncItem[];
};
function extractListings(productsEl: HTMLElement) {
const children = Array.from(productsEl.children);
return children.map((child) => {
// Lấy title
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
const title = titleEl?.textContent?.trim() || "";
// Lấy giá
const priceEl = Array.from(child.querySelectorAll('span[dir="auto"]')).find(
(el) =>
/\d/.test(el.textContent || "") && /[AU$]/.test(el.textContent || "")
);
const priceMatch = priceEl?.textContent?.match(/[\d,]+(?:\.\d+)?/);
const price = priceMatch ? parseFloat(priceMatch[0].replace(/,/g, "")) : 0;
return { title, price, el: productsEl };
});
}
const syncListing = async () => {
const url = window.location.href;
if (!url.includes("https://www.facebook.com/marketplace/you/selling")) return;
const products = await getProducts();
if (!products.length) return;
const response = await productApi.sync(
products.map((item) => ({
title: item.title,
price: item.price,
})) as ISyncItem[]
);
console.log({ response });
};
const closeTab = async (data: IItem) => {
chrome.runtime.sendMessage({
type: "close-tab",
payload: data,
});
};
const handleDelete = async (payload: IItem) => {
const products = await getProducts();
const product = products.find((product) => {
return product.title == payload.title && product.price == payload.price;
});
console.log({ payload, product, products });
if (!product) return;
const el = product.el;
const optionEl = el.querySelector(
`[aria-label="More options for ${product.title}"]`
);
console.log({ optionEl });
if (!optionEl) return;
(optionEl as any).click?.();
await delay(2000);
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
console.log({ items });
const deleteItem = items.find((item) =>
item.textContent.toLocaleLowerCase().includes("delete")
);
(deleteItem as any).click?.();
await delay(1000);
const btnDelete = await thiefService.getElementByXPath(
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
{
xpathFallback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
}
);
console.log({ btnDelete });
btnDelete?.click();
const closeModal = await thiefService.getElementByXPath(
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div"
);
closeModal?.click();
await finistDelete(payload, { published: false });
chrome.runtime.sendMessage({ type: "delete-done" });
};
import { delay, delayRD } from "./features/app";
import { facebookService } from "./services/facebook.service";
const port = chrome.runtime.connect();
// Listent event to get payload publist
port.onMessage.addListener(async (message) => {
if (message.type === "publist-event") {
if (message.type === "PUBLIST_EVENT") {
const data = message.payload as IItem;
if (!data) return;
console.log("Received new product event:", data);
// data.images = ["images/1.png", "images/2.png"];
console.log("[PUBLIST_EVENT] Received new product event:", data);
try {
await delay(500);
await handle(data);
await delayRD(500, 600);
await facebookService.handlePublist(data);
} catch (error) {
await finistPublist(data, {
await productApi.finistPublist(data, {
error: (error as { message: string }).message,
published: false,
});
} finally {
await finistPublist(data, { published: true });
await productApi.finistPublist(data, { published: true });
await delay(5000);
await closeTab(data);
await facebookService.closeTab(data);
}
}
});
// content.js
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "DELETE_STREAM_DATA") {
console.log("Nhận dữ liệu từ background:", message.payload);
// Listent event to get payload edit
port.onMessage.addListener(async (message) => {
if (message.type === "EDIT_EVENT") {
const { data, prev } = message.payload as { prev: IItem; data: IItem };
handleDelete(message.payload);
if (!data) return;
console.log("[PUBLIST_EVENT] Received new product event:", data);
try {
await facebookService.handleUpdate(data);
} catch (error) {
await productApi.updatePublist(data, {
error: (error as { message: string }).message,
published: true,
publist_id: prev.publist_id,
});
} finally {
await delay(3000);
await productApi.updatePublist(data, {
published: true,
message: `Edited product ${data.title}`,
publist_id: prev.publist_id,
});
await facebookService.closeTab(data);
}
}
});
// Listent event to delete
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "DELETE_STREAM_DATA") {
const data = message.payload as IItem;
if (!data) return;
console.log("[DELETE_STREAM_DATA] Received new product event:", data);
try {
await facebookService.handleDelete(message.payload);
} catch (error) {
await productApi.finistPublist(data, {
error: (error as { message: string }).message,
published: false,
});
}
}
});
// Listent event to get publist id
chrome.runtime.onMessage.addListener(async (message) => {
if (message.type === "GET_PUBLIST_ID") {
console.log({ message });
const {
data: { prev },
} = message;
const publist_id = await facebookService.handleGetPublistID(prev);
// Gửi message đến background/content script thông báo là get đã xong
chrome.runtime.sendMessage({ type: "GET_PUBLIST_ID_DONE", publist_id });
}
return true; // Để cho phép async sendResponse
});
async function init() {
// const { data } = await axios.get("products/33");
// const { data } = await axios.get("products/53");
// if (!data.data) return;
// const item = data.data as IItem;
// await handle(item);
// await facebookService.handleUpdate(item);
await syncListing();
await facebookService.syncListing();
}
init();

View File

@ -16,7 +16,7 @@ export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
export function delayRD(minMs: number, maxMs: number): Promise<void> {
const time = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return delay(time);
}

View File

@ -11,6 +11,7 @@ interface IItem {
sku: string;
location?: string;
id: number;
publist_id?: string;
}
interface ISyncItem {

View File

@ -0,0 +1,630 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { delay, delayRD } from "@/features/app";
import { thiefService } from "./thief.service";
import { productApi } from "@/api/product-api.service";
class FacebookService {
sellingPath = "https://www.facebook.com/marketplace/you/selling";
selectors = {
file__image_input: 'input[type="file"]',
title_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[2]/div[1]/div[2]/div/div/div[5]/div/div/div/label/div/input",
price_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[6]/div/div/div/label/div/input",
brand_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/input",
brand_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[2]/div/div/div/label/div/input",
description_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[3]/div/div/div/label/div/div/textarea",
description_input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[3]/div/div/div/label/div/div/textarea",
sku_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[6]/div/div/div[1]/label/div/input",
sku_input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[6]/div/div/div[1]/label/div/input",
category_select: {
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div/span/div",
},
condition_select: {
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[8]/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/div/div/div/div[1]/div",
},
tags_input: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
plus_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]",
},
tags_edit_input: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[4]/div/div/div/div[1]/label/div/div/div[2]/div[1]/textarea",
input_falback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div/textarea",
plus_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div/div[1]/label/div/div/div[2]/div[2]",
},
location_select: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
container_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
},
location_edit_select: {
input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[6]/div/div/div/div/div/div/div/div/label/div[2]/input",
input_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[10]/div/div/div[7]/div/div/div/div/div/div/div/div/label/div[2]/input",
wraper:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[7]/div/div/div/div/div/div/div/div",
container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
container_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[2]/div/div/div[1]/div[1]/div/ul",
},
next_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
update_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div/div/div",
publish_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
products:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div/div/span/div/div",
products_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div[2]/div",
option_btn:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
option_btn_fallback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
close_btn_modal_feedback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div",
images_container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[2]/div[1]/div[2]/div/div/div[3]/div[2]/div",
description_edit_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/div/textarea",
sku_edit_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div[1]/label/div/input",
};
clearImages = async () => {
const el = await thiefService.getElementByXPath(
this.selectors.images_container
);
if (!el) throw new Error(`Can't not found xpath images_container`);
const children = Array.from(el.children) as HTMLElement[];
console.log(children);
// Ví dụ: clear hết
children.forEach((child) =>
(child.querySelector('[aria-label="Remove"]') as any)?.click()
);
};
uploadImages = async (item: IItem) => {
// Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer();
for (const image of item.images) {
const base64 = await thiefService.imageUrlToBase64(image);
console.log("Base64:", image.slice(0, 50) + "...");
const file = thiefService.base64ToFile(
base64,
item.sku,
thiefService.getImageExtension(image) || "jpg"
);
dt.items.add(file);
}
// Tìm input file của Facebook
const input: HTMLInputElement | null = document.querySelector(
this.selectors.file__image_input
);
if (input) {
// Gán file vào input
input.files = dt.files;
// Gửi event change
input.dispatchEvent(new Event("change", { bubbles: true }));
} else {
console.error("Không tìm thấy input[type='file']");
}
};
chooseSelect = async (
value: string,
xpaths: { wraper: string; container: string }
) => {
const el = await thiefService.getElementByXPath(xpaths.wraper);
if (!el) throw new Error("Wrapper xpath not found");
thiefService.scrollToElement(el);
thiefService.clickByPoint(el);
await delayRD(300, 500);
const container = await thiefService.getElementByXPath(xpaths.container);
if (!container) throw new Error("Container xpath not found");
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find((child) =>
child.textContent
?.trim()
.toLocaleLowerCase()
.replace(//g, "-")
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
chooseLocation = async (
value: string,
{
input,
...xpaths
}: {
wraper: string;
container: string;
container_fallback?: string;
input: string;
input_fallback?: string;
}
) => {
await thiefService.writeToInput(value, input, xpaths.input_fallback);
await delay(200);
const container = await thiefService.getElementByXPath(xpaths.container, {
xpathFallback: xpaths.container_fallback,
});
if (!container) throw new Error("Container xpath not found");
thiefService.scrollToElement(container);
// Tìm phần tử con có nội dung giống value
const matchingChild = Array.from(container.children).find((child) =>
child.textContent
?.trim()
.toLocaleLowerCase()
.includes(value.toLocaleLowerCase())
) as HTMLElement | undefined;
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(200);
thiefService.clickByPoint(matchingChild);
};
writeTags = async (
tags: string[],
xpaths: { input: string; input_falback?: string; plus_btn: string }
) => {
const input = await thiefService.getElementByXPath(xpaths.input, {
xpathFallback: xpaths?.input_falback,
});
if (!input) throw new Error("Input is not found");
thiefService.scrollToElement(input);
await delay(200);
for (const tag of tags) {
await thiefService.writeToInput(tag, xpaths.input, xpaths?.input_falback);
await delay(200);
thiefService.pressEnter(input);
}
};
clickNext = async () => {
const btn = await thiefService.getElementByXPath(this.selectors.next_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
clickUpdate = async () => {
const btn = await thiefService.getElementByXPath(this.selectors.update_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
clickPublist = async () => {
const btn = await thiefService.getElementByXPath(
this.selectors.publish_btn
);
if (!btn) throw new Error("Publist button is not found");
thiefService.clickByPoint(btn);
};
/**
* B1. Upload images
* B2. Write title
* B3. Write price
* B4. Select category
* B5. Select condition
* .....
*/
handlePublist = async (item: IItem) => {
console.log({ item });
await delayRD(1000, 2000);
// B1. Upload images
await this.uploadImages(item);
await delayRD(300, 500);
// B2. Write title
thiefService.writeToInput(item.title, this.selectors.title_input);
await delayRD(300, 500);
// B3. Write price
thiefService.writeToInput(String(item.price), this.selectors.price_input);
await delayRD(300, 500);
// B4. Select category
await this.chooseSelect(item.category, this.selectors.category_select);
await delayRD(300, 500);
// B5. Select condition
await this.chooseSelect(item.condition, this.selectors.condition_select);
if (item.brand) {
await delayRD(300, 500);
// B6. Write brand
await thiefService.writeToInput(
item.brand,
this.selectors.brand_input,
this.selectors.brand_input_fallback
);
}
await delayRD(300, 500);
// B7. Write description
await thiefService.writeToInput(
item.description,
this.selectors.description_input,
this.selectors.description_input_falback
);
await delayRD(300, 500);
await this.writeTags(item.tags, this.selectors.tags_input);
await delayRD(300, 500);
// B8. Write sku
await thiefService.writeToInput(
item.sku,
this.selectors.sku_input,
this.selectors.sku_input_fallback
);
if (item?.location) {
await delayRD(300, 500);
await this.chooseLocation(item.location, this.selectors.location_select);
}
await delayRD(300, 500);
await this.clickNext();
await delayRD(300, 500);
await this.clickPublist();
return true;
};
/**
* B1. Upload images
* B2. Write title
* B3. Write price
* B4. Select category
* B5. Select condition
* .....
*/
handleUpdate = async (item: IItem) => {
console.log({ item });
await delayRD(1000, 2000);
await this.clearImages();
await delayRD(1000, 2000);
await this.uploadImages(item);
await delayRD(1000, 2000);
thiefService.writeToInput(item.title, this.selectors.title_input);
await delayRD(300, 500);
thiefService.writeToInput(String(item.price), this.selectors.price_input);
await delayRD(300, 500);
await this.chooseSelect(item.category, this.selectors.category_select);
await delayRD(300, 500);
await this.chooseSelect(item.condition, this.selectors.condition_select);
await delayRD(300, 500);
await thiefService.writeToInput(
item.description,
this.selectors.description_edit_input
);
await delayRD(300, 500);
await this.writeTags(item.tags, this.selectors.tags_edit_input);
await delayRD(300, 500);
await thiefService.writeToInput(item.sku, this.selectors.sku_edit_input);
if (item?.location) {
await delayRD(300, 500);
await this.chooseLocation(
item.location,
this.selectors.location_edit_select
);
}
await delayRD(300, 500);
await this.clickUpdate();
return true;
};
getProducts = async () => {
const products1 = await thiefService.getElementByXPath(
this.selectors.products
);
const products2 = await thiefService.getElementByXPath(
this.selectors.products_fallback
);
// Gom 2 cái vào một mảng, bỏ null
const allProductsEls = [products1, products2].filter(
Boolean
) as HTMLElement[];
if (allProductsEls.length === 0) return [];
// Nối tất cả kết quả extractListings từ mỗi element
return allProductsEls.flatMap((el) =>
this.extractListings(el)
) as ISyncItem[];
};
extractListings(productsEl: HTMLElement) {
const children = Array.from(productsEl.children);
return children.map((child) => {
// Lấy title
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
const title = titleEl?.textContent?.trim() || "";
// Lấy giá
const priceEl = Array.from(
child.querySelectorAll('span[dir="auto"]')
).find(
(el) =>
/\d/.test(el.textContent || "") && /[AU$]/.test(el.textContent || "")
);
const priceMatch = priceEl?.textContent?.match(/[\d,]+(?:\.\d+)?/);
const price = priceMatch
? parseFloat(priceMatch[0].replace(/,/g, ""))
: 0;
return { title, price, el: productsEl };
});
}
closeTab = async (data: IItem) => {
chrome.runtime.sendMessage({
type: "close-tab",
payload: data,
});
};
clickOptionOfProduct(el: HTMLElement, data: IItem) {
// Tìm nút "More options" cho sản phẩm đó
const optionEl = el.querySelector(
`[aria-label="More options for ${data.title}"]`
);
if (!optionEl)
throw new Error(
`Not found option buttin in product ${data.title}, ID: ${data.id}`
);
// Click vào nút "More options" để mở menu
(optionEl as any).click?.();
}
async getOptionEls(el: HTMLElement, data: IItem) {
this.clickOptionOfProduct(el, data);
// Delay 2 giây để menu hiển thị hoàn toàn
await delay(2000);
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
return items;
}
clickItemInList(items: Element[], innerText: string) {
// Tìm item chứa chữ "delete"
const item = items.find((item) =>
item.textContent.toLocaleLowerCase().includes(innerText)
);
if (!item) throw new Error(`Not found item ${innerText} in options list`);
// Click vào item "Delete"
(item as any).click?.();
}
getItemInList(items: Element[], innerText: string) {
// Tìm item chứa chữ "delete"
const item = items.find((item) =>
item.textContent.toLocaleLowerCase().includes(innerText)
);
if (!item) throw new Error(`Not found item ${innerText} in options list`);
return item;
}
handleDelete = async (payload: IItem) => {
// Lấy tất cả sản phẩm hiện tại trên Facebook Marketplace
const products = await facebookService.getProducts();
// Tìm sản phẩm khớp với title và price từ payload
const product = products.find((product) => {
return product.title == payload.title && product.price == payload.price;
});
console.log({ payload, product, products });
// Nếu không tìm thấy sản phẩm nào khớp thì kết thúc
if (!product) return;
const el = product.el; // element HTML đại diện cho sản phẩm trên trang
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = await this.getOptionEls(el, payload);
console.log({ items });
// // Tìm item chứa chữ "delete"
this.clickItemInList(items, "delete");
// Delay 1 giây để modal xác nhận xóa hiện ra
await delay(1000);
// Lấy nút "Options" trong modal (hoặc fallback nếu xpath chính không tìm thấy)
const confirmBtn = await thiefService.getElementByXPath(
this.selectors.option_btn,
{
xpathFallback: this.selectors.option_btn_fallback,
}
);
console.log({ confirmBtn });
// Click vào nút "Options" trong modal nếu tìm thấy
confirmBtn?.click();
// Lấy nút "Close" của modal feedback (nếu có) để đóng modal sau khi xóa
const closeBtnModalFeedback = await thiefService.getElementByXPath(
this.selectors.close_btn_modal_feedback
);
closeBtnModalFeedback?.click();
// Gọi API backend để đánh dấu sản phẩm đã xóa (published = false)
await productApi.finistDelete(payload, { published: false });
// Gửi message đến background/content script thông báo là delete đã xong
chrome.runtime.sendMessage({ type: "delete-done" });
};
syncListing = async () => {
const url = window.location.href;
if (!url.includes(this.sellingPath)) return;
const products = await facebookService.getProducts();
const response = await productApi.sync(
products.map((item) => ({
title: item.title,
price: item.price,
})) as ISyncItem[]
);
console.log({ response });
};
extractMarketplaceItemId(url: string): string | null {
const match = url.match(/\/marketplace\/item\/(\d+)/);
return match ? match[1] : null;
}
handleGetPublistID = async (payload: IItem) => {
// Lấy tất cả sản phẩm hiện tại trên Facebook Marketplace
const products = await facebookService.getProducts();
// Tìm sản phẩm khớp với title và price từ payload
const product = products.find((product) => {
return product.title == payload.title && product.price == payload.price;
});
console.log({ payload, product, products });
// Nếu không tìm thấy sản phẩm nào khớp thì kết thúc
if (!product) return;
const el = product.el; // element HTML đại diện cho sản phẩm trên trang
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = await this.getOptionEls(el, payload);
const item = this.getItemInList(items, "view listing");
const publistID = this.extractMarketplaceItemId((item as any)["href"]);
this.clickOptionOfProduct(el, payload);
return publistID;
};
}
export const facebookService = new FacebookService();

View File

@ -1,3 +1,3 @@
VITE_APP_NAME = 'Admin'
VITE_BASE_URL = 'http://localhost:4000/api/v1/'
VITE_BASE_URL = 'http://10.20.2.227:4000/api/v1/'

View File

@ -34,36 +34,36 @@ export class BaseApiService<T extends { id: number }> {
return response.data;
}
async create(data: Partial<Omit<T, "id" | "created_at" | "updated_at">>) {
async create(values: Partial<Omit<T, "id" | "created_at" | "updated_at">>) {
try {
const newData = removeUndefinedValues(data);
const { data: result } = await axios({
const newData = removeUndefinedValues(values);
const { data } = await axios({
url: this.resourceUrl,
// withCredentials: true,
method: "POST",
data: newData,
});
handleSuccess(result, this.resourceUrl);
return result;
handleSuccess(data, this.resourceUrl);
return data;
} catch (error) {
handleError(error);
}
}
async update(id: T["id"], data: Partial<T>) {
async update(id: T["id"], values: Partial<T>) {
try {
const cleaned = removeUndefinedValues(data);
const { data: result } = await axios({
const cleaned = removeUndefinedValues(values);
const { data } = await axios({
url: `${this.resourceUrl}/${id}`,
// withCredentials: true,
method: "PUT",
data: cleaned,
});
handleSuccess(result, this.resourceUrl);
handleSuccess(data, this.resourceUrl);
return result;
return data;
} catch (error) {
handleError(error);
}

View File

@ -16,8 +16,8 @@ import { Button } from "../ui/button";
export function ConfirmAlert({
children,
title = "Bạn có chắc không?",
description = "Hành động này không thể hoàn tác.",
title = "Are you sure ?",
description = "This action cannot be undone.",
onConfirm,
}: {
children: ReactNode;
@ -36,7 +36,7 @@ export function ConfirmAlert({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Hủy</AlertDialogCancel>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
disabled={loading}
onClick={async (e) => {
@ -55,7 +55,7 @@ export function ConfirmAlert({
setOpen(false);
}}
>
Tiếp tục
Continue
{loading && <Loader color="white" size="size-3" />}
</Button>
</AlertDialogFooter>

View File

@ -28,6 +28,8 @@ import {
X,
} from "lucide-react";
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useMemo,
@ -224,7 +226,7 @@ export interface DataTableProps<T> {
pageSize?: number;
onPageSizeChange?: (newPageSize: number) => void;
onRowClick?: (row: T) => void;
onEdit?: (row: T) => void;
onEdit?: (row: T, children?: ReactNode) => void | ReactNode;
onDelete?: (row: T) => void;
onView?: (row: T) => void;
selectable?: boolean;
@ -1448,16 +1450,28 @@ export function DataTable<T extends Record<string, any>>({
Xem
</DropdownMenuItem>
)}
{onEdit && (
<DropdownMenuItem
onClick={() => onEdit(row)}
className="cursor-pointer"
>
<Edit className="mr-2 h-4 w-4" />
Sửa
</DropdownMenuItem>
)}
{onEdit &&
(() => {
const content = (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault(); // Ngăn dropdown đóng lại
e.stopPropagation();
}}
onClick={(e) => onEdit(row)}
className="cursor-pointer"
>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
);
const wrapped = onEdit(row, content);
return isValidElement(wrapped)
? cloneElement(wrapped, {}, content)
: content;
})()}
{/* Custom actions */}
{visibleCustomActions.map((action) => (
<DropdownMenuItem

View File

@ -1,4 +1,5 @@
import _ from "lodash";
export function removeFalsyValues<T extends object>(obj: T): Partial<T> {
return _.pickBy(obj, Boolean);
}

View File

@ -1,11 +1,13 @@
"use client";
import { useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, X, Upload, Link, ImageIcon } from "lucide-react";
import { ImageIcon, Link, Plus, Upload, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import {
Dialog,
DialogContent,
@ -14,7 +16,6 @@ import {
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
@ -22,21 +23,28 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Card, CardContent } from "~/components/ui/card";
import { Textarea } from "~/components/ui/textarea";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import z from "zod/v3";
import { productApi } from "~/api/products-api.service";
import { ConfirmAlert } from "~/components/btn/confirm-alert";
import Loader from "~/components/loader";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "~/components/ui/form";
import z from "zod/v3";
import { urlToBase64 } from "~/features/base64";
import { productApi } from "~/api/products-api.service";
import { delay } from "~/features/delay";
import { cn } from "~/lib/utils";
import { Checkbox } from "~/components/ui/checkbox";
import { Label } from "~/components/ui/label";
export const productSchema = z.object({
images: z.array(z.string()).min(1, "At least 1 image is required"),
@ -63,16 +71,19 @@ export const productSchema = z.object({
tags: z.array(z.string()).optional(),
sku: z.string().min(1, "Sku must be required"),
location: z.string().optional(),
publist: z.boolean().optional(),
});
export type ProductFormData = z.infer<typeof productSchema>;
export default function ProductModal({
children,
data,
...props
}: {
children: ReactNode;
onSubmit?: () => void;
data?: IProduct;
}) {
const [open, setOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
@ -91,6 +102,7 @@ export default function ProductModal({
tags: [],
sku: "",
location: "",
publist: false,
},
});
@ -100,6 +112,74 @@ export default function ProductModal({
const conditions = ["New", "Used - like new", "Used - good", "Used - fair"];
const categories = ["Tools"];
const { isLoading, refetch, ...query } = useQuery({
queryKey: ["product", data?.id],
queryFn: async () => {
if (!data) return null;
await delay(300); // Giả lập delay để thấy loading
const res = await productApi.get(data.id);
return res;
},
enabled: false,
});
const delImageMutation = useMutation({
mutationFn: async (imageUrl: string) => {
await delay(300);
return await axios.delete(imageUrl);
},
onSuccess: (data) => {
refetch();
},
});
const actionMutation = useMutation({
mutationFn: async (formData: ProductFormData & { id?: number }) => {
const { id, brand, publist, ...rest } = formData;
let response;
if (id) {
// Update
response = await productApi.update(id, { ...rest, id });
if (publist && response?.data) {
const action = data?.status ? "re-publist" : "publist";
await productApi.customAction(
response.data.id,
action,
response.data
);
}
} else {
// Create
response = await productApi.create({ ...rest, brand });
if (publist && response?.data) {
await productApi.customAction(
response.data.id,
"publist",
response.data
);
}
}
return response;
},
onSuccess: () => {
refetch(); // làm mới danh sách
setOpen(false);
setUrlInput("");
setTagInput("");
props.onSubmit?.();
form.reset();
},
onError: (error) => {
console.error("Mutation failed:", error);
},
});
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
@ -123,12 +203,17 @@ export default function ProductModal({
}
};
const removeImage = (index: number) => {
const removeImage = (index: number, image: string) => {
const currentImages = form.getValues("images");
form.setValue(
"images",
currentImages.filter((_, i) => i !== index)
);
if (data && isHttpUrl(image)) {
delImageMutation.mutate(image);
} else {
form.setValue(
"images",
currentImages.filter((_, i) => i !== index)
);
}
};
const addTag = () => {
@ -147,41 +232,62 @@ export default function ProductModal({
);
};
const onSubmit = async (data: ProductFormData) => {
const onSubmit = async ({ images, ...values }: ProductFormData) => {
try {
const images = data.images;
let imagesToConvert: string[] = [];
if (data) {
// Có data => đang update
const oldImages = data.images || [];
const newImages = images || [];
// Lấy ra hình khác so với data.images
imagesToConvert = newImages.filter((img) => !oldImages.includes(img));
} else {
// Không có data => đang create
imagesToConvert = images || [];
}
// Convert blob url sang base64 nếu cần
const convertedImages = await Promise.all(
images.map(async (img) => {
if (img.startsWith("blob:")) {
// convert blob url to base64
return await urlToBase64(img);
}
return img; // giữ nguyên nếu ko phải blob url
})
imagesToConvert.map(async (img) =>
img.startsWith("blob:") ? await urlToBase64(img) : img
)
);
const dataToSubmit = {
...data,
const dataToSubmit: ProductFormData = {
...values,
images: convertedImages,
};
console.log("Product data to submit:", dataToSubmit);
const response = await productApi.create(dataToSubmit);
// const response = data
// ? await (async () => {
// const { brand, ...d } = dataToSubmit;
// return await productApi.update(data.id, { ...d, id: data.id });
// })()
// : await productApi.create(dataToSubmit);
if (!response) return;
// if (!response) return;
setOpen(false);
form.reset();
setUrlInput("");
setTagInput("");
actionMutation.mutate({ ...dataToSubmit, id: data?.id });
props.onSubmit?.();
console.log("Hình mới cần xử lý:", convertedImages);
} catch (error) {
console.error("Error submitting form:", error);
}
};
const isHttpUrl = (url: string) => {
try {
const u = new URL(url);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
};
const handleClose = () => {
setOpen(false);
form.reset();
@ -189,6 +295,18 @@ export default function ProductModal({
setTagInput("");
};
useEffect(() => {
if (data) {
form.reset(data);
}
}, [data]);
useEffect(() => {
if (query.isSuccess && query.isFetched && query.data?.data) {
form.reset(query.data?.data);
}
}, [delImageMutation.isSuccess, query.isFetched, query.isSuccess]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
@ -196,7 +314,7 @@ export default function ProductModal({
{/* Header */}
<DialogHeader className="p-6 border-b">
<DialogTitle className="text-2xl font-bold">
Create new product
{data ? "Edit product" : "Create new product"}
</DialogTitle>
</DialogHeader>
@ -292,15 +410,40 @@ export default function ProductModal({
alt={`Preview ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2 h-8 w-8 p-0 opacity-0 group-hover:opacity-100"
onClick={() => removeImage(index)}
>
<X className="w-4 h-4" />
</Button>
{data && isHttpUrl(image) ? (
<ConfirmAlert
onConfirm={() => removeImage(index, image)}
>
<Button
type="button"
variant="destructive"
size="sm"
className={cn(
"absolute top-2 right-2 h-8 w-8 p-0 opacity-0 ",
{
["group-hover:opacity-100"]:
!delImageMutation.isPending,
}
)}
>
{delImageMutation.isPending ? (
<Loader color="white" />
) : (
<X className="w-4 h-4" />
)}
</Button>
</ConfirmAlert>
) : (
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2 h-8 w-8 p-0 opacity-0 group-hover:opacity-100"
onClick={() => removeImage(index, image)}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
))}
</div>
@ -415,7 +558,11 @@ export default function ProductModal({
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="VD: Cisco" {...field} />
<Input
readOnly={!!data}
placeholder="VD: Cisco"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -469,15 +616,13 @@ export default function ProductModal({
<div className="flex flex-wrap gap-2">
{watchedTags.map((tag) => (
<Badge
onClick={() => removeTag(tag)}
key={tag}
variant="secondary"
className="flex items-center gap-1 px-3 py-1"
>
{tag}
<X
className="w-3 h-3 cursor-pointer hover:text-red-500"
onClick={() => removeTag(tag)}
/>
<X className="w-3 h-3 cursor-pointer hover:text-red-500" />
</Badge>
))}
</div>
@ -492,6 +637,7 @@ export default function ProductModal({
<FormLabel>Sku *</FormLabel>
<FormControl>
<Input
readOnly={!!data}
placeholder="VD: MBP14-2023-512GB"
{...field}
/>
@ -517,6 +663,41 @@ export default function ProductModal({
</FormItem>
)}
/>
<FormField
control={form.control}
name="publist"
render={({ field }) => {
const isUpdate = !!data?.id; // đang update nếu có id
const canRepublish = isUpdate && data?.status === true;
return (
<FormItem>
<FormControl>
<div className="flex items-start gap-3">
<Checkbox
id="publish"
checked={field.value}
onCheckedChange={field.onChange}
/>
<div className="grid gap-1 leading-none">
<Label htmlFor="publish">
{canRepublish
? "Re-publish this product"
: "Publish after saving"}
</Label>
<p className="text-sm text-muted-foreground">
{canRepublish
? "Make this product visible again after updating"
: "Automatically publish this product when saving"}
</p>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</CardContent>
</Card>
</form>
@ -533,7 +714,13 @@ export default function ProductModal({
onClick={form.handleSubmit(onSubmit)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Đang thêm..." : "Create"}
{form.formState.isSubmitting ? (
<Loader color="white" />
) : data ? (
"Save"
) : (
"Create"
)}
</Button>
</div>
</DialogContent>

View File

@ -3,16 +3,12 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import _ from "lodash";
import {
Archive,
Check,
Download,
HistoryIcon,
Plus,
Share,
Star,
RefreshCcw,
Trash2,
UploadCloud,
UserPlus,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@ -25,6 +21,7 @@ import {
type TableState,
} from "~/components/core/data-table";
import Loader from "~/components/loader";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
@ -33,18 +30,16 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Textarea } from "~/components/ui/textarea";
import { delay } from "~/features/delay";
import {
stateToURLQuery,
urlQueryToState,
} from "~/features/state-url-converter";
import { useAppSelector } from "~/hooks/use-app-dispatch";
import { useUsersContext } from "~/layouts/contexts/user-layout.context";
import type { RootState } from "~/store";
import ProductModal from "./components/modals/product-modal";
import { Badge } from "~/components/ui/badge";
import { HistoryModal } from "./components/modals/history-modal";
import { Textarea } from "~/components/ui/textarea";
import ProductModal from "./components/modals/product-modal";
export default function List() {
// const { setEdit } = useUserModal();
@ -52,6 +47,8 @@ export default function List() {
const [openHistories, setOpenHistories] = useState<IHistory[]>([]);
const [openEdit, setOpenEdit] = useState<IProduct | null>(null);
const prevStates = useRef<Record<string, any>>(null);
const [initStates, setInitStates] = useState<Record<string, any> | undefined>(
@ -217,6 +214,15 @@ export default function List() {
handlePublist(data);
},
},
{
key: "re-publist",
label: "Re publist",
icon: <RefreshCcw className="h-4 w-4" />,
action: (data: IProduct) => {
handleRePublist(data);
},
show: (data: IProduct) => data.status,
},
{
key: "histories",
label: "Histories",
@ -277,6 +283,18 @@ export default function List() {
},
});
const rePublistMutation = useMutation({
mutationFn: async (data: Partial<IProduct>) => {
await delay(300);
return productApi.customAction(data.id || 0, "re-publist", data);
},
onSuccess: (data) => {
console.log({ data });
refetch();
},
});
const handlePublist = async (data: Partial<IProduct>) => {
toast.promise(
listingMutation.mutateAsync(data), // gọi function để trả về promise
@ -288,6 +306,17 @@ export default function List() {
);
};
const handleRePublist = async (data: Partial<IProduct>) => {
toast.promise(
rePublistMutation.mutateAsync(data), // gọi function để trả về promise
{
loading: "Loading...",
success: (result) => `${data?.title} toast has been added`,
error: "Error",
}
);
};
if (!initStates) return <Loader />;
return (
@ -359,7 +388,13 @@ export default function List() {
);
}}
onDelete={handleDelete}
// onEdit={setEdit}
onEdit={(data: IProduct, children) => {
return (
<ProductModal onSubmit={refetch} data={data}>
{children}
</ProductModal>
);
}}
options={{
disableDel(data) {
return data.id === user?.id;

View File

Before

Width:  |  Height:  |  Size: 518 KiB

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -55,6 +55,9 @@ export class Product extends CoreEntity {
@Column({ type: 'varchar', unique: true })
sku: string;
@Column({ type: 'varchar', unique: true, nullable: true, default: null })
publist_id: string;
@Column({ type: 'varchar', nullable: true })
location?: string;

View File

@ -1,55 +1,26 @@
import { Controller, Get, NotFoundException, Param, Res } from '@nestjs/common';
import * as path from 'path';
import * as fs from 'fs';
import { Controller, Delete, Get, Param, Res } from '@nestjs/common';
import { Response } from 'express';
import { SystemLang } from '@/system/lang/system.lang';
import { MediasService } from './medias.service';
@Controller('medias')
export class MediasController {
@Get('avatars/:username/:filename')
async avartarImage(
@Param('username') username: string,
constructor(private readonly mediaService: MediasService) {}
@Get(':type/:subdir/:filename')
async serveMedia(
@Param('type') type: 'avatars' | 'products',
@Param('subdir') subdir: string,
@Param('filename') filename: string,
@Res() res: Response,
) {
const filePath = path.join(
process.cwd(),
'public',
'medias',
'avatars',
username,
filename,
);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(
SystemLang.getText('messages', 'file_not_found'),
);
}
res.sendFile(filePath);
return this.mediaService.serveMedia(type, subdir, filename, res);
}
@Get('products/:title/:filename')
async serveImage(
@Param('title') title: string,
@Delete(':type/:subdir/:filename')
async deleteMedia(
@Param('type') type: 'avatars' | 'products',
@Param('subdir') subdir: string,
@Param('filename') filename: string,
@Res() res: Response,
) {
const filePath = path.join(
process.cwd(),
'public',
'medias',
'products',
title,
filename,
);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(
SystemLang.getText('messages', 'file_not_found'),
);
}
res.sendFile(filePath);
return this.mediaService.deleteMedia(type, subdir, filename);
}
}

View File

@ -1,13 +1,25 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import {
Injectable,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { writeFile } from 'fs/promises';
import * as fs from 'fs';
import * as path from 'path';
import sizeOf from 'image-size';
import { promises } from 'fs';
import axios from 'axios';
import { SystemLang } from '@/system/lang/system.lang';
import { Response } from 'express';
import AppResponse from '@/system/response/ktq-response';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class MediasService {
constructor(private eventEmitter: EventEmitter2) {}
private ROOT_MEDIA_FOLDER = './public';
private readonly allowedTypes = ['avatars', 'products'];
async saveBase64Image(
base64: string,
@ -133,6 +145,19 @@ export class MediasService {
};
}
/**
* Extract ID from filename
* Example: "product-51-1755224843970.jpg" => 51
* Example: "avatar-123-1755224843970.png" => 123
*/
extractIdFromFilename(filename: string): number | null {
const match = filename.match(/^[^-]+-(\d+)-/);
if (match) {
return Number(match[1]);
}
return null;
}
async downloadImageAndSave(
url: string,
folder: string,
@ -193,6 +218,44 @@ export class MediasService {
}
}
async renameMediasFolder(
oldFilepath: string,
folder: string,
newFilename: string,
): Promise<void> {
if (!oldFilepath) return;
// Đường dẫn cũ
const oldRelativePath = path.join('medias/' + folder, oldFilepath);
const oldFullPath = path.join(this.ROOT_MEDIA_FOLDER, oldRelativePath);
// Đường dẫn mới
const newRelativePath = path.join('medias/' + folder, newFilename);
const newFullPath = path.join(this.ROOT_MEDIA_FOLDER, newRelativePath);
const rootPath = path.resolve(this.ROOT_MEDIA_FOLDER);
const resolvedOldFullPath = path.resolve(oldFullPath);
const resolvedNewFullPath = path.resolve(newFullPath);
// Ngăn đổi tên ra ngoài thư mục ROOT_MEDIA_FOLDER
if (
!resolvedOldFullPath.startsWith(rootPath) ||
!resolvedNewFullPath.startsWith(rootPath)
) {
throw new Error('Invalid: Cannot rename outside the specified directory');
}
try {
if (fs.existsSync(resolvedOldFullPath)) {
await fs.promises.rename(resolvedOldFullPath, resolvedNewFullPath);
} else {
console.warn(`File not found: ${oldFullPath}`);
}
} catch (error) {
console.error(`Error when renaming file: ${error.message}`);
}
}
private _getExtensionFromMime(mime: string): string | null {
const map = {
'image/jpeg': 'jpg',
@ -210,4 +273,84 @@ export class MediasService {
const dd = String(now.getDate()).padStart(2, '0');
return [yyyy, mm, dd];
}
private buildFilePath(
type: string,
subdir: string,
filename: string,
): string {
return path.join(process.cwd(), 'public', 'medias', type, subdir, filename);
}
private validateType(type: string) {
if (!this.allowedTypes.includes(type)) {
throw new NotFoundException(
SystemLang.getText('messages', 'file_not_found'),
);
}
}
async serveMedia(
type: 'avatars' | 'products',
subdir: string,
filename: string,
res: Response,
) {
this.validateType(type);
const filePath = this.buildFilePath(type, subdir, filename);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(
SystemLang.getText('messages', 'file_not_found'),
);
}
return res.sendFile(filePath);
}
async deleteMedia(
type: 'avatars' | 'products',
subdir: string,
filename: string,
) {
if (!['avatars', 'products'].includes(type)) {
throw new ForbiddenException(
SystemLang.getText('messages', 'file_not_found'),
);
}
const filePath = path.join(
process.cwd(),
'public',
'medias',
type,
subdir,
filename,
);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(
SystemLang.getText('messages', 'file_not_found'),
);
}
try {
fs.unlinkSync(filePath);
// bắn event sau khi xóa
this.eventEmitter.emit('media.deleted', {
type,
subdir,
filename,
filePath,
});
return AppResponse.toResponse(true);
} catch (err) {
throw new ForbiddenException(
SystemLang.getText('messages', 'file_delete_failed'),
);
}
}
}

View File

@ -60,4 +60,8 @@ export class CreateProductDto {
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsString()
publist_id?: string;
}

View File

@ -1,10 +1,18 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class PublistFinishDto {
export class HistoryDto {
@IsBoolean()
published: boolean;
@IsString()
@IsOptional()
error?: string;
@IsString()
@IsOptional()
message?: string;
@IsString()
@IsOptional()
publist_id?: string;
}

View File

@ -0,0 +1,61 @@
import { Product } from '@/entities/product.entity';
import { SystemLang } from '@/system/lang/system.lang';
import { IsExistInDatabase } from '@/system/validators/decorators/is-exist-in-database';
import { Type } from 'class-transformer';
import { IsArray, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class UpdateProductDto {
@IsNumber()
id: number;
@IsString()
@IsOptional()
title: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
images: string[];
@Type(() => Number) // đảm bảo chuyển đổi từ string -> number khi gửi form-data
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
@IsOptional()
price: number;
@IsString()
@IsOptional()
category: string;
@IsString()
@IsOptional()
condition: string;
@IsString()
@IsOptional()
description: string;
@IsArray()
@IsString({ each: true })
@IsOptional()
tags: string[];
@IsString()
@IsOptional()
@IsExistInDatabase(
Product,
'sku',
{},
{
message: SystemLang.getCustomText({
en: 'SKU must be unique',
vi: 'SKU phải là duy nhất',
}),
},
)
sku: string;
@IsOptional()
@IsString()
location?: string;
}

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ProductsService } from './products.service';
import { MediasService } from '../medias/medias.service';
@Injectable()
export class ProductsListener {
constructor(
private readonly service: ProductsService,
private readonly mediaService: MediasService,
) {}
@OnEvent('media.deleted')
async handleMediaDeletedEvent(payload: {
type: string;
subdir: string;
filename: string;
filePath: string;
}) {
const id = this.mediaService.extractIdFromFilename(payload.filename);
const product = await this.service.repo.findOne({ where: { id } });
if (!product) return;
// Loại bỏ ảnh trùng với filename
product.images = product.images.filter(
(image) => !image.includes(payload.filename),
);
await this.service.repo.update(product.id, product);
}
}

View File

@ -1,10 +1,12 @@
import { Product } from '@/entities/product.entity';
import CoreController from '@/system/core/core-controller';
import { Body, Controller, Param, Post, Sse } from '@nestjs/common';
import { Body, Controller, Param, Post, Put, Req, Sse } from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dtos/create-product.dto';
import { PublistFinishDto } from './dtos/publist-finish.dto';
import { HistoryDto } from './dtos/publist-finish.dto';
import { SyncDto } from './dtos/syncs.dto';
import { UpdateProductDto } from './dtos/update-product.dto';
import { Request } from 'express';
@Controller('products')
export class ProductsController extends CoreController<
@ -19,6 +21,14 @@ export class ProductsController extends CoreController<
async create(@Body() data: CreateProductDto): Promise<any> {
return this.service.create(data);
}
@Put(':id')
async update(
@Body() data: UpdateProductDto,
@Param('id') id: Product['id'],
@Req() request: Request,
): Promise<any> {
return this.service.update(id, data as any, request);
}
@Post('sync')
async sync(@Body() data: SyncDto): Promise<any> {
@ -35,21 +45,35 @@ export class ProductsController extends CoreController<
return this.service.unlist(id);
}
@Post('re-publist/:id')
async rePublist(@Param('id') id: Product['id']): Promise<any> {
return this.service.rePublist(id);
}
@Post('publist-finish/:id')
async publistFinish(
@Param('id') id: Product['id'],
@Body() data: PublistFinishDto,
@Body() data: HistoryDto,
): Promise<any> {
return this.service.publistFinish(id, data);
}
@Post('delete-finish/:id')
async deleteFinish(
@Param('id') id: Product['id'],
@Body() data: PublistFinishDto,
@Body() data: HistoryDto,
): Promise<any> {
return this.service.deleteFinish(id, data);
}
@Post('update-finish/:id')
async updateFinish(
@Param('id') id: Product['id'],
@Body() data: HistoryDto,
): Promise<any> {
return this.service.updateFinish(id, data);
}
@Sse('publist-stream')
async publistStream() {
return this.service.sendPublistEvents();
@ -59,4 +83,9 @@ export class ProductsController extends CoreController<
async deleteStream() {
return this.service.sendDeleteEvents();
}
@Sse('edit-stream')
async editStream() {
return this.service.sendEditEvents();
}
}

View File

@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { MediasModule } from '../medias/medias.module';
import { EventsModule } from '../events/events.module';
import { PublistHistory } from '@/entities/publist-history.entity';
import { ProductsListener } from './products-listener.event';
@Module({
imports: [
@ -13,7 +14,7 @@ import { PublistHistory } from '@/entities/publist-history.entity';
MediasModule,
EventsModule,
],
providers: [ProductsService],
providers: [ProductsService, ProductsListener],
controllers: [ProductsController],
})
export class ProductsModule {}

View File

@ -17,19 +17,24 @@ import { Repository } from 'typeorm';
import { EventsService } from '../events/events.service';
import { MediasService } from '../medias/medias.service';
import { CreateProductDto } from './dtos/create-product.dto';
import { PublistFinishDto } from './dtos/publist-finish.dto';
import { HistoryDto } from './dtos/publist-finish.dto';
import { Request } from 'express';
import { SyncDto } from './dtos/syncs.dto';
import { Paginated } from 'nestjs-paginate';
@Injectable()
export class ProductsService extends CoreService<Product> {
public static EVENTS = {
SEND_PUBLIST: 'send-publist',
SEND_DELETE: 'send-delete',
SEND_EDIT: 'send-edit',
PUBLIST_FINISH: 'publist-finish',
DElETE_FINISH: 'delete-finish',
EDIT_FINISH: 'edit-finish',
};
private TIMEOUT_WATING = 60000;
constructor(
@InjectRepository(Product)
readonly repo: Repository<Product>,
@ -50,14 +55,22 @@ export class ProductsService extends CoreService<Product> {
sku: true,
created_at: true,
},
relations: {
histories: true,
},
},
Product,
);
}
protected async beforeIndex(data: Paginated<Product>): Promise<void> {
for (const item of data.data) {
const histories = await this.historiesRepo.find({
where: { product: { id: item.id } },
order: { created_at: 'DESC' },
});
item.histories = histories;
}
}
protected async afterDelete(
id: number,
data: Partial<Product>,
@ -74,40 +87,15 @@ export class ProductsService extends CoreService<Product> {
}
}
// protected async beforeDelete(
// id: number,
// data: Partial<Product>,
// req: Request,
// ): Promise<boolean> {
// // Emit sự kiện để bắt đầu publish
// this.eventService.sendEvent(ProductsService.EVENTS.SEND_DELETE, {
// ...data,
// });
// // Đợi phản hồi từ client
// const result = await this.waitForDeleteResult({ ...data, id });
// if (!result)
// throw new BadRequestException(
// AppResponse.toResponse(false, {
// message: SystemLang.getText('messages', 'try_again'),
// }),
// );
// return true;
// }
async create(data: CreateProductDto): Promise<any> {
const product = this.repo.create(data);
if (data.images && data.images.length) {
async mapImages(data: Product, images: string[]) {
if (images && images.length) {
const newImages = [];
for (const image of data.images) {
for (const image of images) {
if (image.startsWith('data:')) {
const result = await this.mediasService.saveBase64Image(
image,
'product',
`product-${data.id}`,
`medias/products/${data.title.toLowerCase().replaceAll(' ', '-')}`,
);
newImages.push(result.filename);
@ -116,7 +104,7 @@ export class ProductsService extends CoreService<Product> {
) {
const result = await this.mediasService.downloadImageAndSave(
image,
'product',
`product-${data.id}`,
`medias/products/${data.title.toLowerCase().replaceAll(' ', '-')}`,
);
newImages.push(result.filename);
@ -126,15 +114,127 @@ export class ProductsService extends CoreService<Product> {
}
if (newImages.length) {
product.images = newImages;
return newImages;
}
}
await this.repo.save(product);
return [];
}
async create(data: CreateProductDto): Promise<any> {
const product = this.repo.create(data);
const result = await this.repo.save({ ...product, images: [] });
const newImages = await this.mapImages(result, data.images);
result.images = newImages;
await this.repo.update(result.id, result);
return AppResponse.toResponse(result);
}
async update(
id: number,
{ images, ...data }: Partial<Product> | any,
request: Request,
): Promise<any> {
const prev = await this.repo.findOne({ where: { id } });
if (!prev)
throw new NotFoundException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
const newImages = await this.mapImages(prev, images);
data.images = [...prev.images, ...newImages];
const result = await this.repo.update(id, data);
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
if (data?.title) {
await this.mediasService.renameMediasFolder(
`${prev.title.toLowerCase().replaceAll(' ', '-')}` as string,
'products',
`${data.title.toLowerCase().replaceAll(' ', '-')}` as string,
);
}
const product = await this.repo.findOne({ where: { id } });
// const plainData = plainToClass(Product, product);
// let base64Image: string[] = [];
// try {
// base64Image = await this.toImagesBase64(plainData);
// } catch (error) {
// console.error('Error converting images to Base64:', error);
// throw new BadRequestException(
// AppResponse.toResponse(null, {
// message: SystemLang.getText('messages', 'server_error'),
// }),
// );
// }
// this.eventService.sendEvent(ProductsService.EVENTS.SEND_EDIT, {
// prev: prev,
// data: { ...plainData, images: base64Image },
// });
// const { publist_id } = await this.waitForEditResult(prev, product);
// if (publist_id && !product.publist_id) {
// await this.repo.update(id, { publist_id });
// }
return AppResponse.toResponse(product);
}
async rePublist(id: Product['id']) {
const product = await this.repo.findOne({ where: { id } });
if (!product) {
throw new NotFoundException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
}
const plainData = plainToClass(Product, product);
let base64Image: string[] = [];
try {
base64Image = await this.toImagesBase64(plainData);
} catch (error) {
console.error('Error converting images to Base64:', error);
throw new BadRequestException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'server_error'),
}),
);
}
this.eventService.sendEvent(ProductsService.EVENTS.SEND_EDIT, {
prev: product,
data: { ...plainData, images: base64Image },
});
const { publist_id } = await this.waitForEditResult(product, product);
if (publist_id && !product.publist_id) {
await this.repo.update(id, { publist_id });
}
return AppResponse.toResponse(plainData);
}
async toImagesBase64(product: Product) {
const images = [];
@ -230,7 +330,7 @@ export class ProductsService extends CoreService<Product> {
try {
return await this.eventService.waitForEvent<PublistHistory>(
`${ProductsService.EVENTS.PUBLIST_FINISH}_${data.id}`,
60000,
this.TIMEOUT_WATING,
);
} catch {
throw new BadRequestException(
@ -247,7 +347,7 @@ export class ProductsService extends CoreService<Product> {
try {
return await this.eventService.waitForEvent<PublistHistory>(
`${ProductsService.EVENTS.DElETE_FINISH}_${data.id}`,
60000,
this.TIMEOUT_WATING,
);
} catch {
throw new BadRequestException(
@ -258,7 +358,22 @@ export class ProductsService extends CoreService<Product> {
}
}
async publistFinish(id: Product['id'], data: PublistFinishDto) {
private async waitForEditResult(prev: Product, data: Product): Promise<any> {
try {
return await this.eventService.waitForEvent<PublistHistory>(
`${ProductsService.EVENTS.EDIT_FINISH}_${prev.id}`,
this.TIMEOUT_WATING,
);
} catch {
throw new BadRequestException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'try_again'),
}),
);
}
}
async publistFinish(id: Product['id'], data: HistoryDto) {
const product = await this.repo.findOne({ where: { id } });
if (!product)
@ -280,7 +395,7 @@ export class ProductsService extends CoreService<Product> {
return AppResponse.toResponse(plainData);
}
async deleteFinish(id: Product['id'], data: PublistFinishDto) {
async deleteFinish(id: Product['id'], data: HistoryDto) {
const product = await this.repo.findOne({ where: { id } });
if (!product)
@ -302,8 +417,32 @@ export class ProductsService extends CoreService<Product> {
return AppResponse.toResponse(plainData);
}
async updateFinish(id: Product['id'], { publist_id, ...data }: HistoryDto) {
const product = await this.repo.findOne({ where: { id } });
if (!product)
throw new NotFoundException(
AppResponse.toResponse(null, {
message: SystemLang.getText('messages', 'not_found'),
}),
);
const plainData = plainToClass(Product, product);
const result = await this.historiesRepo.save({
...data,
product: { id: product.id },
});
this.eventService.sendEvent(
`${ProductsService.EVENTS.EDIT_FINISH}_${plainData.id}`,
{ history: plainToClass(PublistHistory, result), publist_id },
);
return AppResponse.toResponse(plainData);
}
sendPublistEvents(): Observable<{ data: any }> {
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
return fromEvent(
this.eventService.event,
ProductsService.EVENTS.SEND_PUBLIST,
@ -315,7 +454,6 @@ export class ProductsService extends CoreService<Product> {
}
sendDeleteEvents(): Observable<{ data: any }> {
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
return fromEvent(
this.eventService.event,
ProductsService.EVENTS.SEND_DELETE,
@ -326,7 +464,46 @@ export class ProductsService extends CoreService<Product> {
);
}
sendEditEvents(): Observable<{ data: any }> {
return fromEvent(
this.eventService.event,
ProductsService.EVENTS.SEND_EDIT,
).pipe(
map((data) => ({
data, // gửi data về client
})),
);
}
async sync({ items }: SyncDto) {
// Nếu items rỗng thì disable toàn bộ sản phẩm
if (!items.length) {
const products = await this.repo.find({ select: ['id'] });
if (products.length) {
await this.repo
.createQueryBuilder()
.update(Product)
.set({ status: false })
.whereInIds(products.map((p) => p.id))
.execute();
await this.historiesRepo.save(
products.map((product) => ({
message: `Product was disabled (empty sync) at: ${new Date().toISOString()}`,
product,
published: false,
})),
);
}
return {
disabled: products.length,
enabled: 0,
inserted: 0,
};
}
const incomingTitles = new Set(items.map((d) => d.title));
// Lấy danh sách sản phẩm hiện tại trong DB
@ -380,18 +557,6 @@ export class ProductsService extends CoreService<Product> {
await this.repo.update(product.id, { status: true, price });
}
// Insert sản phẩm mới + log
if (toInsert.length) {
const newProducts = this.repo.create(
toInsert.map((item) => ({
title: item.title,
price: item.price,
status: true,
})),
);
await this.repo.save(newProducts);
}
return {
disabled: toDisable.length,
enabled: toEnable.length,

View File

@ -37,6 +37,7 @@ export class SystemLang {
'Đổi mật khẩu thành công. Vui lòng đăng nhập lại !',
too_many_request: 'Yêu cầu vượt quá mức quy định',
feature_disabled: 'Tính năng này đang tạm ngưng',
file_delete_failed: 'Không thể xóa tệp. Vui lòng thử lại sau.',
},
en: {
error: 'An error occurred!',
@ -74,6 +75,8 @@ export class SystemLang {
change_pass_success: 'Change password success. Please re-login !',
too_many_request: 'To many request',
feature_disabled: 'This feature is disabled',
file_delete_failed:
'Failed to delete the file. Please try again later.',
},
},
};