edit and re publist
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 305 B |
|
Before Width: | Height: | Size: 522 B |
|
|
@ -7,7 +7,6 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"dev:build": "vite build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface IItem {
|
|||
sku: string;
|
||||
location?: string;
|
||||
id: number;
|
||||
publist_id?: string;
|
||||
}
|
||||
|
||||
interface ISyncItem {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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/'
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import _ from "lodash";
|
||||
|
||||
export function removeFalsyValues<T extends object>(obj: T): Partial<T> {
|
||||
return _.pickBy(obj, Boolean);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 518 KiB After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,8 @@ export class CreateProductDto {
|
|||
@IsOptional()
|
||||
@IsString()
|
||||
location?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
publist_id?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||