update UI
This commit is contained in:
parent
276abd49f8
commit
eb4e711e58
73
README.md
73
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export class Contants {
|
||||
public static ID_EXTENSION = "ex-root";
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue