first commit

This commit is contained in:
Admin 2025-08-21 10:28:31 +07:00
commit 922362dc6f
64 changed files with 17135 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-content
dist-ssr
auto-post-facebook-extensions
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

69
README.md Normal file
View File

@ -0,0 +1,69 @@
# 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...
},
},
])
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

29
eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"eslint-disable-next-line react-refresh/only-export-components": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "warn",
"eslint-disable no-case-declarations": "off",
},
},
]);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7135
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "auto-post-facebook-extensions",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"build:all": "vite build --config vite.config.content.ts && vite build --config vite.config.popup.ts && vite build",
"dev:build": "vite build --watch",
"dev:content": "vite build --watch --config vite.config.content.ts",
"dev:popup": "vite build --watch --config vite.config.popup.ts",
"dev:main": "vite build --watch --config vite.config.ts",
"dev:all": "run-p dev:content dev:popup dev:main"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"fs-extra": "^11.3.1",
"lucide-react": "^0.540.0",
"p-queue": "^8.1.0",
"react": "^19.1.1",
"react-day-picker": "^9.9.0",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"webextension-polyfill": "^0.12.0",
"zod": "^4.0.17"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/chrome": "^0.1.4",
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/webextension-polyfill": "^0.12.3",
"@vitejs/plugin-react-swc": "^4.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"npm-run-all": "^4.1.5",
"tw-animate-css": "^1.3.7",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

21
public/manifest.json Normal file
View File

@ -0,0 +1,21 @@
{
"manifest_version": 3,
"name": "Auto post facebook extensions",
"version": "1.0",
"action": {
"default_popup": "index.html"
},
"background": {
"service_worker": "background/background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"type": "module"
}
],
"permissions": ["storage", "tabs", "activeTab", "scripting"]
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

27
server/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-content
dist-ssr
auto-post-facebook-extensions
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

8
server/data.json Normal file
View File

@ -0,0 +1,8 @@
[
{
"title": "Cisco 2911 Voice Bundle, Pvdm3-16, Uc License Pak, Fl- Cube10 - CISCO2911-V/K9",
"price": 3595,
"el": {},
"date": "2025-08-21T03:25:51.274Z"
}
]

848
server/package-lock.json generated Normal file
View File

@ -0,0 +1,848 @@
{
"name": "server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/http-errors/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.6.3",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

18
server/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0"
}
}

45
server/server.js Normal file
View File

@ -0,0 +1,45 @@
import express from "express";
import fs from "fs";
import bodyParser from "body-parser";
import cors from "cors";
const app = express();
// Cho phép tất cả origin gọi API
app.use(cors());
// parse JSON body
app.use(bodyParser.json());
// API lưu dữ liệu
app.post("/sync", (req, res) => {
const data = req.body;
if (!Array.isArray(data)) {
return res.status(400).json({ error: "Dữ liệu phải là array" });
}
// Thêm field date cho mỗi item
const mapped = data.map((item) => ({
...item,
date: new Date().toISOString(),
}));
// Ghi xuống file
fs.writeFileSync("data.json", JSON.stringify(mapped, null, 2));
res.json({ message: "Đã lưu dữ liệu thành công", saved: mapped.length });
});
// API đọc lại dữ liệu
app.get("/data", (req, res) => {
if (!fs.existsSync("data.json")) {
return res.json([]);
}
const content = fs.readFileSync("data.json", "utf-8");
res.json(JSON.parse(content));
});
app.listen(3000, () => {
console.log("Server chạy tại http://localhost:3000");
});

View File

@ -0,0 +1,77 @@
import axios from "@/lib/axios";
import type { IPost, ISyncItem } from "@/lib/utils";
class ProductApiService {
item_per_page = 10;
token =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2ludC5pcHN1cHBseS5jb20uYXUvYXBpL2xvZ2luIiwiaWF0IjoxNzIxNjA5MTEzLCJleHAiOjMyMzUzMzcxMTMsIm5iZiI6MTcyMTYwOTExMywianRpIjoiWHpCZkZPc0ZJUlFxaEZvaiIsInN1YiI6MSwicHJ2IjoiYzhlZTFmYzg5ZTc3NWVjNGM3Mzg2NjdlNWJlMTdhNTkwYjZkNDBmYyJ9.kFFEPpdmNUp-kn_G3cIIc26bivO6tbfcvkpG9I8Im7M";
async index(filter?: {
skip?: number;
limit?: number;
order?: string;
where?: Record<string, any>;
}) {
const defaultFilter = {
skip: 0,
limit: 10,
order: "updatedAt desc",
where: {
account: "prology_net",
status: "Updated",
},
};
return axios({
url: "transferGetData",
headers: {
Authorization: "Bearer " + this.token,
},
method: "POST",
data: {
urlAPI: "/api/ebay-listing/listing-get-list",
filter: {
...defaultFilter,
...filter, // merge filter vào defaultFilter
where: {
...defaultFilter.where,
...filter?.where, // merge where riêng để không mất account/status mặc định
},
},
},
});
}
async get(data: IPost) {
return axios({
url: "transferGetData",
headers: {
Authorization: "Bearer " + this.token,
},
method: "POST",
data: {
urlAPI: "/api/product-model/get-info/" + data.id,
pageCurrent: "/productdata/products/edit/" + data.id,
},
});
}
async sync(data: ISyncItem[]) {
return axios({
baseURL: import.meta.env.VITE_API_SYNC_URL,
url: "sync",
method: "POST",
data,
});
}
async getPublistedProducts() {
return axios({
baseURL: import.meta.env.VITE_API_SYNC_URL,
url: "data",
});
}
}
export const productApi = new ProductApiService();

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,34 @@
// background.ts
import { BackgroundService } from "@/services/background.service";
import PQueue from "p-queue";
// Hàng đợi để xử lý tuần tự
const queue = new PQueue({ concurrency: 1 });
const service = new BackgroundService();
// Forward hoặc xử lý logic
service.msgService.on("popup-to-content", (payload) => {
console.log("Background nhận từ popup:", payload);
service.msgService.send("content", "popup-to-content", payload);
switch (payload?.type) {
case "publist": {
queue.add(() => service.publishJob(payload.data, 60000));
break;
}
case "unlist": {
queue.add(() => service.unlistJob(payload.data, 60000));
break;
}
case "re-publist": {
queue.add(() => service.rePublistJob(payload.data, 60000));
break;
}
}
});
service.msgService.on("content-to-popup", (payload) => {
console.log("Background nhận từ content:", payload);
service.msgService.send("popup", "content-to-popup", payload);
});

View File

@ -0,0 +1,65 @@
import { useState, type ReactNode } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "../ui/button";
import { delay } from "@/features/app";
import Loader from "../loader";
export function ConfirmAlert({
children,
title = "Are you sure ?",
description = "This action cannot be undone.",
onConfirm,
}: {
children: ReactNode;
title?: string;
description?: string | ReactNode;
onConfirm: () => void | Promise<void>;
}) {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
disabled={loading}
onClick={async (e) => {
e.stopPropagation();
// Set loading
setLoading(true);
// Set delay 0.5s cho cảm giác dễ chịu khi sử dụng
await delay(500);
// Gọi callback
await onConfirm?.();
// Tắt modal và loading
setLoading(false);
setOpen(false);
}}
>
Continue
{loading && <Loader color="white" size="size-3" />}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,254 @@
# 📊 `DataTable` Component
Component bảng dữ liệu mạnh mẽ, tùy biến cao, hỗ trợ các chức năng như tìm kiếm, lọc, phân trang, sắp xếp, hành động hàng loạt và hành động tùy chỉnh — được xây dựng bằng Tailwind, Shadcn UI, và Lucide Icons.
---
## 🚀 Cài đặt
```tsx
import { DataTable } from "@/components/data-table"; // Đường dẫn có thể khác tùy theo cấu trúc dự án
```
---
## 🧩 Props
### 1. `data: T[]`
Danh sách dữ liệu hiển thị.
### 2. `columns: Column<T>[]`
Cấu hình cột (xem bên dưới).
### 3. `searchKeys?: (keyof T)[]`
Các field dùng để tìm kiếm toàn cục.
### 4. `filterOptions?: FilterOption[]`
Cấu hình bộ lọc nâng cao (theo field, số, ngày, v.v.).
### 5. `pageSize?: number`
Số dòng mặc định mỗi trang (mặc định: 10).
### 6. `onPageSizeChange?: (size: number) => void`
Callback khi thay đổi `pageSize`.
### 7. `onRowClick?: (row: T) => void`
Sự kiện click vào 1 hàng.
### 8. `onView`, `onEdit`, `onDelete`
Callback khi người dùng chọn thao tác tương ứng từ menu hành động.
### 9. `selectable?: boolean`
Cho phép chọn nhiều dòng.
### 10. `bulkActions?: BulkAction<T>[]`
Hành động hàng loạt với các dòng được chọn.
### 11. `customActions?: CustomAction<T>[]`
Hành động tùy chỉnh cho từng dòng (menu 3 chấm).
### 12. `pagination?: PaginationData`
Phân trang điều khiển từ ngoài (server-side).
### 13. `onPaginationChange?: (pagination: PaginationData) => void`
Callback khi thay đổi phân trang.
### 14. `onSortChange`, `onFilterChange`, `onDataChange`
Callback khi sort / lọc / dữ liệu thay đổi.
### 15. `loading?: boolean`
Trạng thái đang tải dữ liệu.
---
## 🧱 Column Definition
```ts
interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
filterable?: boolean;
displayType?: "text" | "avatar" | "badge" | "currency" | ...;
displayOptions?: object;
render?: (value: any, row: T) => React.ReactNode;
}
```
> Hỗ trợ nhiều kiểu hiển thị: `text`, `password`, `image`, `badge`, `currency`, `boolean`, `link`, `email`, `tags`, `progress`, `status`, `rating`, `filesize`, `percentage`, `custom`, v.v.
---
## 🔍 Filter Options
```ts
interface FilterOption {
key: string;
label: string;
type: "select" | "text" | "date" | "dateRange" | "number" | "numberRange";
options?: { value: string; label: string }[];
}
```
> Hiển thị bộ lọc nâng cao thông qua dialog.
---
## 🔧 Bulk & Custom Actions
```ts
interface BulkAction<T> {
key: string;
label: string;
action: (selected: T[]) => void;
confirmMessage?: string;
}
interface CustomAction<T> {
key: string;
label: string;
action: (row: T) => void;
show?: (row: T) => boolean;
}
```
---
## 🟢 Hiển thị Trạng Thái (`displayType: "status"`)
Dùng để hiển thị trạng thái dưới dạng badge màu. Ví dụ các trạng thái như `"active"`, `"inactive"`, `"pending"`.
### ✅ Cú pháp:
```ts
{
key: "status",
label: "Trạng thái",
displayType: "status",
displayOptions: {
statusMap: {
active: {
label: "Hoạt động",
variant: "default",
},
inactive: {
label: "Ngừng hoạt động",
variant: "secondary",
},
pending: {
label: "Đang chờ",
variant: "outline",
},
},
},
}
```
### 🎨 Tuỳ chọn nâng cao với màu cụ thể (dùng Tailwind):
```ts
{
key: "status",
label: "Trạng thái",
displayType: "status",
displayOptions: {
statusMap: {
active: {
label: "Hoạt động",
variant: "default",
color: "bg-green-100 text-green-800 border-green-200",
},
inactive: {
label: "Ngừng hoạt động",
variant: "secondary",
color: "bg-gray-100 text-gray-800 border-gray-200",
},
pending: {
label: "Đang chờ",
variant: "outline",
color: "bg-yellow-100 text-yellow-800 border-yellow-200",
},
},
},
}
```
> 🔁 Nếu `statusMap` không được định nghĩa:
>
> - `label` sẽ là giá trị gốc (`value`) của field
> - `variant` sẽ mặc định là `"default"`
---
## 📦 Ví dụ sử dụng
```tsx
<DataTable
data={users}
columns={[
{ key: "name", label: "Tên", sortable: true },
{ key: "email", label: "Email", displayType: "email" },
{
key: "status",
label: "Trạng thái",
displayType: "status",
displayOptions: {
statusMap: {
active: { label: "Hoạt động", variant: "default" },
inactive: { label: "Ngừng hoạt động", variant: "secondary" },
pending: { label: "Đang chờ", variant: "outline" },
},
},
},
]}
searchKeys={["name", "email"]}
filterOptions={[
{
key: "status",
label: "Trạng thái",
type: "select",
options: [
{ value: "active", label: "Hoạt động" },
{ value: "inactive", label: "Ngừng hoạt động" },
{ value: "pending", label: "Đang chờ" },
],
},
]}
selectable
onEdit={(row) => console.log("Edit:", row)}
onDelete={(row) => console.log("Delete:", row)}
bulkActions={[
{
key: "delete",
label: "Xoá đã chọn",
variant: "destructive",
confirmMessage: "Bạn có chắc chắn muốn xoá những dòng này?",
action: (rows) => console.log("Bulk delete:", rows),
},
]}
/>
```
---
## ✅ TODO
- Export CSV / Excel
- Resizable columns
- Grouping columns
- Column reorder / drag-drop

File diff suppressed because it is too large Load Diff

82
src/components/core/type.d.ts vendored Normal file
View File

@ -0,0 +1,82 @@
interface FilterOption {
key: string;
label: string;
type: "select" | "text" | "date" | "dateRange" | "number" | "numberRange";
options?: { value: string; label: string }[];
placeholder?: string;
min?: number;
max?: number;
}
interface BulkAction<T> {
key: string;
label: string;
icon?: React.ReactNode;
variant?: "default" | "secondary" | "destructive" | "outline";
action: (selectedRows: T[]) => void;
confirmMessage?: string;
}
interface PaginationData {
currentPage: number;
pageSize: number;
totalPages: number;
totalItems: number;
startIndex: number;
endIndex: number;
}
interface InitialState<T> {
pagination?: {
currentPage?: number;
pageSize?: number;
};
search?: string;
sort?: {
key: keyof T;
direction: "asc" | "desc";
};
filters?: {
select?: Record<string, string[]>;
text?: Record<string, string>;
date?: Record<string, Date>;
dateRange?: Record<string, { from?: Date; to?: Date }>;
number?: Record<string, number>;
numberRange?: Record<string, { min?: number; max?: number }>;
};
}
interface TableState<T> {
pagination: {
currentPage: number;
pageSize: number;
totalPages: number;
totalItems: number;
startIndex: number;
endIndex: number;
};
search: string;
sort: {
key: keyof T | null;
direction: "asc" | "desc";
};
filters: {
select: Record<string, string[]>;
text: Record<string, string>;
date: Record<string, Date | undefined>;
dateRange: Record<string, { from?: Date; to?: Date }>;
number: Record<string, number | undefined>;
numberRange: Record<string, { min?: number; max?: number }>;
};
data: {
filtered: T[];
sorted: T[];
paginated: T[];
};
selection: T[];
}
interface SortOption<T> {
key: keyof T;
label: string;
}

37
src/components/loader.tsx Normal file
View File

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
import type { DetailedHTMLProps } from "react";
export interface ILoaderProps
extends DetailedHTMLProps<
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
size?: string;
showLabel?: boolean;
color?: "black" | "white";
}
export default function Loader({ color = "black", ...props }: ILoaderProps) {
return (
<div
{...props}
className={cn(
"flex items-center justify-center flex-col gap-4",
props.className
)}
>
<div
className={cn(
"animate-spin rounded-full size-4 border-b-2",
props.size,
{
["border-primary"]: color === "black",
["border-white"]: color === "white",
}
)}
></div>
{props.showLabel && <span>Đang tải...</span>}
</div>
);
}

View File

@ -0,0 +1,464 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { productApi } from "@/api/product-api.service";
import { mapToIPost, type IPost } from "@/lib/utils";
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,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { removeFalsyValues } from "@/features/app";
import MessageService from "@/services/message.service";
import {
ChevronLeft,
ChevronRight,
Edit,
Eye,
EyeOff,
Filter,
MoreHorizontal,
Search,
} from "lucide-react";
import Loader from "./loader";
import ProductModal from "./product-modal";
export function PostTable() {
const msg = new MessageService("popup");
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [conditionFilter, setConditionFilter] = useState("all");
const [brandFilter, setBrandFilter] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const queryKey = useMemo(
() => ["products", { currentPage, searchTerm }],
[currentPage, searchTerm]
);
// --- React Query fetch ---
const { data: rawProducts, isLoading } = useQuery({
queryKey,
queryFn: async () => {
const { data } = await productApi.index(
removeFalsyValues({
skip: (currentPage - 1) * productApi.item_per_page,
where: {
productModelCode: searchTerm,
},
})
);
return data;
},
});
const { data: publistedProducts } = useQuery({
queryKey: ["publised-products"],
queryFn: async () => {
const { data } = await productApi.getPublistedProducts();
return data ?? [];
},
staleTime: 0, // luôn coi là stale -> gọi lại API mỗi lần mount
refetchOnMount: "always",
});
const data: IPost[] = useMemo(() => {
if (!rawProducts || !rawProducts?.data) return [];
return rawProducts?.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("");
setCategoryFilter("all");
setConditionFilter("all");
setBrandFilter("all");
setCurrentPage(1);
};
const activeFiltersCount = [
categoryFilter !== "all",
conditionFilter !== "all",
brandFilter !== "all",
searchTerm !== "",
].filter(Boolean).length;
const totalPages = useMemo(() => {
if (!rawProducts?.total) return 0;
return Math.ceil(rawProducts.total / productApi.item_per_page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawProducts?.total, productApi.item_per_page]);
// --- reset page when filter changes ---
// eslint-disable-next-line react-hooks/exhaustive-deps
// useMemo(() => setCurrentPage(1), [filteredPosts.length]);
const from = useMemo(() => {
return (currentPage - 1) * productApi.item_per_page + 1;
}, [currentPage]);
const to = useMemo(() => {
return Math.min(
currentPage * productApi.item_per_page,
rawProducts?.total ?? 0
);
}, [currentPage, rawProducts]);
// popup.ts
const handleActionListing = async (data: IPost) => {
const res = await productApi.get(data);
if (!res || !res.data?.data) return;
if (data.status) {
msg.send("content", "popup-to-content", {
type: "unlist",
data: data,
});
} else {
msg.send("content", "popup-to-content", {
type: "publist",
data: mapToIPost({ ...data, ...res.data?.data }),
});
}
};
// const handleUnListing = async (data: IPost) => {
// msg.send("content", "popup-to-content", {
// type: "unlist",
// data: data,
// });
// };
// const handleRePublist = async (data: IPost) => {
// const res = await productApi.get(data);
// if (!res || !res.data?.data) return;
// msg.send("content", "popup-to-content", {
// type: "re-publist",
// data: mapToIPost({ ...data, ...res.data?.data }),
// });
// };
return (
<div className="space-y-4">
<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>
<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 className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Product Filters</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Select
value={conditionFilter}
onValueChange={setConditionFilter}
>
<SelectTrigger>
<SelectValue placeholder="Condition" />
</SelectTrigger>
</Select>
<Select value={brandFilter} onValueChange={setBrandFilter}>
<SelectTrigger>
<SelectValue placeholder="Brand" />
</SelectTrigger>
</Select>
<div className="flex gap-2">
<Button
variant="outline"
onClick={clearFilters}
className="flex-1 bg-transparent"
>
Clear Filters
</Button>
<Button
onClick={() => setIsFilterModalOpen(false)}
className="flex-1"
>
Apply
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {from}-{to} of {rawProducts?.total ?? 0} products
</span>
</div>
<div className="rounded-md border overflow-x-auto relative">
{isLoading && (
<div className="absolute inset-0 bg-[rgba(255,255,255,.6)] flex items-center justify-center z-10">
<Loader />
</div>
)}
<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">
{data.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={10}
className="text-center py-8 text-muted-foreground"
>
No products found
</TableCell>
</TableRow>
) : (
data.map((post) => {
const status = publistedProducts.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-[200px] w-fit"
title={post.title}
>
{post.title}
</div>
<div
className="text-sm text-muted-foreground truncate max-w-[200px]"
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>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 mx-auto"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</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"}
</DropdownMenuItem>
{/* <DropdownMenuItem onClick={() => handleUnListing(post)}>
unList
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRePublist(post)}>
re publist
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
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="sm"
onClick={() => setCurrentPage(i + 1)}
className="w-8 h-8 p-0"
>
{i + 1}
</Button>
))
) : (
// Show pages with ellipsis for more than 7 pages
<>
{/* First page */}
<Button
variant={currentPage === 1 ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(1)}
className="w-8 h-8 p-0"
>
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="sm"
onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0"
>
{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="sm"
onClick={() => setCurrentPage(totalPages)}
className="w-8 h-8 p-0"
>
{totalPages}
</Button>
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,632 @@
/* 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 { useForm } from "react-hook-form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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,
FormField,
FormItem,
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";
// eslint-disable-next-line react-refresh/only-export-components
export const productSchema = z.object({
images: z.array(z.string()).min(1, "At least 1 image is required"),
title: z
.string()
.min(1, "Product title is required")
.min(5, "Product title must be at least 5 characters"),
price: z.preprocess(
(val) => (typeof val === "string" ? Number(val) : val),
z
.number({
invalid_type_error: "Price must be a number",
required_error: "Price is required",
})
.min(1, "Price must be greater than 0")
),
category: z.string().min(1, "Category is required"),
condition: z.string().min(1, "Condition is required"),
brand: z.string().optional(),
description: z
.string()
.min(1, "Description is required")
.min(20, "Description must be at least 20 characters"),
tags: z.array(z.string()).optional(),
sku: z.string().min(1, "Sku must be required"),
location: z.string().optional(),
publist: z.boolean().optional(),
});
export type ProductFormData = z.infer<typeof productSchema>;
export default function ProductModal({
children,
data,
...props
}: {
children: ReactNode;
onSubmit?: () => void;
data?: IPost;
}) {
const [open, setOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const [urlInput, setUrlInput] = useState("");
const form = useForm<ProductFormData>({
resolver: zodResolver(productSchema) as any,
defaultValues: {
images: [],
title: "",
price: 0,
category: "",
condition: "",
brand: "",
description: "",
tags: [],
sku: "",
location: "",
publist: false,
},
});
const watchedImages = form.watch("images");
const watchedTags = form.watch("tags");
const conditions = ["New", "Used - like new", "Used - good", "Used - fair"];
const categories = ["Tools"];
const { isLoading, refetch, ...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") || [];
form.setValue("tags", [...currentTags, tagInput.trim()]);
setTagInput("");
}
};
const removeTag = (tagToRemove: string) => {
const currentTags = form.getValues("tags") || [];
form.setValue(
"tags",
currentTags.filter((tag) => tag !== tagToRemove)
);
};
const onSubmit = async ({ images, ...values }: ProductFormData) => {
try {
let imagesToConvert: string[] = [];
if (data) {
// Có data => đang update
const oldImages = data.images || [];
const newImages = images || [];
// Lấy ra hình khác so với data.images
imagesToConvert = newImages.filter((img) => !oldImages.includes(img));
} else {
// Không có data => đang create
imagesToConvert = images || [];
}
// Convert blob url sang base64 nếu cần
const convertedImages = await Promise.all(
imagesToConvert.map(async (img) =>
img.startsWith("blob:") ? await urlToBase64(img) : img
)
);
const dataToSubmit: ProductFormData = {
...values,
images: convertedImages,
};
console.log("Product data to submit:", dataToSubmit);
// const response = data
// ? await (async () => {
// const { brand, ...d } = dataToSubmit;
// return await productApi.update(data.id, { ...d, id: data.id });
// })()
// : await productApi.create(dataToSubmit);
// if (!response) return;
// actionMutation.mutate({ ...dataToSubmit, id: data?.id });
console.log("Hình mới cần xử lý:", convertedImages);
} catch (error) {
console.error("Error submitting form:", error);
}
};
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();
setUrlInput("");
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]);
// useEffect(() => {
// if (query.isSuccess && query.isFetched && query.data?.data) {
// form.reset(query.data?.data);
// }
// }, [delImageMutation.isSuccess, query.isFetched, query.isSuccess]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="md:max-w-4xl h-[95vh] flex flex-col p-0">
{/* Header */}
<DialogHeader className="p-6 border-b">
<DialogTitle className="text-2xl font-bold">
{data ? "Edit product" : "Create new product"}
</DialogTitle>
</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 />
{/* 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>
)}
</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>
)}
/>
<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>
)}
/>
</div>
<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>
{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="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 }) => {
const isUpdate = !!data?.id; // đang update nếu có id
const canRepublish = isUpdate && data?.status === true;
return (
<FormItem>
<FormControl>
<div className="flex items-start gap-3">
<Checkbox
id="publish"
checked={field.value}
onCheckedChange={field.onChange}
/>
<div className="grid gap-1 leading-none">
<Label htmlFor="publish">
{canRepublish
? "Re-publish this product"
: "Publish after saving"}
</Label>
<p className="text-sm text-muted-foreground">
{canRepublish
? "Make this product visible again after updating"
: "Automatically publish this product when saving"}
</p>
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</CardContent>
</Card>
</form>
</Form>
</div>
{/* Footer */}
<div className="p-6 border-t bg-white flex justify-end gap-3">
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
onClick={form.handleSubmit(onSubmit)}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader color="white" />
) : data ? (
"Save"
) : (
"Create"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
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"
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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,59 @@
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";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
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",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,255 @@
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"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
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",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

165
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,183 @@
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"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

116
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

114
src/content/content.ts Normal file
View File

@ -0,0 +1,114 @@
// content.ts
import { productApi } from "@/api/product-api.service";
import { delayRD } from "@/features/app";
import type { IPost } from "@/lib/utils";
import { facebookService } from "@/services/facebook.service";
import MessageService from "@/services/message.service";
import { thiefService } from "@/services/thief.service";
const msgService = new MessageService("content");
// Nhận từ background [publist]
msgService.on<{ data: IPost }>(
"background-to-content-publish",
async (payload) => {
try {
console.log("[publish] Content nhận:", payload);
await delayRD(1000, 2000);
await facebookService.handlePublist(payload.data);
} catch (error) {
console.log({ error });
} finally {
// Gửi ngược lại bacground
msgService.send("background", "content-to-background-publish", {
msg: payload,
});
}
}
);
// Nhận từ background [unlist]
msgService.on<{ data: IPost }>(
"background-to-content-unlist",
async (payload) => {
try {
console.log("[unlist] Content nhận:", payload);
await delayRD(1000, 2000);
await facebookService.gotoSell();
await delayRD(1000, 2000);
await facebookService.handleDelete(payload.data);
await delayRD(1000, 2000);
const products = await facebookService.getProducts();
await delayRD(1000, 2000);
productApi.sync(products);
} catch (error) {
console.log({ error });
} finally {
// Gửi ngược lại bacground
msgService.send("background", "content-to-background-unlist", {
msg: payload,
});
}
}
);
// Nhận từ background [re-publist]
msgService.on<{ data: IPost }>(
"background-to-content-re-publist",
async (payload) => {
try {
console.log("[re-publist] Content nhận:", payload);
await delayRD(1000, 2000);
await facebookService.gotoSell();
await delayRD(1000, 2000);
await facebookService.handleRePublist(payload.data);
} catch (error) {
console.log({ error });
} finally {
// Gửi ngược lại bacground
msgService.send("background", "content-to-background-re-publist", {
msg: payload,
});
}
}
);
(async () => {
await delayRD(1000, 2000);
// Sử dụng
thiefService
.waitForElement('[aria-label="Collection of your Marketplace items"]')
.then(async (el) => {
console.log("Element đã xuất hiện:", el);
await facebookService.waitForPageReady(
facebookService.sellingPath,
'[aria-label="Collection of your Marketplace items"]'
);
await delayRD(1000, 2000);
const products = await facebookService.getProducts();
console.log({ products });
productApi.sync(products);
})
.catch((err) => {
console.error(err);
});
})();

22
src/features/app.ts Normal file
View File

@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export function removeFalsyValues<T extends Record<string, any>>(
obj: T,
excludeKeys: (keyof T)[] = []
): Partial<T> {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value || excludeKeys.includes(key as keyof T)) {
acc[key as keyof T] = value;
}
return acc;
}, {} as Partial<T>);
}
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function delayRD(minMs: number, maxMs: number): Promise<void> {
const time = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return delay(time);
}

27
src/features/base64.ts Normal file
View File

@ -0,0 +1,27 @@
export const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
export async function urlToBase64(blobUrl: string): Promise<string> {
return fetch(blobUrl)
.then((res) => res.blob())
.then(
(blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject("Failed to convert blob to base64");
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
})
);
}

View File

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

120
src/index.css Normal file
View File

@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--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-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--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-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

11
src/lib/axios.ts Normal file
View File

@ -0,0 +1,11 @@
import ax from "axios";
const axios = ax.create({
// Dev
baseURL: import.meta.env.VITE_API_URL,
headers: {
"Content-Type": "application/json",
},
});
export default axios;

11
src/lib/react-query.ts Normal file
View File

@ -0,0 +1,11 @@
// lib/react-query.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 phút cache
refetchOnWindowFocus: false,
},
},
});

82
src/lib/utils.ts Normal file
View File

@ -0,0 +1,82 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export interface IPost {
images: string[];
title: string;
price: number;
category: string;
condition: string;
brand?: string;
description: string;
tags: string[];
sku: string;
location?: string;
status?: boolean;
id: string;
publist_id?: string;
}
export interface ISyncItem {
title: string;
price: number;
el: HTMLElement;
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function mapToIPost(raw: any): IPost {
let images: string[] = [];
if (Array.isArray(raw?.listImages)) {
images = raw.listImages
.map((img: any) =>
typeof img === "string"
? img
: `${(import.meta.env.VITE_API_URL as string).replaceAll("api", "")}${
img.url
}`
)
.slice(0, 10); // chỉ lấy tối đa 10 ảnh
}
// xử lý package_contain
let packageContainText = "";
if (Array.isArray(raw?.package_contain)) {
packageContainText = raw.package_contain
.map((item: string) => `- ${item}`)
.join("\n");
} else if (typeof raw?.package_contain === "string") {
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 || "",
(typeof raw?.category === "string" ? raw.category : raw?.category?.name) ||
"",
packageContainText,
]
.filter(Boolean) // loại bỏ cái rỗng
.join("\n\n"); // cách 1 dòng giữa các block
return {
images,
title: `${raw.name} - ${raw.code}`,
price: raw.price,
category: "electronics & computers",
condition: "new",
brand: typeof raw.manufactor === "string" ? raw.manufactor : raw?.name,
description: descriptionLines,
tags: raw.tags || [],
sku: raw.code,
location: raw.location || "Sydney, Australia",
status: false,
id: raw.id,
publist_id: raw.listingId,
};
}

11
src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { queryClient } from "@/lib/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import ReactDOM from "react-dom/client";
import "./index.css";
import Popup from "./popup/popup";
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<Popup />
</QueryClientProvider>
);

9
src/popup/popup.tsx Normal file
View File

@ -0,0 +1,9 @@
import { PostTable } from "@/components/post-table";
export default function Popup() {
return (
<div className="w-fit min-h-[600px] p-8">
<PostTable />
</div>
);
}

View File

@ -0,0 +1,176 @@
import type { IPost } from "@/lib/utils";
import MessageService from "./message.service";
import { facebookService } from "./facebook.service";
export class BackgroundService {
msgService: MessageService;
constructor() {
this.msgService = new MessageService("background");
}
publishJob(data: IPost, timeout = 30000): Promise<any> {
return new Promise((resolve, reject) => {
chrome.tabs.query({}, () => {
const openAndSend = (tabId: number) => {
// Đợi tab load xong
const listener = (updatedTabId: number, info: any) => {
if (updatedTabId === tabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
// Gửi message xuống content
this.msgService.send("content", "background-to-content-publish", {
data,
});
}
};
chrome.tabs.onUpdated.addListener(listener);
// Chờ message phản hồi từ content
this.msgService
.waitForMessage("content-to-background-publish", timeout)
.then(resolve)
.catch(reject);
};
// Tạo tab mới
chrome.tabs.create({ url: facebookService.createItem }, (tab) => {
if (!tab?.id) {
reject(new Error("Không mở được tab"));
return;
}
console.log("Đã mở tab mới:", tab.id);
openAndSend(tab.id);
});
});
});
}
unlistJob(data: IPost, timeout = 30000): Promise<any> {
return new Promise((resolve, reject) => {
const openAndSend = (tabId: number, shouldClose: boolean) => {
const listener = (updatedTabId: number, info: any) => {
if (updatedTabId === tabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
// Gửi message xuống content
this.msgService.send("content", "background-to-content-unlist", {
data,
});
}
};
chrome.tabs.onUpdated.addListener(listener);
// Chờ message phản hồi từ content
this.msgService
.waitForMessage("content-to-background-unlist", timeout)
.then((res) => {
if (shouldClose) {
chrome.tabs.remove(tabId, () => {
console.log("Đã đóng tab:", tabId);
});
} else {
console.log("Hoàn thành nhưng giữ tab:", tabId);
}
resolve(res);
})
.catch((err) => {
if (shouldClose) {
chrome.tabs.remove(tabId, () => {
console.warn("Đóng tab do timeout/lỗi:", tabId);
});
}
reject(err);
});
};
// Tìm tab đã có sẵn
chrome.tabs.query({ url: `${facebookService.sellingPath}*` }, (tabs) => {
if (tabs.length > 0 && tabs[0].id) {
console.log("Dùng lại tab cũ:", tabs[0].id);
// Xài lại tab, không đóng sau khi xong
this.msgService.send("content", "background-to-content-unlist", {
data,
});
this.msgService
.waitForMessage("content-to-background-unlist", timeout)
.then((res) => {
console.log("Hoàn thành, giữ tab cũ:", tabs[0].id!);
resolve(res);
})
.catch((err) => {
console.warn("Lỗi khi xử lý tab cũ:", tabs[0].id!);
reject(err);
});
} else {
// Không có tab nào, tạo tab mới
chrome.tabs.create(
{ url: facebookService.marketPath, active: false },
(tab) => {
if (!tab?.id) {
reject(new Error("Không mở được tab"));
return;
}
console.log("Đã mở tab mới:", tab.id);
// Tab mới => đóng sau khi xong
openAndSend(tab.id, true);
}
);
}
});
});
}
rePublistJob(data: IPost, timeout = 30000): Promise<any> {
return new Promise((resolve, reject) => {
chrome.tabs.query({}, () => {
const openAndSend = (tabId: number) => {
// Đợi tab load xong
const listener = (updatedTabId: number, info: any) => {
if (updatedTabId === tabId && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
// Gửi message xuống content
this.msgService.send(
"content",
"background-to-content-re-publist",
{
data,
}
);
}
};
chrome.tabs.onUpdated.addListener(listener);
// Chờ message phản hồi từ content
this.msgService
.waitForMessage("content-to-background-re-publist", timeout)
.then((res) => {
// ✅ Xong thì đóng tab
chrome.tabs.remove(tabId, () => {
console.log("Đã đóng tab:", tabId);
});
resolve(res);
})
.catch((err) => {
// ❌ Nếu lỗi hoặc timeout thì cũng đóng tab
chrome.tabs.remove(tabId, () => {
console.warn("Đóng tab do timeout/lỗi:", tabId);
});
reject(err);
});
};
// Tạo tab mới
chrome.tabs.create({ url: facebookService.marketPath }, (tab) => {
if (!tab?.id) {
reject(new Error("Không mở được tab"));
return;
}
console.log("Đã mở tab mới:", tab.id);
openAndSend(tab.id);
});
});
});
}
}

View File

@ -0,0 +1,633 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { delay, delayRD } from "@/features/app";
import type { IPost, ISyncItem } from "@/lib/utils";
import { thiefService } from "./thief.service";
class FacebookService {
sellingPath = "https://www.facebook.com/marketplace/you/selling";
marketPath = "https://www.facebook.com/marketplace";
createItem = "https://www.facebook.com/marketplace/create/item";
editItem = "https://www.facebook.com/marketplace/edit";
selectors = {
file__image_input: 'input[type="file"]',
next_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
update_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div/div/div",
publish_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
products:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div[2]/div",
products_fallback:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[3]/div/div/span/div[2]/div",
option_btn:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
option_btn_fallback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
close_btn_modal_feedback:
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div",
images_container:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[2]/div[1]/div[2]/div/div/div[3]/div[2]/div",
description_edit_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[2]/div/div/div/label/div/div/textarea",
sku_edit_input:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[9]/div/div/div[5]/div/div/div[1]/label/div/input",
new_item_btn:
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[3]/div[1]/div[2]/div/div/div[7]/a/div",
item_for_sell:
"/html/body/div[1]/div/div[1]/div/div[5]/div/div/div[3]/div[2]/div[2]/div/div/div[2]/div[1]/div/span/div/div/div[1]/div/div",
market_container: '[aria-label="Marketplace"]',
};
findInputByTitle(title: string, type: "input" | "textarea" = "input") {
const el = thiefService.findNestedElement(
this.selectors.market_container,
"span",
title
);
if (!el) throw new Error("Not found input by title: " + title);
return thiefService.findElInParent(el, type) as HTMLInputElement;
}
clearImages = async () => {
document
.querySelectorAll('[aria-label="Remove"]')
.forEach((item) => (item as any)?.click());
};
uploadImages = async (item: IPost) => {
// Tạo DataTransfer để giả lập FileList
const dt: DataTransfer = new DataTransfer();
for (const image of item.images) {
const base64 = await thiefService.imageUrlToBase64(image);
console.log("Base64:", image.slice(0, 50) + "...");
const file = thiefService.base64ToFile(
base64,
item.sku,
thiefService.getImageExtension(image) || "jpg"
);
dt.items.add(file);
}
// Tìm input file của Facebook
const input: HTMLInputElement | null = document.querySelector(
this.selectors.file__image_input
);
if (input) {
// Gán file vào input
input.files = dt.files;
// Gửi event change
input.dispatchEvent(new Event("change", { bubbles: true }));
} else {
console.error("Không tìm thấy input[type='file']");
}
};
chooseSelect = async (
value: string,
title: string,
dropContainer: string = '[aria-label="Dropdown menu"]'
) => {
const el = thiefService.findNestedElement(
this.selectors.market_container,
"span",
title
);
thiefService.scrollToElement(el!);
await delayRD(500, 600); // delay sau scroll
el?.click();
await delayRD(500, 600); // delay sau click
const matchingChild = thiefService.findNestedElement(
dropContainer,
"span",
value
);
await delayRD(500, 600); // delay sau khi tìm
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delayRD(500, 600); // delay sau scroll đến child
matchingChild.click();
await delayRD(500, 600); // delay sau click child
};
chooseLocation = async (value: string) => {
const input = this.findInputByTitle("location");
await thiefService.writeToInput(value, input);
await delay(600);
// input.click();
const matchingChilds =
document.querySelector('[role="listbox"]')?.querySelectorAll("li") || [];
const matchingChild = Array.from(matchingChilds).find((item) =>
item.innerHTML.trim().toLowerCase().includes(value.toLowerCase())
);
console.log(matchingChild);
if (!matchingChild) throw new Error(`No child found with text "${value}"`);
thiefService.scrollToElement(matchingChild);
await delay(400);
thiefService.clickByPoint(matchingChild);
};
writeTags = async (tags: string[]) => {
const input = this.findInputByTitle("product tags", "textarea");
if (!input) throw new Error("Input is not found");
thiefService.scrollToElement(input);
await delay(500);
for (const tag of tags) {
await thiefService.writeToInput(tag, input);
await delay(300);
thiefService.pressEnter(input);
}
};
clickNext = async () => {
const btn = await thiefService.getElementByXPath(this.selectors.next_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
clickUpdate = async () => {
const btn = await thiefService.getElementByXPath(this.selectors.update_btn);
if (!btn) throw new Error("Next button is not found");
thiefService.clickByPoint(btn);
};
clickPublist = async () => {
const btn = await thiefService.getElementByXPath(
this.selectors.publish_btn
);
if (!btn) throw new Error("Publist button is not found");
thiefService.clickByPoint(btn);
};
/**
* B1. Upload images
* B2. Write title
* B3. Write price
* B4. Select category
* B5. Select condition
* .....
*/
handlePublist = async (item: IPost) => {
console.log({ item });
await delayRD(600, 1000);
// B1. Upload images
await this.uploadImages(item);
await delayRD(600, 1000);
// B2. Write title
const title_input = this.findInputByTitle("title");
thiefService.writeToInput(item.title, title_input);
await delayRD(600, 1000);
// B3. Write price
const price_input = this.findInputByTitle("price");
thiefService.writeToInput(String(item.price), price_input);
await delayRD(600, 1000);
// // B4. Select category
await this.chooseSelect(item.category, "category");
await delayRD(600, 1000);
// // B5. Select condition
await this.chooseSelect(
item.condition,
"condition",
'[aria-label="Select an option"]'
);
if (item.brand) {
await delayRD(600, 1000);
// B6. Write brand
const brand_input = this.findInputByTitle("brand");
await thiefService.writeToInput(item.brand, brand_input);
}
await delayRD(600, 1000);
// B7. Write description
const description_input = this.findInputByTitle("description", "textarea");
await thiefService.writeToInput(item.description, description_input);
await delayRD(600, 1000);
await this.writeTags(item.tags);
await delayRD(600, 1000);
// B8. Write sku
const sku_input = this.findInputByTitle("sku");
await thiefService.writeToInput(item.sku, sku_input);
if (item?.location) {
await delayRD(600, 1000);
await this.chooseLocation(item.location);
}
await delayRD(300, 500);
await this.clickNext();
// await delayRD(300, 500);
// await this.clickPublist();
return true;
};
/**
* B1. Upload images
* B2. Write title
* B3. Write price
* B4. Select category
* B5. Select condition
* .....
*/
handleRePublist = async (item: IPost) => {
console.log({ item });
await thiefService.waitForElement(
'[aria-label="Collection of your Marketplace items"]'
);
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = await this.getOptionEls(item);
console.log({ items });
// // Tìm item chứa chữ "delete"
this.clickItemInList(items, "edit");
// Delay 1 giây để modal xác nhận xóa hiện ra
await delay(1000);
// CHỜ tới khi URL đổi sang sellingPath
await this.waitForPageReady(this.editItem, this.selectors.market_container);
await delayRD(1000, 2000);
await this.clearImages();
await delayRD(600, 1000);
// B1. Upload images
await this.uploadImages(item);
await delayRD(600, 1000);
// B2. Write title
const title_input = this.findInputByTitle("title");
thiefService.writeToInput(item.title, title_input);
await delayRD(600, 1000);
// B3. Write price
const price_input = this.findInputByTitle("price");
thiefService.writeToInput(String(item.price), price_input);
await delayRD(600, 1000);
// // B4. Select category
await this.chooseSelect(item.category, "category");
await delayRD(600, 1000);
// // B5. Select condition
await this.chooseSelect(
item.condition,
"condition",
'[aria-label="Select an option"]'
);
await delayRD(600, 1000);
// B7. Write description
const description_input = this.findInputByTitle("description", "textarea");
await thiefService.writeToInput(item.description, description_input);
await delayRD(600, 1000);
await this.writeTags(item.tags);
await delayRD(600, 1000);
if (item?.location) {
await delayRD(600, 1000);
await this.chooseLocation(item.location);
}
return true;
};
getProducts = async () => {
const products1 = await thiefService.getElementByXPath(
this.selectors.products
);
const products2 = await thiefService.getElementByXPath(
this.selectors.products_fallback
);
console.log({ products1, products2 });
// Gom 2 cái vào một mảng, bỏ null
const allProductsEls = [products1, products2].filter(
Boolean
) as HTMLElement[];
if (allProductsEls.length === 0) return [];
// Nối tất cả kết quả extractListings từ mỗi element
return allProductsEls.flatMap((el) =>
this.extractListings(el)
) as ISyncItem[];
};
extractListings(productsEl: HTMLElement) {
const children = Array.from(productsEl.children);
return children.map((child) => {
// Lấy title
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
const title = titleEl?.textContent?.trim() || "";
const els = document.querySelectorAll(`[aria-label="${title}"]`);
// Chuyển sang mảng và filter
const filtered = Array.from(els).filter(
(el) => !el.querySelector("img")
)[0];
const priceEl = thiefService.findNestedIcElement(filtered, "span", "AU$");
const priceMatch = priceEl?.textContent?.match(/[\d,]+(?:\.\d+)?/);
const price = priceMatch
? parseFloat(priceMatch[0].replace(/,/g, ""))
: 0;
return { title, price, el: productsEl };
});
}
closeTab = async (data: IPost) => {
chrome.runtime.sendMessage({
type: "close-tab",
payload: data,
});
};
clickOptionOfProduct(data: IPost) {
// Tìm nút "More options" cho sản phẩm đó
const optionEl = document.querySelector(
`[aria-label="More options for ${data.title}"]`
);
if (!optionEl)
throw new Error(
`Not found option buttin in product ${`[aria-label="More options for ${data.title}"]`}, ID: ${
data.id
}`
);
thiefService.scrollToElement(optionEl as any);
// Click vào nút "More options" để mở menu
(optionEl as any).click?.();
}
async getOptionEls(data: IPost) {
this.clickOptionOfProduct(data);
// Delay 2 giây để menu hiển thị hoàn toàn
await delay(2000);
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
return items;
}
clickItemInList(items: Element[], innerText: string) {
// Tìm item chứa chữ "delete"
const item = items.find((item) =>
item.textContent.toLocaleLowerCase().includes(innerText)
);
if (!item) throw new Error(`Not found item ${innerText} in options list`);
// Click vào item "Delete"
(item as any).click?.();
}
getItemInList(items: Element[], innerText: string) {
// Tìm item chứa chữ "delete"
const item = items.find((item) =>
item.textContent.toLocaleLowerCase().includes(innerText)
);
if (!item) throw new Error(`Not found item ${innerText} in options list`);
return item;
}
handleDelete = async (payload: IPost) => {
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = await this.getOptionEls(payload);
console.log({ items });
// // Tìm item chứa chữ "delete"
this.clickItemInList(items, "delete");
// Delay 1 giây để modal xác nhận xóa hiện ra
await delay(1000);
// Lấy nút "Options" trong modal (hoặc fallback nếu xpath chính không tìm thấy)
const confirmBtn = await thiefService.getElementByXPath(
this.selectors.option_btn,
{
xpathFallback: this.selectors.option_btn_fallback,
}
);
console.log({ confirmBtn });
// Click vào nút "Options" trong modal nếu tìm thấy
confirmBtn?.click();
// Lấy nút "Close" của modal feedback (nếu có) để đóng modal sau khi xóa
const closeBtnModalFeedback = await thiefService.getElementByXPath(
this.selectors.close_btn_modal_feedback
);
closeBtnModalFeedback?.click();
// Gọi API backend để đánh dấu sản phẩm đã xóa (published = false)
// await productApi.finistDelete(payload, { published: false });
// Gửi message đến background/content script thông báo là delete đã xong
chrome.runtime.sendMessage({ type: "delete-done" });
};
waitForUrlContains(path: string, timeout = 10000) {
return new Promise<void>((resolve, reject) => {
const start = Date.now();
const check = () => {
if (window.location.href.includes(path)) {
resolve();
} else if (Date.now() - start > timeout) {
reject(new Error("Timeout waiting for URL change"));
} else {
setTimeout(check, 500);
}
};
check();
});
}
waitForPageReady(path: string, selector: string, timeout = 15000) {
return new Promise<void>((resolve, reject) => {
const start = Date.now();
const check = async () => {
// Đúng URL + DOM có dấu hiệu đã render
if (
window.location.href.includes(path) &&
// document.querySelector('[role="main"]') // thay bằng selector chắc chắn có trong selling page
document.querySelector(selector)
) {
resolve();
} else if (Date.now() - start > timeout) {
reject(new Error("Timeout waiting for selling page ready"));
} else {
setTimeout(check, 500);
}
};
// chạy liền để check ngay
check();
});
}
syncListing = async () => {
const url = window.location.href;
if (!url.includes(this.sellingPath)) {
(
document.querySelector('a[href="/marketplace/you/selling/"]') as any
)?.click?.();
// CHỜ tới khi URL đổi sang sellingPath
await this.waitForPageReady(this.sellingPath, this.selectors.products);
}
const products = await facebookService.getProducts();
// const response = await productApi.sync(
// products.map((item) => ({
// title: item.title,
// price: item.price,
// })) as ISyncItem[]
// );
// console.log({ response });
};
extractMarketplaceItemId(url: string): string | null {
const match = url.match(/\/marketplace\/item\/(\d+)/);
return match ? match[1] : null;
}
handleGetPublistID = async (payload: IPost) => {
// Lấy tất cả sản phẩm hiện tại trên Facebook Marketplace
const products = await facebookService.getProducts();
// Tìm sản phẩm khớp với title và price từ payload
const product = products.find((product) => {
return product.title == payload.title && product.price == payload.price;
});
console.log({ payload, product, products });
// Nếu không tìm thấy sản phẩm nào khớp thì kết thúc
if (!product) return;
const el = product.el; // element HTML đại diện cho sản phẩm trên trang
// Lấy tất cả các item trong menu (thường là 'Edit', 'Delete', ...)
const items = await this.getOptionEls(payload);
const item = this.getItemInList(items, "view listing");
const publistID = this.extractMarketplaceItemId((item as any)["href"]);
this.clickOptionOfProduct(payload);
return publistID;
};
async gotoPublist() {
const btn = await thiefService.getElementByXPath(
this.selectors.new_item_btn
);
btn?.click();
await delayRD(400, 800);
const forSell = await thiefService.getElementByXPath(
this.selectors.item_for_sell
);
forSell?.click();
}
async gotoSell() {
const btn = document.querySelector('[href="/marketplace/you/selling/"]');
(btn as any)?.click();
}
}
export const facebookService = new FacebookService();

View File

@ -0,0 +1,133 @@
/* eslint-disable prefer-const */
type Role = "popup" | "background" | "content";
type Target = "popup" | "background" | "content";
interface IMessage<T = any> {
from: Role;
to: Target;
type: string;
payload?: T;
}
type MessageHandler<T = any> = (
payload: T,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void
) => void;
export default class MessageService {
private role: Role;
private listeners: Map<string, MessageHandler>;
constructor(role: Role) {
this.role = role;
this.listeners = new Map();
chrome.runtime.onMessage.addListener(
(message: IMessage, sender, sendResponse) => {
if (message?.type && this.listeners.has(message.type)) {
const handler = this.listeners.get(message.type)!;
handler(message.payload, sender, sendResponse);
return true; // cho phép async response
}
}
);
}
/**
* Đăng handler cho một loại message
*/
on<T = any>(type: string, handler: MessageHandler<T>): void {
this.listeners.set(type, handler as MessageHandler);
}
/**
* Gửi message tới target
*/
send<T = any>(to: Target, type: string, payload?: T): void {
const message: IMessage<T> = { from: this.role, to, type, payload };
if (this.role === "popup" || this.role === "content") {
// popup hoặc content luôn gửi qua background
chrome.runtime.sendMessage(message);
}
if (this.role === "background") {
if (to === "content") {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]?.id) {
chrome.tabs.sendMessage(tabs[0].id, message, () => {
if (chrome.runtime.lastError) {
console.warn(
"Không gửi được tới content script:",
chrome.runtime.lastError.message
);
}
});
}
});
} else if (to === "popup") {
chrome.runtime.sendMessage(message, () => {
if (chrome.runtime.lastError) {
console.warn(
"Không gửi được tới popup:",
chrome.runtime.lastError.message
);
}
});
}
}
}
/**
* Gửi message trực tiếp tới tab chỉ đnh (chỉ dùng cho background)
*/
sendToTab<T = any>(tabId: number, type: string, payload?: T): void {
if (this.role !== "background") {
throw new Error("sendToTab chỉ được gọi từ background");
}
const message: IMessage<T> = {
from: this.role,
to: "content",
type,
payload,
};
chrome.tabs.sendMessage(tabId, message, () => {
if (chrome.runtime.lastError) {
console.warn(
`Không gửi được tới content script ở tab ${tabId}:`,
chrome.runtime.lastError.message
);
}
});
}
waitForMessage<T = any>(type: string, timeout = 30000): Promise<T> {
return new Promise((resolve, reject) => {
let timer: any;
const handler: MessageHandler<T> = (payload) => {
clearTimeout(timer);
this.off(type, handler);
resolve(payload);
};
// timeout
timer = setTimeout(() => {
this.off(type, handler);
reject(new Error(`Timeout khi chờ message: ${type}`));
}, timeout);
this.on(type, handler);
});
}
off(type: string, handler: MessageHandler): void {
const current = this.listeners.get(type);
if (current === handler) {
this.listeners.delete(type);
}
}
}

View File

@ -0,0 +1,298 @@
class ThiefService {
base64ToFile(base64: string, filename: string, mimeType: string): File {
// Nếu có tiền tố "data:image/xxx;base64," thì cắt bỏ
const pureBase64 = base64.includes(",") ? base64.split(",")[1] : base64;
const byteString = atob(pureBase64);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: mimeType });
return new File([blob], filename, { type: mimeType });
}
clickByPoint(el: Element) {
const rect: DOMRect = el.getBoundingClientRect();
const x: number = rect.left + rect.width / 2;
const y: number = rect.top + rect.height / 2;
const target: Element | null = document.elementFromPoint(x, y);
if (target) {
target.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window,
})
);
}
}
async getElementByXPath(
xpath: string,
{
retryCount = 2,
delay = 100,
xpathFallback,
}: { retryCount?: number; delay?: number; xpathFallback?: string } = {}
): Promise<HTMLElement | null> {
return new Promise((resolve) => {
let attempts = 0;
let usingFallback = false;
const tryFind = () => {
const el: Node | null = document.evaluate(
usingFallback && xpathFallback ? xpathFallback : xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
if (el instanceof HTMLElement) {
resolve(el);
return;
}
attempts++;
if (attempts < retryCount) {
setTimeout(tryFind, delay);
} else {
if (!usingFallback && xpathFallback) {
usingFallback = true;
attempts = 0;
setTimeout(tryFind, delay);
} else {
resolve(null);
}
}
};
tryFind();
});
}
async imageUrlToBase64(url: string): Promise<string> {
const response = await fetch(url);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Kết quả là data URL (base64)
resolve(reader.result.split(",")[1]);
} else {
reject("Không thể đọc dữ liệu ảnh");
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
getImageExtension(url: string): string | null {
try {
const pathname = new URL(url).pathname; // Lấy phần path từ URL
const match = pathname.match(/\.([a-zA-Z0-9]+)$/); // Tìm đuôi file
return match ? match[1].toLowerCase() : null;
} catch {
// Nếu url không hợp lệ thì fallback sang split
const parts = url.split("?");
const path = parts[0];
const match = path.match(/\.([a-zA-Z0-9]+)$/);
return match ? match[1].toLowerCase() : null;
}
}
imageLocalToBase64(relativePath: string): Promise<string> {
return new Promise((resolve, reject) => {
try {
// Lấy URL đầy đủ trong extension
const url = chrome.runtime.getURL(`${relativePath}`);
// Fetch file
fetch(url)
.then((res) => res.blob())
.then((blob) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); // base64
reader.onerror = reject;
reader.readAsDataURL(blob);
})
.catch(reject);
} catch (err) {
reject(err);
}
});
}
scrollToElement(el: HTMLElement, behavior: ScrollBehavior = "smooth") {
if (!el) return;
el.scrollIntoView({
behavior, // "smooth" hoặc "auto"
block: "center", // Đưa phần tử ra giữa màn hình
inline: "nearest",
});
}
getElementPointCoores(el: HTMLElement): { x: number; y: number } | null {
if (!el) return null;
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
return { x, y };
}
setInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
if (!el) return;
// Gán giá trị trực tiếp
el.value = value;
// Tạo và dispatch sự kiện input
el.dispatchEvent(new Event("input", { bubbles: true }));
// Tạo và dispatch sự kiện change (nếu cần)
el.dispatchEvent(new Event("change", { bubbles: true }));
}
writeToInput = async (value: string, element: string | HTMLInputElement) => {
let el;
if (typeof element === "string") {
el = (await this.getElementByXPath(element)) as HTMLInputElement;
} else {
el = element;
}
if (!el) throw new Error("Xpath is not found with value: " + value);
// Scroll to EL
this.scrollToElement(el);
this.clickByPoint(el);
this.setInputValue(el, value);
};
pressEnter(el: HTMLElement) {
if (!el) {
throw new Error("Textarea not found:", el);
}
el.focus();
// Chuỗi sự kiện bàn phím
["keydown", "keypress", "keyup"].forEach((type) => {
el.dispatchEvent(
new KeyboardEvent(type, {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true,
})
);
});
}
/**
* Tìm phần tử con nằm trong 1 cha, selector + text cụ thể
* @param parentSelector - selector của cha
* @param childSelector - selector của con
* @param text - innerText cần match
* @returns HTMLElement | null
*/
findNestedElement(
parentSelector: string,
childSelector: string,
text: string
): HTMLElement | null {
const parent = document.querySelector<HTMLElement>(parentSelector);
if (!parent) return null;
const children = parent.querySelectorAll<HTMLElement>(childSelector);
for (const el of children) {
if (el.innerText.trim().toLowerCase() === text.toLowerCase()) {
return el;
}
}
return null;
}
findNestedIcElement(
parentSelector: string | Element,
childSelector: string,
text: string
): HTMLElement | null {
let parent = null;
if (typeof parentSelector === "string") {
document.querySelector<HTMLElement>(parentSelector);
if (!parent) return null;
} else {
parent = parentSelector;
}
const children = parent.querySelectorAll<HTMLElement>(childSelector);
for (const el of children) {
if (el.innerText.trim().toLowerCase().includes(text.toLowerCase())) {
return el;
}
}
return null;
}
/**
* Tìm parent element theo selector từ 1 element cho trước
* @param el - phần tử bắt đu
* @param selector - selector của cha cần tìm
* @returns HTMLElement | null
*/
findElInParent(el: HTMLElement, selector: string): HTMLElement | null {
if (!el) return null;
const parent = el.parentElement;
return parent?.querySelector(selector) || null;
}
waitForElement(selector: string, checkInterval = 100): Promise<HTMLElement> {
return new Promise((resolve) => {
const isLoaded = (el: HTMLElement) =>
el.offsetParent !== null && // element hiển thị
(el.childElementCount > 0 || el.innerText.trim().length > 0); // đã có nội dung
const tryResolve = () => {
const el = document.querySelector<HTMLElement>(selector);
if (el && isLoaded(el)) {
observer.disconnect();
clearInterval(interval);
resolve(el);
}
};
// MutationObserver để bắt khi element xuất hiện
const observer = new MutationObserver(tryResolve);
observer.observe(document.body, { childList: true, subtree: true });
// Kiểm tra theo interval để chờ element load nội dung
const interval = setInterval(tryResolve, checkInterval);
// Thử ngay lần đầu
tryResolve();
});
}
}
export const thiefService = new ThiefService();

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

31
tsconfig.app.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

23
vite.config.content.ts Normal file
View File

@ -0,0 +1,23 @@
// vite.config.content.ts
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "dist-content",
emptyOutDir: false, // giữ dist gốc
rollupOptions: {
input: path.resolve(__dirname, "src/content/content.ts"),
output: {
entryFileNames: "content/content.js",
format: "iife",
inlineDynamicImports: true,
},
},
},
});

33
vite.config.popup.ts Normal file
View File

@ -0,0 +1,33 @@
// vite.config.popup.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import path, { resolve } from "path";
import pkg from "./package.json";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: pkg.name,
rollupOptions: {
input: {
popup: resolve(__dirname, "index.html"),
content: resolve(__dirname, "src/content/content.ts"),
background: resolve(__dirname, "src/background/background.ts"),
},
output: {
entryFileNames: (chunk) => {
if (chunk.name === "content") return "content/content.js";
if (chunk.name === "background") return "background/background.js";
return "assets/[name].js";
},
},
},
},
});

34
vite.config.ts Normal file
View File

@ -0,0 +1,34 @@
import fs from "fs";
import fse from "fs-extra";
import path, { resolve } from "path";
import { defineConfig } from "vite";
import pkg from "./package.json";
export default defineConfig({
plugins: [
{
name: "copy-content-script",
closeBundle: async () => {
const src = resolve(__dirname, "dist-content/content/content.js");
const destDir = resolve(__dirname, pkg.name, "content");
const dest = resolve(destDir, "content.js");
if (!fs.existsSync(src)) {
console.warn(
"⚠️ content.js chưa được build từ vite.config.content.ts"
);
return;
}
await fse.ensureDir(destDir);
await fse.copyFile(src, dest);
console.log(`✅ Copied content.js → ${dest}`);
},
},
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});