edit and re publist

This commit is contained in:
Admin 2025-08-18 14:46:53 +07:00
parent 461331bace
commit ac365acbfc
14 changed files with 258 additions and 63 deletions

View File

@ -3,6 +3,7 @@ import PQueue from "p-queue";
let ports: chrome.runtime.Port[] = []; let ports: chrome.runtime.Port[] = [];
const queue = new PQueue({ concurrency: 1 }); const queue = new PQueue({ concurrency: 1 });
const targetUrl = "https://www.facebook.com/marketplace/you/selling";
chrome.runtime.onConnect.addListener((port) => { chrome.runtime.onConnect.addListener((port) => {
ports.push(port); ports.push(port);
@ -264,11 +265,34 @@ function ensureMarketplaceSellingTab() {
}); });
} }
// Hàm check tab load xong
function waitForTargetTabLoad(): Promise<void> {
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 () => { const init = async () => {
handleListenPublists(); handleListenPublists();
handleListenDeletes(); handleListenDeletes();
handleListenEdits(); handleListenEdits();
ensureMarketplaceSellingTab(); ensureMarketplaceSellingTab();
createAlarm();
}; };
chrome.runtime.onMessage.addListener((message, sender) => { chrome.runtime.onMessage.addListener((message, sender) => {
@ -278,4 +302,53 @@ chrome.runtime.onMessage.addListener((message, sender) => {
} }
}); });
// Khởi tạo alarm (3040 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(); init();

View File

@ -2,9 +2,13 @@
import type { TableState } from "~/components/core/data-table"; import type { TableState } from "~/components/core/data-table";
import { removeUndefinedValues } from "~/features/remove-falsy-values"; import { removeUndefinedValues } from "~/features/remove-falsy-values";
import { mapTableStateToPaginationQueryDSL } from "~/features/table"; import { mapTableStateToPaginationQueryDSL } from "~/features/table";
import type { DeepPartial } from "react-hook-form";
import { handleError, handleSuccess } from "."; import { handleError, handleSuccess } from ".";
import axios from "../lib/axios"; import axios from "../lib/axios";
import type { DeepPartial } from "react-hook-form";
export interface IAPIOption {
toast_success: boolean;
}
export class BaseApiService<T extends { id: number }> { export class BaseApiService<T extends { id: number }> {
constructor(protected readonly resourceUrl: string) {} constructor(protected readonly resourceUrl: string) {}
@ -17,84 +21,111 @@ export class BaseApiService<T extends { id: number }> {
const response = await axios({ const response = await axios({
url: this.resourceUrl, url: this.resourceUrl,
params: params, params: params,
// withCredentials: true, withCredentials: true,
method: "GET", method: "GET",
}); });
return response.data; return response.data;
} }
async get(id: T["id"]) { async get(id: T["id"], options: IAPIOption = { toast_success: false }) {
const response = await axios({
url: this.resourceUrl + "/" + id,
// withCredentials: true,
method: "GET",
});
return response.data;
}
async create(values: Partial<Omit<T, "id" | "created_at" | "updated_at">>) {
try { try {
const newData = removeUndefinedValues(values); const response = await axios({
const { data } = 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<Omit<T, "id" | "created_at" | "updated_at">>,
options: IAPIOption = { toast_success: true }
) {
try {
const newData = removeUndefinedValues(data);
const { data: result } = await axios({
url: this.resourceUrl, url: this.resourceUrl,
// withCredentials: true, withCredentials: true,
method: "POST", method: "POST",
data: newData, data: newData,
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
return data; handleSuccess(result, this.resourceUrl);
}
return result;
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} }
} }
async update(id: T["id"], values: Partial<T>) { async update(
id: T["id"],
data: Partial<T>,
options: IAPIOption = { toast_success: false }
) {
try { try {
const cleaned = removeUndefinedValues(values); const cleaned = removeUndefinedValues(data);
const { data } = await axios({ const { data: result } = await axios({
url: `${this.resourceUrl}/${id}`, url: `${this.resourceUrl}/${id}`,
// withCredentials: true, withCredentials: true,
method: "PUT", method: "PUT",
data: cleaned, data: cleaned,
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
handleSuccess(result, this.resourceUrl);
}
return data; return result;
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} }
} }
async delete(entity: T) { async delete(entity: T, options: IAPIOption = { toast_success: false }) {
try { try {
const { data } = await axios({ const { data } = await axios({
url: `${this.resourceUrl}/${entity.id}`, url: `${this.resourceUrl}/${entity.id}`,
// withCredentials: true, withCredentials: true,
method: "DELETE", method: "DELETE",
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
handleSuccess(data, this.resourceUrl);
}
return data; return data;
} catch (error) { } catch (error) {
handleError(error); handleError(error);
} }
} }
async bulkDelete(entities: T[]) { async bulkDelete(
entities: T[],
options: IAPIOption = { toast_success: false }
) {
const ids = entities.map((e) => e.id); const ids = entities.map((e) => e.id);
try { try {
const { data } = await axios({ const { data } = await axios({
url: `${this.resourceUrl}/bulk-delete`, url: `${this.resourceUrl}/bulk-delete`,
// withCredentials: true, withCredentials: true,
method: "DELETE", method: "DELETE",
data: { ids }, data: { ids },
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
handleSuccess(data, this.resourceUrl);
}
return data; return data;
} catch (error) { } catch (error) {
@ -102,16 +133,21 @@ export class BaseApiService<T extends { id: number }> {
} }
} }
async bulkUpdate(entities: T[]) { async bulkUpdate(
entities: T[],
options: IAPIOption = { toast_success: false }
) {
try { try {
const { data } = await axios({ const { data } = await axios({
url: `${this.resourceUrl}/bulk-update`, url: `${this.resourceUrl}/bulk-update`,
// withCredentials: true, withCredentials: true,
method: "PUT", method: "PUT",
data: entities, data: entities,
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
handleSuccess(data, this.resourceUrl);
}
return data; return data;
} catch (error) { } catch (error) {
@ -124,17 +160,20 @@ export class BaseApiService<T extends { id: number }> {
id: number, id: number,
endpoint: string, endpoint: string,
payload?: Record<string, any>, payload?: Record<string, any>,
method?: string method?: string,
options: IAPIOption = { toast_success: false }
) { ) {
try { try {
const { data } = await axios({ const { data } = await axios({
url: `${this.resourceUrl}/${endpoint}/${id}`, url: `${this.resourceUrl}/${endpoint}/${id}`,
method: method || "POST", method: method || "POST",
data: removeUndefinedValues(payload || {}), data: removeUndefinedValues(payload || {}),
// withCredentials: true, withCredentials: true,
}); });
handleSuccess(data, this.resourceUrl); if (options.toast_success) {
handleSuccess(data, this.resourceUrl);
}
return data; return data;
} catch (error) { } catch (error) {
handleError(error); handleError(error);

View File

@ -22,7 +22,7 @@ export function ConfirmAlert({
}: { }: {
children: ReactNode; children: ReactNode;
title?: string; title?: string;
description?: string; description?: string | ReactNode;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
}) { }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@ -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<void>;
}) {
const [unlist, setUnlist] = useState(false);
return (
<ConfirmAlert
title={title}
description={
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
This action will permanently delete the item. You can also choose to
unlist it before deleting.
</p>
{data.status && (
<div className="flex items-start gap-3">
<Checkbox
id="unlist-option"
checked={unlist}
onCheckedChange={(checked) => setUnlist(!!checked)}
/>
<div className="grid gap-1">
<Label htmlFor="unlist-option" className="font-medium">
Also unlist this item before deletion
</Label>
</div>
</div>
)}
</div>
}
onConfirm={() => onConfirm(unlist)}
>
{children}
</ConfirmAlert>
);
}

View File

@ -164,7 +164,8 @@ interface CustomAction<T> {
icon?: React.ReactNode; icon?: React.ReactNode;
variant?: "default" | "secondary" | "destructive" | "outline"; variant?: "default" | "secondary" | "destructive" | "outline";
action: (row: T) => void; 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<T> { export interface InitialState<T> {
@ -227,7 +228,7 @@ export interface DataTableProps<T> {
onPageSizeChange?: (newPageSize: number) => void; onPageSizeChange?: (newPageSize: number) => void;
onRowClick?: (row: T) => void; onRowClick?: (row: T) => void;
onEdit?: (row: T, children?: ReactNode) => void | ReactNode; onEdit?: (row: T, children?: ReactNode) => void | ReactNode;
onDelete?: (row: T) => void; onDelete?: (row: T, children?: ReactNode) => void | ReactNode;
onView?: (row: T) => void; onView?: (row: T) => void;
selectable?: boolean; selectable?: boolean;
onSelectionChange?: (selectedRows: T[]) => void; onSelectionChange?: (selectedRows: T[]) => void;
@ -1497,26 +1498,45 @@ export function DataTable<T extends Record<string, any>>({
)} )}
{/* Delete action (always last) */} {/* Delete action (always last) */}
{onDelete && ( {onDelete &&
<DropdownMenuItem (() => {
disabled={ const baseItem = (
typeof options?.disableDel === "function" <DropdownMenuItem
? options.disableDel(row) // Gọi hàm với `row` là dữ liệu hiện tại onSelect={(e) => {
: options?.disableDel ?? false e.preventDefault();
e.stopPropagation();
}}
className="cursor-pointer"
>
<div className="flex items-center gap-2 cursor-pointer text-destructive hover:!text-destructive">
<Trash2 className="mr-2 h-4 w-4 text-destructive" />
Xóa
</div>
</DropdownMenuItem>
);
// 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 return (
e.stopPropagation(); <DropdownMenuItem
}} onSelect={(e) => {
> e.preventDefault();
<ConfirmAlert onConfirm={() => onDelete(row)}> e.stopPropagation();
<div className="flex items-center gap-2 cursor-pointer text-destructive hover:!text-destructive"> }}
<Trash2 className="mr-2 h-4 w-4 text-destructive" /> className="cursor-pointer"
Xóa >
</div> <ConfirmAlert onConfirm={() => onDelete(row) as any}>
</ConfirmAlert> {baseItem.props.children}
</DropdownMenuItem> </ConfirmAlert>
)} </DropdownMenuItem>
);
})()}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@ -149,7 +149,9 @@ export default function ProductModal({
await productApi.customAction( await productApi.customAction(
response.data.id, response.data.id,
action, action,
response.data response.data,
"POST",
{ toast_success: false }
); );
} }
} else { } else {

View File

@ -11,7 +11,7 @@ import {
UploadCloud, UploadCloud,
X, X,
} from "lucide-react"; } 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 { useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { productApi } from "~/api/products-api.service"; import { productApi } from "~/api/products-api.service";
@ -40,6 +40,8 @@ import { useAppSelector } from "~/hooks/use-app-dispatch";
import type { RootState } from "~/store"; import type { RootState } from "~/store";
import { HistoryModal } from "./components/modals/history-modal"; import { HistoryModal } from "./components/modals/history-modal";
import ProductModal from "./components/modals/product-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() { export default function List() {
// const { setEdit } = useUserModal(); // const { setEdit } = useUserModal();
@ -61,8 +63,8 @@ export default function List() {
const { user } = useAppSelector((state: RootState) => state.app); const { user } = useAppSelector((state: RootState) => state.app);
const handleDelete = async (data: IProduct) => { const handleDelete = async (unlist: boolean, data: IProduct) => {
if (data.status) { if (data.status && unlist) {
const response = await productApi.customAction( const response = await productApi.customAction(
data.id || 0, data.id || 0,
"unlist", "unlist",
@ -397,7 +399,16 @@ export default function List() {
} as TableState<IProduct>) } as TableState<IProduct>)
); );
}} }}
onDelete={handleDelete} onDelete={(data: IProduct, children) => {
return (
<ConfirmDeleteAlert
data={data}
onConfirm={(unlist) => handleDelete(unlist, data)}
>
{children}
</ConfirmDeleteAlert>
);
}}
onEdit={(data: IProduct, children) => { onEdit={(data: IProduct, children) => {
return ( return (
<ProductModal onSubmit={refetch} data={data}> <ProductModal onSubmit={refetch} data={data}>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB