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 Servers matching 'ai':
Server Name: AI training tool server
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Command: ssh root@172.16.7.48
Password: Work1234
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...
},
},
])
```

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

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js", "service_worker": "background/background.js",
"type": "module" "type": "module"
}, },
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module" "type": "module"
} }
], ],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"] "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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@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", "@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0", "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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@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", "@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0", "axios": "^1.11.0",

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js", "service_worker": "background/background.js",
"type": "module" "type": "module"
}, },
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module" "type": "module"
} }
], ],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"] "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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Vite + React + TS</title>
<script type="module" crossorigin src="/assets/popup.js"></script> <script type="module" crossorigin src="/assets/popup.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js"> <link rel="modulepreload" crossorigin href="/assets/base64-BbJB1hmJ.js">
<link rel="modulepreload" crossorigin href="/assets/product-api.service-Dn0sYl6J.js"> <link rel="modulepreload" crossorigin href="/assets/product-api.service-DR0CE0o9.js">
<link rel="modulepreload" crossorigin href="/assets/message.service-DcR3euAR.js"> <link rel="modulepreload" crossorigin href="/assets/app-C7VSS-q9.js">
<link rel="stylesheet" crossorigin href="/assets/popup-DxX4E5UP.css"> <link rel="modulepreload" crossorigin href="/assets/popup-DvUDMaaW.js">
<link rel="stylesheet" crossorigin href="/assets/style.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -9,6 +9,7 @@
"service_worker": "background/background.js", "service_worker": "background/background.js",
"type": "module" "type": "module"
}, },
"host_permissions": ["http://172.16.7.48/*"],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
@ -16,6 +17,12 @@
"type": "module" "type": "module"
} }
], ],
"web_accessible_resources": [
{
"resources": ["content/inject-ui.js", "assets/*"],
"matches": ["<all_urls>"]
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"] "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", "title": "2-Port Analog Telephone Adapter - ATA191-K9",
"price": 220, "price": 220,
"el": {}, "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", "title": "Cisco 2911 Voice Bundle, Pvdm3-16, Uc License Pak, Fl- Cube10 - CISCO2911-V/K9",
"price": 125, "price": 125,
"el": {}, "el": {},
"date": "2025-08-21T08:34:00.451Z" "date": "2025-08-21T08:56:43.192Z"
},
{
"title": "Air-ct7510-k9 - AIR-CT7510-K9",
"price": 8562,
"el": {},
"date": "2025-08-21T08:34:00.451Z"
} }
] ]

View File

@ -142,7 +142,7 @@ app.post("/", async (req, res) => {
); );
let filteredData = data.data.filter((item) => { 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" return status === "listed"
? listedCodes.has(key) ? listedCodes.has(key)
: !listedCodes.has(key); : !listedCodes.has(key);

View File

@ -40,8 +40,15 @@ class ProductApiService {
return axios({ return axios({
method: "POST", method: "POST",
data: { data: {
urlAPI: "/api/product-model/get-info/" + data.id, filter: {
pageCurrent: "/productdata/products/edit/" + data.id, 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", 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(); export const productApi = new ProductApiService();

View File

@ -8,9 +8,9 @@ const queue = new PQueue({ concurrency: 1 });
const service = new BackgroundService(); const service = new BackgroundService();
// Forward hoặc xử lý logic // 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); 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) { switch (payload?.type) {
case "publist": { case "publist": {
@ -32,3 +32,9 @@ service.msgService.on("content-to-popup", (payload) => {
console.log("Background nhận từ content:", payload); console.log("Background nhận từ content:", payload);
service.msgService.send("popup", "content-to-popup", 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
@ -10,9 +8,10 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "../ui/button";
import { delay } from "@/features/app"; import { delay } from "@/features/app";
import { useState, type ReactNode } from "react";
import Loader from "../loader"; import Loader from "../loader";
import { Button } from "../ui/button";
export function ConfirmAlert({ export function ConfirmAlert({
children, 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 <div
className={cn( className={cn(
"animate-spin rounded-full size-4 border-b-2", "!animate-spin rounded-full size-4 border-b-2",
props.size, props.size,
{ {
["border-primary"]: color === "black", ["border-primary"]: color === "black",

View File

@ -6,13 +6,6 @@ import { useMemo, useState } from "react";
import { productApi } from "@/api/product-api.service"; import { productApi } from "@/api/product-api.service";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -20,15 +13,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@ -46,9 +30,9 @@ import {
Edit, Edit,
Eye, Eye,
EyeOff, EyeOff,
Filter,
MoreHorizontal, MoreHorizontal,
Search, Search,
X,
} from "lucide-react"; } from "lucide-react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import Loader from "./loader"; import Loader from "./loader";
@ -111,13 +95,11 @@ export function PostTable() {
const clearFilters = () => { const clearFilters = () => {
setSearchTerm(""); setSearchTerm("");
setStatusFilter("all");
setFilter({}); setFilter({});
setCurrentPage(1); setCurrentPage(1);
setIsFilterModalOpen(false);
}; };
const activeFiltersCount = [statusFilter !== "all", searchTerm !== ""].filter( const activeFiltersCount = [filter.statusFilter, searchTerm !== ""].filter(
Boolean Boolean
).length; ).length;
@ -197,59 +179,26 @@ export function PostTable() {
/> />
</div> </div>
<Dialog open={isFilterModalOpen} onOpenChange={setIsFilterModalOpen}> <div className="flex items-center gap-2">
<DialogTrigger asChild> <Button
<Button variant="outline" className="relative bg-transparent"> variant={filter.statusFilter === "listed" ? "default" : "ghost"}
<Filter className="h-4 w-4 mr-2" /> onClick={() => setFilter({ statusFilter: "listed" })}
Filters >
{activeFiltersCount > 0 && ( Listed
<Badge className="ml-2 h-5 w-5 p-0 text-xs flex items-center justify-center"> </Button>
{activeFiltersCount} <Button
</Badge> variant={filter.statusFilter === "unlisted" ? "default" : "ghost"}
)} onClick={() => setFilter({ statusFilter: "unlisted" })}
</Button> >
</DialogTrigger> Un listed
<DialogContent aria-describedby={undefined} className="sm:max-w-md"> </Button>
<DialogHeader>
<DialogTitle>Product Filters</DialogTitle>
</DialogHeader>
<div className="space-y-4"> {activeFiltersCount > 0 && (
<Select <Button onClick={clearFilters} size={"icon"}>
value={statusFilter === "all" ? "" : statusFilter} <X />
onValueChange={setStatusFilter} </Button>
> )}
<SelectTrigger className="w-[180px]"> </div>
<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>
</div> </div>
<div className="flex items-center justify-between text-sm text-muted-foreground"> <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 */ /* eslint-disable no-constant-binary-expression */
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, X } from "lucide-react"; 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 { useForm } from "react-hook-form";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -26,9 +27,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { productApi } from "@/api/product-api.service";
import Loader from "@/components/loader"; import Loader from "@/components/loader";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Form, Form,
FormControl, FormControl,
@ -37,13 +36,12 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { delay } from "@/features/app";
import { urlToBase64 } from "@/features/base64"; 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 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 // eslint-disable-next-line react-refresh/only-export-components
export const productSchema = z.object({ export const productSchema = z.object({
@ -114,102 +112,6 @@ export default function ProductModal({
const { ...query } = usePost(data); 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 = () => { const addTag = () => {
if (tagInput.trim() && !watchedTags?.includes(tagInput.trim())) { if (tagInput.trim() && !watchedTags?.includes(tagInput.trim())) {
const currentTags = form.getValues("tags") || []; 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 = () => { const handleClose = () => {
setOpen(false); setOpen(false);
form.reset(); form.reset();
@ -289,20 +182,26 @@ export default function ProductModal({
setTagInput(""); setTagInput("");
}; };
useEffect(() => { const mutation = useMutation({
console.log({ a: { ...data, ...query.data?.data.data } }); mutationKey: ["createBlobUrl", data?.sku],
if (query.data?.data?.data && data) { mutationFn: (images: string[]) => {
const values = mapToIPost({ ...data, ...query.data?.data.data }); return productApi.apiRequest("createBlobUrl", images);
form.reset(values); },
} });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query.data, data]);
// useEffect(() => { useEffect(() => {
// if (query.isSuccess && query.isFetched && query.data?.data) { (async () => {
// form.reset(query.data?.data); if (!(query.data as any)?.data || !data) return;
// } const images = mapToIPost({ ...(query.data as any)?.data }).images;
// }, [delImageMutation.isSuccess, query.isFetched, query.isSuccess]);
const urls = await mutation.mutateAsync(images);
const values = {
...data,
images: urls as string[],
};
form.reset(values);
})();
}, [query.data, data]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@ -319,154 +218,67 @@ export default function ProductModal({
</DialogHeader> </DialogHeader>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto p-6"> {!query.isLoading && !mutation.isPending && (
<Form {...form}> <div className="flex-1 overflow-y-auto p-6">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <Form {...form}>
{/* Images */} <form
<Card> onSubmit={form.handleSubmit(onSubmit)}
<CardContent className="p-6"> className="space-y-8"
<FormField >
control={form.control} {/* Images */}
name="images" <Card>
render={() => ( <CardContent className="p-6">
<FormItem> <FormField
<FormLabel className="text-lg font-semibold"> control={form.control}
Images * name="images"
</FormLabel> render={() => (
<FormMessage /> <FormItem>
<FormLabel className="text-lg font-semibold">
Images *
</FormLabel>
<FormMessage />
{/* Image preview */} {/* Image preview */}
{watchedImages.length > 0 && ( {watchedImages.length > 0 && (
<div className="mt-6 overflow-x-auto"> <div className="mt-6 overflow-x-auto">
<div className="flex gap-3"> <div className="flex gap-3">
{watchedImages.map((image, index) => ( {watchedImages.map((image, index) => (
<div <div
key={index} key={index}
className="relative flex-shrink-0 w-32 h-32 group rounded-lg border overflow-hidden" className="relative flex-shrink-0 w-32 h-32 group rounded-lg border overflow-hidden"
> >
<img <img
src={image || "/placeholder.svg"} src={image || "/placeholder.svg"}
alt={`Preview ${index + 1}`} alt={`Preview ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div> </div>
))} ))}
</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> </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 <FormField
control={form.control} control={form.control}
name="condition" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Condition *</FormLabel> <FormLabel>Title *</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> <FormControl>
<Input <Input
readOnly={!!data} placeholder="VD: MacBook Pro 14-inch 2023"
placeholder="VD: Cisco"
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -474,102 +286,197 @@ export default function ProductModal({
</FormItem> </FormItem>
)} )}
/> />
</div>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="price"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description *</FormLabel> <FormLabel>Price ($) *</FormLabel>
<FormControl> <FormControl>
<Textarea rows={4} {...field} /> <Input type="number" placeholder="10" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</CardContent>
</Card>
{/* Additional info */} <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <FormField
<CardContent className="p-6 space-y-6"> control={form.control}
<h3 className="text-lg font-semibold border-b pb-2"> name="category"
Additional information render={({ field }) => (
</h3> <FormItem>
<FormLabel>Category *</FormLabel>
<FormControl>
{/* <Input placeholder="Tools" {...field} /> */}
{/* Tags */} <Select
<div className="space-y-3"> onValueChange={field.onChange}
<FormLabel>Tags</FormLabel> value={field.value}
<div className="flex gap-2"> >
<Input <SelectTrigger className="w-full">
value={tagInput} <SelectValue placeholder="Select category" />
onChange={(e) => setTagInput(e.target.value)} </SelectTrigger>
placeholder="Tag and press Enter" <SelectContent>
onKeyDown={(e) => { {categories.map((c) => (
if (e.key === "Enter") { <SelectItem key={c} value={c.toLowerCase()}>
e.preventDefault(); {c}
addTag(); </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> </div>
{watchedTags && watchedTags?.length > 0 && (
<div className="flex flex-wrap gap-2"> <FormField
{watchedTags.map((tag) => ( control={form.control}
<Badge name="description"
onClick={() => removeTag(tag)} render={({ field }) => (
key={tag} <FormItem>
variant="secondary" <FormLabel>Description *</FormLabel>
className="flex items-center gap-1 px-3 py-1" <FormControl>
> <Textarea rows={4} {...field} />
{tag} </FormControl>
<X className="w-3 h-3 cursor-pointer hover:text-red-500" /> <FormMessage />
</Badge> </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> <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 <FormField
control={form.control} control={form.control}
name="sku" name="sku"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Sku *</FormLabel> <FormLabel>Sku *</FormLabel>
<FormControl> <FormControl>
<Input <Input
readOnly={!!data} readOnly={!!data}
placeholder="VD: MBP14-2023-512GB" placeholder="VD: MBP14-2023-512GB"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="location" name="location"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Location</FormLabel> <FormLabel>Location</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="VD: Hà Nội, Việt Nam" placeholder="VD: Hà Nội, Việt Nam"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* <FormField {/* <FormField
control={form.control} control={form.control}
name="publist" name="publist"
render={({ field }) => { render={({ field }) => {
@ -604,30 +511,24 @@ export default function ProductModal({
); );
}} }}
/> */} /> */}
</CardContent> </CardContent>
</Card> </Card>
</form> </form>
</Form> </Form>
</div> </div>
)}
{(query.isLoading || mutation.isPending) && (
<div className="flex items-center justify-center w-full h-full">
<Loader />
</div>
)}
{/* Footer */} {/* Footer */}
<div className="p-6 border-t bg-white flex justify-end gap-3"> <div className="p-6 border-t bg-white flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleClose}> <Button type="button" variant="outline" onClick={handleClose}>
Close Close
</Button> </Button>
{/* <Button
type="submit"
onClick={form.handleSubmit(onSubmit)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader color="white" />
) : data ? (
"Save"
) : (
"Create"
)}
</Button> */}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( 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", "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", variant: "default",
}, },
} }
) );
function Badge({ function Badge({
className, className,
@ -32,7 +32,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -24,6 +24,7 @@ const buttonVariants = cva(
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", 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", 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", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
}, },

View File

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

View File

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

View File

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

View File

@ -1,25 +1,25 @@
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@ -28,7 +28,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@ -45,7 +45,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@ -80,7 +80,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@ -93,7 +93,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@ -117,7 +117,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@ -130,7 +130,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@ -148,7 +148,7 @@ function SelectScrollUpButton({
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@ -166,7 +166,7 @@ function SelectScrollDownButton({
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@ -180,4 +180,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, 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">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 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)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -49,7 +49,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -62,7 +62,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -75,7 +75,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -88,7 +88,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -101,7 +101,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -113,4 +113,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, 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 // content.ts
import { productApi } from "@/api/product-api.service"; 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 type { IPost } from "@/lib/utils";
import { facebookService } from "@/services/facebook.service"; import { facebookService } from "@/services/facebook.service";
import MessageService from "@/services/message.service"; import MessageService from "@/services/message.service";
@ -8,6 +9,28 @@ import { thiefService } from "@/services/thief.service";
const msgService = new MessageService("content"); 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] // Nhận từ background [publist]
msgService.on<{ data: IPost }>( msgService.on<{ data: IPost }>(
"background-to-content-publish", "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 () => { (async () => {
// Call to inject modal
injectApp();
await delayRD(800, 1000); await delayRD(800, 1000);
// Sử dụng // 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 { productApi } from "@/api/product-api.service";
import { delay } from "@/features/app"; import { delay } from "@/features/app";
import { type IPost } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
const usePost = (post?: IPost) => { const usePost = (post?: IPost) => {
const { isLoading, refetch, ...query } = useQuery({ const { isLoading, refetch, ...query } = useQuery({
@ -10,7 +10,7 @@ const usePost = (post?: IPost) => {
if (!post) return null; if (!post) return null;
await delay(300); // Giả lập delay để thấy loading 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; return res;
}, },
}); });

View File

@ -55,7 +55,7 @@
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 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); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
@ -83,7 +83,7 @@
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 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); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 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; status?: boolean;
id: string; id: string;
publist_id?: string; publist_id?: string;
raw_condition?: string;
base64Images?: string[];
} }
export interface ISyncItem { export interface ISyncItem {
@ -30,8 +32,8 @@ export function cn(...inputs: ClassValue[]) {
export function mapToIPost(raw: any): IPost { export function mapToIPost(raw: any): IPost {
let images: string[] = []; let images: string[] = [];
if (Array.isArray(raw?.listImages)) { if (Array.isArray(raw?.listImage)) {
images = raw.listImages images = raw.listImage
.map((img: any) => .map((img: any) =>
typeof img === "string" typeof img === "string"
? img ? img
@ -41,20 +43,23 @@ export function mapToIPost(raw: any): IPost {
} }
// xử lý package_contain // xử lý package_contain
let packageContainText = ""; let packageContainText = "Package contain:\n";
if (Array.isArray(raw?.package_contain)) { if (Array.isArray(raw?.package_contain)) {
packageContainText = raw.package_contain packageContainText += raw.package_contain
.map((item: string) => `- ${item}`) .map((item: string) => `- ${item}`)
.join("\n"); .join("\n");
} else if (typeof raw?.package_contain === "string") { } else if (
packageContainText = raw.package_contain; 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 // build description rõ ràng, không có thụt đầu dòng
const descriptionLines = [ const descriptionLines = [
raw.description || "", raw.description || "",
raw.code || "", raw.code || "",
raw.name || "", raw.title || "",
(typeof raw?.category === "string" ? raw.category : raw?.category?.name) || (typeof raw?.category === "string" ? raw.category : raw?.category?.name) ||
"", "",
packageContainText, packageContainText,
@ -64,10 +69,12 @@ export function mapToIPost(raw: any): IPost {
return { return {
images, images,
title: `${raw.name} - ${raw.code}`, title: `${raw.title} - ${raw.code}`,
price: raw.price, price: raw.price,
category: "electronics & computers", 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, brand: typeof raw.manufactor === "string" ? raw.manufactor : raw?.name,
description: descriptionLines, description: descriptionLines,
tags: raw.tags || [], tags: raw.tags || [],
@ -76,5 +83,6 @@ export function mapToIPost(raw: any): IPost {
status: false, status: false,
id: raw.id, id: raw.id,
publist_id: raw.listingId, 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() { 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 ( return (
<div className="w-fit min-h-[600px] p-8"> <Popover>
<PostTable /> <PopoverTrigger asChild>
</div> <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 type { IPost } from "@/lib/utils";
import MessageService from "./message.service";
import { facebookService } from "./facebook.service"; import { facebookService } from "./facebook.service";
import MessageService from "./message.service";
export class BackgroundService { export class BackgroundService {
msgService: MessageService; msgService: MessageService;
@ -29,8 +30,21 @@ export class BackgroundService {
// Chờ message phản hồi từ content // Chờ message phản hồi từ content
this.msgService this.msgService
.waitForMessage("content-to-background-publish", timeout) .waitForMessage("content-to-background-publish", timeout)
.then(resolve) .then((data) => {
.catch(reject); 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 // Tạo tab mới
chrome.tabs.create({ url: facebookService.createItem }, (tab) => { chrome.tabs.create({ url: facebookService.createItem }, (tab) => {
@ -71,6 +85,12 @@ export class BackgroundService {
} else { } else {
console.log("Hoàn thành nhưng giữ tab:", tabId); console.log("Hoàn thành nhưng giữ tab:", tabId);
} }
this.msgService.send("content", "background-to-content-actions", {
data,
type: "unlist",
});
resolve(res); resolve(res);
}) })
.catch((err) => { .catch((err) => {
@ -79,6 +99,12 @@ export class BackgroundService {
console.warn("Đóng tab do timeout/lỗi:", tabId); console.warn("Đóng tab do timeout/lỗi:", tabId);
}); });
} }
this.msgService.send("content", "background-to-content-actions", {
data: null,
type: "unlist",
});
reject(err); 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: { input: {
popup: resolve(__dirname, "index.html"), popup: resolve(__dirname, "index.html"),
content: resolve(__dirname, "src/content/content.ts"), content: resolve(__dirname, "src/content/content.ts"),
"inject-ui": resolve(__dirname, "src/content/inject-ui.tsx"),
background: resolve(__dirname, "src/background/background.ts"), background: resolve(__dirname, "src/background/background.ts"),
}, },
output: { output: {
entryFileNames: (chunk) => { entryFileNames: (chunk) => {
if (chunk.name === "content") return "content/content.js"; 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"; if (chunk.name === "background") return "background/background.js";
return "assets/[name].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]";
},
}, },
}, },
}, },