diff --git a/auto-listing-facebook-marketplace/src/background.ts b/auto-listing-facebook-marketplace/src/background.ts index 9f0a8ab..fcbdfd7 100644 --- a/auto-listing-facebook-marketplace/src/background.ts +++ b/auto-listing-facebook-marketplace/src/background.ts @@ -3,6 +3,7 @@ import PQueue from "p-queue"; let ports: chrome.runtime.Port[] = []; const queue = new PQueue({ concurrency: 1 }); +const targetUrl = "https://www.facebook.com/marketplace/you/selling"; chrome.runtime.onConnect.addListener((port) => { ports.push(port); @@ -264,11 +265,34 @@ function ensureMarketplaceSellingTab() { }); } +// Hàm check tab load xong +function waitForTargetTabLoad(): Promise { + return new Promise((resolve) => { + // Query tab có url trước + chrome.tabs.query({ url: targetUrl }, (tabs) => { + if (tabs.length > 0 && tabs[0].status === "complete") { + return resolve(); + } + }); + + // Nếu chưa load xong → lắng nghe sự kiện + const listener = (tabId: number, changeInfo: any, tab: chrome.tabs.Tab) => { + if (changeInfo.status === "complete" && tab.url === targetUrl) { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + + chrome.tabs.onUpdated.addListener(listener); + }); +} + const init = async () => { handleListenPublists(); handleListenDeletes(); handleListenEdits(); ensureMarketplaceSellingTab(); + createAlarm(); }; chrome.runtime.onMessage.addListener((message, sender) => { @@ -278,4 +302,53 @@ chrome.runtime.onMessage.addListener((message, sender) => { } }); +// Khởi tạo alarm (30–40 giây, random) +function createAlarm() { + const delay = 30 + Math.floor(Math.random() * 11); // 30 → 40 giây + chrome.alarms.create("checkQueue", { delayInMinutes: delay / 60 }); +} + +// Task reload extension (nằm trong queue) +async function reloadExtensionTask() { + console.log("🔄 Reloading extension..."); + + chrome.runtime.reload(); + // Sau khi reload, extension sẽ restart → task này chưa resolve. + // => Đặt 1 flag trong storage để biết đang chờ tab load + chrome.storage.local.set({ waitingForTab: true }); +} + +// Khi extension khởi động lại → check có đang chờ tab load không +chrome.runtime.onStartup.addListener(async () => { + chrome.storage.local.get("waitingForTab", async (data) => { + if (data.waitingForTab) { + console.log("⏳ Extension restarted, waiting for target tab..."); + + await waitForTargetTabLoad(); + + console.log("✅ Target tab loaded → task done"); + chrome.storage.local.remove("waitingForTab"); + } + }); +}); + +// Alarm chạy định kỳ +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "checkQueue") { + console.log("Alarm triggered, checking queue..."); + + const isIdle = queue.size === 0 && queue.pending === 0; + + if (isIdle) { + console.log("Queue is empty → enqueue reload task"); + queue.add(reloadExtensionTask); + } else { + console.log("Queue still busy → skip reload"); + } + + // đặt lại alarm mới + createAlarm(); + } +}); + init(); diff --git a/client/app/api/core-api-service.ts b/client/app/api/core-api-service.ts index 223f9fe..86a955f 100644 --- a/client/app/api/core-api-service.ts +++ b/client/app/api/core-api-service.ts @@ -2,9 +2,13 @@ import type { TableState } from "~/components/core/data-table"; import { removeUndefinedValues } from "~/features/remove-falsy-values"; import { mapTableStateToPaginationQueryDSL } from "~/features/table"; +import type { DeepPartial } from "react-hook-form"; import { handleError, handleSuccess } from "."; import axios from "../lib/axios"; -import type { DeepPartial } from "react-hook-form"; + +export interface IAPIOption { + toast_success: boolean; +} export class BaseApiService { constructor(protected readonly resourceUrl: string) {} @@ -17,84 +21,111 @@ export class BaseApiService { const response = await axios({ url: this.resourceUrl, params: params, - // withCredentials: true, + withCredentials: true, method: "GET", }); return response.data; } - async get(id: T["id"]) { - const response = await axios({ - url: this.resourceUrl + "/" + id, - // withCredentials: true, - method: "GET", - }); - - return response.data; - } - - async create(values: Partial>) { + async get(id: T["id"], options: IAPIOption = { toast_success: false }) { try { - const newData = removeUndefinedValues(values); - const { data } = await axios({ + const response = await axios({ + url: this.resourceUrl + "/" + id, + withCredentials: true, + method: "GET", + }); + + if (options.toast_success) { + handleSuccess(response.data, this.resourceUrl); + } + + return response.data; + } catch (error) { + handleError(error); + } + } + + async create( + data: Partial>, + options: IAPIOption = { toast_success: true } + ) { + try { + const newData = removeUndefinedValues(data); + const { data: result } = await axios({ url: this.resourceUrl, - // withCredentials: true, + withCredentials: true, method: "POST", data: newData, }); - handleSuccess(data, this.resourceUrl); - return data; + if (options.toast_success) { + handleSuccess(result, this.resourceUrl); + } + + return result; } catch (error) { handleError(error); } } - async update(id: T["id"], values: Partial) { + async update( + id: T["id"], + data: Partial, + options: IAPIOption = { toast_success: false } + ) { try { - const cleaned = removeUndefinedValues(values); - const { data } = await axios({ + const cleaned = removeUndefinedValues(data); + const { data: result } = await axios({ url: `${this.resourceUrl}/${id}`, - // withCredentials: true, + withCredentials: true, method: "PUT", data: cleaned, }); - handleSuccess(data, this.resourceUrl); + if (options.toast_success) { + handleSuccess(result, this.resourceUrl); + } - return data; + return result; } catch (error) { handleError(error); } } - async delete(entity: T) { + async delete(entity: T, options: IAPIOption = { toast_success: false }) { try { const { data } = await axios({ url: `${this.resourceUrl}/${entity.id}`, - // withCredentials: true, + withCredentials: true, method: "DELETE", }); - handleSuccess(data, this.resourceUrl); + if (options.toast_success) { + handleSuccess(data, this.resourceUrl); + } return data; } catch (error) { handleError(error); } } - async bulkDelete(entities: T[]) { + async bulkDelete( + entities: T[], + options: IAPIOption = { toast_success: false } + ) { const ids = entities.map((e) => e.id); try { const { data } = await axios({ url: `${this.resourceUrl}/bulk-delete`, - // withCredentials: true, + withCredentials: true, method: "DELETE", data: { ids }, }); - handleSuccess(data, this.resourceUrl); + if (options.toast_success) { + handleSuccess(data, this.resourceUrl); + } return data; } catch (error) { @@ -102,16 +133,21 @@ export class BaseApiService { } } - async bulkUpdate(entities: T[]) { + async bulkUpdate( + entities: T[], + options: IAPIOption = { toast_success: false } + ) { try { const { data } = await axios({ url: `${this.resourceUrl}/bulk-update`, - // withCredentials: true, + withCredentials: true, method: "PUT", data: entities, }); - handleSuccess(data, this.resourceUrl); + if (options.toast_success) { + handleSuccess(data, this.resourceUrl); + } return data; } catch (error) { @@ -124,17 +160,20 @@ export class BaseApiService { id: number, endpoint: string, payload?: Record, - method?: string + method?: string, + options: IAPIOption = { toast_success: false } ) { try { const { data } = await axios({ url: `${this.resourceUrl}/${endpoint}/${id}`, method: method || "POST", data: removeUndefinedValues(payload || {}), - // withCredentials: true, + withCredentials: true, }); - handleSuccess(data, this.resourceUrl); + if (options.toast_success) { + handleSuccess(data, this.resourceUrl); + } return data; } catch (error) { handleError(error); diff --git a/client/app/components/btn/confirm-alert.tsx b/client/app/components/btn/confirm-alert.tsx index 1f09c6a..4d86da8 100644 --- a/client/app/components/btn/confirm-alert.tsx +++ b/client/app/components/btn/confirm-alert.tsx @@ -22,7 +22,7 @@ export function ConfirmAlert({ }: { children: ReactNode; title?: string; - description?: string; + description?: string | ReactNode; onConfirm: () => void | Promise; }) { const [loading, setLoading] = useState(false); diff --git a/client/app/components/btn/confirm-delete-alert.tsx b/client/app/components/btn/confirm-delete-alert.tsx new file mode 100644 index 0000000..3c306de --- /dev/null +++ b/client/app/components/btn/confirm-delete-alert.tsx @@ -0,0 +1,50 @@ +import { type ReactNode, useState } from "react"; +import { Checkbox } from "../ui/checkbox"; +import { Label } from "../ui/label"; +import { ConfirmAlert } from "./confirm-alert"; + +export function ConfirmDeleteAlert({ + children, + title = "Confirm Delete", + data, + onConfirm, +}: { + children: ReactNode; + title?: string; + data: IProduct; + onConfirm: (unlist: boolean) => void | Promise; +}) { + const [unlist, setUnlist] = useState(false); + + return ( + +

+ This action will permanently delete the item. You can also choose to + unlist it before deleting. +

+ + {data.status && ( +
+ setUnlist(!!checked)} + /> +
+ +
+
+ )} + + } + onConfirm={() => onConfirm(unlist)} + > + {children} +
+ ); +} diff --git a/client/app/components/core/data-table.tsx b/client/app/components/core/data-table.tsx index c1ca096..e38f217 100644 --- a/client/app/components/core/data-table.tsx +++ b/client/app/components/core/data-table.tsx @@ -164,7 +164,8 @@ interface CustomAction { icon?: React.ReactNode; variant?: "default" | "secondary" | "destructive" | "outline"; action: (row: T) => void; - show?: (row: T) => boolean; // Điều kiện hiển thị action + show?: (row: T) => boolean; // Điều kiện hiển thị action, + render?: (row: T, content: ReactNode) => ReactNode; } export interface InitialState { @@ -227,7 +228,7 @@ export interface DataTableProps { onPageSizeChange?: (newPageSize: number) => void; onRowClick?: (row: T) => void; onEdit?: (row: T, children?: ReactNode) => void | ReactNode; - onDelete?: (row: T) => void; + onDelete?: (row: T, children?: ReactNode) => void | ReactNode; onView?: (row: T) => void; selectable?: boolean; onSelectionChange?: (selectedRows: T[]) => void; @@ -1497,26 +1498,45 @@ export function DataTable>({ )} {/* Delete action (always last) */} - {onDelete && ( - { + const baseItem = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + className="cursor-pointer" + > +
+ + Xóa +
+
+ ); + + // Gọi thử onDelete(row, baseItem) + const result = onDelete(row, baseItem); + + // Nếu onDelete trả về element thì dùng luôn, nếu không thì quấn ConfirmAlert + if (isValidElement(result)) { + return cloneElement(result, {}, baseItem); } - onSelect={(e) => { - e.preventDefault(); // Ngăn dropdown đóng lại - e.stopPropagation(); - }} - > - onDelete(row)}> -
- - Xóa -
-
-
- )} + + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + className="cursor-pointer" + > + onDelete(row) as any}> + {baseItem.props.children} + + + ); + })()} ); diff --git a/client/app/routes/products/components/modals/product-modal.tsx b/client/app/routes/products/components/modals/product-modal.tsx index 3304295..7b860b6 100644 --- a/client/app/routes/products/components/modals/product-modal.tsx +++ b/client/app/routes/products/components/modals/product-modal.tsx @@ -149,7 +149,9 @@ export default function ProductModal({ await productApi.customAction( response.data.id, action, - response.data + response.data, + "POST", + { toast_success: false } ); } } else { diff --git a/client/app/routes/products/list.tsx b/client/app/routes/products/list.tsx index 0036e24..a2a46aa 100644 --- a/client/app/routes/products/list.tsx +++ b/client/app/routes/products/list.tsx @@ -11,7 +11,7 @@ import { UploadCloud, X, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useSearchParams } from "react-router"; import { toast } from "sonner"; import { productApi } from "~/api/products-api.service"; @@ -40,6 +40,8 @@ import { useAppSelector } from "~/hooks/use-app-dispatch"; import type { RootState } from "~/store"; import { HistoryModal } from "./components/modals/history-modal"; import ProductModal from "./components/modals/product-modal"; +import { ConfirmAlert } from "~/components/btn/confirm-alert"; +import { ConfirmDeleteAlert } from "~/components/btn/confirm-delete-alert"; export default function List() { // const { setEdit } = useUserModal(); @@ -61,8 +63,8 @@ export default function List() { const { user } = useAppSelector((state: RootState) => state.app); - const handleDelete = async (data: IProduct) => { - if (data.status) { + const handleDelete = async (unlist: boolean, data: IProduct) => { + if (data.status && unlist) { const response = await productApi.customAction( data.id || 0, "unlist", @@ -397,7 +399,16 @@ export default function List() { } as TableState) ); }} - onDelete={handleDelete} + onDelete={(data: IProduct, children) => { + return ( + handleDelete(unlist, data)} + > + {children} + + ); + }} onEdit={(data: IProduct, children) => { return ( diff --git a/server/public/medias/products/edited-product-asr-9901-chassis-payg-120g-base-hw-piddd/product-53-1755224843970.jpg b/server/public/medias/products/edited-product-asr-9901-chassis-payg-120g-base-hw-piddd/product-53-1755224843970.jpg deleted file mode 100644 index 8fe79d1..0000000 Binary files a/server/public/medias/products/edited-product-asr-9901-chassis-payg-120g-base-hw-piddd/product-53-1755224843970.jpg and /dev/null differ diff --git a/server/public/medias/products/meraki-ms120-48fp-1g-l2-cld-managed-48x-gige-740w-poe-switch/product-60-1755313243202.jpg b/server/public/medias/products/meraki-ms120-48fp-1g-l2-cld-managed-48x-gige-740w-poe-switch/product-60-1755313243202.jpg deleted file mode 100644 index eb4f22d..0000000 Binary files a/server/public/medias/products/meraki-ms120-48fp-1g-l2-cld-managed-48x-gige-740w-poe-switch/product-60-1755313243202.jpg and /dev/null differ diff --git a/server/public/medias/products/wegewgewgew/product-41-1755154798642.png b/server/public/medias/products/wegewgewgew/product-41-1755154798642.png deleted file mode 100644 index fa9a07a..0000000 Binary files a/server/public/medias/products/wegewgewgew/product-41-1755154798642.png and /dev/null differ diff --git a/server/public/medias/products/wegwegwegew/product-58-1755312884011.png b/server/public/medias/products/wegwegwegew/product-58-1755312884011.png deleted file mode 100644 index 230a55b..0000000 Binary files a/server/public/medias/products/wegwegwegew/product-58-1755312884011.png and /dev/null differ diff --git a/server/public/medias/products/wegwgwegwegew/product-67-1755314045883.jpg b/server/public/medias/products/wegwgwegwegew/product-67-1755314045883.jpg deleted file mode 100644 index eb4f22d..0000000 Binary files a/server/public/medias/products/wegwgwegwegew/product-67-1755314045883.jpg and /dev/null differ diff --git a/server/public/medias/products/wegwgwegwegew/product-67-1755314724835.jpg b/server/public/medias/products/wehwehewhewhewhew/product-75-1755503038821.jpg similarity index 100% rename from server/public/medias/products/wegwgwegwegew/product-67-1755314724835.jpg rename to server/public/medias/products/wehwehewhewhewhew/product-75-1755503038821.jpg diff --git a/server/public/medias/products/meraki-ms120-48fp-1g-l2-cld-managed-48x-gige-740w-poe-switch/product-60-1755313243203.jpg b/server/public/medias/products/wehwehewhewhewhew/product-75-1755503038822.jpg similarity index 100% rename from server/public/medias/products/meraki-ms120-48fp-1g-l2-cld-managed-48x-gige-740w-poe-switch/product-60-1755313243203.jpg rename to server/public/medias/products/wehwehewhewhewhew/product-75-1755503038822.jpg