commit b4c7b49bc69422596206457098ec893a6493ee89 Author: Alireza Hassani Date: Thu Sep 3 23:41:56 2026 +0330 Initial commit of the Mado Motor admin dashboard. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56316db --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_WEBSITE_URL= +VITE_API_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f57fea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.env +.env.* +!.env.example diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6d3dff --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Mado Motor Dashboard + +React + Vite clone of the admin.madomotor.ir UI. + +## Run + +```bash +npm install +npm run dev +``` + +Dev server: + +- http://manage.madomotor.com:5175 +- http://localhost:5175 + +Local domains are mapped in `/etc/hosts` (see `../scripts/setup-hosts.sh`). + +## Notes + +- Auth is local/mock for now (any non-empty mobile + password works). +- Pages and routes mirror the live admin; API wiring comes next. +- Design tokens: IranYekan / Montserrat, primary `#2c86ed`, RTL layout. diff --git a/index.html b/index.html new file mode 100644 index 0000000..33c7eb0 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + مادو موتور | پنل مدیریت + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..03304ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1353 @@ +{ + "name": "dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", + "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", + "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", + "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", + "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", + "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", + "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", + "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", + "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", + "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", + "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", + "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", + "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", + "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", + "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", + "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", + "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", + "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", + "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", + "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", + "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.81.0", + "@oxlint/binding-android-arm64": "1.81.0", + "@oxlint/binding-darwin-arm64": "1.81.0", + "@oxlint/binding-darwin-x64": "1.81.0", + "@oxlint/binding-freebsd-x64": "1.81.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", + "@oxlint/binding-linux-arm-musleabihf": "1.81.0", + "@oxlint/binding-linux-arm64-gnu": "1.81.0", + "@oxlint/binding-linux-arm64-musl": "1.81.0", + "@oxlint/binding-linux-ppc64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-gnu": "1.81.0", + "@oxlint/binding-linux-riscv64-musl": "1.81.0", + "@oxlint/binding-linux-s390x-gnu": "1.81.0", + "@oxlint/binding-linux-x64-gnu": "1.81.0", + "@oxlint/binding-linux-x64-musl": "1.81.0", + "@oxlint/binding-openharmony-arm64": "1.81.0", + "@oxlint/binding-win32-arm64-msvc": "1.81.0", + "@oxlint/binding-win32-ia32-msvc": "1.81.0", + "@oxlint/binding-win32-x64-msvc": "1.81.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.27", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.27.tgz", + "integrity": "sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d15dfa7 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "madomotor-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.3" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..6ecc6e8 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,68 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { AuthProvider } from './auth/AuthContext' +import RequireAuth from './auth/RequireAuth' +import AuthLayout from './layouts/AuthLayout' +import AppLayout from './layouts/AppLayout' +import LoginPage from './pages/LoginPage' +import RegisterPage from './pages/RegisterPage' +import ForgotPasswordPage from './pages/ForgotPasswordPage' +import DashboardPage from './pages/DashboardPage' +import OrdersPage from './pages/OrdersPage' +import ProductsPage from './pages/ProductsPage' +import ProductFormPage from './pages/ProductFormPage' +import CustomersPage from './pages/CustomersPage' +import CustomerFormPage from './pages/CustomerFormPage' +import ReviewsPage from './pages/ReviewsPage' +import BlogListPage from './pages/BlogListPage' +import BlogFormPage from './pages/BlogFormPage' +import RepresentationRequestsPage from './pages/RepresentationRequestsPage' +import ActiveRepresentationsPage from './pages/ActiveRepresentationsPage' +import RepresentationFormPage from './pages/RepresentationFormPage' +import CallRequestsPage from './pages/CallRequestsPage' +import CallRequestFormPage from './pages/CallRequestFormPage' + +export default function App() { + return ( + + + }> + } /> + } /> + } /> + + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + ) +} diff --git a/src/assets/fonts/iranyekan/eot/iranyekanwebbold.eot b/src/assets/fonts/iranyekan/eot/iranyekanwebbold.eot new file mode 100644 index 0000000..16e8fbc Binary files /dev/null and b/src/assets/fonts/iranyekan/eot/iranyekanwebbold.eot differ diff --git a/src/assets/fonts/iranyekan/eot/iranyekanweblight.eot b/src/assets/fonts/iranyekan/eot/iranyekanweblight.eot new file mode 100644 index 0000000..512a46a Binary files /dev/null and b/src/assets/fonts/iranyekan/eot/iranyekanweblight.eot differ diff --git a/src/assets/fonts/iranyekan/eot/iranyekanwebmedium.eot b/src/assets/fonts/iranyekan/eot/iranyekanwebmedium.eot new file mode 100644 index 0000000..e2d5f34 Binary files /dev/null and b/src/assets/fonts/iranyekan/eot/iranyekanwebmedium.eot differ diff --git a/src/assets/fonts/iranyekan/ttf/iranyekanwebbold.ttf b/src/assets/fonts/iranyekan/ttf/iranyekanwebbold.ttf new file mode 100644 index 0000000..1c6bbd4 Binary files /dev/null and b/src/assets/fonts/iranyekan/ttf/iranyekanwebbold.ttf differ diff --git a/src/assets/fonts/iranyekan/ttf/iranyekanweblight.ttf b/src/assets/fonts/iranyekan/ttf/iranyekanweblight.ttf new file mode 100644 index 0000000..9e81193 Binary files /dev/null and b/src/assets/fonts/iranyekan/ttf/iranyekanweblight.ttf differ diff --git a/src/assets/fonts/iranyekan/ttf/iranyekanwebmedium.ttf b/src/assets/fonts/iranyekan/ttf/iranyekanwebmedium.ttf new file mode 100644 index 0000000..4dc2ad7 Binary files /dev/null and b/src/assets/fonts/iranyekan/ttf/iranyekanwebmedium.ttf differ diff --git a/src/assets/fonts/iranyekan/woff/iranyekanwebbold.woff b/src/assets/fonts/iranyekan/woff/iranyekanwebbold.woff new file mode 100644 index 0000000..7707c6f Binary files /dev/null and b/src/assets/fonts/iranyekan/woff/iranyekanwebbold.woff differ diff --git a/src/assets/fonts/iranyekan/woff/iranyekanweblight.woff b/src/assets/fonts/iranyekan/woff/iranyekanweblight.woff new file mode 100644 index 0000000..9e6d42c Binary files /dev/null and b/src/assets/fonts/iranyekan/woff/iranyekanweblight.woff differ diff --git a/src/assets/fonts/iranyekan/woff/iranyekanwebmedium.woff b/src/assets/fonts/iranyekan/woff/iranyekanwebmedium.woff new file mode 100644 index 0000000..8fe8e40 Binary files /dev/null and b/src/assets/fonts/iranyekan/woff/iranyekanwebmedium.woff differ diff --git a/src/assets/fonts/montserrat/Montserrat-Light.woff b/src/assets/fonts/montserrat/Montserrat-Light.woff new file mode 100644 index 0000000..ff7cd8f Binary files /dev/null and b/src/assets/fonts/montserrat/Montserrat-Light.woff differ diff --git a/src/assets/fonts/montserrat/Montserrat-Regular.woff b/src/assets/fonts/montserrat/Montserrat-Regular.woff new file mode 100644 index 0000000..e77f1bc Binary files /dev/null and b/src/assets/fonts/montserrat/Montserrat-Regular.woff differ diff --git a/src/assets/fonts/montserrat/montserrat-bold.woff b/src/assets/fonts/montserrat/montserrat-bold.woff new file mode 100644 index 0000000..f2f7a8f Binary files /dev/null and b/src/assets/fonts/montserrat/montserrat-bold.woff differ diff --git a/src/assets/images/auth-bg-preview.jpg b/src/assets/images/auth-bg-preview.jpg new file mode 100644 index 0000000..b6b8482 Binary files /dev/null and b/src/assets/images/auth-bg-preview.jpg differ diff --git a/src/assets/images/auth-bg.jpg b/src/assets/images/auth-bg.jpg new file mode 100644 index 0000000..a97d88c Binary files /dev/null and b/src/assets/images/auth-bg.jpg differ diff --git a/src/assets/images/back-chevron.png b/src/assets/images/back-chevron.png new file mode 100644 index 0000000..99ec8fb Binary files /dev/null and b/src/assets/images/back-chevron.png differ diff --git a/src/assets/images/blogs/blog-1.png b/src/assets/images/blogs/blog-1.png new file mode 100644 index 0000000..ca52ce1 Binary files /dev/null and b/src/assets/images/blogs/blog-1.png differ diff --git a/src/assets/images/blogs/blog-2.png b/src/assets/images/blogs/blog-2.png new file mode 100644 index 0000000..2084eae Binary files /dev/null and b/src/assets/images/blogs/blog-2.png differ diff --git a/src/assets/images/login-promo.png b/src/assets/images/login-promo.png new file mode 100644 index 0000000..7aecb00 Binary files /dev/null and b/src/assets/images/login-promo.png differ diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png new file mode 100644 index 0000000..7985a19 Binary files /dev/null and b/src/assets/images/logo.png differ diff --git a/src/assets/images/products/brake.svg b/src/assets/images/products/brake.svg new file mode 100644 index 0000000..bb5457f --- /dev/null +++ b/src/assets/images/products/brake.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/images/products/scooter.svg b/src/assets/images/products/scooter.svg new file mode 100644 index 0000000..30afd63 --- /dev/null +++ b/src/assets/images/products/scooter.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/images/products/shock.svg b/src/assets/images/products/shock.svg new file mode 100644 index 0000000..981b353 --- /dev/null +++ b/src/assets/images/products/shock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/products/wheel.svg b/src/assets/images/products/wheel.svg new file mode 100644 index 0000000..9808063 --- /dev/null +++ b/src/assets/images/products/wheel.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/images/tiles/cart.png b/src/assets/images/tiles/cart.png new file mode 100644 index 0000000..4bdd714 Binary files /dev/null and b/src/assets/images/tiles/cart.png differ diff --git a/src/assets/images/tiles/chat.png b/src/assets/images/tiles/chat.png new file mode 100644 index 0000000..c8ac8f6 Binary files /dev/null and b/src/assets/images/tiles/chat.png differ diff --git a/src/assets/images/tiles/doc.png b/src/assets/images/tiles/doc.png new file mode 100644 index 0000000..e3bb983 Binary files /dev/null and b/src/assets/images/tiles/doc.png differ diff --git a/src/assets/images/tiles/rss.png b/src/assets/images/tiles/rss.png new file mode 100644 index 0000000..9dd623b Binary files /dev/null and b/src/assets/images/tiles/rss.png differ diff --git a/src/assets/images/tiles/scooter.png b/src/assets/images/tiles/scooter.png new file mode 100644 index 0000000..15bcc51 Binary files /dev/null and b/src/assets/images/tiles/scooter.png differ diff --git a/src/assets/images/tiles/star.png b/src/assets/images/tiles/star.png new file mode 100644 index 0000000..4c91391 Binary files /dev/null and b/src/assets/images/tiles/star.png differ diff --git a/src/assets/images/tiles/store.png b/src/assets/images/tiles/store.png new file mode 100644 index 0000000..c4c4db0 Binary files /dev/null and b/src/assets/images/tiles/store.png differ diff --git a/src/assets/images/tiles/user.svg b/src/assets/images/tiles/user.svg new file mode 100644 index 0000000..d5a5357 --- /dev/null +++ b/src/assets/images/tiles/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/images/tiles/wrench.png b/src/assets/images/tiles/wrench.png new file mode 100644 index 0000000..d5d3465 Binary files /dev/null and b/src/assets/images/tiles/wrench.png differ diff --git a/src/assets/images/user-icon.svg b/src/assets/images/user-icon.svg new file mode 100644 index 0000000..4d83faf --- /dev/null +++ b/src/assets/images/user-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 0000000..e94ea7e --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -0,0 +1,111 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react' +import { + login as apiLogin, + loginSendCode as apiLoginSendCode, + loginVerify as apiLoginVerify, + logout as apiLogout, +} from '../lib/authApi' +import { + clearSession, + getAuthUser, + isElevatedRole, + type AuthUser, +} from '../lib/auth' +import { ApiError } from '../lib/api' +import { isValidCellNumber, normalizeCellNumber } from '../lib/phone' + +type AuthContextValue = { + user: AuthUser | null + isAuthenticated: boolean + login: (mobile: string, password: string) => Promise + loginSendCode: (mobile: string) => Promise<{ expiresInSeconds: number; resendAfterSeconds: number }> + loginVerify: (mobile: string, code: string) => Promise + logout: () => Promise + reloadUser: () => void + isAdmin: boolean +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(() => getAuthUser()) + + const login = useCallback(async (mobile: string, password: string) => { + const cellNumber = normalizeCellNumber(mobile) + if (!cellNumber || !password.trim()) { + throw new Error('شماره همراه و رمز عبور را وارد کنید') + } + if (!isValidCellNumber(cellNumber)) { + throw new Error('شماره همراه معتبر نیست') + } + try { + const nextUser = await apiLogin(cellNumber, password) + setUser(nextUser) + } catch (err) { + if (err instanceof ApiError) throw new Error(err.message) + throw err + } + }, []) + + const loginSendCode = useCallback(async (mobile: string) => { + const cellNumber = normalizeCellNumber(mobile) + if (!isValidCellNumber(cellNumber)) { + throw new Error('شماره همراه معتبر نیست') + } + try { + return await apiLoginSendCode(cellNumber) + } catch (err) { + if (err instanceof ApiError) throw new Error(err.message) + throw err + } + }, []) + + const loginVerify = useCallback(async (mobile: string, code: string) => { + try { + const nextUser = await apiLoginVerify(normalizeCellNumber(mobile), code.trim()) + setUser(nextUser) + } catch (err) { + if (err instanceof ApiError) throw new Error(err.message) + throw err + } + }, []) + + const logout = useCallback(async () => { + await apiLogout() + clearSession() + setUser(null) + }, []) + + const reloadUser = useCallback(() => { + setUser(getAuthUser()) + }, []) + + const value = useMemo( + () => ({ + user, + isAuthenticated: Boolean(user), + login, + loginSendCode, + loginVerify, + logout, + reloadUser, + isAdmin: isElevatedRole(user?.role), + }), + [user, login, loginSendCode, loginVerify, logout, reloadUser], + ) + + return {children} +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx new file mode 100644 index 0000000..7e8b258 --- /dev/null +++ b/src/auth/RequireAuth.tsx @@ -0,0 +1,14 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' + +export default function RequireAuth() { + const { isAuthenticated } = useAuth() + const location = useLocation() + + if (!isAuthenticated) { + const redirect = encodeURIComponent(location.pathname + location.search) + return + } + + return +} diff --git a/src/components/DashboardTile.css b/src/components/DashboardTile.css new file mode 100644 index 0000000..bd96eb0 --- /dev/null +++ b/src/components/DashboardTile.css @@ -0,0 +1,51 @@ +.dt { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + text-align: center; + min-height: 0; + padding: 20px 10px 16px; + border: 1px solid transparent; + border-radius: 4px; + color: var(--color-primary); + transition: border-color 0.2s ease; + cursor: pointer; +} + +.dt:hover { + border-color: #d8d8d8; +} + +.dt--disabled { + cursor: default; + opacity: 0.75; +} + +.dt--disabled:hover { + border-color: transparent; +} + +.dt__icon { + width: 64px; + height: 64px; + object-fit: contain; + margin-bottom: 22px; +} + +.dt h4 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 300; + line-height: 1.5; + color: var(--color-primary); +} + +.dt h5 { + margin: 0; + font-size: 12px; + font-weight: 300; + color: rgba(0, 0, 0, 0.45); + line-height: 1.6; + max-width: 150px; +} diff --git a/src/components/DashboardTile.tsx b/src/components/DashboardTile.tsx new file mode 100644 index 0000000..c86430e --- /dev/null +++ b/src/components/DashboardTile.tsx @@ -0,0 +1,31 @@ +import { Link } from 'react-router-dom' +import type { DashboardTile } from '../data/tiles' +import './DashboardTile.css' + +type Props = { + tile: DashboardTile +} + +export default function DashboardTileCard({ tile }: Props) { + const body = ( + <> + +

{tile.title}

+
{tile.subtitle}
+ + ) + + if (tile.to) { + return ( + + {body} + + ) + } + + return ( +
+ {body} +
+ ) +} diff --git a/src/components/FabButton.css b/src/components/FabButton.css new file mode 100644 index 0000000..851f9ab --- /dev/null +++ b/src/components/FabButton.css @@ -0,0 +1,20 @@ +.fab { + position: fixed; + bottom: 32px; + left: max(24px, calc(5vw + 12px)); + width: 56px; + height: 56px; + border: 0; + border-radius: 50%; + background: var(--color-primary); + color: #fff; + display: grid; + place-items: center; + cursor: pointer; + box-shadow: 0 6px 16px rgba(44, 134, 237, 0.35); + z-index: 100; +} + +.fab:hover { + background: var(--color-primary-deep); +} diff --git a/src/components/FabButton.tsx b/src/components/FabButton.tsx new file mode 100644 index 0000000..edc00b7 --- /dev/null +++ b/src/components/FabButton.tsx @@ -0,0 +1,15 @@ +import Icon from './Icon' +import './FabButton.css' + +type Props = { + label: string + onClick?: () => void +} + +export default function FabButton({ label, onClick }: Props) { + return ( + + ) +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..a157aa3 --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,167 @@ +export type IconName = + | 'cart' + | 'scooter' + | 'user' + | 'star' + | 'chat' + | 'wrench' + | 'doc' + | 'store' + | 'rss' + | 'home' + | 'power' + | 'edit' + | 'plus' + | 'chevron' + | 'trash' + | 'info' + | 'phone' + +type IconProps = { + name: IconName + size?: number + className?: string +} + +export default function Icon({ name, size = 24, className }: IconProps) { + const common = { + width: size, + height: size, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.6, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + className, + 'aria-hidden': true, + } + + switch (name) { + case 'home': + return ( + + + + + ) + case 'power': + return ( + + + + + ) + case 'user': + return ( + + + + + ) + case 'cart': + return ( + + + + + + ) + case 'scooter': + return ( + + + + + + + + ) + case 'star': + return ( + + + + ) + case 'chat': + return ( + + + + + ) + case 'wrench': + return ( + + + + ) + case 'doc': + return ( + + + + + + ) + case 'store': + return ( + + + + + + ) + case 'rss': + return ( + + + + + + ) + case 'edit': + return ( + + + + + ) + case 'trash': + return ( + + + + + + + ) + case 'plus': + return ( + + + + ) + case 'chevron': + return ( + + + + ) + case 'info': + return ( + + + + + ) + case 'phone': + return ( + + + + ) + default: + return null + } +} diff --git a/src/components/PageTitle.css b/src/components/PageTitle.css new file mode 100644 index 0000000..8b8b927 --- /dev/null +++ b/src/components/PageTitle.css @@ -0,0 +1,65 @@ +.page-title { + display: flex; + align-items: center; + margin-bottom: 28px; +} + +.page-title__back { + margin-left: 0; + margin-right: 8px; + border: 0; + background: transparent; + padding: 4px; + cursor: pointer; + display: grid; + place-items: center; + flex-shrink: 0; +} + +.page-title__back img { + display: block; + width: 15px; + height: auto; +} + +.page-title__divider { + width: 1px; + align-self: stretch; + min-height: 36px; + margin: 8px 20px; + background: #e0e0e0; + flex-shrink: 0; +} + +.page-title__content { + display: flex; + align-items: center; + min-width: 0; +} + +.page-title__icon { + height: auto; + margin-left: 12px; + flex-shrink: 0; +} + +.page-title__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.page-title h1 { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.4; +} + +.page-title p { + margin: 2px 0 0; + font-size: 0.9rem; + color: gray; + font-weight: 300; +} diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx new file mode 100644 index 0000000..297b40c --- /dev/null +++ b/src/components/PageTitle.tsx @@ -0,0 +1,43 @@ +import userIcon from '../assets/images/user-icon.svg' +import backChevron from '../assets/images/back-chevron.png' +import './PageTitle.css' + +type Props = { + title: string + subtitle?: string + image?: string + imageWidth?: number + showBack?: boolean + onBack?: () => void +} + +export default function PageTitle({ + title, + subtitle, + image = userIcon, + imageWidth = 45, + showBack = true, + onBack, +}: Props) { + return ( +
+ {showBack ? ( + + ) : null} + + {showBack ? : null} + +
+ {image ? ( + + ) : null} +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+
+
+ ) +} diff --git a/src/components/Pagination.css b/src/components/Pagination.css new file mode 100644 index 0000000..9a49211 --- /dev/null +++ b/src/components/Pagination.css @@ -0,0 +1,50 @@ +.pager { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 20px; + flex-wrap: wrap; +} + +.pager__page, +.pager__nav { + min-width: 34px; + height: 34px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: #e3f2fd; + color: #1565c0; + display: grid; + place-items: center; + cursor: pointer; + font-family: var(--font-fa); + font-size: 14px; + font-weight: 500; +} + +.pager__page.is-active { + background: var(--color-primary); + color: #fff; +} + +.pager__nav:hover:not(:disabled), +.pager__page:hover:not(.is-active) { + background: #bbdefb; +} + +.pager__nav:disabled { + opacity: 0.35; + cursor: default; +} + +.pager__nav--next { + transform: scaleX(-1); +} + +.pager__ellipsis { + min-width: 20px; + text-align: center; + color: #9e9e9e; +} diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..0e13294 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,73 @@ +import Icon from './Icon' +import { toFaDigits } from '../lib/faDigits' +import './Pagination.css' + +type Props = { + page: number + pageCount: number + onPageChange: (page: number) => void +} + +function visiblePages(page: number, pageCount: number): Array { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i + 1) + } + + const set = new Set([1, pageCount, page, page - 1, page + 1, page - 2, page + 2]) + const nums = [...set].filter((n) => n >= 1 && n <= pageCount).sort((a, b) => a - b) + const out: Array = [] + for (const n of nums) { + if (out.length > 0) { + const prev = out[out.length - 1] + if (typeof prev === 'number' && n - prev > 1) out.push('ellipsis') + } + out.push(n) + } + return out +} + +export default function Pagination({ page, pageCount, onPageChange }: Props) { + if (pageCount <= 1) return null + + return ( + + ) +} diff --git a/src/components/PrimaryButton.css b/src/components/PrimaryButton.css new file mode 100644 index 0000000..c0cc6e8 --- /dev/null +++ b/src/components/PrimaryButton.css @@ -0,0 +1,40 @@ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn--primary { + background: var(--color-primary); + color: var(--color-white); +} + +.btn--primary:hover:not(:disabled) { + background: var(--color-primary-deep); +} + +.btn--secondary { + background: var(--color-secondary); + color: #444; +} + +.btn--secondary:hover:not(:disabled) { + background: #e4e4e4; +} + +.btn--ghost { + background: transparent; + color: #666; +} diff --git a/src/components/PrimaryButton.tsx b/src/components/PrimaryButton.tsx new file mode 100644 index 0000000..d36e09a --- /dev/null +++ b/src/components/PrimaryButton.tsx @@ -0,0 +1,20 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' +import './PrimaryButton.css' + +type Props = ButtonHTMLAttributes & { + variant?: 'primary' | 'secondary' | 'ghost' + children: ReactNode +} + +export default function PrimaryButton({ + variant = 'primary', + children, + className = '', + ...rest +}: Props) { + return ( + + ) +} diff --git a/src/components/TextField.css b/src/components/TextField.css new file mode 100644 index 0000000..a703160 --- /dev/null +++ b/src/components/TextField.css @@ -0,0 +1,33 @@ +.tf { + position: relative; + display: block; + width: 100%; +} + +.tf__label { + position: absolute; + top: -9px; + right: 14px; + background: var(--color-white); + padding: 0 6px; + font-size: 12px; + color: #777; + z-index: 1; +} + +.tf__input { + width: 100%; + height: 48px; + border: 1px solid #d7d7d7; + border-radius: 6px; + padding: 0 14px; + background: #f7fafc; + color: var(--color-text); + outline: none; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.tf__input:focus { + border-color: var(--color-primary); + background: var(--color-white); +} diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx new file mode 100644 index 0000000..559f1d6 --- /dev/null +++ b/src/components/TextField.tsx @@ -0,0 +1,33 @@ +import './TextField.css' + +type TextFieldProps = { + label: string + value: string + onChange: (value: string) => void + type?: string + autoComplete?: string + dir?: 'rtl' | 'ltr' +} + +export default function TextField({ + label, + value, + onChange, + type = 'text', + autoComplete, + dir, +}: TextFieldProps) { + return ( + + ) +} diff --git a/src/data/mock.ts b/src/data/mock.ts new file mode 100644 index 0000000..666cbaf --- /dev/null +++ b/src/data/mock.ts @@ -0,0 +1,321 @@ +import wheelImg from '../assets/images/products/wheel.svg' +import brakeImg from '../assets/images/products/brake.svg' +import shockImg from '../assets/images/products/shock.svg' +import scooterImg from '../assets/images/products/scooter.svg' +import blogImg1 from '../assets/images/blogs/blog-1.png' +import blogImg2 from '../assets/images/blogs/blog-2.png' + +export type Order = { + id: number + customerName: string + mobile: string + date: string + itemCount: number +} + +export type Product = { + id: number + nameFa: string + nameEn: string + price: number + category: string + stock: number + image?: string +} + +export type Customer = { + id: number + name: string + mobile: string + city: string + ordersCount: number +} + +export type BlogPost = { + id: number + title: string + summary: string + author: string + image?: string +} + +export type RepresentationRequest = { + id: number + name: string + mobile: string + city: string + address: string + date: string + owner?: string +} + +export type Review = { + id: number + author: string + product: string + rating: number + status: 'pending' | 'approved' +} + +export type CallRequest = { + id: number + name: string + mobile: string + date: string + note: string +} + +export const mockOrders: Order[] = [ + { + id: 75, + customerName: 'علیرضا حسنی', + mobile: '09127004945', + date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴', + itemCount: 1, + }, + { + id: 74, + customerName: 'علیرضا حسنی', + mobile: '09127004945', + date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴', + itemCount: 1, + }, + { + id: 73, + customerName: 'علیرضا حسنی', + mobile: '09127004945', + date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴', + itemCount: 2, + }, + { + id: 72, + customerName: 'علیرضا حسنی', + mobile: '09127004945', + date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴', + itemCount: 1, + }, + { + id: 71, + customerName: 'علیرضا حسنی', + mobile: '09127004945', + date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴', + itemCount: 2, + }, +] + +export const mockProducts: Product[] = [ + { + id: 1, + nameFa: 'چرخ عقب موتورسیکلت برقی مادو ۲۳۰', + nameEn: 'Rear wheel of Mado 230 electric motorcycle', + price: 0, + stock: 0, + category: 'موتورسیکلت', + image: wheelImg, + }, + { + id: 2, + nameFa: 'دیسک ترمز موتورسیکلت برقی', + nameEn: 'Electric motorcycle brake disc', + price: 0, + stock: 0, + category: 'قطعات', + image: brakeImg, + }, + { + id: 3, + nameFa: 'کمک فنر جلو موتورسیکلت برقی', + nameEn: 'Electric motorcycle front shock absorber', + price: 0, + stock: 0, + category: 'قطعات', + image: shockImg, + }, + { + id: 4, + nameFa: 'کمک فنر عقب موتورسیکلت برقی', + nameEn: 'Electric motorcycle rear shock absorber', + price: 0, + stock: 0, + category: 'قطعات', + image: shockImg, + }, + { + id: 5, + nameFa: 'مادو ۱۳۰', + nameEn: 'Mado 130 electric scooter', + price: 185000000, + stock: 3, + category: 'موتورسیکلت', + image: scooterImg, + }, + { + id: 6, + nameFa: 'مادو ۲۳۰', + nameEn: 'Mado 230 electric scooter', + price: 245000000, + stock: 2, + category: 'موتورسیکلت', + image: scooterImg, + }, +] + +export const mockCustomers: Customer[] = [ + { id: 1, name: 'علیرضا حسنی', mobile: '09127004945', city: 'تهران', ordersCount: 12 }, + { id: 2, name: 'سارا محمدی', mobile: '09121234567', city: 'اصفهان', ordersCount: 3 }, + { id: 3, name: 'رضا کریمی', mobile: '09351234567', city: 'شیراز', ordersCount: 0 }, +] + +export const mockBlogs: BlogPost[] = [ + { + id: 1, + title: 'معرفی مادو ۱۳۰', + summary: 'نگاهی به ویژگی‌های موتور جدید مادو و آنچه آن را از سایر محصولات متمایز می‌کند.', + author: 'علیرضا حسنی', + }, + { + id: 2, + title: 'نکات ایمنی کلاه کاسکت', + summary: 'چگونه از سلامت خود در مسیر محافظت کنیم و چه نکاتی را هنگام خرید کلاه جدی بگیریم.', + author: 'علیرضا حسنی', + }, + { + id: 3, + title: 'نگهداری باتری موتور برقی', + summary: 'راهنمای ساده برای افزایش عمر باتری و عملکرد بهتر در سفرهای روزانه.', + author: 'علیرضا حسنی', + image: blogImg1, + }, + { + id: 4, + title: 'مادو ۲۳۰ در شهر', + summary: 'تجربه رانندگی شهری با مادو ۲۳۰ و نکاتی برای انتخاب رنگ و تجهیزات مناسب.', + author: 'علیرضا حسنی', + image: blogImg2, + }, +] + +export const mockRepresentations: RepresentationRequest[] = [ + { + id: 105, + name: 'حسین افکاری', + mobile: '09152424890', + city: 'مشهد', + address: 'میدان شهید فهمیده', + date: 'جمعه ۰۶ شهریور ۱۴۰۵', + }, + { + id: 104, + name: 'محمد رضایی', + mobile: '09121112233', + city: 'الیگودرز', + address: 'خیابان امام خمینی', + date: 'پنجشنبه ۰۵ شهریور ۱۴۰۵', + }, + { + id: 103, + name: 'حسین اکبری', + mobile: '09123334455', + city: 'تبریز', + address: 'بلوار آزادی', + date: 'چهارشنبه ۰۴ شهریور ۱۴۰۵', + }, +] + +export const mockActiveRepresentations: RepresentationRequest[] = [ + { + id: 1, + name: 'حسین خوشحال', + owner: 'نماینده کرج', + mobile: '09121234567', + city: 'کرج', + address: 'اندیشه فاز دو', + date: '۱۴۰۳/۰۱/۱۰', + }, + { + id: 2, + name: 'میرایی', + owner: 'نماینده بندر ترکمن', + mobile: '09117654321', + city: 'بندر ترکمن', + address: 'گلستان - بندرترکمن', + date: '۱۴۰۳/۰۲/۱۵', + }, + { + id: 3, + name: 'جهان سیکلت', + owner: 'نماینده بوشهر', + mobile: '09171234567', + city: 'بوشهر', + address: 'بندر دیر، خیابان شهید بهشتی', + date: '۱۴۰۳/۰۳/۲۰', + }, + { + id: 4, + name: 'نمایندگی کمال آبادی', + owner: 'نماینده اراک', + mobile: '09181234567', + city: 'اراک', + address: 'خیابان مشهد، نبش کوچه طالقانی', + date: '۱۴۰۳/۰۴/۰۵', + }, +] + +export const mockReviews: Review[] = [ + { + id: 1, + author: 'کاربر مادو', + product: 'مادو ۱۳۰', + rating: 5, + status: 'approved', + }, + { + id: 2, + author: 'رضا', + product: 'مادو ۲۳۰', + rating: 4, + status: 'pending', + }, +] + +export const mockCallRequests: CallRequest[] = [ + { + id: 592, + name: 'احسان خیرخواه', + mobile: '09038600949', + date: 'پنجشنبه، ۱۲ شهریور ماه ۱۴۰۵', + note: 'درخواست مشاوره خرید', + }, + { + id: 591, + name: 'مصطفی صالحی', + mobile: '09121234567', + date: 'چهارشنبه، ۱۱ شهریور ماه ۱۴۰۵', + note: 'سوال درباره نمایندگی', + }, + { + id: 590, + name: 'مینا احمدی', + mobile: '09120001122', + date: 'سه‌شنبه، ۱۰ شهریور ماه ۱۴۰۵', + note: 'پیگیری سفارش', + }, + { + id: 589, + name: 'علی نوری', + mobile: '09350001122', + date: 'دوشنبه، ۹ شهریور ماه ۱۴۰۵', + note: 'درخواست تماس', + }, + { + id: 588, + name: 'سارا محمدی', + mobile: '09123334455', + date: 'یکشنبه، ۸ شهریور ماه ۱۴۰۵', + note: 'مشاوره فنی', + }, +] + +export function formatToman(price: number): string { + return `${price.toLocaleString('en-US')} تومان` +} diff --git a/src/data/tiles.ts b/src/data/tiles.ts new file mode 100644 index 0000000..ed6947c --- /dev/null +++ b/src/data/tiles.ts @@ -0,0 +1,88 @@ +import cart from '../assets/images/tiles/cart.png' +import scooter from '../assets/images/tiles/scooter.png' +import user from '../assets/images/tiles/user.svg' +import star from '../assets/images/tiles/star.png' +import chat from '../assets/images/tiles/chat.png' +import wrench from '../assets/images/tiles/wrench.png' +import doc from '../assets/images/tiles/doc.png' +import store from '../assets/images/tiles/store.png' +import rss from '../assets/images/tiles/rss.png' + +export type DashboardTile = { + id: number + title: string + subtitle: string + to?: string + icon: string +} + +export const adminTiles: DashboardTile[] = [ + { + id: 1, + title: 'سفارشات مادو موتور', + subtitle: 'سفارشات وبسایت', + to: '/admin/orders', + icon: cart, + }, + { + id: 2, + title: 'موتور ها و قطعات', + subtitle: 'همه ی محصولات من', + to: '/admin/product/product-list', + icon: scooter, + }, + { + id: 3, + title: 'مشتریان من', + subtitle: 'سرمایه های مادو موتور', + to: '/admin/my-customers', + icon: user, + }, + { + id: 4, + title: 'بررسی های کاربران', + subtitle: 'نظرات تخصصی کاربران', + to: '/admin/user-reviews', + icon: star, + }, + { + id: 5, + title: 'انتقادات و شکایات مشتریان', + subtitle: 'نظر کاربران برای ما مهم است', + icon: chat, + }, + { + id: 6, + title: 'خدمات پس از فروش', + subtitle: 'تعهد و احترام ما به مشتریان', + icon: wrench, + }, + { + id: 7, + title: 'درخواست های نمایندگی', + subtitle: 'درخواست های کاربران از سرتاسر ایران', + to: '/admin/representation/representation-list', + icon: doc, + }, + { + id: 8, + title: 'نمایندگی های فعال', + subtitle: 'نمایندگی های ما در سراسر ایران', + to: '/admin/representation/active-representation-list', + icon: store, + }, + { + id: 9, + title: 'اخبار و مطالب', + subtitle: 'هر خبر یا مطلبی که مربوط به مادو موتور می باشد.', + to: '/blog-list', + icon: rss, + }, + { + id: 10, + title: 'درخواست های تماس من', + subtitle: 'درخواست های تماس کاربران از سرتاسر ایران', + to: '/admin/call-request-list', + icon: store, + }, +] diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b0c1fbc --- /dev/null +++ b/src/index.css @@ -0,0 +1,165 @@ +@font-face { + font-family: 'IranYekan'; + src: url('./assets/fonts/iranyekan/woff/iranyekanweblight.woff') format('woff'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'IranYekan'; + src: url('./assets/fonts/iranyekan/woff/iranyekanwebmedium.woff') format('woff'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'IranYekan'; + src: url('./assets/fonts/iranyekan/woff/iranyekanwebbold.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Montserrat'; + src: url('./assets/fonts/montserrat/Montserrat-Light.woff') format('woff'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Montserrat'; + src: url('./assets/fonts/montserrat/Montserrat-Regular.woff') format('woff'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Montserrat'; + src: url('./assets/fonts/montserrat/montserrat-bold.woff') format('woff'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +:root { + --color-primary: #2c86ed; + --color-primary-deep: #2073d2; + --color-navy: #2b43b2; + --color-text: #1a1a1a; + --color-muted: #9c9c9c; + --color-secondary: #f0f0f0; + --color-border: #e6e6e6; + --color-white: #ffffff; + --color-orange: #f5a623; + --color-page: #eef2f6; + --font-fa: 'IranYekan', Tahoma, sans-serif; + --font-en: 'Montserrat', sans-serif; + --radius: 8px; + --radius-lg: 14px; + --shadow: 0 8px 28px rgba(20, 36, 76, 0.08); + --header-h: 72px; + --max-width: 1180px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + direction: rtl; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-fa); + font-weight: 300; + color: var(--color-text); + background: #eef2f6; + -webkit-font-smoothing: antialiased; +} + +button, +input, +textarea, +select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +.app-shell { + min-height: 100vh; + height: 100%; + display: flex; + flex-direction: column; + background-image: url('./assets/images/auth-bg.jpg'); + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} + +/* Matches original .pl — 90% centered frame */ +.app-frame { + width: 90%; + margin: 1rem auto 0; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding-bottom: 1.5rem; +} + +.app-main { + margin-top: 1rem; + display: flex; + flex-direction: column; + flex: 0 1 auto; +} + +.panel { + background: var(--color-white); + border-radius: 4px; + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.12), 0 4px 5px 0 rgba(0, 0, 0, 0.08); + padding: 24px; + width: 100%; + height: auto; + box-sizing: border-box; +} + +.panel.products-page { + padding: 0; + overflow: hidden; +} + +.en { + font-family: var(--font-en); + font-weight: 300; + direction: ltr; + unicode-bidi: isolate; +} + +.muted { + color: var(--color-muted); +} diff --git a/src/layouts/AppLayout.css b/src/layouts/AppLayout.css new file mode 100644 index 0000000..76d0265 --- /dev/null +++ b/src/layouts/AppLayout.css @@ -0,0 +1,78 @@ +.app-header { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 18px 24px; + background: var(--color-white); + border-radius: 4px; + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.12), 0 4px 5px 0 rgba(0, 0, 0, 0.08); +} + +.app-header__nav { + display: flex; + align-items: center; + gap: 18px; + justify-content: flex-start; +} + +.app-header__nav a { + color: #666; + font-size: 14px; + font-weight: 300; +} + +.app-header__nav a.active { + color: var(--color-primary); + font-weight: 500; +} + +.app-header__icon-link { + display: grid; + place-items: center; + color: #777; +} + +.app-header__icon-link.active { + color: var(--color-primary); +} + +.app-header__logo { + display: grid; + place-items: center; +} + +.app-header__logo img { + height: 42px; + width: auto; +} + +.app-header__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.app-header__icon-btn { + width: 40px; + height: 40px; + border: 0; + background: transparent; + color: #9a9a9a; + cursor: pointer; + display: grid; + place-items: center; + border-radius: 999px; +} + +.app-header__icon-btn:hover { + color: var(--color-primary); + background: #f5f8fc; +} + +@media (max-width: 760px) { + .app-header__nav a:not(.app-header__icon-link) { + display: none; + } +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..d76a23e --- /dev/null +++ b/src/layouts/AppLayout.tsx @@ -0,0 +1,50 @@ +import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' +import Icon from '../components/Icon' +import logo from '../assets/images/logo.png' +import './AppLayout.css' + +export default function AppLayout() { + const { logout } = useAuth() + const navigate = useNavigate() + + return ( +
+
+
+ + + + مادو موتور + + +
+ + +
+
+ +
+ +
+
+
+ ) +} diff --git a/src/layouts/AuthLayout.css b/src/layouts/AuthLayout.css new file mode 100644 index 0000000..fcacb06 --- /dev/null +++ b/src/layouts/AuthLayout.css @@ -0,0 +1,164 @@ +.auth-page { + min-height: 100vh; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background-image: url('../assets/images/auth-bg.jpg'); + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} + +.auth-card { + position: relative; + width: 90%; + min-height: 80%; + margin: auto; + background: var(--color-white); + border-radius: 4px; + box-shadow: + 0 3px 14px 2px rgba(0, 0, 0, 0.12), + 0 8px 10px 1px rgba(0, 0, 0, 0.14); + display: flex; + flex-direction: column; + padding: 28px 32px 32px; + overflow: hidden; +} + +/* Center vertical divider — starts below logo, stops above bottom */ +.auth-card::before { + content: ''; + position: absolute; + top: 100px; + bottom: 32px; + left: 50%; + width: 1px; + background: #ececec; + transform: translateX(-50%); + pointer-events: none; + z-index: 0; +} + +.auth-card__logo { + position: relative; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + margin-bottom: 8px; + width: fit-content; + margin-inline: auto; + padding: 0 20px; +} + +.auth-card__logo img { + width: 153px; + height: auto; +} + +.auth-card__body { + position: relative; + z-index: 1; + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + min-height: 0; +} + +.auth-card__promo { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 40px; + text-align: center; + height: 100%; +} + +.auth-card__image { + width: min(100%, 350px); + margin-bottom: 28px; +} + +.auth-card__copy h3 { + margin: 0 0 10px; + font-size: 16px; + font-weight: 300; + color: #333; +} + +.auth-card__copy h3 span { + font-weight: 700; +} + +.auth-card__copy h2 { + margin: 0; + font-size: 18px; + font-weight: 300; + line-height: 1.8; + color: #333; +} + +.auth-card__copy strong { + color: var(--color-primary); + font-weight: 700; + font-size: 1.15em; +} + +.auth-card__form { + padding: 12px 48px 24px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.auth-card__form-inner { + width: 100%; + max-width: 420px; +} + +@media (max-width: 1199px) { + .auth-card { + min-height: 100%; + } +} + +@media (max-width: 900px) { + .auth-page { + height: auto; + min-height: 100vh; + padding: 16px; + } + + .auth-card { + width: 100%; + min-height: auto; + padding: 20px 16px; + } + + .auth-card::before { + display: none; + } + + .auth-card__body { + grid-template-columns: 1fr; + } + + .auth-card__form { + border-bottom: 1px solid #ececec; + padding: 20px 12px 28px; + } + + .auth-card__form-inner { + max-width: none; + } + + .auth-card__image { + width: 200px; + } +} diff --git a/src/layouts/AuthLayout.tsx b/src/layouts/AuthLayout.tsx new file mode 100644 index 0000000..12b229f --- /dev/null +++ b/src/layouts/AuthLayout.tsx @@ -0,0 +1,42 @@ +import { Outlet } from 'react-router-dom' +import loginPromo from '../assets/images/login-promo.png' +import logo from '../assets/images/logo.png' +import './AuthLayout.css' + +const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174' + +export default function AuthLayout() { + return ( +
+
+
+ + مادو موتور + +
+ +
+ {/* Form first so RTL places it on the right */} +
+
+ +
+
+
+ +
+

+ چقدر برای سلامتی خود ارزش قائل اید؟ +

+

+ یک عدد کــــــــــلاه کـــــــــاسکت +
+ هدیه ی ما برای ســــلامتی شما. +

+
+
+
+
+
+ ) +} diff --git a/src/lib/adminApi.ts b/src/lib/adminApi.ts new file mode 100644 index 0000000..011e657 --- /dev/null +++ b/src/lib/adminApi.ts @@ -0,0 +1,223 @@ +import { apiRequest, apiUpload } from './api' + +export type Product = { + id: string + nameFa: string + nameEn: string + price: number + stock: number + category: string + imageUrl: string | null + imageKey: string | null + createdAt: string + updatedAt: string +} + +export type Customer = { + id: string + name: string + firstName: string + lastName: string + cellNumber: string + city: string + disabled: boolean + ordersCount: number + createdAt: string + updatedAt: string +} + +export type Order = { + id: number + customerName: string + customerPhone: string + itemCount: number + status: string + createdAt: string + items?: Array<{ + id: string + nameFa: string + quantity: number + unitPrice: number + }> +} + +export type BlogPost = { + id: string + title: string + summary: string + body: string + author: string + imageUrl: string | null + imageKey: string | null + published: boolean + createdAt: string + updatedAt: string +} + +export type Representation = { + id: string + name: string + owner: string + mobile: string + city: string + address: string + status: 'pending' | 'active' | 'rejected' + createdAt: string + updatedAt: string +} + +export type Review = { + id: string + author: string + product: string + rating: number + body: string + status: 'pending' | 'approved' | 'rejected' + createdAt: string +} + +export type CallRequest = { + id: string + name: string + mobile: string + note: string + handled: boolean + createdAt: string +} + +export type MediaUploadResult = { + url: string + storageKey: string + storageDisk: string +} + +export function formatFaDate(iso: string): string { + try { + return new Intl.DateTimeFormat('fa-IR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(iso)) + } catch { + return iso + } +} + +// Products +export const listProducts = () => apiRequest('/products') +export const getProduct = (id: string) => apiRequest(`/products/${id}`) +export const createProduct = (body: Partial & { nameFa: string; price: number }) => + apiRequest('/products', { method: 'POST', body }) +export const updateProduct = (id: string, body: Partial) => + apiRequest(`/products/${id}`, { method: 'PATCH', body }) +export const deleteProduct = (id: string) => + apiRequest<{ ok: true }>(`/products/${id}`, { method: 'DELETE' }) + +// Customers +export const listCustomers = () => apiRequest('/customers') +export const getCustomer = (id: string) => apiRequest(`/customers/${id}`) +export const createCustomer = (body: { + firstName: string + lastName: string + cellNumber: string + city?: string + password: string +}) => apiRequest('/customers', { method: 'POST', body }) +export const updateCustomer = ( + id: string, + body: { name?: string; city?: string; disabled?: boolean }, +) => apiRequest(`/customers/${id}`, { method: 'PATCH', body }) +export const deleteCustomer = (id: string) => + apiRequest<{ ok: true }>(`/customers/${id}`, { method: 'DELETE' }) + +// Orders +export const listOrders = () => apiRequest('/orders') +export const getOrder = (id: number | string) => apiRequest(`/orders/${id}`) +export const updateOrderStatus = (id: number | string, status: string) => + apiRequest(`/orders/${id}/status`, { method: 'PATCH', body: { status } }) + +// Blogs +export const listBlogs = () => apiRequest('/blogs') +export const getBlog = (id: string) => apiRequest(`/blogs/${id}`) +export const createBlog = (body: { + title: string + summary?: string + body?: string + author?: string + imageUrl?: string + imageKey?: string + published?: boolean +}) => apiRequest('/blogs', { method: 'POST', body }) +export const updateBlog = ( + id: string, + body: Partial<{ + title: string + summary: string + body: string + author: string + imageUrl: string | null + imageKey: string | null + published: boolean + }>, +) => apiRequest(`/blogs/${id}`, { method: 'PATCH', body }) +export const deleteBlog = (id: string) => + apiRequest<{ ok: true }>(`/blogs/${id}`, { method: 'DELETE' }) + +// Representations +export const listRepresentations = (status?: 'pending' | 'active' | 'rejected') => + apiRequest( + status ? `/representations?status=${status}` : '/representations', + ) +export const createRepresentation = ( + body: { + name: string + owner?: string + mobile: string + city: string + address?: string + }, + asActive = false, +) => + apiRequest(asActive ? '/representations/active' : '/representations', { + method: 'POST', + body, + }) +export const updateRepresentation = ( + id: string, + body: Partial<{ name: string; owner: string; mobile: string; city: string; address: string }>, +) => apiRequest(`/representations/${id}`, { method: 'PATCH', body }) +export const approveRepresentation = (id: string) => + apiRequest(`/representations/${id}/approve`, { method: 'POST' }) +export const rejectRepresentation = (id: string) => + apiRequest(`/representations/${id}/reject`, { method: 'POST' }) +export const deleteRepresentation = (id: string) => + apiRequest<{ ok: true }>(`/representations/${id}`, { method: 'DELETE' }) + +// Reviews +export const listReviews = () => apiRequest('/reviews') +export const approveReview = (id: string) => + apiRequest(`/reviews/${id}/approve`, { method: 'POST' }) +export const rejectReview = (id: string) => + apiRequest(`/reviews/${id}/reject`, { method: 'POST' }) +export const deleteReview = (id: string) => + apiRequest<{ ok: true }>(`/reviews/${id}`, { method: 'DELETE' }) + +// Call requests +export const listCallRequests = () => apiRequest('/call-requests') +export const createCallRequest = (body: { name: string; mobile: string; note?: string }) => + apiRequest('/call-requests', { method: 'POST', body }) +export const markCallHandled = (id: string) => + apiRequest(`/call-requests/${id}/handled`, { method: 'PATCH' }) +export const deleteCallRequest = (id: string) => + apiRequest<{ ok: true }>(`/call-requests/${id}`, { method: 'DELETE' }) + +// Media +export async function uploadMedia( + kind: 'main' | 'gallery' | 'blog' | 'temp', + file: File, +): Promise { + const formData = new FormData() + formData.append('file', file) + return apiUpload(`/media/upload?kind=${kind}`, formData) +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..8590c83 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,155 @@ +import { getAccessToken, getRefreshToken, setSession, clearSession } from './auth' + +const API_BASE_URL = + import.meta.env.VITE_API_URL?.replace(/\/$/, '') ?? 'http://api.madomotor.com:3001' + +const API_PREFIX = `${API_BASE_URL}/api/v1` + +export class ApiError extends Error { + status: number + + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +type RequestOptions = { + method?: string + body?: unknown + auth?: boolean + skipRefresh?: boolean +} + +let refreshInFlight: Promise | null = null + +function parseErrorMessage(payload: unknown, fallback: string) { + if (!payload || typeof payload !== 'object') return fallback + const message = (payload as { message?: unknown }).message + if (typeof message === 'string' && message.trim()) return message + if (Array.isArray(message)) { + const parts = message.filter((item) => typeof item === 'string') + if (parts.length > 0) return parts.join('، ') + } + return fallback +} + +async function tryRefreshSession(): Promise { + if (refreshInFlight) return refreshInFlight + + refreshInFlight = (async () => { + const refreshToken = getRefreshToken() + if (!refreshToken) { + clearSession() + return false + } + + try { + const response = await fetch(`${API_PREFIX}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }) + + const payload = await response.json().catch(() => null) + if (!response.ok) { + clearSession() + return false + } + + setSession({ + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + user: payload.user, + }) + return true + } catch { + clearSession() + return false + } finally { + refreshInFlight = null + } + })() + + return refreshInFlight +} + +export async function apiRequest( + path: string, + options: RequestOptions = {}, +): Promise { + const { method = 'GET', body, auth = true, skipRefresh = false } = options + + const headers: Record = {} + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + } + if (auth) { + const token = getAccessToken() + if (token) headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(`${API_PREFIX}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + if (response.status === 401 && auth && !skipRefresh) { + const refreshed = await tryRefreshSession() + if (refreshed) { + return apiRequest(path, { ...options, skipRefresh: true }) + } + } + + if (response.status === 204) { + return undefined as T + } + + const payload = await response.json().catch(() => null) + + if (!response.ok) { + throw new ApiError( + response.status, + parseErrorMessage(payload, 'خطایی رخ داد'), + ) + } + + return payload as T +} + +export async function apiUpload( + path: string, + formData: FormData, + options: { auth?: boolean; skipRefresh?: boolean } = {}, +): Promise { + const { auth = true, skipRefresh = false } = options + const headers: Record = {} + if (auth) { + const token = getAccessToken() + if (token) headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(`${API_PREFIX}${path}`, { + method: 'POST', + headers, + body: formData, + }) + + if (response.status === 401 && auth && !skipRefresh) { + const refreshed = await tryRefreshSession() + if (refreshed) { + return apiUpload(path, formData, { ...options, skipRefresh: true }) + } + } + + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new ApiError( + response.status, + parseErrorMessage(payload, 'خطایی رخ داد'), + ) + } + return payload as T +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..918634c --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,65 @@ +const AUTH_KEY = 'mado-dashboard-auth' + +export type UserRole = 'Admin' | 'User' | 'Agent' | 'superAdmin' + +export type AuthUser = { + id: string + firstName: string + lastName: string + cellNumber: string + role: UserRole + name: string +} + +export type AuthSession = { + accessToken: string + refreshToken: string + user: AuthUser +} + +function isSession(value: unknown): value is AuthSession { + if (!value || typeof value !== 'object') return false + const session = value as Partial + return ( + typeof session.accessToken === 'string' && + typeof session.refreshToken === 'string' && + !!session.user && + typeof session.user === 'object' && + typeof session.user.id === 'string' + ) +} + +export function getSession(): AuthSession | null { + try { + const raw = localStorage.getItem(AUTH_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as unknown + return isSession(parsed) ? parsed : null + } catch { + return null + } +} + +export function setSession(session: AuthSession) { + localStorage.setItem(AUTH_KEY, JSON.stringify(session)) +} + +export function clearSession() { + localStorage.removeItem(AUTH_KEY) +} + +export function getAccessToken(): string | null { + return getSession()?.accessToken ?? null +} + +export function getRefreshToken(): string | null { + return getSession()?.refreshToken ?? null +} + +export function getAuthUser(): AuthUser | null { + return getSession()?.user ?? null +} + +export function isElevatedRole(role: UserRole | undefined | null) { + return role === 'Admin' || role === 'superAdmin' +} diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts new file mode 100644 index 0000000..b096f90 --- /dev/null +++ b/src/lib/authApi.ts @@ -0,0 +1,125 @@ +import { apiRequest } from './api' +import { clearSession, getRefreshToken, setSession, type AuthUser } from './auth' + +export type AuthTokensResponse = { + accessToken: string + refreshToken: string + user: AuthUser +} + +export type OtpSendCodeResponse = { + ok: true + expiresInSeconds: number + resendAfterSeconds: number +} + +function setAuthSession(data: AuthTokensResponse) { + setSession({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + user: data.user, + }) + return data.user +} + +export async function login(cellNumber: string, password: string) { + const data = await apiRequest('/auth/login', { + method: 'POST', + auth: false, + body: { cellNumber, password }, + }) + return setAuthSession(data) +} + +export async function loginSendCode(cellNumber: string) { + return apiRequest('/auth/login/send-code', { + method: 'POST', + auth: false, + body: { cellNumber }, + }) +} + +export async function loginVerify(cellNumber: string, code: string) { + const data = await apiRequest('/auth/login/verify', { + method: 'POST', + auth: false, + body: { cellNumber, code }, + }) + return setAuthSession(data) +} + +export async function forgotPasswordSendCode(cellNumber: string) { + return apiRequest('/auth/forgot-password/send-code', { + method: 'POST', + auth: false, + body: { cellNumber }, + }) +} + +export async function forgotPasswordVerify(cellNumber: string, code: string) { + return apiRequest<{ ok: true }>('/auth/forgot-password/verify', { + method: 'POST', + auth: false, + body: { cellNumber, code }, + }) +} + +export async function forgotPasswordReset(input: { + cellNumber: string + code: string + newPassword: string +}) { + const data = await apiRequest('/auth/forgot-password/reset', { + method: 'POST', + auth: false, + body: input, + }) + return setAuthSession(data) +} + +export async function registerSendCode(input: { + firstName: string + lastName: string + cellNumber: string + password: string +}) { + return apiRequest('/auth/register/send-code', { + method: 'POST', + auth: false, + body: input, + }) +} + +export async function registerVerify(cellNumber: string, code: string) { + const data = await apiRequest('/auth/register/verify', { + method: 'POST', + auth: false, + body: { cellNumber, code }, + }) + return setAuthSession(data) +} + +export async function logout() { + const refreshToken = getRefreshToken() + try { + if (refreshToken) { + await apiRequest('/auth/logout', { + method: 'POST', + auth: false, + skipRefresh: true, + body: { refreshToken }, + }) + } + } catch { + // Always clear local session + } finally { + clearSession() + } +} + +export async function uploadMedia(kind: 'main' | 'gallery' | 'blog' | 'temp', file: File) { + const formData = new FormData() + formData.append('file', file) + const { apiUpload } = await import('./api') + return apiUpload<{ url: string; storageKey: string }>('/media/upload?kind=' + kind, formData) +} diff --git a/src/lib/faDigits.ts b/src/lib/faDigits.ts new file mode 100644 index 0000000..18f7ce5 --- /dev/null +++ b/src/lib/faDigits.ts @@ -0,0 +1,9 @@ +const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const + +export function toFaDigits(value: string | number): string { + return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)]) +} + +export function toEnDigits(value: string): string { + return value.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))) +} diff --git a/src/lib/phone.ts b/src/lib/phone.ts new file mode 100644 index 0000000..55224a3 --- /dev/null +++ b/src/lib/phone.ts @@ -0,0 +1,11 @@ +export function normalizeCellNumber(raw: string): string { + let digits = raw.replace(/\D/g, '') + if (digits.startsWith('0098')) digits = digits.slice(4) + else if (digits.startsWith('98')) digits = digits.slice(2) + if (digits.startsWith('9') && digits.length === 10) digits = `0${digits}` + return digits +} + +export function isValidCellNumber(raw: string): boolean { + return /^09\d{9}$/.test(normalizeCellNumber(raw)) +} diff --git a/src/lib/usePagination.ts b/src/lib/usePagination.ts new file mode 100644 index 0000000..0a9b203 --- /dev/null +++ b/src/lib/usePagination.ts @@ -0,0 +1,27 @@ +import { useEffect, useMemo, useState } from 'react' + +export const LIST_PAGE_SIZE = 10 + +export function usePagination(items: T[], pageSize = LIST_PAGE_SIZE) { + const [page, setPage] = useState(1) + const pageCount = Math.max(1, Math.ceil(items.length / pageSize) || 1) + + useEffect(() => { + setPage((current) => Math.min(current, pageCount)) + }, [pageCount]) + + const pageItems = useMemo(() => { + const start = (page - 1) * pageSize + return items.slice(start, start + pageSize) + }, [items, page, pageSize]) + + return { + page, + setPage, + pageCount, + pageItems, + pageSize, + total: items.length, + offset: (page - 1) * pageSize, + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8e0deb1 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/src/pages/ActiveRepresentationsPage.tsx b/src/pages/ActiveRepresentationsPage.tsx new file mode 100644 index 0000000..efe5fd4 --- /dev/null +++ b/src/pages/ActiveRepresentationsPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { + deleteRepresentation, + listRepresentations, + type Representation, +} from '../lib/adminApi' +import storeIcon from '../assets/images/tiles/store.png' +import { usePagination } from '../lib/usePagination' +import './ListPages.css' + +export default function ActiveRepresentationsPage() { + const navigate = useNavigate() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setItems(await listRepresentations('active')) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری نمایندگی‌ها ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onDelete(id: string) { + if (!window.confirm('این نمایندگی حذف شود؟')) return + try { + await deleteRepresentation(id) + setItems((prev) => prev.filter((item) => item.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems, offset } = usePagination(items) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {items.length === 0 ?

نمایندگی فعالی نیست

: null} + {pageItems.map((item, index) => ( +
+
{offset + index + 1}
+ +
+ {item.name} +
{item.owner || item.mobile}
+
+ +
+
+ {item.city} + {item.address} +
+
+ +
+ + +
+
+ ))} +
+ + + ) : null} + + navigate('/admin/representation/active/new')} + /> +
+ ) +} diff --git a/src/pages/BlogFormPage.tsx b/src/pages/BlogFormPage.tsx new file mode 100644 index 0000000..7cc37dc --- /dev/null +++ b/src/pages/BlogFormPage.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import { useAuth } from '../auth/AuthContext' +import { ApiError } from '../lib/api' +import { createBlog, getBlog, updateBlog, uploadMedia } from '../lib/adminApi' +import './ListPages.css' + +export default function BlogFormPage() { + const navigate = useNavigate() + const { id } = useParams() + const { user } = useAuth() + const isEdit = Boolean(id) + + const [form, setForm] = useState({ + title: '', + summary: '', + content: '', + author: user?.name ?? '', + }) + const [imageUrl, setImageUrl] = useState(null) + const [imageKey, setImageKey] = useState(null) + const [loading, setLoading] = useState(isEdit) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!id) return + void (async () => { + try { + const post = await getBlog(id) + setForm({ + title: post.title, + summary: post.summary, + content: post.body, + author: post.author, + }) + setImageUrl(post.imageUrl) + setImageKey(post.imageKey) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری مطلب ناموفق بود') + } finally { + setLoading(false) + } + })() + }, [id]) + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onImageChange(file: File | null) { + if (!file) return + try { + const uploaded = await uploadMedia('blog', file) + setImageUrl(uploaded.url) + setImageKey(uploaded.storageKey) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'آپلود تصویر ناموفق بود') + } + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + try { + const payload = { + title: form.title.trim(), + summary: form.summary.trim(), + body: form.content.trim(), + author: form.author.trim() || user?.name || '', + imageUrl: imageUrl ?? undefined, + imageKey: imageKey ?? undefined, + } + if (isEdit && id) { + await updateBlog(id, payload) + } else { + await createBlog(payload) + } + navigate('/blog-list') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود') + } finally { + setSaving(false) + } + } + + return ( +
+ navigate('/blog-list')} + /> + {loading ?

در حال بارگذاری...

: null} + {!loading ? ( +
void onSubmit(e)}> +
+ update('title', v)} /> +
+
+ update('summary', v)} /> +
+
+ update('content', v)} /> +
+
+ update('author', v)} /> +
+
+ + void onImageChange(e.target.files?.[0] ?? null)} + /> + {imageUrl ? ( + + ) : null} +
+ {error ?

{error}

: null} +
+ + ذخیره + + navigate('/blog-list')}> + انصراف + +
+
+ ) : null} +
+ ) +} diff --git a/src/pages/BlogListPage.css b/src/pages/BlogListPage.css new file mode 100644 index 0000000..8eaa430 --- /dev/null +++ b/src/pages/BlogListPage.css @@ -0,0 +1,100 @@ +.blog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.blog-card { + display: flex; + flex-direction: row; + min-height: 180px; + border: 1px solid #e6e6e6; + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.blog-card__media { + flex: 0 0 42%; + max-width: 42%; + min-height: 100%; + background: #f3f3f3; +} + +.blog-card__media img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.blog-card__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 20px; + min-width: 0; +} + +.blog-card__title { + margin: 0; + font-size: 18px; + font-weight: 700; + color: #222; + line-height: 1.4; +} + +.blog-card__summary { + margin: 0; + font-size: 13px; + line-height: 1.8; + color: #777; +} + +.blog-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 12px; +} + +.blog-card__author { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: #efefef; + color: #666; + font-size: 12px; + white-space: nowrap; +} + +.blog-card__actions { + display: flex; + gap: 8px; +} + +a.icon-action { + text-decoration: none; +} + +@media (max-width: 900px) { + .blog-grid { + grid-template-columns: 1fr; + } + + .blog-card--media { + flex-direction: column; + } + + .blog-card__media { + flex: none; + max-width: none; + width: 100%; + height: 180px; + } +} diff --git a/src/pages/BlogListPage.tsx b/src/pages/BlogListPage.tsx new file mode 100644 index 0000000..8e06dc3 --- /dev/null +++ b/src/pages/BlogListPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { deleteBlog, listBlogs, type BlogPost } from '../lib/adminApi' +import { usePagination } from '../lib/usePagination' +import rssIcon from '../assets/images/tiles/rss.png' +import './ListPages.css' +import './BlogListPage.css' + +export default function BlogListPage() { + const navigate = useNavigate() + const [posts, setPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setPosts(await listBlogs()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری مطالب ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onDelete(id: string) { + if (!window.confirm('این مطلب حذف شود؟')) return + try { + await deleteBlog(id) + setPosts((prev) => prev.filter((p) => p.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems } = usePagination(posts) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {posts.length === 0 ?

مطلبی ثبت نشده است

: null} + {pageItems.map((post) => ( +
+ {post.imageUrl ? ( +
+ +
+ ) : null} + +
+

{post.title}

+

{post.summary}

+ +
+
+ + {post.author || '—'} +
+ +
+ + + + +
+
+
+
+ ))} +
+ + + ) : null} + + navigate('/add-blog')} /> +
+ ) +} diff --git a/src/pages/CallRequestFormPage.tsx b/src/pages/CallRequestFormPage.tsx new file mode 100644 index 0000000..213c8ed --- /dev/null +++ b/src/pages/CallRequestFormPage.tsx @@ -0,0 +1,63 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import { ApiError } from '../lib/api' +import { createCallRequest } from '../lib/adminApi' +import './ListPages.css' + +export default function CallRequestFormPage() { + const navigate = useNavigate() + const [form, setForm] = useState({ name: '', mobile: '', note: '' }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + try { + await createCallRequest({ + name: form.name.trim(), + mobile: form.mobile.trim(), + note: form.note.trim(), + }) + navigate('/admin/call-request-list') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود') + } finally { + setSaving(false) + } + } + + return ( +
+ navigate('/admin/call-request-list')} + /> +
void onSubmit(e)}> + update('name', v)} /> + update('mobile', v)} dir="ltr" /> +
+ update('note', v)} /> +
+ {error ?

{error}

: null} +
+ + ذخیره + + navigate('/admin/call-request-list')}> + انصراف + +
+ +
+ ) +} diff --git a/src/pages/CallRequestsPage.tsx b/src/pages/CallRequestsPage.tsx new file mode 100644 index 0000000..e0797fe --- /dev/null +++ b/src/pages/CallRequestsPage.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { + deleteCallRequest, + formatFaDate, + listCallRequests, + markCallHandled, + type CallRequest, +} from '../lib/adminApi' +import storeIcon from '../assets/images/tiles/store.png' +import { usePagination } from '../lib/usePagination' +import './ListPages.css' + +export default function CallRequestsPage() { + const navigate = useNavigate() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setItems(await listCallRequests()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری درخواست‌ها ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onHandled(id: string) { + try { + await markCallHandled(id) + setItems((prev) => + prev.map((item) => (item.id === id ? { ...item, handled: true } : item)), + ) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'عملیات ناموفق بود') + } + } + + async function onDelete(id: string) { + if (!window.confirm('این درخواست حذف شود؟')) return + try { + await deleteCallRequest(id) + setItems((prev) => prev.filter((item) => item.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems, offset } = usePagination(items) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {items.length === 0 ?

درخواستی ثبت نشده است

: null} + {pageItems.map((item, index) => ( +
+
{offset + index + 1}
+ +
+ {item.name} +
{item.mobile}
+
+ +
+
+ requestID: {item.id.slice(-6)} +
+
{formatFaDate(item.createdAt)}
+
+ +
+ + + +
+
+ ))} +
+ + + ) : null} + + navigate('/admin/call-request/new')} + /> +
+ ) +} diff --git a/src/pages/CustomerFormPage.tsx b/src/pages/CustomerFormPage.tsx new file mode 100644 index 0000000..4154d9f --- /dev/null +++ b/src/pages/CustomerFormPage.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import { ApiError } from '../lib/api' +import { createCustomer, getCustomer, updateCustomer } from '../lib/adminApi' +import './ListPages.css' + +export default function CustomerFormPage() { + const navigate = useNavigate() + const { id } = useParams() + const isEdit = Boolean(id) + + const [form, setForm] = useState({ + firstName: '', + lastName: '', + cellNumber: '', + city: '', + password: '', + }) + const [loading, setLoading] = useState(isEdit) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!id) return + void (async () => { + try { + const customer = await getCustomer(id) + setForm({ + firstName: customer.firstName, + lastName: customer.lastName, + cellNumber: customer.cellNumber, + city: customer.city, + password: '', + }) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری مشتری ناموفق بود') + } finally { + setLoading(false) + } + })() + }, [id]) + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + try { + if (isEdit && id) { + await updateCustomer(id, { + name: `${form.firstName} ${form.lastName}`.trim(), + city: form.city.trim(), + }) + } else { + await createCustomer({ + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + cellNumber: form.cellNumber.trim(), + city: form.city.trim(), + password: form.password || '1234', + }) + } + navigate('/admin/my-customers') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود') + } finally { + setSaving(false) + } + } + + return ( +
+ navigate('/admin/my-customers')} + /> + {loading ?

در حال بارگذاری...

: null} + {!loading ? ( +
void onSubmit(e)}> + update('firstName', v)} /> + update('lastName', v)} /> + update('cellNumber', v)} + dir="ltr" + /> + update('city', v)} /> + {!isEdit ? ( + update('password', v)} + type="password" + dir="ltr" + /> + ) : null} + {error ?

{error}

: null} +
+ + ذخیره + + navigate('/admin/my-customers')}> + انصراف + +
+ + ) : null} +
+ ) +} diff --git a/src/pages/CustomersPage.tsx b/src/pages/CustomersPage.tsx new file mode 100644 index 0000000..393888a --- /dev/null +++ b/src/pages/CustomersPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { deleteCustomer, listCustomers, type Customer } from '../lib/adminApi' +import { usePagination } from '../lib/usePagination' +import userIcon from '../assets/images/tiles/user.svg' +import './ListPages.css' + +export default function CustomersPage() { + const navigate = useNavigate() + const [customers, setCustomers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setCustomers(await listCustomers()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری مشتریان ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onDelete(id: string) { + if (!window.confirm('این مشتری حذف شود؟')) return + try { + await deleteCustomer(id) + setCustomers((prev) => prev.filter((c) => c.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems } = usePagination(customers) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {customers.length === 0 ?

مشتری ثبت نشده است

: null} + {pageItems.map((customer) => ( +
+
+ {customer.name} +
{customer.cellNumber}
+
+ +
{customer.city || '—'}
+ +
+
+ {customer.ordersCount} + + مجموع +
+ سفارشات +
+
+
+ +
+ + + +
+
+ ))} +
+ + + ) : null} + + navigate('/admin/my-customers/new')} /> +
+ ) +} diff --git a/src/pages/DashboardPage.css b/src/pages/DashboardPage.css new file mode 100644 index 0000000..4b3cc8e --- /dev/null +++ b/src/pages/DashboardPage.css @@ -0,0 +1,26 @@ +.dashboard { + padding: 8px 8px 16px; + height: 100%; + align-items: flex-start; + align-content: flex-start; +} + +.dashboard__grid { + width: 100%; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 4px; + align-content: start; +} + +@media (max-width: 1100px) { + .dashboard__grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .dashboard__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..7e2dcb5 --- /dev/null +++ b/src/pages/DashboardPage.tsx @@ -0,0 +1,15 @@ +import DashboardTileCard from '../components/DashboardTile' +import { adminTiles } from '../data/tiles' +import './DashboardPage.css' + +export default function DashboardPage() { + return ( +
+
+ {adminTiles.map((tile) => ( + + ))} +
+
+ ) +} diff --git a/src/pages/ForgotPasswordPage.tsx b/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..394966a --- /dev/null +++ b/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,128 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import Icon from '../components/Icon' +import userIcon from '../assets/images/user-icon.svg' +import { ApiError } from '../lib/api' +import { + forgotPasswordReset, + forgotPasswordSendCode, + forgotPasswordVerify, +} from '../lib/authApi' +import { isValidCellNumber, normalizeCellNumber } from '../lib/phone' +import './LoginPage.css' + +const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174' + +type Step = 'mobile' | 'code' | 'password' + +export default function ForgotPasswordPage() { + const { reloadUser } = useAuth() + const navigate = useNavigate() + const [step, setStep] = useState('mobile') + const [mobile, setMobile] = useState('') + const [code, setCode] = useState('') + const [password, setPassword] = useState('') + const [passwordConfirmation, setPasswordConfirmation] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const cellNumber = normalizeCellNumber(mobile) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + if (step === 'mobile') { + if (!isValidCellNumber(cellNumber)) { + throw new Error('شماره همراه معتبر نیست') + } + await forgotPasswordSendCode(cellNumber) + setStep('code') + return + } + + if (step === 'code') { + await forgotPasswordVerify(cellNumber, code.trim()) + setStep('password') + return + } + + if (password !== passwordConfirmation) { + throw new Error('رمز عبور و تکرار آن یکسان نیستند') + } + await forgotPasswordReset({ + cellNumber, + code: code.trim(), + newPassword: password, + }) + reloadUser() + navigate('/', { replace: true }) + } catch (err) { + setError(err instanceof ApiError || err instanceof Error ? err.message : 'خطا در بازیابی رمز') + } finally { + setLoading(false) + } + } + + return ( +
+
+ + + + +
+

بازیابی رمز عبور

+

+ {step === 'mobile' && 'شماره همراه خود را وارد کنید'} + {step === 'code' && `کد ارسال‌شده به ${cellNumber} را وارد کنید`} + {step === 'password' && 'رمز عبور جدید را انتخاب کنید'} +

+
+
+
+ {step === 'mobile' ? ( + + ) : null} + {step === 'code' ? ( + + ) : null} + {step === 'password' ? ( + <> + + + + ) : null} + + {error ?

{error}

: null} + +
+ + بازگشت + + + {step === 'mobile' && 'ارسال کد تایید'} + {step === 'code' && 'ثبت'} + {step === 'password' && 'تغییر رمز'} + +
+ +
+ ) +} diff --git a/src/pages/ListPages.css b/src/pages/ListPages.css new file mode 100644 index 0000000..69058f1 --- /dev/null +++ b/src/pages/ListPages.css @@ -0,0 +1,441 @@ +.list-page { + padding-top: 24px; + padding-bottom: 88px; +} + +.list { + display: flex; + flex-direction: column; +} + +.list-row { + display: grid; + align-items: center; + gap: 16px; + padding: 10px 4px; + border-bottom: 1px solid #efefef; +} + +.list-row:last-child { + border-bottom: 0; +} + +.order-row { + grid-template-columns: 1.4fr 1.2fr 0.7fr auto; +} + +.order-card { + display: grid; + grid-template-columns: 1.3fr 1.2fr auto auto; + align-items: center; + gap: 16px; + padding: 8px 14px; + border: 1px solid #e6e6e6; + border-right: 3px solid var(--color-primary); + border-radius: 10px; + background: #fff; +} + +.order-card__id, +.order-card__customer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.order-card__meta { + font-size: 14px; + color: #555; +} + +.order-card__meta span { + font-weight: 500; + color: #333; +} + +.order-card__date, +.order-card__mobile { + font-size: 12px; + color: #888; +} + +.order-card__customer strong { + font-weight: 500; +} + +.order-card__items { + display: flex; + justify-content: flex-start; +} + +.order-row__id { + display: flex; + align-items: center; + gap: 14px; +} + +.order-row__id-num { + font-size: 34px; + font-weight: 700; + color: var(--color-primary); + line-height: 1; + min-width: 48px; +} + +.order-row__meta { + font-size: 13px; + color: #555; +} + +.order-row__date { + margin-top: 4px; + font-size: 12px; + color: var(--color-muted); +} + +.order-row__customer { + display: flex; + flex-direction: column; + gap: 4px; +} + +.order-row__customer strong { + font-weight: 500; +} + +.order-row__customer span { + font-size: 12px; + color: var(--color-muted); +} + +.order-row__count { + text-align: center; + color: var(--color-primary); +} + +.order-row__count strong { + display: block; + font-size: 30px; + line-height: 1; + font-weight: 700; +} + +.order-row__count span { + font-size: 12px; +} + +.order-row__actions { + display: flex; + gap: 8px; +} + +.icon-action { + width: 36px; + height: 36px; + border: 0; + border-radius: 6px; + display: grid; + place-items: center; + cursor: pointer; +} + +.icon-action svg { + stroke-width: 2.2; +} + +.icon-action:disabled { + opacity: 0.4; + cursor: default; +} + +.icon-action--red { + background: #fce4ec; + color: #e91e63; +} + +.icon-action--red:hover { + background: #f8bbd0; +} + +.icon-action--blue { + background: #e3f2fd; + color: #1565c0; +} + +.icon-action--blue:hover { + background: #bbdefb; +} + +.icon-action--orange { + background: #fff3e0; + color: #ef6c00; +} + +.icon-action--orange:hover { + background: #ffe0b2; +} + +.icon-action--teal { + background: #e0f7fa; + color: #00838f; +} + +.icon-action--teal:hover { + background: #b2ebf2; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: end; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #f0f0f0; +} + +.filters__label { + font-size: 13px; + color: #666; + margin-left: 8px; +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 18px; +} + +.product-row, +.simple-row { + grid-template-columns: 1.5fr 1fr 1fr auto; +} + +.customer-list { + gap: 6px; +} + +.customer-card { + display: grid; + grid-template-columns: 1.6fr 1fr 1fr auto; + align-items: center; + gap: 16px; + padding: 8px 14px; + border: 1px solid #e6e6e6; + border-radius: 10px; + background: #fff; +} + +.customer-card__info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.customer-card__info strong { + font-weight: 500; +} + +.customer-card__mobile { + font-size: 13px; + color: #888; + text-align: start; +} + +.customer-card__city { + font-size: 14px; + color: #666; +} + +.customer-card__orders { + display: flex; + justify-content: center; + justify-self: stretch; +} + +.order-chip { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: max-content; + padding: 6px 16px; + border-radius: 24px; + background: #e3f2fd; + color: #1976d2; + line-height: 1.15; + direction: rtl; +} + +.order-chip__count { + font-size: 36px; + font-weight: 400; + line-height: 1; +} + +.order-chip__label { + font-size: 13px; + line-height: 1.2; + text-align: start; +} + +.request-card { + display: grid; + grid-template-columns: auto 1fr 1.2fr 1.6fr auto; + align-items: center; + gap: 16px; + padding: 8px 14px; + border: 1px solid #e6e6e6; + border-radius: 10px; + background: #fff; +} + +.request-card--active { + grid-template-columns: auto 1.2fr 1.8fr auto; +} + +.request-card--call { + grid-template-columns: auto 1.2fr 1.4fr auto; +} + +.request-card__index { + font-size: 34px; + font-weight: 700; + color: var(--color-primary); + line-height: 1; + min-width: 28px; +} + +.request-card__id, +.request-card__person { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.request-card__id-label { + font-size: 13px; + color: #888; +} + +.request-card__id-label span { + font-weight: 400; +} + +.request-card__date, +.request-card__mobile { + font-size: 12px; + color: #888; +} + +.request-card__person strong { + font-weight: 500; +} + +.request-card__place { + display: flex; + justify-content: flex-start; + min-width: 0; +} + +.place-chip { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + width: max-content; + padding: 8px 14px; + border-radius: 8px; + background: #e3f2fd; + color: #1976d2; +} + +.place-chip strong { + font-weight: 700; + white-space: nowrap; +} + +.place-chip span { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 280px; +} + +.product-row strong, +.simple-row strong { + font-weight: 500; +} + +.product-row .en, +.simple-row .muted { + font-size: 12px; + color: var(--color-muted); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + background: #eef5ff; + color: var(--color-primary); +} + +.badge--pending { + background: #fff4e5; + color: #c47a00; +} + +.empty { + padding: 40px 0; + text-align: center; + color: var(--color-muted); +} + +.login__error { + color: #d32f2f; + font-size: 13px; + margin: 0; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.form-grid .full { + grid-column: 1 / -1; +} + +@media (max-width: 900px) { + .order-row, + .product-row, + .simple-row, + .customer-card, + .request-card, + .order-card { + grid-template-columns: 1fr; + gap: 10px; + } + + .order-row__actions, + .list-row .order-row__actions { + justify-content: flex-start; + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/LoginPage.css b/src/pages/LoginPage.css new file mode 100644 index 0000000..7b30de1 --- /dev/null +++ b/src/pages/LoginPage.css @@ -0,0 +1,88 @@ +.login__heading { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 28px; +} + +.login__user-icon { + width: 36px; + height: auto; + flex-shrink: 0; +} + +.login__heading-text { + min-width: 0; +} + +.login__heading h1 { + margin: 0; + font-size: 18px; + font-weight: 500; +} + +.login__heading p { + margin: 4px 0 0; + font-size: 12px; + color: var(--color-muted); +} + +.login__back { + color: #c5c5c5; + display: grid; + place-items: center; + width: 28px; + height: 36px; + flex-shrink: 0; +} + +.login__back:hover { + color: var(--color-primary); +} + +.login__form { + display: grid; + gap: 18px; +} + +.login__actions { + display: grid; + grid-template-columns: 1fr 1.15fr; + gap: 12px; + margin-top: 4px; + align-items: start; +} + +.login__actions .btn { + width: 100%; + height: 42px; +} + +.login__actions .btn--secondary { + margin-left: 0; +} + +@media (min-width: 1200px) { + .login__actions .btn--secondary { + margin-left: 4rem; + } +} + +.login__forgot { + display: block; + margin-top: 22px; + text-align: center; + font-size: 12px; + color: #666; +} + +.login__submit-icon { + display: inline-grid; + transform: rotate(180deg); +} + +.login__error { + margin: 0; + color: #d32f2f; + font-size: 13px; +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..835aace --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,92 @@ +import { useState, type FormEvent } from 'react' +import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import Icon from '../components/Icon' +import userIcon from '../assets/images/user-icon.svg' +import './LoginPage.css' + +const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174' + +export default function LoginPage() { + const { login, isAuthenticated } = useAuth() + const navigate = useNavigate() + const [params] = useSearchParams() + const [mobile, setMobile] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + if (isAuthenticated) { + return + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + try { + await login(mobile, password) + navigate(params.get('redirect') || '/', { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'ورود ناموفق بود') + } finally { + setLoading(false) + } + } + + return ( +
+
+ + + + +
+

حساب کاربری من

+

ورود به محیط مدیریت شما

+
+
+ +
+ + + + {error ?

{error}

: null} + +
+ {/* Register first → right in RTL; Login second → left (matches original) */} + + + ثبت نام + + + ورود به سیستم + + + + +
+ + + + حساب کاربری دارم، ولی رمز عبورم را فراموش کرده ام + +
+ ) +} diff --git a/src/pages/OrdersPage.tsx b/src/pages/OrdersPage.tsx new file mode 100644 index 0000000..ebcd144 --- /dev/null +++ b/src/pages/OrdersPage.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { formatFaDate, listOrders, type Order } from '../lib/adminApi' +import { usePagination } from '../lib/usePagination' +import cartIcon from '../assets/images/tiles/cart.png' +import './ListPages.css' + +export default function OrdersPage() { + const navigate = useNavigate() + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + void (async () => { + try { + setOrders(await listOrders()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری سفارشات ناموفق بود') + } finally { + setLoading(false) + } + })() + }, []) + + const { page, setPage, pageCount, pageItems } = usePagination(orders) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {orders.length === 0 ?

سفارشی ثبت نشده است

: null} + {pageItems.map((order) => ( +
+
+
+ OrderID {order.id} +
+
{formatFaDate(order.createdAt)}
+
+ +
+ {order.customerName} +
{order.customerPhone}
+
+ +
+
+ {order.itemCount} + + مجموع +
+ آیتم ها +
+
+
+ +
+ + +
+
+ ))} +
+ + + ) : null} + + +
+ ) +} diff --git a/src/pages/ProductFormPage.tsx b/src/pages/ProductFormPage.tsx new file mode 100644 index 0000000..aeefbf9 --- /dev/null +++ b/src/pages/ProductFormPage.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import { ApiError } from '../lib/api' +import { + createProduct, + getProduct, + updateProduct, + uploadMedia, +} from '../lib/adminApi' +import './ListPages.css' + +export default function ProductFormPage() { + const navigate = useNavigate() + const { id } = useParams() + const isEdit = Boolean(id) + + const [form, setForm] = useState({ + nameFa: '', + nameEn: '', + price: '', + stock: '0', + category: '', + }) + const [imageUrl, setImageUrl] = useState(null) + const [imageKey, setImageKey] = useState(null) + const [loading, setLoading] = useState(isEdit) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!id) return + void (async () => { + try { + const product = await getProduct(id) + setForm({ + nameFa: product.nameFa, + nameEn: product.nameEn, + price: String(product.price), + stock: String(product.stock), + category: product.category, + }) + setImageUrl(product.imageUrl) + setImageKey(product.imageKey) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری محصول ناموفق بود') + } finally { + setLoading(false) + } + })() + }, [id]) + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onImageChange(file: File | null) { + if (!file) return + try { + const uploaded = await uploadMedia('main', file) + setImageUrl(uploaded.url) + setImageKey(uploaded.storageKey) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'آپلود تصویر ناموفق بود') + } + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + try { + const payload = { + nameFa: form.nameFa.trim(), + nameEn: form.nameEn.trim(), + category: form.category.trim(), + price: Number(form.price) || 0, + stock: Number(form.stock) || 0, + imageUrl: imageUrl ?? undefined, + imageKey: imageKey ?? undefined, + } + if (isEdit && id) { + await updateProduct(id, payload) + } else { + await createProduct(payload) + } + navigate('/admin/product/product-list') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود') + } finally { + setSaving(false) + } + } + + return ( +
+ navigate('/admin/product/product-list')} + /> + + {loading ?

در حال بارگذاری...

: null} + + {!loading ? ( +
void onSubmit(e)}> + update('nameFa', v)} /> + update('nameEn', v)} dir="ltr" /> + update('category', v)} /> + update('price', v)} dir="ltr" /> + update('stock', v)} dir="ltr" /> +
+ + void onImageChange(e.target.files?.[0] ?? null)} + /> + {imageUrl ? ( + + ) : null} +
+ {error ?

{error}

: null} +
+ + {isEdit ? 'ذخیره تغییرات' : 'ثبت محصول'} + + navigate('/admin/product/product-list')}> + انصراف + +
+ + ) : null} +
+ ) +} diff --git a/src/pages/ProductsPage.css b/src/pages/ProductsPage.css new file mode 100644 index 0000000..5518f2c --- /dev/null +++ b/src/pages/ProductsPage.css @@ -0,0 +1,189 @@ +.products-page { + position: relative; + padding: 0; + overflow: visible; +} + +/* Info banner */ +.products-banner { + display: flex; + align-items: center; + gap: 12px; + background: #e3f2fd; + color: #1565c0; + font-size: 13px; + padding: 12px 20px; + margin-bottom: 0; + flex-wrap: wrap; +} + +.products-banner__icon { + flex-shrink: 0; + display: grid; + place-items: center; + width: 32px; + height: 32px; + background: var(--color-primary); + border-radius: 50%; + color: #fff; +} + +.products-banner__excel { + margin-right: auto; + min-height: 36px; + padding: 0 14px; + font-size: 13px; + border-radius: 4px; +} + +/* Filters */ +.products-filters { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + background: #fff; +} + +.filters__label { + font-size: 13px; + color: #444; + white-space: nowrap; +} + +.products-filter-select, +.products-filter-input { + height: 38px; + border: 1px solid #d7d7d7; + border-radius: 4px; + padding: 0 10px; + font-family: inherit; + font-size: 13px; + color: #555; + background: #fff; + outline: none; + min-width: 160px; + direction: rtl; +} + +.products-filter-select:focus, +.products-filter-input:focus { + border-color: var(--color-primary); +} + +/* Card grid */ +.products-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 16px 12px 80px; +} + +@media (max-width: 1200px) { + .products-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 800px) { + .products-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.product-card { + display: flex; + flex-direction: column; + border: 1px solid #e0e0e0; + background: #fff; + transition: box-shadow 0.15s ease; +} + +.product-card:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + z-index: 1; + position: relative; +} + +.product-card__img { + aspect-ratio: 1 / 1; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: #fff; + overflow: hidden; +} + +.product-card__img img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.product-card__body { + padding: 12px 14px 8px; + text-align: center; + flex: 1; +} + +.product-card__body h4 { + margin: 0 0 4px; + font-size: 14px; + font-weight: 500; + line-height: 1.5; + color: #1a1a1a; +} + +.product-card__body p { + margin: 0; + font-size: 12px; + color: #9e9e9e; + line-height: 1.5; +} + +.product-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-top: 1px solid #f5f5f5; +} + +.product-card__actions { + display: flex; + gap: 6px; +} + +.product-card__actions .icon-action { + width: 30px; + height: 30px; + border-radius: 4px; +} + +.product-card__price { + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.2; +} + +.product-card__price .en { + font-size: 20px; + font-weight: 600; + color: #1a1a1a; +} + +.product-card__price small { + font-size: 11px; + color: #9e9e9e; +} + +.products-page .pager { + padding: 4px 16px 20px; +} + +/* FAB lives in shared FabButton component */ diff --git a/src/pages/ProductsPage.tsx b/src/pages/ProductsPage.tsx new file mode 100644 index 0000000..bcbf398 --- /dev/null +++ b/src/pages/ProductsPage.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import Icon from '../components/Icon' +import FabButton from '../components/FabButton' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { deleteProduct, listProducts, type Product } from '../lib/adminApi' +import { usePagination } from '../lib/usePagination' +import scooterIcon from '../assets/images/tiles/scooter.png' +import './ListPages.css' +import './ProductsPage.css' + +type Filters = { + category: string + minPrice: string + maxPrice: string +} + +export default function ProductsPage() { + const navigate = useNavigate() + const [products, setProducts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [filters, setFilters] = useState({ category: '', minPrice: '', maxPrice: '' }) + + async function load() { + setLoading(true) + setError('') + try { + setProducts(await listProducts()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری محصولات ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + const categories = useMemo( + () => [...new Set(products.map((p) => p.category).filter(Boolean))], + [products], + ) + + const filtered = useMemo( + () => + products.filter((p) => { + if (filters.category && p.category !== filters.category) return false + if (filters.minPrice && p.price < Number(filters.minPrice)) return false + if (filters.maxPrice && p.price > Number(filters.maxPrice)) return false + return true + }), + [products, filters], + ) + + const { page, setPage, pageCount, pageItems } = usePagination(filtered) + + async function onDelete(id: string) { + if (!window.confirm('این محصول حذف شود؟')) return + try { + await deleteProduct(id) + setProducts((prev) => prev.filter((p) => p.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + return ( +
+
+ + + + + شما می توانید لیست قیمت و موجودی محصولات را به صورت فایل اکسل مرور کنید + + +
+ +
+ فیلترها : + + setFilters((f) => ({ ...f, minPrice: e.target.value }))} + /> + setFilters((f) => ({ ...f, maxPrice: e.target.value }))} + /> +
+ + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {filtered.length === 0 ?

محصولی ثبت نشده است

: null} + {pageItems.map((product) => ( +
+
+ {product.nameFa} +
+
+

{product.nameFa}

+

{product.nameEn}

+
+
+
+ {product.price.toLocaleString('en-US')} + تومان +
+
+ + + + + +
+
+
+ ))} +
+ + + ) : null} + + navigate('/admin/product/add-product')} + /> +
+ ) +} diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..265b27d --- /dev/null +++ b/src/pages/RegisterPage.tsx @@ -0,0 +1,143 @@ +import { useState, type FormEvent } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAuth } from '../auth/AuthContext' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import Icon from '../components/Icon' +import userIcon from '../assets/images/user-icon.svg' +import { ApiError } from '../lib/api' +import { registerSendCode, registerVerify } from '../lib/authApi' +import { isValidCellNumber, normalizeCellNumber } from '../lib/phone' +import './LoginPage.css' + +const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174' + +export default function RegisterPage() { + const { reloadUser } = useAuth() + const navigate = useNavigate() + const [step, setStep] = useState<'form' | 'code'>('form') + const [form, setForm] = useState({ + mobile: '', + firstName: '', + lastName: '', + password: '', + passwordConfirmation: '', + code: '', + }) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setLoading(true) + const cellNumber = normalizeCellNumber(form.mobile) + try { + if (step === 'form') { + if (!form.firstName.trim() || !form.lastName.trim()) { + throw new Error('نام و نام خانوادگی را وارد کنید') + } + if (!isValidCellNumber(cellNumber)) { + throw new Error('شماره همراه معتبر نیست') + } + if (form.password.length < 4) { + throw new Error('رمز عبور باید حداقل ۴ کاراکتر باشد') + } + if (form.password !== form.passwordConfirmation) { + throw new Error('رمز عبور و تکرار آن یکسان نیستند') + } + await registerSendCode({ + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + cellNumber, + password: form.password, + }) + setStep('code') + return + } + + await registerVerify(cellNumber, form.code.trim()) + reloadUser() + navigate('/', { replace: true }) + } catch (err) { + setError(err instanceof ApiError || err instanceof Error ? err.message : 'ثبت نام ناموفق بود') + } finally { + setLoading(false) + } + } + + return ( +
+
+ + + + +
+

ثبت نام

+

+ {step === 'form' + ? 'ایجاد حساب کاربری جدید' + : `کد ارسال‌شده به ${normalizeCellNumber(form.mobile)} را وارد کنید`} +

+
+
+
+ {step === 'form' ? ( + <> + update('firstName', v)} /> + update('lastName', v)} + /> + update('mobile', v)} + type="tel" + dir="ltr" + /> + update('password', v)} + type="password" + dir="ltr" + /> + update('passwordConfirmation', v)} + type="password" + dir="ltr" + /> + + ) : ( + update('code', v)} + type="tel" + dir="ltr" + /> + )} + + {error ?

{error}

: null} + +
+ + ورود به سیستم + + + {step === 'form' ? 'ارسال کد تایید' : 'ثبت نام'} + +
+ +
+ ) +} diff --git a/src/pages/RepresentationFormPage.tsx b/src/pages/RepresentationFormPage.tsx new file mode 100644 index 0000000..5b72127 --- /dev/null +++ b/src/pages/RepresentationFormPage.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams, useLocation } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import TextField from '../components/TextField' +import PrimaryButton from '../components/PrimaryButton' +import { ApiError } from '../lib/api' +import { + createRepresentation, + listRepresentations, + updateRepresentation, +} from '../lib/adminApi' +import './ListPages.css' + +export default function RepresentationFormPage() { + const navigate = useNavigate() + const { id } = useParams() + const location = useLocation() + const asActive = location.pathname.includes('/active') + const isEdit = Boolean(id) + const backTo = asActive + ? '/admin/representation/active-representation-list' + : '/admin/representation/representation-list' + + const [form, setForm] = useState({ + name: '', + owner: '', + mobile: '', + city: '', + address: '', + }) + const [loading, setLoading] = useState(isEdit) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (!id) return + void (async () => { + try { + const items = await listRepresentations() + const item = items.find((row) => row.id === id) + if (!item) throw new Error('نمایندگی یافت نشد') + setForm({ + name: item.name, + owner: item.owner, + mobile: item.mobile, + city: item.city, + address: item.address, + }) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود') + } finally { + setLoading(false) + } + })() + }, [id]) + + function update(key: keyof typeof form, value: string) { + setForm((prev) => ({ ...prev, [key]: value })) + } + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError('') + try { + const payload = { + name: form.name.trim(), + owner: form.owner.trim(), + mobile: form.mobile.trim(), + city: form.city.trim(), + address: form.address.trim(), + } + if (isEdit && id) { + await updateRepresentation(id, payload) + } else { + await createRepresentation(payload, asActive) + } + navigate(backTo) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود') + } finally { + setSaving(false) + } + } + + return ( +
+ navigate(backTo)} + /> + {loading ?

در حال بارگذاری...

: null} + {!loading ? ( +
void onSubmit(e)}> + update('name', v)} /> + update('owner', v)} /> + update('mobile', v)} dir="ltr" /> + update('city', v)} /> +
+ update('address', v)} /> +
+ {error ?

{error}

: null} +
+ + ذخیره + + navigate(backTo)}> + انصراف + +
+ + ) : null} +
+ ) +} diff --git a/src/pages/RepresentationRequestsPage.tsx b/src/pages/RepresentationRequestsPage.tsx new file mode 100644 index 0000000..4afe2a0 --- /dev/null +++ b/src/pages/RepresentationRequestsPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { + approveRepresentation, + deleteRepresentation, + formatFaDate, + listRepresentations, + type Representation, +} from '../lib/adminApi' +import docIcon from '../assets/images/tiles/doc.png' +import { usePagination } from '../lib/usePagination' +import './ListPages.css' + +export default function RepresentationRequestsPage() { + const navigate = useNavigate() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setItems(await listRepresentations('pending')) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری درخواست‌ها ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onApprove(id: string) { + try { + await approveRepresentation(id) + setItems((prev) => prev.filter((item) => item.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'تایید ناموفق بود') + } + } + + async function onDelete(id: string) { + if (!window.confirm('این درخواست حذف شود؟')) return + try { + await deleteRepresentation(id) + setItems((prev) => prev.filter((item) => item.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems, offset } = usePagination(items) + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {items.length === 0 ?

درخواستی ثبت نشده است

: null} + {pageItems.map((item, index) => ( +
+
{offset + index + 1}
+ +
+
+ requestID {item.id.slice(-6)} +
+
{formatFaDate(item.createdAt)}
+
+ +
+ {item.name} +
{item.mobile}
+
+ +
+
+ {item.city} + {item.address} +
+
+ +
+ + +
+
+ ))} +
+ + + ) : null} + + navigate('/admin/representation/new')} + /> +
+ ) +} diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx new file mode 100644 index 0000000..8ecb115 --- /dev/null +++ b/src/pages/ReviewsPage.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import PageTitle from '../components/PageTitle' +import FabButton from '../components/FabButton' +import Icon from '../components/Icon' +import Pagination from '../components/Pagination' +import { ApiError } from '../lib/api' +import { + approveReview, + deleteReview, + listReviews, + rejectReview, + type Review, +} from '../lib/adminApi' +import starIcon from '../assets/images/tiles/star.png' +import { usePagination } from '../lib/usePagination' +import './ListPages.css' + +export default function ReviewsPage() { + const navigate = useNavigate() + const [reviews, setReviews] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function load() { + setLoading(true) + setError('') + try { + setReviews(await listReviews()) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'بارگذاری نظرات ناموفق بود') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void load() + }, []) + + async function onApprove(id: string) { + try { + const updated = await approveReview(id) + setReviews((prev) => prev.map((r) => (r.id === id ? updated : r))) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'تایید ناموفق بود') + } + } + + async function onReject(id: string) { + try { + const updated = await rejectReview(id) + setReviews((prev) => prev.map((r) => (r.id === id ? updated : r))) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'رد ناموفق بود') + } + } + + async function onDelete(id: string) { + if (!window.confirm('این نظر حذف شود؟')) return + try { + await deleteReview(id) + setReviews((prev) => prev.filter((r) => r.id !== id)) + } catch (err) { + window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود') + } + } + + const { page, setPage, pageCount, pageItems } = usePagination(reviews) + + function statusLabel(status: Review['status']) { + if (status === 'pending') return 'در انتظار تایید' + if (status === 'approved') return 'تایید شده' + return 'رد شده' + } + + return ( +
+ navigate('/')} + /> + + {error ?

{error}

: null} + {loading ?

در حال بارگذاری...

: null} + + {!loading && !error ? ( + <> +
+ {reviews.length === 0 ?

نظری ثبت نشده است

: null} + {pageItems.map((review) => ( +
+
+ نویسنده ی بررسی : {review.author} +
{review.product}
+
+
امتیاز {review.rating} از 5
+
+ + {statusLabel(review.status)} + +
+
+ {review.status === 'pending' ? ( + <> + + + + ) : null} + +
+
+ ))} +
+ + + ) : null} + + +
+ ) +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..49154a5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5175, + strictPort: true, + allowedHosts: [ + 'localhost', + '127.0.0.1', + 'manage.madomotor.com', + 'admin.madomotor.ir', + 'madomotor.ir', + ], + }, +})