update UI

This commit is contained in:
Admin 2025-08-22 15:51:35 +07:00
parent 276abd49f8
commit eb4e711e58
44 changed files with 1390 additions and 1033 deletions

View File

@ -1,69 +1,4 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
Servers matching 'ai':
Server Name: AI training tool server
Command: ssh root@172.16.7.48
Password: Work1234

View File

@ -1 +0,0 @@
function e(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}export{e as g};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
function h(n,e=[]){return Object.entries(n).reduce((r,[s,t])=>((t||e.includes(s))&&(r[s]=t),r),{})}function a(n){return new Promise(e=>setTimeout(e,n))}function l(n,e){const r=Math.floor(Math.random()*(e-n+1))+n;return a(r)}class u{role;listeners;constructor(e){this.role=e,this.listeners=new Map,chrome.runtime.onMessage.addListener((r,s,t)=>{if(r?.type&&this.listeners.has(r.type))return this.listeners.get(r.type)(r.payload,s,t),!0})}on(e,r){this.listeners.set(e,r)}send(e,r,s){const t={from:this.role,to:e,type:r,payload:s};(this.role==="popup"||this.role==="content")&&chrome.runtime.sendMessage(t),this.role==="background"&&(e==="content"?chrome.tabs.query({active:!0,currentWindow:!0},o=>{o[0]?.id&&chrome.tabs.sendMessage(o[0].id,t,()=>{chrome.runtime.lastError&&console.warn("Không gửi được tới content script:",chrome.runtime.lastError.message)})}):e==="popup"&&chrome.runtime.sendMessage(t,()=>{chrome.runtime.lastError&&console.warn("Không gửi được tới popup:",chrome.runtime.lastError.message)}))}sendToTab(e,r,s){if(this.role!=="background")throw new Error("sendToTab chỉ được gọi từ background");const t={from:this.role,to:"content",type:r,payload:s};chrome.tabs.sendMessage(e,t,()=>{chrome.runtime.lastError&&console.warn(`Không gửi được tới content script ở tab ${e}:`,chrome.runtime.lastError.message)})}waitForMessage(e,r=3e4){return new Promise((s,t)=>{let o;const i=c=>{clearTimeout(o),this.off(e,i),s(c)};o=setTimeout(()=>{this.off(e,i),t(new Error(`Timeout khi chờ message: ${e}`))},r),this.on(e,i)})}off(e,r){this.listeners.get(e)===r&&this.listeners.delete(e)}}export{u as M,l as a,a as d,h as r};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,10 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/popup.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/product-api.service-Dn0sYl6J.js">
<link rel="modulepreload" crossorigin href="/assets/message.service-DcR3euAR.js">
<link rel="stylesheet" crossorigin href="/assets/popup-DxX4E5UP.css">
<link rel="modulepreload" crossorigin href="/assets/base64-BbJB1hmJ.js">
<link rel="modulepreload" crossorigin href="/assets/product-api.service-DR0CE0o9.js">
<link rel="modulepreload" crossorigin href="/assets/app-C7VSS-q9.js">
<link rel="modulepreload" crossorigin href="/assets/popup-DvUDMaaW.js">
<link rel="stylesheet" crossorigin href="/assets/style.css">
</head>
<body>
<div id="root"></div>

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js",
"type": "module"
},
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module"
}
],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"]
}

56
package-lock.json generated
View File

@ -20,6 +20,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
@ -1538,6 +1540,60 @@
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle-group": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@ -28,6 +28,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js",
"type": "module"
},
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module"
}
],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,10 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/popup.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/product-api.service-Dn0sYl6J.js">
<link rel="modulepreload" crossorigin href="/assets/message.service-DcR3euAR.js">
<link rel="stylesheet" crossorigin href="/assets/popup-DxX4E5UP.css">
<link rel="modulepreload" crossorigin href="/assets/base64-BbJB1hmJ.js">
<link rel="modulepreload" crossorigin href="/assets/product-api.service-DR0CE0o9.js">
<link rel="modulepreload" crossorigin href="/assets/app-C7VSS-q9.js">
<link rel="modulepreload" crossorigin href="/assets/popup-DvUDMaaW.js">
<link rel="stylesheet" crossorigin href="/assets/style.css">
</head>
<body>
<div id="root"></div>

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js",
"type": "module"
},
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module"
}
],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"]
}

View File

@ -1,20 +1,20 @@
[
{
"title": "Air-ct7510-k9 - AIR-CT7510-K9",
"price": 8562,
"el": {},
"date": "2025-08-21T08:56:43.192Z"
},
{
"title": "2-Port Analog Telephone Adapter - ATA191-K9",
"price": 220,
"el": {},
"date": "2025-08-21T08:34:00.451Z"
"date": "2025-08-21T08:56:43.192Z"
},
{
"title": "Cisco 2911 Voice Bundle, Pvdm3-16, Uc License Pak, Fl- Cube10 - CISCO2911-V/K9",
"price": 125,
"el": {},
"date": "2025-08-21T08:34:00.451Z"
},
{
"title": "Air-ct7510-k9 - AIR-CT7510-K9",
"price": 8562,
"el": {},
"date": "2025-08-21T08:34:00.451Z"
"date": "2025-08-21T08:56:43.192Z"
}
]

View File

@ -142,7 +142,7 @@ app.post("/", async (req, res) => {
);
let filteredData = data.data.filter((item) => {
const key = `${item.name} - ${item.code}${item.price}`.toLowerCase();
const key = `${item.title} - ${item.code}${item.price}`.toLowerCase();
return status === "listed"
? listedCodes.has(key)
: !listedCodes.has(key);

View File

@ -40,8 +40,15 @@ class ProductApiService {
return axios({
method: "POST",
data: {
urlAPI: "/api/product-model/get-info/" + data.id,
pageCurrent: "/productdata/products/edit/" + data.id,
filter: {
where: {
account: "prology_net",
condition: data.raw_condition,
productModelCode: data.sku,
},
},
pageCurrent: "/ebaytools/listing-ebay",
urlAPI: "/api/ebay-listing/get-image-listing",
},
});
}
@ -59,6 +66,50 @@ class ProductApiService {
url: "data",
});
}
async apiRequest(
fn:
| "index"
| "getPublistedProducts"
| "get"
| "createBlobUrl"
| "publist"
| "unlist",
body: Record<string, any>
) {
return new Promise((resolve, reject) => {
const requestId = "req_" + Date.now() + "_" + Math.random();
// Lắng nghe 1 lần response cho requestId
function handleResponse(event: any) {
if (
event.data.type === "API_RESPONSE" &&
event.data.requestId === requestId
) {
window.removeEventListener("message", handleResponse);
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data.payload);
}
}
}
window.addEventListener("message", handleResponse);
// Gửi yêu cầu
window.postMessage(
{
type: "API_REQUEST",
requestId,
payload: body,
fn,
},
"*"
);
});
}
}
export const productApi = new ProductApiService();

View File

@ -8,9 +8,9 @@ const queue = new PQueue({ concurrency: 1 });
const service = new BackgroundService();
// Forward hoặc xử lý logic
service.msgService.on("popup-to-content", (payload) => {
service.msgService.on("content-to-background-actions", (payload) => {
console.log("Background nhận từ popup:", payload);
service.msgService.send("content", "popup-to-content", payload);
// service.msgService.send("content", "popup-to-content", payload);
switch (payload?.type) {
case "publist": {
@ -32,3 +32,9 @@ service.msgService.on("content-to-popup", (payload) => {
console.log("Background nhận từ content:", payload);
service.msgService.send("popup", "content-to-popup", payload);
});
// Listent to handle create blob image urls
service.msgService.on<string[]>(
"content-to-background-create-blod-urls",
(payload) => service.createBlobUrls(payload)
);

View File

@ -1,7 +1,5 @@
import { useState, type ReactNode } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@ -10,9 +8,10 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "../ui/button";
import { delay } from "@/features/app";
import { useState, type ReactNode } from "react";
import Loader from "../loader";
import { Button } from "../ui/button";
export function ConfirmAlert({
children,

View File

@ -0,0 +1,66 @@
"use client";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Eye, EyeOff, X } from "lucide-react";
interface FilterToggleProps {
filter: {
statusFilter: string;
};
setFilter: (filter: { statusFilter: string }) => void;
activeFiltersCount: number;
clearFilters: () => void;
}
export function ImprovedToggleFilter({
filter,
setFilter,
activeFiltersCount,
clearFilters,
}: FilterToggleProps) {
return (
<div className="flex items-center gap-3">
<ToggleGroup
size="sm"
type="single"
value={filter.statusFilter || ""} // nếu rỗng thì ToggleGroup sẽ clear
onValueChange={(val) => setFilter({ statusFilter: val || "" })} // val null => reset filter
className="flex items-center gap-1 p-1 bg-muted rounded-lg"
>
<ToggleGroupItem
size={"sm"}
value="listed"
aria-label="Hiển thị công khai"
className="flex items-center gap-2 px-3 py-2 rounded-md data-[state=on]:bg-green-100 data-[state=on]:text-green-700 data-[state=on]:border-green-200 hover:bg-green-50 transition-colors"
>
<Eye className="h-2 w-2" />
<span className="text-sm font-medium">Listed</span>
</ToggleGroupItem>
<ToggleGroupItem
size={"sm"}
value="unlisted"
aria-label="Ẩn khỏi danh sách"
className="flex items-center gap-2 px-3 py-2 rounded-md data-[state=on]:bg-orange-100 data-[state=on]:text-orange-700 data-[state=on]:border-orange-200 hover:bg-orange-50 transition-colors"
>
<EyeOff className="h-2 w-2" />
<span className="text-sm font-medium">Unlisted</span>
</ToggleGroupItem>
</ToggleGroup>
{activeFiltersCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground bg-transparent"
aria-label="clear"
>
<X className="h-2 w-2" />
<span className="text-sm">Clear</span>
</Button>
)}
</div>
);
}

View File

@ -22,7 +22,7 @@ export default function Loader({ color = "black", ...props }: ILoaderProps) {
>
<div
className={cn(
"animate-spin rounded-full size-4 border-b-2",
"!animate-spin rounded-full size-4 border-b-2",
props.size,
{
["border-primary"]: color === "black",

View File

@ -6,13 +6,6 @@ import { useMemo, useState } from "react";
import { productApi } from "@/api/product-api.service";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -20,15 +13,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@ -46,9 +30,9 @@ import {
Edit,
Eye,
EyeOff,
Filter,
MoreHorizontal,
Search,
X,
} from "lucide-react";
import { useDebounce } from "use-debounce";
import Loader from "./loader";
@ -111,13 +95,11 @@ export function PostTable() {
const clearFilters = () => {
setSearchTerm("");
setStatusFilter("all");
setFilter({});
setCurrentPage(1);
setIsFilterModalOpen(false);
};
const activeFiltersCount = [statusFilter !== "all", searchTerm !== ""].filter(
const activeFiltersCount = [filter.statusFilter, searchTerm !== ""].filter(
Boolean
).length;
@ -197,59 +179,26 @@ export function PostTable() {
/>
</div>
<Dialog open={isFilterModalOpen} onOpenChange={setIsFilterModalOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="relative bg-transparent">
<Filter className="h-4 w-4 mr-2" />
Filters
{activeFiltersCount > 0 && (
<Badge className="ml-2 h-5 w-5 p-0 text-xs flex items-center justify-center">
{activeFiltersCount}
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Product Filters</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-2">
<Button
variant={filter.statusFilter === "listed" ? "default" : "ghost"}
onClick={() => setFilter({ statusFilter: "listed" })}
>
Listed
</Button>
<Button
variant={filter.statusFilter === "unlisted" ? "default" : "ghost"}
onClick={() => setFilter({ statusFilter: "unlisted" })}
>
Un listed
</Button>
<div className="space-y-4">
<Select
value={statusFilter === "all" ? "" : statusFilter}
onValueChange={setStatusFilter}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent aria-describedby={undefined}>
<SelectGroup>
<SelectItem value="listed">Listed</SelectItem>
<SelectItem value="unlisted">Unlisted</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
variant="outline"
onClick={clearFilters}
className="flex-1 bg-transparent"
>
Clear Filters
</Button>
<Button
onClick={() => {
setIsFilterModalOpen(false);
setFilter({ statusFilter });
}}
className="flex-1"
>
Apply
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{activeFiltersCount > 0 && (
<Button onClick={clearFilters} size={"icon"}>
<X />
</Button>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">

View File

@ -1,9 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable no-constant-binary-expression */
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { Badge } from "@/components/ui/badge";
@ -26,9 +27,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { productApi } from "@/api/product-api.service";
import Loader from "@/components/loader";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@ -37,13 +36,12 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { delay } from "@/features/app";
import { urlToBase64 } from "@/features/base64";
import { mapToIPost, type IPost } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import z from "zod/v3";
import usePost from "@/hooks/use-post-hook";
import { mapToIPost, type IPost } from "@/lib/utils";
import z from "zod/v3";
import { useMutation } from "@tanstack/react-query";
import { productApi } from "@/api/product-api.service";
// eslint-disable-next-line react-refresh/only-export-components
export const productSchema = z.object({
@ -114,102 +112,6 @@ export default function ProductModal({
const { ...query } = usePost(data);
// 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,
// "POST",
// { toast_success: false }
// );
// }
// } else {
// // Create
// response = await productApi.create({ ...rest, brand });
// if (publist && response?.data) {
// await productApi.customAction(
// response.data.id,
// "publist",
// response.data,
// "POST",
// { toast_success: false }
// );
// }
// }
// 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) {
const newImages: string[] = [];
const currentImages = form.getValues("images");
Array.from(files).forEach((file) => {
const url = URL.createObjectURL(file);
newImages.push(url);
});
form.setValue("images", [...currentImages, ...newImages]);
}
};
const addImageFromUrl = () => {
if (urlInput.trim() && !watchedImages.includes(urlInput.trim())) {
const currentImages = form.getValues("images");
form.setValue("images", [...currentImages, urlInput.trim()]);
setUrlInput("");
}
};
const removeImage = (index: number, image: string) => {
const currentImages = form.getValues("images");
if (data && isHttpUrl(image)) {
// delImageMutation.mutate(image);
} else {
form.setValue(
"images",
currentImages.filter((_, i) => i !== index)
);
}
};
const addTag = () => {
if (tagInput.trim() && !watchedTags?.includes(tagInput.trim())) {
const currentTags = form.getValues("tags") || [];
@ -273,15 +175,6 @@ export default function ProductModal({
}
};
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();
@ -289,20 +182,26 @@ export default function ProductModal({
setTagInput("");
};
useEffect(() => {
console.log({ a: { ...data, ...query.data?.data.data } });
if (query.data?.data?.data && data) {
const values = mapToIPost({ ...data, ...query.data?.data.data });
form.reset(values);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query.data, data]);
const mutation = useMutation({
mutationKey: ["createBlobUrl", data?.sku],
mutationFn: (images: string[]) => {
return productApi.apiRequest("createBlobUrl", images);
},
});
// useEffect(() => {
// if (query.isSuccess && query.isFetched && query.data?.data) {
// form.reset(query.data?.data);
// }
// }, [delImageMutation.isSuccess, query.isFetched, query.isSuccess]);
useEffect(() => {
(async () => {
if (!(query.data as any)?.data || !data) return;
const images = mapToIPost({ ...(query.data as any)?.data }).images;
const urls = await mutation.mutateAsync(images);
const values = {
...data,
images: urls as string[],
};
form.reset(values);
})();
}, [query.data, data]);
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -319,154 +218,67 @@ export default function ProductModal({
</DialogHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Images */}
<Card>
<CardContent className="p-6">
<FormField
control={form.control}
name="images"
render={() => (
<FormItem>
<FormLabel className="text-lg font-semibold">
Images *
</FormLabel>
<FormMessage />
{!query.isLoading && !mutation.isPending && (
<div className="flex-1 overflow-y-auto p-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
{/* Images */}
<Card>
<CardContent className="p-6">
<FormField
control={form.control}
name="images"
render={() => (
<FormItem>
<FormLabel className="text-lg font-semibold">
Images *
</FormLabel>
<FormMessage />
{/* Image preview */}
{watchedImages.length > 0 && (
<div className="mt-6 overflow-x-auto">
<div className="flex gap-3">
{watchedImages.map((image, index) => (
<div
key={index}
className="relative flex-shrink-0 w-32 h-32 group rounded-lg border overflow-hidden"
>
<img
src={image || "/placeholder.svg"}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
{/* Image preview */}
{watchedImages.length > 0 && (
<div className="mt-6 overflow-x-auto">
<div className="flex gap-3">
{watchedImages.map((image, index) => (
<div
key={index}
className="relative flex-shrink-0 w-32 h-32 group rounded-lg border overflow-hidden"
>
<img
src={image || "/placeholder.svg"}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
</div>
)}
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Product info */}
<Card>
<CardContent className="p-6 space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">
Product information
</h3>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input
placeholder="VD: MacBook Pro 14-inch 2023"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price ($) *</FormLabel>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category *</FormLabel>
<FormControl>
{/* <Input placeholder="Tools" {...field} /> */}
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c.toLowerCase()}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
)}
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Product info */}
<Card>
<CardContent className="p-6 space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">
Product information
</h3>
<FormField
control={form.control}
name="condition"
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Condition *</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select condition" />
</SelectTrigger>
<SelectContent>
{conditions.map((c) => (
<SelectItem key={c} value={c.toLowerCase()}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input
readOnly={!!data}
placeholder="VD: Cisco"
placeholder="VD: MacBook Pro 14-inch 2023"
{...field}
/>
</FormControl>
@ -474,102 +286,197 @@ export default function ProductModal({
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea rows={4} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price ($) *</FormLabel>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Additional info */}
<Card>
<CardContent className="p-6 space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">
Additional information
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category *</FormLabel>
<FormControl>
{/* <Input placeholder="Tools" {...field} /> */}
{/* Tags */}
<div className="space-y-3">
<FormLabel>Tags</FormLabel>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Tag and press Enter"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c.toLowerCase()}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition *</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select condition" />
</SelectTrigger>
<SelectContent>
{conditions.map((c) => (
<SelectItem key={c} value={c.toLowerCase()}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input
readOnly={!!data}
placeholder="VD: Cisco"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="button" onClick={addTag} variant="outline">
<Plus className="w-4 h-4" />
</Button>
</div>
{watchedTags && watchedTags?.length > 0 && (
<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" />
</Badge>
))}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea rows={4} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Additional info */}
<Card>
<CardContent className="p-6 space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">
Additional information
</h3>
{/* Tags */}
<div className="space-y-3">
<FormLabel>Tags</FormLabel>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Tag and press Enter"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
/>
<Button
type="button"
onClick={addTag}
variant="outline"
>
<Plus className="w-4 h-4" />
</Button>
</div>
)}
</div>
{watchedTags && watchedTags?.length > 0 && (
<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" />
</Badge>
))}
</div>
)}
</div>
<FormField
control={form.control}
name="sku"
render={({ field }) => (
<FormItem>
<FormLabel>Sku *</FormLabel>
<FormControl>
<Input
readOnly={!!data}
placeholder="VD: MBP14-2023-512GB"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sku"
render={({ field }) => (
<FormItem>
<FormLabel>Sku *</FormLabel>
<FormControl>
<Input
readOnly={!!data}
placeholder="VD: MBP14-2023-512GB"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input
placeholder="VD: Hà Nội, Việt Nam"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input
placeholder="VD: Hà Nội, Việt Nam"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="publist"
render={({ field }) => {
@ -604,30 +511,24 @@ export default function ProductModal({
);
}}
/> */}
</CardContent>
</Card>
</form>
</Form>
</div>
</CardContent>
</Card>
</form>
</Form>
</div>
)}
{(query.isLoading || mutation.isPending) && (
<div className="flex items-center justify-center w-full h-full">
<Loader />
</div>
)}
{/* Footer */}
<div className="p-6 border-t bg-white flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleClose}>
Close
</Button>
{/* <Button
type="submit"
onClick={form.handleSubmit(onSubmit)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader color="white" />
) : data ? (
"Save"
) : (
"Create"
)}
</Button> */}
</div>
</DialogContent>
</Dialog>

View File

@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@ -23,7 +23,7 @@ const badgeVariants = cva(
variant: "default",
},
}
)
);
function Badge({
className,
@ -32,7 +32,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@ -24,6 +24,7 @@ const buttonVariants = cva(
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
xs: "h-7 rounded-md gap-1 px-2.5 has-[>svg]:px-2",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},

View File

@ -1,15 +1,15 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
@ -21,9 +21,9 @@ function Calendar({
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
@ -133,13 +133,13 @@ function Calendar({
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
);
}
if (orientation === "right") {
@ -148,12 +148,12 @@ function Calendar({
className={cn("size-4", className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
@ -163,13 +163,13 @@ function Calendar({
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@ -178,12 +178,12 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
@ -207,7 +207,7 @@ function CalendarDayButton({
)}
{...props}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar, CalendarDayButton };

View File

@ -1,33 +1,33 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
@ -43,7 +43,7 @@ function DialogOverlay({
)}
{...props}
/>
)
);
}
function DialogContent({
@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@ -77,7 +77,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -100,7 +100,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
)}
{...props}
/>
)
);
}
function DialogTitle({
@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@ -140,4 +140,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

View File

@ -1,13 +1,13 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
);
}
function DropdownMenuTrigger({
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger"
{...props}
/>
)
);
}
function DropdownMenuContent({
@ -46,7 +46,7 @@ function DropdownMenuContent({
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
);
}
function DropdownMenuGroup({
@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
);
}
function DropdownMenuItem({
@ -63,8 +63,8 @@ function DropdownMenuItem({
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
@ -77,7 +77,7 @@ function DropdownMenuItem({
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
@ -158,7 +158,7 @@ function DropdownMenuLabel({
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
@ -187,13 +187,13 @@ function DropdownMenuShortcut({
)}
{...props}
/>
)
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
@ -202,14 +202,14 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
"!focus:bg-accent !focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
@ -217,7 +217,7 @@ function DropdownMenuSubTrigger({
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@ -233,7 +233,7 @@ function DropdownMenuSubContent({
)}
{...props}
/>
)
);
}
export {
@ -252,4 +252,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};

View File

@ -1,25 +1,25 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@ -28,7 +28,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@ -45,7 +45,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@ -80,7 +80,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@ -93,7 +93,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
);
}
function SelectItem({
@ -117,7 +117,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@ -130,7 +130,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@ -148,7 +148,7 @@ function SelectScrollUpButton({
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@ -166,7 +166,7 @@ function SelectScrollDownButton({
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@ -180,4 +180,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View File

@ -1,8 +1,8 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -49,7 +49,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -62,7 +62,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -75,7 +75,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -88,7 +88,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
)}
{...props}
/>
)
);
}
function TableCaption({
@ -101,7 +101,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
);
}
export {
@ -113,4 +113,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@ -0,0 +1,71 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@ -1,6 +1,7 @@
// content.ts
import { productApi } from "@/api/product-api.service";
import { delayRD } from "@/features/app";
import { delayRD, removeFalsyValues } from "@/features/app";
import { Contants } from "@/lib/contants";
import type { IPost } from "@/lib/utils";
import { facebookService } from "@/services/facebook.service";
import MessageService from "@/services/message.service";
@ -8,6 +9,28 @@ import { thiefService } from "@/services/thief.service";
const msgService = new MessageService("content");
const injectApp = () => {
const id = Contants.ID_EXTENSION;
if (document.getElementById(id)) return;
const root = document.createElement("div");
root.id = id;
document.body.appendChild(root);
// inject script
const script = document.createElement("script");
script.src = chrome.runtime.getURL("content/inject-ui.js");
script.type = "module";
document.body.appendChild(script);
// inject css
const style = document.createElement("link");
style.rel = "stylesheet";
style.type = "text/css";
style.href = chrome.runtime.getURL("assets/style.css"); // nhớ đổi đúng tên file css khi build
document.head.appendChild(style);
};
// Nhận từ background [publist]
msgService.on<{ data: IPost }>(
"background-to-content-publish",
@ -87,7 +110,111 @@ msgService.on<{ data: IPost }>(
}
);
window.addEventListener("message", async (event) => {
if (event.source !== window) return;
if (event.data.type === "API_REQUEST") {
const body = event.data.payload;
const fn = event.data.fn;
const { requestId } = event.data;
console.log({ requestId, event, body });
try {
let data = null;
switch (fn) {
case "index": {
const response = await productApi.index(body);
data = response.data;
break;
}
case "getPublistedProducts": {
const response = await productApi.getPublistedProducts();
data = response.data;
break;
}
case "get": {
const response = await productApi.get(body);
data = response.data;
console.log({ data, body });
break;
}
case "createBlobUrl": {
msgService.send(
"background",
"content-to-background-create-blod-urls",
body
);
data = await msgService.waitForMessage(
"background-to-content-create-blod-urls"
);
console.log({ data });
break;
}
case "publist": {
msgService.send("background", "content-to-background-actions", {
type: "publist",
data: body,
});
data = await msgService.waitForMessage(
"background-to-content-actions"
);
console.log({ publisted: data });
break;
}
case "unlist": {
msgService.send("background", "content-to-background-actions", {
type: "unlist",
data: body,
});
console.log({ body });
data = await msgService.waitForMessage(
"background-to-content-actions"
);
console.log({ data });
break;
}
}
window.postMessage(
{
type: "API_RESPONSE",
requestId,
payload: data,
},
"*"
);
} catch (error) {
window.postMessage(
{
type: "API_RESPONSE",
requestId,
error: (error as any).message,
},
"*"
);
}
}
});
(async () => {
// Call to inject modal
injectApp();
await delayRD(800, 1000);
// Sử dụng

19
src/content/inject-ui.tsx Normal file
View File

@ -0,0 +1,19 @@
import "@/index.css";
import { Contants } from "@/lib/contants";
import { queryClient } from "@/lib/react-query";
import Popup from "@/popup/popup";
import { QueryClientProvider } from "@tanstack/react-query";
import ReactDOM from "react-dom/client";
const container = document.getElementById(Contants.ID_EXTENSION);
if (container) {
ReactDOM.createRoot(container).render(
<>
<QueryClientProvider client={queryClient}>
<div className="fixed bottom-20 right-6">
<Popup />
</div>
</QueryClientProvider>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { IPost } from "@/lib/utils";
import { productApi } from "@/api/product-api.service";
import { delay } from "@/features/app";
import { type IPost } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
const usePost = (post?: IPost) => {
const { isLoading, refetch, ...query } = useQuery({
@ -10,7 +10,7 @@ const usePost = (post?: IPost) => {
if (!post) return null;
await delay(300); // Giả lập delay để thấy loading
const res = await productApi.get(post);
const res = await productApi.apiRequest("get", post);
return res;
},
});

View File

@ -55,7 +55,7 @@
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent: oklch(0.97 0 0) !important;
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
@ -83,7 +83,7 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary: oklch(0.922 0 0) !important;
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);

3
src/lib/contants.ts Normal file
View File

@ -0,0 +1,3 @@
export class Contants {
public static ID_EXTENSION = "ex-root";
}

View File

@ -15,6 +15,8 @@ export interface IPost {
status?: boolean;
id: string;
publist_id?: string;
raw_condition?: string;
base64Images?: string[];
}
export interface ISyncItem {
@ -30,8 +32,8 @@ export function cn(...inputs: ClassValue[]) {
export function mapToIPost(raw: any): IPost {
let images: string[] = [];
if (Array.isArray(raw?.listImages)) {
images = raw.listImages
if (Array.isArray(raw?.listImage)) {
images = raw.listImage
.map((img: any) =>
typeof img === "string"
? img
@ -41,20 +43,23 @@ export function mapToIPost(raw: any): IPost {
}
// xử lý package_contain
let packageContainText = "";
let packageContainText = "Package contain:\n";
if (Array.isArray(raw?.package_contain)) {
packageContainText = raw.package_contain
packageContainText += raw.package_contain
.map((item: string) => `- ${item}`)
.join("\n");
} else if (typeof raw?.package_contain === "string") {
packageContainText = raw.package_contain;
} else if (
typeof raw?.package_contain === "string" &&
raw.package_contain.trim() !== ""
) {
packageContainText += raw.package_contain;
}
// build description rõ ràng, không có thụt đầu dòng
const descriptionLines = [
raw.description || "",
raw.code || "",
raw.name || "",
raw.title || "",
(typeof raw?.category === "string" ? raw.category : raw?.category?.name) ||
"",
packageContainText,
@ -64,10 +69,12 @@ export function mapToIPost(raw: any): IPost {
return {
images,
title: `${raw.name} - ${raw.code}`,
title: `${raw.title} - ${raw.code}`,
price: raw.price,
category: "electronics & computers",
condition: "new",
condition: (raw?.conditionEbay as string)?.toLowerCase()?.includes("new")
? "new"
: "used like new",
brand: typeof raw.manufactor === "string" ? raw.manufactor : raw?.name,
description: descriptionLines,
tags: raw.tags || [],
@ -76,5 +83,6 @@ export function mapToIPost(raw: any): IPost {
status: false,
id: raw.id,
publist_id: raw.listingId,
raw_condition: raw?.condition,
};
}

View File

@ -1,9 +1,414 @@
import { PostTable } from "@/components/post-table";
import { productApi } from "@/api/product-api.service";
import { ImprovedToggleFilter } from "@/components/improved-toggle-filter";
import Loader from "@/components/loader";
import ProductModal from "@/components/product-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { removeFalsyValues } from "@/features/app";
import { mapToIPost, type IPost } from "@/lib/utils";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
DoorOpenIcon,
Edit,
Eye,
EyeOff,
MoreHorizontal,
Search,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useDebounce } from "use-debounce";
export default function Popup() {
const [searchTerm, setSearchTerm] = useState("");
const [filter, setFilter] = useState<Record<string, any>>({});
const [currentPage, setCurrentPage] = useState(1);
const [search] = useDebounce(searchTerm, 400);
const queryKey = useMemo(
() => ["products", { currentPage, search, filter }],
[currentPage, search, filter]
);
// --- React Query fetch ---
const { data: rawProducts, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const data = await productApi.apiRequest(
"index",
removeFalsyValues({
skip: (currentPage - 1) * productApi.item_per_page,
where: {
productModelCode: searchTerm,
status_listing:
filter?.statusFilter === "all" ? undefined : filter.statusFilter,
},
})
);
return data;
},
});
const { data: publistedProducts } = useQuery({
queryKey: ["publised-products"],
queryFn: async () => {
const data = await productApi.apiRequest("getPublistedProducts", {});
return data ?? [];
},
staleTime: 0, // luôn coi là stale -> gọi lại API mỗi lần mount
refetchOnMount: "always",
});
const actionMutation = useMutation({
mutationKey: ["action-mutaions"],
mutationFn: async (data: IPost) => {
if (data.status) {
return productApi.apiRequest("unlist", data);
}
const res = await productApi.apiRequest("get", data);
if (!res || !(res as any)?.data) return;
return productApi.apiRequest("publist", {
...data,
images: mapToIPost({ ...(res as any)?.data }).images,
});
},
});
const data: IPost[] = useMemo(() => {
if (!rawProducts || !(rawProducts as any)?.data) return [];
return (rawProducts as any)?.data.map((item: any) => mapToIPost(item));
}, [rawProducts]);
const formatPrice = (price: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
const clearFilters = () => {
setSearchTerm("");
setFilter({ statusFilter: "" });
setCurrentPage(1);
};
const activeFiltersCount = [filter.statusFilter, searchTerm !== ""].filter(
Boolean
).length;
const totalPages = useMemo(() => {
if (!(rawProducts as any)?.total) return 0;
return Math.ceil((rawProducts as any).total / productApi.item_per_page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(rawProducts as any)?.total, productApi.item_per_page]);
// --- reset page when filter changes ---
// eslint-disable-next-line react-hooks/exhaustive-deps
const from = useMemo(() => {
return (currentPage - 1) * productApi.item_per_page + 1;
}, [currentPage]);
const to = useMemo(() => {
return Math.min(
currentPage * productApi.item_per_page,
(rawProducts as any)?.total ?? 0
);
}, [currentPage, rawProducts]);
const handleActionListing = async (data: IPost) => {
console.log({ post: data });
actionMutation.mutate(data);
};
return (
<div className="w-fit min-h-[600px] p-8">
<PostTable />
</div>
<Popover>
<PopoverTrigger asChild>
<Button size={"icon"}>
<DoorOpenIcon />
</Button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-auto">
<div className="space-y-4 min-h-[722px] min-w-[638px]">
<div className="flex items-center justify-between gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<ImprovedToggleFilter
filter={filter as any}
setFilter={setFilter}
activeFiltersCount={activeFiltersCount}
clearFilters={clearFilters}
/>
</div>
<div className="flex items-center justify-between w-full gap-8">
<span>
Showing {from}-{to} of {(rawProducts as any)?.total ?? 0} products
</span>
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="xs"
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
>
<ChevronLeft />
Previous
</Button>
<div className="flex items-center gap-1">
{totalPages <= 7 ? (
// Show all pages if 7 or fewer
Array.from({ length: totalPages }, (_, i) => (
<Button
key={i + 1}
variant={currentPage === i + 1 ? "default" : "outline"}
size="xs"
onClick={() => setCurrentPage(i + 1)}
>
{i + 1}
</Button>
))
) : (
// Show pages with ellipsis for more than 7 pages
<>
{/* First page */}
<Button
variant={currentPage === 1 ? "default" : "outline"}
size="xs"
onClick={() => setCurrentPage(1)}
>
1
</Button>
{/* Left ellipsis */}
{currentPage > 4 && (
<span className="px-2 text-muted-foreground">...</span>
)}
{/* Middle pages */}
{Array.from({ length: 3 }, (_, i) => {
let pageNum;
if (currentPage <= 4) {
pageNum = i + 2;
} else if (currentPage >= totalPages - 3) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 1 + i;
}
if (pageNum > 1 && pageNum < totalPages) {
return (
<Button
key={pageNum}
variant={
currentPage === pageNum ? "default" : "outline"
}
size="xs"
onClick={() => setCurrentPage(pageNum)}
>
{pageNum}
</Button>
);
}
return null;
}).filter(Boolean)}
{/* Right ellipsis */}
{currentPage < totalPages - 3 && (
<span className="px-2 text-muted-foreground">...</span>
)}
{/* Last page */}
<Button
variant={
currentPage === totalPages ? "default" : "outline"
}
size="xs"
onClick={() => setCurrentPage(totalPages)}
>
{totalPages}
</Button>
</>
)}
</div>
<Button
variant="outline"
size="xs"
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
>
Next
<ChevronRight />
</Button>
</div>
)}
</div>
<div className="rounded-md border overflow-x-auto relative">
<Table className="min-w-[500px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">Name</TableHead>
<TableHead className="w-[80px]">Price</TableHead>
<TableHead className="w-[80px]">Status</TableHead>
<TableHead className="w-[80px] text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="relative">
{isLoading && (
<TableRow>
<TableCell
colSpan={10}
className="text-center py-8 text-muted-foreground "
>
<div className="h-full flex items-center justify-center w-full">
<Loader />
</div>
</TableCell>
</TableRow>
)}
{data.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={10}
className="text-center py-8 text-muted-foreground"
>
<div className="h-full flex items-center justify-center w-full">
<span> No products found</span>
</div>
</TableCell>
</TableRow>
) : (
data.map((post) => {
const status = (publistedProducts as any)?.some(
(item: any) => item.title.includes(post.sku)
);
post.status = status;
return (
<TableRow key={post.id}>
<TableCell className="font-medium">
<div
className="truncate max-w-[340px] w-fit"
title={post.title}
>
{post.title}
</div>
<div
className="text-sm text-muted-foreground truncate max-w-[340px]"
title={post.description}
>
{post.description}
</div>
</TableCell>
<TableCell className="font-semibold">
{formatPrice(post.price)}
</TableCell>
<TableCell>
<Badge variant="secondary">
{post?.status ? "Listed" : "Unlisted"}
</Badge>
</TableCell>
<TableCell className="text-center sticky right-0 bg-background border-l shadow-[-4px_0_8px_rgba(0,0,0,0.1)]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="w-full flex items-center justify-center">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 mx-auto"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<ProductModal data={post}>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault(); // Ngăn dropdown đóng lại
e.stopPropagation();
}}
>
<Edit className="h-4 w-4 mr-2" /> Edit
</DropdownMenuItem>
</ProductModal>
<DropdownMenuItem
onClick={() => handleActionListing(post)}
>
{post.status ? (
<EyeOff className="h-4 w-4 mr-2" />
) : (
<Eye className="h-4 w-4 mr-2" />
)}
{post.status ? "Unlist" : "List"}
{actionMutation.isPending && <Loader />}
</DropdownMenuItem>
{/* <DropdownMenuItem onClick={() => handleUnListing(post)}>
unList
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRePublist(post)}>
re publist
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,6 +1,7 @@
import { urlToBase64 } from "@/features/base64";
import type { IPost } from "@/lib/utils";
import MessageService from "./message.service";
import { facebookService } from "./facebook.service";
import MessageService from "./message.service";
export class BackgroundService {
msgService: MessageService;
@ -29,8 +30,21 @@ export class BackgroundService {
// Chờ message phản hồi từ content
this.msgService
.waitForMessage("content-to-background-publish", timeout)
.then(resolve)
.catch(reject);
.then((data) => {
this.msgService.send("content", "background-to-content-actions", {
data,
type: "publist",
});
resolve(data);
})
.catch((data) => {
this.msgService.send("content", "background-to-content-actions", {
data: null,
type: "publist",
});
reject(data);
});
};
// Tạo tab mới
chrome.tabs.create({ url: facebookService.createItem }, (tab) => {
@ -71,6 +85,12 @@ export class BackgroundService {
} else {
console.log("Hoàn thành nhưng giữ tab:", tabId);
}
this.msgService.send("content", "background-to-content-actions", {
data,
type: "unlist",
});
resolve(res);
})
.catch((err) => {
@ -79,6 +99,12 @@ export class BackgroundService {
console.warn("Đóng tab do timeout/lỗi:", tabId);
});
}
this.msgService.send("content", "background-to-content-actions", {
data: null,
type: "unlist",
});
reject(err);
});
};
@ -173,4 +199,21 @@ export class BackgroundService {
});
});
}
async createBlobUrls(payload: string[]) {
console.log("Background nhận từ content:", payload);
const data = [];
for (const img of payload) {
const response = await urlToBase64(img);
data.push(response);
}
this.msgService.send(
"content",
"background-to-content-create-blod-urls",
data
);
}
}

View File

@ -20,14 +20,22 @@ export default defineConfig({
input: {
popup: resolve(__dirname, "index.html"),
content: resolve(__dirname, "src/content/content.ts"),
"inject-ui": resolve(__dirname, "src/content/inject-ui.tsx"),
background: resolve(__dirname, "src/background/background.ts"),
},
output: {
entryFileNames: (chunk) => {
if (chunk.name === "content") return "content/content.js";
if (chunk.name === "inject-ui") return "content/inject-ui.js";
if (chunk.name === "background") return "background/background.js";
return "assets/[name].js";
},
assetFileNames: (assetInfo) => {
if (assetInfo.name && assetInfo.name.endsWith(".css")) {
return "assets/style.css"; // luôn build thành style.css
}
return "assets/[name]-[hash][extname]";
},
},
},
},