commit f566387c61f43d272fa20fd2046c932c2d8b1ad9 Author: Alireza Hassani Date: Wed Jul 22 13:48:53 2026 +0330 Initial commit: Meshkee dashboards monorepo. Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c838103 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.env +.env.local +*.local +.DS_Store +.certs/ +*.log +.idea/ +.vscode/ +.cursor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecc2d84 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# MeshkeeApp Monorepo + +Dashboard applications and shared packages for the Meshkee platform. + +**Agent context:** see [docs/PROJECT_CONTEXT.md](./docs/PROJECT_CONTEXT.md) and `.cursor/rules/`. + +## Structure + +``` +MeshkeeApp/ + apps/ + super-admin/ # port 5174 — meshkee.app + business/ # port 5173 — business.{domain} + customer/ # port 5175 — customer.{domain} + packages/ + dashboard-core/ # @meshkee/dashboard-core + dashboard-ui/ # @meshkee/dashboard-ui +``` + +## Setup + +```bash +cd MeshkeeApp +npm install +``` + +## Dev servers + +From the monorepo root: + +```bash +npm run dev:customer # http://customer.sanihome.ir:5175 +npm run dev:business # http://business.sanihome.ir:5173 +npm run dev:super-admin # https://meshkee.app:5174 +``` + +Or from an app directory: + +```bash +cd apps/customer && npm run dev +``` + +## Build + +```bash +npm run build # packages + all apps +npm run build:customer +npm run build:business +npm run build:super-admin +``` + +## Shared packages + +See [packages/README.md](./packages/README.md). + +- **dashboard-core** — API client, auth types, utils, design tokens +- **dashboard-ui** — Breadcrumbs, SectionCard, Pagination, Toast, RouteLoader, DomainGuard + +Customer dashboard uses both packages. Business and Super Admin will migrate incrementally. + +## Local hosts + +``` +127.0.0.1 customer.sanihome.ir +127.0.0.1 business.sanihome.ir +127.0.0.1 meshkee.app +``` diff --git a/apps/business/.env.example b/apps/business/.env.example new file mode 100644 index 0000000..45d98f6 --- /dev/null +++ b/apps/business/.env.example @@ -0,0 +1,7 @@ +# Backend API (NestJS) +VITE_API_BASE_URL=http://localhost:3000/api/v1 + +# Optional localhost-only fallback when not using business.{domain} in /etc/hosts. +# Leave unset for multi-tenant dev — tenant is resolved from hostname +# (e.g. business.sanihome.ir → sanihome.ir, business.safeteb.com → safeteb.com). +# VITE_BUSINESS_DOMAIN=sanihome.ir diff --git a/apps/business/.gitignore b/apps/business/.gitignore new file mode 100644 index 0000000..438657a --- /dev/null +++ b/apps/business/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/business/.oxlintrc.json b/apps/business/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/apps/business/.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/apps/business/README.md b/apps/business/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/apps/business/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/apps/business/index.html b/apps/business/index.html new file mode 100644 index 0000000..b71258d --- /dev/null +++ b/apps/business/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + Business Dashboard + + +
+ + + diff --git a/apps/business/package-lock.json b/apps/business/package-lock.json new file mode 100644 index 0000000..71d9a00 --- /dev/null +++ b/apps/business/package-lock.json @@ -0,0 +1,1453 @@ +{ + "name": "meshkeeapp-gen2", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meshkeeapp-gen2", + "version": "0.0.0", + "dependencies": { + "lucide-react": "^1.23.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-easy-crop": "^6.2.0", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "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.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "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.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "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/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "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", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "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/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-easy-crop": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.0.tgz", + "integrity": "sha512-8CEes7M8UkNoRPaz/krm70xMTbpar07crz/OrPSJwdGopV6lPcqdEmamDYTXgsqukb/1e2fbFd9/mROi8Qkp+A==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "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.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "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.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "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.3.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/apps/business/package.json b/apps/business/package.json new file mode 100644 index 0000000..23a7360 --- /dev/null +++ b/apps/business/package.json @@ -0,0 +1,29 @@ +{ + "name": "@meshkee/business-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@meshkee/dashboard-ui": "*", + "lucide-react": "^1.23.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-easy-crop": "^6.2.0", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/apps/business/public/favicon.svg b/apps/business/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/apps/business/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/business/public/icons.svg b/apps/business/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/apps/business/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/business/public/meshkee-logo.png b/apps/business/public/meshkee-logo.png new file mode 100644 index 0000000..9d12a5b Binary files /dev/null and b/apps/business/public/meshkee-logo.png differ diff --git a/apps/business/public/product-template.png b/apps/business/public/product-template.png new file mode 100644 index 0000000..ee4236f Binary files /dev/null and b/apps/business/public/product-template.png differ diff --git a/apps/business/src/App.tsx b/apps/business/src/App.tsx new file mode 100644 index 0000000..f0d9720 --- /dev/null +++ b/apps/business/src/App.tsx @@ -0,0 +1,118 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { BusinessDomainGuard } from './components/BusinessDomainGuard' +import { DashboardDocumentTitle } from './components/DashboardDocumentTitle' +import { AuthProvider } from './context/AuthContext' +import { BusinessThemeProvider } from './context/BusinessThemeContext' +import { TenantBrandingProvider } from './context/TenantBrandingContext' +import { ToastProvider } from './context/ToastContext' +import { ProtectedRoute } from './components/ProtectedRoute' +import { GuestRoute } from './components/GuestRoute' +import { PageLayout } from './components/PageLayout' +import { HomePage } from './pages/HomePage' +import { ProductsPage } from './pages/ProductsPage' +import { CategoriesPage } from './pages/CategoriesPage' +import { BrandsPage } from './pages/BrandsPage' +import { AddNewProductPage } from './pages/AddNewProductPage' +import { MyProductsPage } from './pages/MyProductsPage' +import { ProductDetailsPage } from './pages/ProductDetailsPage' +import { ProductSettingsPage } from './pages/ProductSettingsPage' +import { BusinessProfilePage } from './pages/BusinessProfilePage' +import { StorePage } from './pages/StorePage' +import { StoreItemsPage } from './pages/StoreItemsPage' +import { StoreSpecialsPage } from './pages/StoreSpecialsPage' +import { CustomersPage } from './pages/CustomersPage' +import { OrdersPage } from './pages/OrdersPage' +import { LoginPage } from './pages/LoginPage' +import { ShoppingCardsPage } from './pages/ShoppingCardsPage' +import { StoreSettingsPage } from './pages/StoreSettingsPage' +import { BlogPage } from './pages/BlogPage' +import { BlogListPage } from './pages/BlogListPage' +import { AddNewBlogPage } from './pages/AddNewBlogPage' +import { BlogCategoriesPage } from './pages/BlogCategoriesPage' +import { BlogSettingsPage } from './pages/BlogSettingsPage' +import { BlogDetailsPage } from './pages/BlogDetailsPage' +import { PortfoliosPage } from './pages/PortfoliosPage' +import { PortfolioListPage } from './pages/PortfolioListPage' +import { AddNewPortfolioPage } from './pages/AddNewPortfolioPage' +import { PortfolioCategoriesPage } from './pages/PortfolioCategoriesPage' +import { PortfolioSettingsPage } from './pages/PortfolioSettingsPage' +import { PortfolioDetailsPage } from './pages/PortfolioDetailsPage' +import { WebsitePage } from './pages/WebsitePage' +import { WebsiteContactPage } from './pages/WebsiteContactPage' +import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage' +import { WebsiteFaqPage } from './pages/WebsiteFaqPage' +import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage' +import { WebsiteEPaymentPage } from './pages/WebsiteEPaymentPage' +import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage' +import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage' +import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage' + +function App() { + return ( + + + + + + + + + }> + } /> + + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + + + ) +} + +export default App diff --git a/apps/business/src/assets/hero.png b/apps/business/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/apps/business/src/assets/hero.png differ diff --git a/apps/business/src/assets/meshkee-logo.png b/apps/business/src/assets/meshkee-logo.png new file mode 100644 index 0000000..9d12a5b Binary files /dev/null and b/apps/business/src/assets/meshkee-logo.png differ diff --git a/apps/business/src/assets/react.svg b/apps/business/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/business/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/business/src/assets/vite.svg b/apps/business/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/apps/business/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/apps/business/src/components/AddCustomerModal.module.css b/apps/business/src/components/AddCustomerModal.module.css new file mode 100644 index 0000000..fe43099 --- /dev/null +++ b/apps/business/src/components/AddCustomerModal.module.css @@ -0,0 +1,22 @@ +.formGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.formFull { + grid-column: 1 / -1; +} + +.hint { + margin-bottom: 12px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); +} + +@media (max-width: 520px) { + .formGrid { + grid-template-columns: 1fr; + } +} diff --git a/apps/business/src/components/AddCustomerModal.tsx b/apps/business/src/components/AddCustomerModal.tsx new file mode 100644 index 0000000..0b68894 --- /dev/null +++ b/apps/business/src/components/AddCustomerModal.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState } from 'react' +import { X } from 'lucide-react' +import { ApiError } from '../lib/api' +import { toE164CellNumber } from '../lib/cellNumber' +import { + createCustomer, + type BusinessCustomerListItem, +} from '../services/customerService' +import formStyles from './AddCustomerModal.module.css' +import modalStyles from './VariationsModal.module.css' + +interface AddCustomerModalProps { + open: boolean + onClose: () => void + onCreated: (customer: BusinessCustomerListItem) => void +} + +const ANIMATION_MS = 220 + +export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalProps) { + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [firstName, setFirstName] = useState('') + const [lastName, setLastName] = useState('') + const [cellNumber, setCellNumber] = useState('') + const [password, setPassword] = useState('') + const [email, setEmail] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setFirstName('') + setLastName('') + setCellNumber('') + setPassword('') + setEmail('') + setError('') + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted) return null + + const canSubmit = + firstName.trim().length >= 2 && + lastName.trim().length >= 2 && + toE164CellNumber(cellNumber.trim()).length > 0 + + async function handleSubmit() { + const normalizedCell = toE164CellNumber(cellNumber.trim()) + if (!canSubmit || !normalizedCell) return + + setIsSubmitting(true) + setError('') + try { + const created = await createCustomer({ + cellNumber: normalizedCell, + firstName: firstName.trim(), + lastName: lastName.trim(), + ...(password.trim() ? { password: password.trim() } : {}), + ...(email.trim() ? { email: email.trim() } : {}), + }) + onCreated({ + id: created.id, + cellNumber: created.cellNumber, + firstName: created.firstName, + lastName: created.lastName, + email: created.email, + label: created.label, + createdAt: created.createdAt, + isEnabled: created.isEnabled, + }) + onClose() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Unable to add customer.') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="add-customer-title" + > +
+
+

+ Add customer +

+

+ Creates a verified customer account or links an existing user to your business. +

+
+ +
+ +
+

+ Password is required only for new accounts. Existing users are added as verified + customers. +

+ + {error &&

{error}

} + +
+
+ + setFirstName(e.target.value)} + disabled={isSubmitting} + /> +
+
+ + setLastName(e.target.value)} + disabled={isSubmitting} + /> +
+
+ + setCellNumber(e.target.value)} + placeholder="0912..." + autoComplete="off" + disabled={isSubmitting} + /> +
+
+ + setPassword(e.target.value)} + placeholder="Min. 8 characters" + disabled={isSubmitting} + /> +
+
+ + setEmail(e.target.value)} + disabled={isSubmitting} + /> +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/business/src/components/AddProductByAiModal.module.css b/apps/business/src/components/AddProductByAiModal.module.css new file mode 100644 index 0000000..4c9f10a --- /dev/null +++ b/apps/business/src/components/AddProductByAiModal.module.css @@ -0,0 +1,25 @@ +.headerBlock { + flex: 1; + min-width: 0; +} + +.subtitle { + font-size: 13px; + color: var(--text-secondary); + margin-top: 4px; + line-height: 1.45; +} + +.error { + margin: 0; + padding: 10px 12px; + font-size: 13px; + color: #b91c1c; + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.25); + border-radius: var(--radius-sm); +} + +.header { + align-items: flex-start; +} diff --git a/apps/business/src/components/AddProductByAiModal.tsx b/apps/business/src/components/AddProductByAiModal.tsx new file mode 100644 index 0000000..30ea18b --- /dev/null +++ b/apps/business/src/components/AddProductByAiModal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState } from 'react' +import { Sparkles, X } from 'lucide-react' +import { useToast } from '../context/ToastContext' +import { ApiError } from '../lib/api' +import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' +import { createProductByAi, type ProductAiLanguage } from '../services/productAiService' +import type { Category } from '../types/category' +import { flattenCategories } from '../utils/categories' +import { SearchableSelect } from './SearchableSelect' +import modalStyles from './CategoryModal.module.css' +import aiStyles from '../styles/ai.module.css' +import styles from './AddProductByAiModal.module.css' +import fieldStyles from '../pages/AddNewProductPage.module.css' + +interface AddProductByAiModalProps { + open: boolean + onClose: () => void + onCreated: (productId: string) => void +} + +const ANIMATION_MS = 220 + +export function AddProductByAiModal({ + open, + onClose, + onCreated, +}: AddProductByAiModalProps) { + const { showToast } = useToast() + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [categories, setCategories] = useState([]) + const [categoryId, setCategoryId] = useState('') + const [name, setName] = useState('') + const [language, setLanguage] = useState('en') + const [isLoadingCategories, setIsLoadingCategories] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState('') + + const categoryOptions = useMemo(() => flattenCategories(categories), [categories]) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setCategoryId('') + setName('') + setLanguage('en') + setError('') + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!open) return + + const controller = new AbortController() + void loadCategories(controller.signal) + return () => controller.abort() + }, [open]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !isSubmitting) onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, isSubmitting, onClose]) + + async function loadCategories(signal?: AbortSignal) { + setIsLoadingCategories(true) + setError('') + + try { + const items = await listProductCategories(signal) + setCategories(items.map(mapProductCategoryToUi)) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load categories.') + } + } finally { + setIsLoadingCategories(false) + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!categoryId || !name.trim()) return + + setIsSubmitting(true) + setError('') + + try { + const result = await createProductByAi({ + categoryId, + name: name.trim(), + language, + }) + showToast(result.message, 'success') + onCreated(result.product.id) + onClose() + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else if (err instanceof Error) { + setError(err.message) + } else { + setError('Unable to create product with AI.') + } + } finally { + setIsSubmitting(false) + } + } + + if (!mounted) return null + + return ( +
e.target === e.currentTarget && !isSubmitting && onClose()} + role="presentation" + > +
+
+
+

+ Add Product by AI +

+

+ AI will generate product content and technical data. You can add images and + variations afterward. +

+
+ +
+ +
void handleSubmit(e)}> +
+ + +
+ +
+ + setName(e.target.value)} + placeholder={ + language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name' + } + dir={language === 'fa' ? 'rtl' : 'ltr'} + className={language === 'fa' ? 'faText' : undefined} + disabled={isSubmitting} + required + /> +
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/apps/business/src/components/AddVariationModal.tsx b/apps/business/src/components/AddVariationModal.tsx new file mode 100644 index 0000000..19323e4 --- /dev/null +++ b/apps/business/src/components/AddVariationModal.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState } from 'react' +import { Plus, X } from 'lucide-react' +import type { Variation, VariationType } from '../types/variation' +import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation' +import styles from './VariationsModal.module.css' + +interface AddVariationModalProps { + open: boolean + onClose: () => void + onSubmit: (variation: Omit) => void +} + +const ANIMATION_MS = 220 + +export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModalProps) { + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [type, setType] = useState('color') + const [customName, setCustomName] = useState('') + const [selectedColors, setSelectedColors] = useState([]) + const [selectedSizes, setSelectedSizes] = useState([]) + const [customValues, setCustomValues] = useState(['']) + const valueInputRefs = useRef<(HTMLInputElement | null)[]>([]) + const pendingFocusIndex = useRef(null) + + useEffect(() => { + if (pendingFocusIndex.current === null) return + valueInputRefs.current[pendingFocusIndex.current]?.focus() + pendingFocusIndex.current = null + }, [customValues.length]) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setType('color') + setCustomName('') + setSelectedColors([]) + setSelectedSizes([]) + setCustomValues(['']) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted) return null + + function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) { + setSelected( + selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value], + ) + } + + function updateCustomValue(index: number, value: string) { + setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v))) + } + + function addCustomRow() { + setCustomValues((prev) => [...prev, '']) + } + + function removeCustomRow(index: number) { + setCustomValues((prev) => prev.filter((_, i) => i !== index)) + } + + function handleValueKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key !== 'Enter') return + e.preventDefault() + + const nextIndex = index + 1 + if (nextIndex < customValues.length) { + valueInputRefs.current[nextIndex]?.focus() + return + } + + pendingFocusIndex.current = nextIndex + setCustomValues((prev) => [...prev, '']) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + if (type === 'color') { + onSubmit({ name: 'Color', type: 'color', values: selectedColors }) + } else if (type === 'size') { + onSubmit({ name: 'Size', type: 'size', values: selectedSizes }) + } else { + const name = customName.trim() || 'Custom' + const values = customValues.map((v) => v.trim()).filter(Boolean) + onSubmit({ name, type: 'custom', values }) + } + } + + const canSubmit = + type === 'color' + ? selectedColors.length > 0 + : type === 'size' + ? selectedSizes.length > 0 + : customValues.some((v) => v.trim()) + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="add-variation-title" + > +
+

+ Add Variation +

+ +
+ +
+
+ + +
+ + {type === 'color' && ( +
+ +
+ {COLOR_OPTIONS.map((color) => ( + + ))} +
+
+ )} + + {type === 'size' && ( +
+ +
+ {SIZE_OPTIONS.map((size) => ( + + ))} +
+
+ )} + + {type === 'custom' && ( + <> +
+ + setCustomName(e.target.value)} + /> +
+
+ +
+ {customValues.map((value, index) => ( +
+ { + valueInputRefs.current[index] = el + }} + type="text" + placeholder={`Value ${index + 1}`} + value={value} + onChange={(e) => updateCustomValue(index, e.target.value)} + onKeyDown={(e) => handleValueKeyDown(index, e)} + /> + {customValues.length > 1 && ( + + )} +
+ ))} + +
+
+ + )} + +
+ + +
+
+
+
+ ) +} diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.module.css b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css new file mode 100644 index 0000000..4d84af2 --- /dev/null +++ b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css @@ -0,0 +1,3 @@ +.modalWide { + max-width: 560px; +} diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.tsx b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx new file mode 100644 index 0000000..d1761d3 --- /dev/null +++ b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { ImageCropper } from './ImageCropper' +import modalStyles from './CategoryModal.module.css' +import styles from './AddWebsiteSliderSlideModal.module.css' + +export interface WebsiteSliderSlideFormData { + image: string | null + title: string + linkUrl: string +} + +interface AddWebsiteSliderSlideModalProps { + open: boolean + onClose: () => void + onSubmit: (data: WebsiteSliderSlideFormData) => void + isSubmitting?: boolean +} + +const ANIMATION_MS = 220 + +export function AddWebsiteSliderSlideModal({ + open, + onClose, + onSubmit, + isSubmitting = false, +}: AddWebsiteSliderSlideModalProps) { + const formRef = useRef(null) + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [image, setImage] = useState(null) + const [title, setTitle] = useState('') + const [linkUrl, setLinkUrl] = useState('') + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setImage(null) + setTitle('') + setLinkUrl('') + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted) return null + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!image) return + onSubmit({ + image, + title: title.trim(), + linkUrl: linkUrl.trim(), + }) + } + + return ( +
e.target === e.currentTarget && onClose()} + role="presentation" + > +
+
+

+ Add slide +

+ +
+ +
+
+ + +
+ +
+ + setTitle(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + setLinkUrl(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/business/src/components/BlogCard.module.css b/apps/business/src/components/BlogCard.module.css new file mode 100644 index 0000000..7cbea6a --- /dev/null +++ b/apps/business/src/components/BlogCard.module.css @@ -0,0 +1,145 @@ +.card { + display: flex; + flex-direction: column; + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--glass-shadow); + transition: transform 0.2s, box-shadow 0.2s; + overflow: hidden; +} + +.card:hover { + transform: translateY(-3px); + box-shadow: 0 12px 32px rgba(31, 38, 135, 0.1); +} + +.clickable { + display: flex; + flex-direction: column; + flex: 1; + text-decoration: none; + color: inherit; + cursor: pointer; +} + +.clickable:hover .title { + color: var(--primary); +} + +.imageWrap { + position: relative; + width: 100%; + aspect-ratio: 3 / 2; + overflow: hidden; + background: rgba(148, 163, 184, 0.08); + border-bottom: 1px solid rgba(148, 163, 184, 0.12); +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.imagePlaceholder { + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(148, 163, 184, 0.12) 0%, + rgba(148, 163, 184, 0.04) 100% + ); +} + +.statusBadge { + position: absolute; + top: 8px; + left: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + border-radius: 50px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12); + z-index: 1; +} + +.waitingBadge { + color: #c2410c; +} + +.verifiedBadge { + color: #15803d; +} + +.body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px 16px; +} + +.title { + font-size: 15px; + font-weight: 600; + line-height: 1.4; + color: var(--text-primary); + transition: color 0.2s; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.abstract { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.meta { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); +} + +.metaDot { + margin: 0 6px; +} + +.verifyActive { + color: #15803d !important; + background: rgba(34, 197, 94, 0.12) !important; +} + +.verifyActive:hover { + background: rgba(34, 197, 94, 0.2) !important; + color: #15803d !important; +} + +.verifyWaiting { + color: #c2410c !important; + background: rgba(249, 115, 22, 0.12) !important; +} + +.verifyWaiting:hover { + background: rgba(249, 115, 22, 0.2) !important; + color: #c2410c !important; +} diff --git a/apps/business/src/components/BlogCard.tsx b/apps/business/src/components/BlogCard.tsx new file mode 100644 index 0000000..a98e36a --- /dev/null +++ b/apps/business/src/components/BlogCard.tsx @@ -0,0 +1,120 @@ +import { Link } from 'react-router-dom' +import { Pencil, MessageSquare, Trash2, BadgeCheck, Clock } from 'lucide-react' +import type { Blog } from '../types/blog' +import { + formatBlogAuthor, + formatBlogCardDate, + isBlogVerified, +} from '../services/blogService' +import { Tooltip } from './Tooltip' +import cardStyles from './BlogCard.module.css' +import controlStyles from './ProductCard.module.css' + +interface BlogCardProps { + blog: Blog + commentCount: number + onEdit: (id: string) => void + onComments: (id: string) => void + onToggleVerify: (id: string) => void + onRemove: (id: string) => void + isVerifying?: boolean +} + +export function BlogCard({ + blog, + commentCount, + onEdit, + onComments, + onToggleVerify, + onRemove, + isVerifying = false, +}: BlogCardProps) { + const verified = isBlogVerified(blog) + const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt) + + return ( +
+ +
+ {blog.titleImageUrl ? ( + {blog.title} + ) : ( + + +
+

{blog.title}

+ {blog.abstract ? ( +

{blog.abstract}

+ ) : ( +

No summary yet.

+ )} +

+ {formatBlogAuthor(blog.author)} + · + {publishDate} +

+
+ + +
+ + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/business/src/components/BlogCategoryRow.tsx b/apps/business/src/components/BlogCategoryRow.tsx new file mode 100644 index 0000000..528b190 --- /dev/null +++ b/apps/business/src/components/BlogCategoryRow.tsx @@ -0,0 +1,81 @@ +import { FolderPlus, Trash2, ChevronRight } from 'lucide-react' +import type { Category } from '../types/category' +import { Tooltip } from './Tooltip' +import styles from './CategoryRow.module.css' + +interface BlogCategoryRowProps { + category: Category + depth: number + hasChildren: boolean + expanded: boolean + onToggle: () => void + onAddSub: (id: string) => void + onRemove: (id: string) => void +} + +export function BlogCategoryRow({ + category, + depth, + hasChildren, + expanded, + onToggle, + onAddSub, + onRemove, +}: BlogCategoryRowProps) { + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={hasChildren ? 'button' : undefined} + tabIndex={hasChildren ? 0 : undefined} + aria-expanded={hasChildren ? expanded : undefined} + > +
+
+ {category.nameEn} + · + {category.nameFa} + {hasChildren && ( + + + + )} +
+ {category.description &&

{category.description}

} +
+
+ +
+ + + + + + +
+
+ ) +} diff --git a/apps/business/src/components/BlogCategoryTree.tsx b/apps/business/src/components/BlogCategoryTree.tsx new file mode 100644 index 0000000..26cfadb --- /dev/null +++ b/apps/business/src/components/BlogCategoryTree.tsx @@ -0,0 +1,64 @@ +import type { Category } from '../types/category' +import { BlogCategoryRow } from './BlogCategoryRow' +import { getChildren, hasChildren } from '../utils/categories' +import styles from './CategoryTree.module.css' + +interface BlogCategoryTreeProps { + categories: Category[] + expandedIds: Set + onToggle: (id: string) => void + onAddSub: (id: string) => void + onRemove: (id: string) => void + parentId?: string | null + depth?: number +} + +export function BlogCategoryTree({ + categories, + expandedIds, + onToggle, + onAddSub, + onRemove, + parentId = null, + depth = 0, +}: BlogCategoryTreeProps) { + const nodes = getChildren(categories, parentId) + + return ( + <> + {nodes.map((category) => { + const childCount = hasChildren(categories, category.id) + const expanded = expandedIds.has(category.id) + + return ( +
+ onToggle(category.id)} + onAddSub={onAddSub} + onRemove={onRemove} + /> + {childCount && ( +
+
+ +
+
+ )} +
+ ) + })} + + ) +} diff --git a/apps/business/src/components/BlogCommentsModal.tsx b/apps/business/src/components/BlogCommentsModal.tsx new file mode 100644 index 0000000..9a9e946 --- /dev/null +++ b/apps/business/src/components/BlogCommentsModal.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from 'react' +import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listBlogComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './ProductCommentsModal.module.css' + +interface BlogCommentsModalProps { + open: boolean + blogId: string + blogTitle: string + onClose: () => void + onCountChange?: (count: number) => void +} + +const ANIMATION_MS = 220 + +export function BlogCommentsModal({ + open, + blogId, + blogTitle, + onClose, + onCountChange, +}: BlogCommentsModalProps) { + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setCurrentPage(1) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, mounted]) + + useEffect(() => { + if (!open || !blogId) return + + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [open, blogId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listBlogComments(blogId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages) + } + }, [currentPage, totalPages]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted) return null + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="blog-comments-title" + > +
+
+

+ Comments +

+ {blogTitle && ( +

+ {blogTitle} · {totalComments}{' '} + {totalComments === 1 ? 'comment' : 'comments'} +

+ )} +
+ +
+ +
+ {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet for this blog post.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
+ {formatCommentDate(comment.createdAt)} +
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( +
+ +
+ )} + + )} +
+
+
+ ) +} diff --git a/apps/business/src/components/BlogCommentsSection.module.css b/apps/business/src/components/BlogCommentsSection.module.css new file mode 100644 index 0000000..cca46b3 --- /dev/null +++ b/apps/business/src/components/BlogCommentsSection.module.css @@ -0,0 +1,157 @@ +.section { + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--glass-shadow); + padding: 24px; +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.count { + font-size: 13px; + color: var(--text-secondary); +} + +.emptyText { + font-size: 14px; + color: var(--text-muted); + text-align: center; + padding: 24px 12px; +} + +.errorText { + font-size: 13px; + color: #dc2626; + margin-bottom: 12px; +} + +.list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.comment { + padding: 14px 16px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: var(--radius-sm); +} + +.commentApproved { + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.04); +} + +.commentHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.commentMeta { + min-width: 0; +} + +.author { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.dateTime { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.likes { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.text { + font-size: 14px; + line-height: 1.55; + color: var(--text-primary); + white-space: pre-wrap; +} + +.commentActions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.approveBtn, +.rejectBtn, +.removeBtn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-sm); + transition: background 0.2s, color 0.2s; +} + +.approveBtn { + color: #15803d; + background: rgba(34, 197, 94, 0.1); +} + +.approveBtn:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.18); +} + +.rejectBtn { + color: #b45309; + background: rgba(251, 191, 36, 0.12); +} + +.rejectBtn:hover:not(:disabled) { + background: rgba(251, 191, 36, 0.2); +} + +.removeBtn { + color: #dc2626; + background: rgba(239, 68, 68, 0.08); +} + +.removeBtn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.14); +} + +.approveBtn:disabled, +.rejectBtn:disabled, +.removeBtn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.paginationWrap { + margin-top: 16px; +} diff --git a/apps/business/src/components/BlogCommentsSection.tsx b/apps/business/src/components/BlogCommentsSection.tsx new file mode 100644 index 0000000..f52a9a6 --- /dev/null +++ b/apps/business/src/components/BlogCommentsSection.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react' +import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' +import type { ProductComment } from '../types/comment' +import { formatCommentDate } from '../data/productComments' +import { ApiError } from '../lib/api' +import { + COMMENTS_PER_PAGE, + deleteComment, + listBlogComments, + updateCommentApproval, +} from '../services/commentService' +import { Pagination } from './Pagination' +import styles from './BlogCommentsSection.module.css' + +interface BlogCommentsSectionProps { + blogId: string + onCountChange?: (count: number) => void +} + +export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSectionProps) { + const [comments, setComments] = useState([]) + const [totalComments, setTotalComments] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [actionId, setActionId] = useState(null) + + const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE)) + + useEffect(() => { + const controller = new AbortController() + void loadComments(currentPage, controller.signal) + return () => controller.abort() + }, [blogId, currentPage]) + + async function loadComments(page: number, signal?: AbortSignal) { + setIsLoading(true) + setError('') + + try { + const data = await listBlogComments(blogId, page, COMMENTS_PER_PAGE, signal) + setComments(data.items) + setTotalComments(data.total) + onCountChange?.(data.total) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to load comments.') + } + } finally { + setIsLoading(false) + } + } + + async function toggleApproval(comment: ProductComment) { + setActionId(comment.id) + setError('') + + try { + const updated = await updateCommentApproval(comment.id, !comment.approved) + setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c))) + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to update comment approval.') + } + } finally { + setActionId(null) + } + } + + async function removeComment(commentId: string) { + setActionId(commentId) + setError('') + + try { + await deleteComment(commentId) + const nextTotal = totalComments - 1 + const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE)) + const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage + setTotalComments(nextTotal) + onCountChange?.(nextTotal) + + if (nextPage !== currentPage) { + setCurrentPage(nextPage) + } else { + setComments((prev) => prev.filter((c) => c.id !== commentId)) + } + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + } else { + setError('Unable to delete comment.') + } + } finally { + setActionId(null) + } + } + + return ( +
+
+

Comments

+ + {totalComments} {totalComments === 1 ? 'comment' : 'comments'} + +
+ + {error && ( +

+ {error} +

+ )} + + {isLoading ? ( +

Loading comments...

+ ) : totalComments === 0 ? ( +

No comments yet for this blog post.

+ ) : ( + <> +
+ {comments.map((comment) => ( +
+
+
+
{comment.author}
+
{formatCommentDate(comment.createdAt)}
+
+ + + {comment.likesCount} + +
+

{comment.text}

+
+ {comment.approved ? ( + + ) : ( + + )} + +
+
+ ))} +
+ + {totalComments > COMMENTS_PER_PAGE && ( +
+ +
+ )} + + )} +
+ ) +} diff --git a/apps/business/src/components/BrandModal.module.css b/apps/business/src/components/BrandModal.module.css new file mode 100644 index 0000000..d89a231 --- /dev/null +++ b/apps/business/src/components/BrandModal.module.css @@ -0,0 +1,184 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.25); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 20px; +} + +.modal { + width: 100%; + max-width: 520px; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px 24px 0; +} + +.title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +.closeBtn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: var(--text-secondary); + transition: background 0.2s, color 0.2s; +} + +.closeBtn:hover { + background: rgba(var(--primary-rgb) / 0.1); + color: var(--primary); +} + +.form { + padding: 20px 24px 24px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.field input, +.field textarea { + padding: var(--field-padding-y) var(--field-padding-x); + font-size: var(--field-font-size); + line-height: 1.4; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: var(--radius-sm); + transition: border-color 0.2s, box-shadow 0.2s; + outline: none; + box-sizing: border-box; +} + +.field input { + min-height: var(--field-height); +} + +.field input:focus, +.field textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); +} + +.field textarea { + resize: vertical; + min-height: calc(var(--field-height) + 24px); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 4px; +} + +.cancelBtn { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: background 0.2s; +} + +.cancelBtn:hover { + background: rgba(148, 163, 184, 0.15); +} + +.submitBtn { + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + color: white; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3); + transition: transform 0.2s, box-shadow 0.2s; +} + +.submitBtn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35); +} + +.overlayIn { + animation: overlayFadeIn 0.22s ease forwards; +} + +.overlayOut { + animation: overlayFadeOut 0.22s ease forwards; +} + +.modalIn { + animation: modalFadeIn 0.25s ease forwards; +} + +.modalOut { + animation: modalFadeOut 0.22s ease forwards; +} + +@keyframes overlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes overlayFadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes modalFadeOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.98); + } +} diff --git a/apps/business/src/components/BrandModal.tsx b/apps/business/src/components/BrandModal.tsx new file mode 100644 index 0000000..54a4e2e --- /dev/null +++ b/apps/business/src/components/BrandModal.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from 'react' +import { X } from 'lucide-react' +import { ImageCropper } from './ImageCropper' +import type { Brand, BrandFormData } from '../types/brand' +import styles from './BrandModal.module.css' + +interface BrandModalProps { + open: boolean + onClose: () => void + onSubmit: (data: BrandFormData) => void + editingBrand?: Brand | null + title?: string + isSubmitting?: boolean +} + +const ANIMATION_MS = 220 + +export function BrandModal({ + open, + onClose, + onSubmit, + editingBrand = null, + title = 'Add Brand', + isSubmitting = false, +}: BrandModalProps) { + const formRef = useRef(null) + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [nameEn, setNameEn] = useState('') + const [nameFa, setNameFa] = useState('') + const [about, setAbout] = useState('') + const [image, setImage] = useState(null) + const [imageMediaId, setImageMediaId] = useState(null) + + useEffect(() => { + if (open) { + setMounted(true) + setClosing(false) + setNameEn(editingBrand?.nameEn ?? '') + setNameFa(editingBrand?.nameFa ?? '') + setAbout(editingBrand?.about ?? '') + setImage(editingBrand?.imageUrl ?? null) + setImageMediaId(editingBrand?.imageMediaId ?? null) + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, editingBrand, mounted]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted) return null + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + onSubmit({ + nameFa: nameFa.trim(), + nameEn: nameEn.trim(), + about: about.trim(), + image, + imageMediaId, + }) + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="brand-modal-title" + > +
+

+ {title} +

+ +
+ +
+
+ + +
+ +
+ + setNameFa(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ + setNameEn(e.target.value)} + required + disabled={isSubmitting} + /> +
+ +
+ +