diff --git a/.env b/.env new file mode 100644 index 0000000..586081f --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +VITE_API_BASE_URL=http://localhost:5054 +VITE_API_FLAG=prod +VITE_GOOGLE_MAPS_API_KEY=stavitekljuclog + diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..90f9401 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,58 @@ +name: Frontend CI Checks + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for the main branch + push: + branches: [develop] + # Triggers the workflow on pull request events targeting the main branch + pull_request: + branches: [develop] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build-and-lint: # You can name the job anything descriptive + runs-on: ubuntu-latest # Use a Linux runner + + strategy: + matrix: + node-version: [20.15.1] # Specify the Node.js version(s) you use + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + # 1. Get the code from the repository + - name: Checkout code + uses: actions/checkout@v4 # Use the standard checkout action + + # 2. Setup Node.js environment + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' # Cache npm dependencies for faster builds + + # 3. Install dependencies (use 'ci' for clean installs in CI) + - name: Install dependencies + run: npm ci # 'npm ci' is generally preferred over 'npm install' in CI + + # 4. Run linters (if you have ESLint configured) + # Make sure you have a lint script in your package.json + # - name: Run linter (ESLint) + # run: npm run lint # Adjust if your lint script has a different name + + # 5. Run formatter check (if you use Prettier) + # Make sure you have a format check script in package.json (e.g., "prettier --check .") + # - name: Check formatting (Prettier) # Optional: Uncomment if using Prettier check + # run: npm run format:check # Adjust script name as needed + + # 6. Build the project (catches syntax errors, type errors if TS, build config issues) + - name: Build project + run: npm run build --if-present # Runs 'npm run build' if the script exists + + + # 7. Run tests (if you have tests configured - Vitest, Jest, etc.) + # Make sure you have a test script in package.json (e.g., "vitest run") + # - name: Run tests # Optional: Uncomment if using tests + # run: npm run test # Adjust script name as needed diff --git a/.gitignore b/.gitignore index fbddad8..efe72dd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.vscode node_modules dist @@ -98,12 +99,7 @@ web_modules/ # Yarn Integrity file .yarn-integrity -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local + # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e2f27ef --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ae9f4f8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,28 @@ +## storeId i buyerId + +prioritet: 0 + +Provuci kroz komponente ideve a ne nazive da se poziv uradi uspjesno, +Naravno korisnku prikazujete nazive ali id se salje bekendu. + +## import/export csv i xlsx + +prioritet: 0 + +Nesto tu steka + +## active store reload + +prioritet: 1 +Ne updatea se vDOM + +## active product preko tackice + +prioritet: 1 +Fali categoryId + +## edit store + +prioritet: 2 + +nije dodana lista regiona i mjesta diff --git a/headers.toml b/headers.toml new file mode 100644 index 0000000..357cf65 --- /dev/null +++ b/headers.toml @@ -0,0 +1,7 @@ +[functions] + directory = "netlify/functions" # Or your functions directory + +[[redirects]] + from = "/api/netlify/directions" # Client will call this path + to = "/.netlify/functions/extRoute" + status = 200 \ No newline at end of file diff --git a/index.html b/index.html index 0c589ec..f072439 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,22 @@ - + - Vite + React + + + + Bazaar Web Panel
diff --git a/netlify/functions/extRoute.js b/netlify/functions/extRoute.js new file mode 100644 index 0000000..38c460f --- /dev/null +++ b/netlify/functions/extRoute.js @@ -0,0 +1,5 @@ +const axios = require('axios'); + +exports.handler = async (event, context) => { + return await axios.get(event.url); +}; diff --git a/package-lock.json b/package-lock.json index f37c1db..6f1dd16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,45 @@ "name": "web-admin", "version": "0.0.0", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mapbox/polyline": "^1.2.1", + "@microsoft/signalr": "^8.0.7", + "@mui/icons-material": "^7.0.2", + "@mui/material": "^7.0.1", + "@mui/styles": "^6.4.11", + "@mui/x-charts": "^8.2.0", + "@react-google-maps/api": "^2.20.6", + "@react-oauth/google": "^0.12.1", + "@reduxjs/toolkit": "^2.6.1", + "axios": "^1.9.0", + "chart.js": "^4.4.9", + "crypto-js": "^4.2.0", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.1.0", + "i18next-http-backend": "^3.0.2", + "js-sha256": "^0.11.0", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.487.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-chartjs-2": "^5.3.0", + "react-countup": "^6.5.3", + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", + "react-funnel-pipeline": "^0.2.0", + "react-hot-toast": "^2.5.2", + "react-i18next": "^15.5.2", + "react-icons": "^5.5.0", + "react-redux": "^9.2.0", + "react-router-dom": "^7.4.1", + "react-toastify": "^11.0.5", + "react-world-flags": "^1.6.0", + "recharts": "^2.15.3", + "web-admin": "file:", + "xlsx": "^0.18.5", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -20,7 +57,9 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", - "vite": "^6.2.0" + "prettier": "^3.5.3", + "superagent": "^10.2.0", + "vite": "^6.3.5" } }, "node_modules/@ampproject/remapping": { @@ -28,7 +67,6 @@ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -41,8 +79,6 @@ "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", @@ -57,7 +93,6 @@ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -67,7 +102,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, - "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -93,12 +127,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/@babel/generator": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.27.0", "@babel/types": "^7.27.0", @@ -115,7 +153,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-validator-option": "^7.25.9", @@ -131,8 +168,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" @@ -146,7 +181,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", @@ -164,7 +198,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -173,8 +206,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -183,8 +214,6 @@ "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -194,7 +223,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -204,7 +232,6 @@ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/template": "^7.27.0", "@babel/types": "^7.27.0" @@ -217,8 +244,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.27.0" }, @@ -234,7 +259,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -250,7 +274,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -261,12 +284,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.3.tgz", + "integrity": "sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/parser": "^7.27.0", @@ -280,8 +310,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.27.0", @@ -299,8 +327,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -309,8 +335,6 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", - "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -319,6 +343,139 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -327,7 +484,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "aix" @@ -344,7 +500,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -361,7 +516,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -378,7 +532,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "android" @@ -395,7 +548,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -412,7 +564,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -429,7 +580,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -446,7 +596,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -463,7 +612,6 @@ "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -480,7 +628,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -497,7 +644,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -514,7 +660,6 @@ "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -531,7 +676,6 @@ "mips64el" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -548,7 +692,6 @@ "ppc64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -565,7 +708,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -582,7 +724,6 @@ "s390x" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -599,7 +740,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -616,7 +756,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -633,7 +772,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -650,7 +788,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -667,7 +804,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -684,7 +820,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "sunos" @@ -701,7 +836,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -718,7 +852,6 @@ "ia32" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -735,7 +868,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -745,11 +877,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", - "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", + "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", "dev": true, - "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" }, @@ -768,7 +899,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -781,17 +911,15 @@ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -802,21 +930,19 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", - "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", + "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -829,7 +955,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -853,7 +978,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -862,11 +986,10 @@ } }, "node_modules/@eslint/js": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", - "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", + "version": "9.25.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.0.tgz", + "integrity": "sha512-iWhsUS8Wgxz9AXNfvfOPFSW4VfMXdVhp1hjkZVhXCrpgh/aLcc45rX6MPu+tIVUWDw0HfNwth7O28M1xDxNf9w==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -876,31 +999,44 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.13.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "license": "Apache-2.0" + }, + "node_modules/@googlemaps/markerclusterer": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz", + "integrity": "sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==", + "license": "Apache-2.0", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "supercluster": "^8.0.1" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } @@ -910,7 +1046,6 @@ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" @@ -924,7 +1059,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -938,7 +1072,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -952,7 +1085,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=18.18" }, @@ -965,8 +1097,6 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -980,8 +1110,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -990,8 +1118,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -999,307 +1125,843 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.38.0.tgz", - "integrity": "sha512-ldomqc4/jDZu/xpYU+aRxo3V4mGCV9HeTgUBANI3oIQMOL+SsxB+S2lxMpkFp5UamSS3XuTMQVbsS24R4J4Qjg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.38.0.tgz", - "integrity": "sha512-VUsgcy4GhhT7rokwzYQP+aV9XnSLkkhlEJ0St8pbasuWO/vwphhZQxYEKUP3ayeCYLhk6gEtacRpYP/cj3GjyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.38.0.tgz", - "integrity": "sha512-buA17AYXlW9Rn091sWMq1xGUvWQFOH4N1rqUxGJtEQzhChxWjldGCCup7r/wUnaI6Au8sKXpoh0xg58a7cgcpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "node_modules/@mapbox/polyline": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mapbox/polyline/-/polyline-1.2.1.tgz", + "integrity": "sha512-sn0V18O3OzW4RCcPoUIVDWvEGQaBNH9a0y5lgqrf5hUycyw1CzrhEoxV5irzrMNXKCkw1xRsZXcaVbsVZggHXA==", + "dependencies": { + "meow": "^9.0.0" + }, + "bin": { + "polyline": "bin/polyline.bin.js" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.38.0.tgz", - "integrity": "sha512-Mgcmc78AjunP1SKXl624vVBOF2bzwNWFPMP4fpOu05vS0amnLcX8gHIge7q/lDAHy3T2HeR0TqrriZDQS2Woeg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@microsoft/signalr": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.38.0.tgz", - "integrity": "sha512-zzJACgjLbQTsscxWqvrEQAEh28hqhebpRz5q/uUd1T7VTwUNZ4VIXQt5hE7ncs0GrF+s7d3S4on4TiXUY8KoQA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "node_modules/@mui/core-downloads-tracker": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.0.2.tgz", + "integrity": "sha512-TfeFU9TgN1N06hyb/pV/63FfO34nijZRMqgHk0TJ3gkl4Fbd+wZ73+ZtOd7jag6hMmzO9HSrBc6Vdn591nhkAg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.38.0.tgz", - "integrity": "sha512-hCY/KAeYMCyDpEE4pTETam0XZS4/5GXzlLgpi5f0IaPExw9kuB+PDTOTLuPtM10TlRG0U9OSmXJ+Wq9J39LvAg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@mui/icons-material": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.0.2.tgz", + "integrity": "sha512-Bo57PFLOqXOqPNrXjd8AhzH5s6TCsNUQbvnQ0VKZ8D+lIlteqKnrk/O1luMJUc/BXONK7BfIdTdc7qOnXYbMdw==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@babel/runtime": "^7.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.0.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.38.0.tgz", - "integrity": "sha512-mimPH43mHl4JdOTD7bUMFhBdrg6f9HzMTOEnzRmXbOZqjijCw8LA5z8uL6LCjxSa67H2xiLFvvO67PT05PRKGg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@mui/material": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.0.2.tgz", + "integrity": "sha512-rjJlJ13+3LdLfobRplkXbjIFEIkn6LgpetgU/Cs3Xd8qINCCQK9qXQIjjQ6P0FXFTPFzEVMj0VgBR1mN+FhOcA==", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/core-downloads-tracker": "^7.0.2", + "@mui/system": "^7.0.2", + "@mui/types": "^7.4.1", + "@mui/utils": "^7.0.2", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.0.2", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.38.0.tgz", - "integrity": "sha512-tPiJtiOoNuIH8XGG8sWoMMkAMm98PUwlriOFCCbZGc9WCax+GLeVRhmaxjJtz6WxrPKACgrwoZ5ia/uapq3ZVg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@mui/private-theming": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.0.2.tgz", + "integrity": "sha512-6lt8heDC9wN8YaRqEdhqnm0cFCv08AMf4IlttFvOVn7ZdKd81PNpD/rEtPGLLwQAFyyKSxBG4/2XCgpbcdNKiA==", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.2", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.38.0.tgz", - "integrity": "sha512-wZco59rIVuB0tjQS0CSHTTUcEde+pXQWugZVxWaQFdQQ1VYub/sTrNdY76D1MKdN2NB48JDuGABP6o6fqos8mA==", + "node_modules/@mui/styled-engine": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.0.2.tgz", + "integrity": "sha512-11Bt4YdHGlh7sB8P75S9mRCUxTlgv7HGbr0UKz6m6Z9KLeiw1Bm9y/t3iqLLVMvSHYB6zL8X8X+LmfTE++gyBw==", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/styles": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@mui/styles/-/styles-6.4.11.tgz", + "integrity": "sha512-tuF8UT5d6gO4u2pKyYrgVGzbQtIJodILkBwB3iBy7Pg2htvX5ecNyEcKI2d0LQPNHt1ouECaF72GVuQTWLH0dA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/hash": "^0.9.2", + "@mui/private-theming": "^6.4.9", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "hoist-non-react-statics": "^3.3.2", + "jss": "^10.10.0", + "jss-plugin-camel-case": "^10.10.0", + "jss-plugin-default-unit": "^10.10.0", + "jss-plugin-global": "^10.10.0", + "jss-plugin-nested": "^10.10.0", + "jss-plugin-props-sort": "^10.10.0", + "jss-plugin-rule-value-function": "^10.10.0", + "jss-plugin-vendor-prefixer": "^10.10.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styles/node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styles/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styles/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.0.2.tgz", + "integrity": "sha512-yFUraAWYWuKIISPPEVPSQ1NLeqmTT4qiQ+ktmyS8LO/KwHxB+NNVOacEZaIofh5x1NxY8rzphvU5X2heRZ/RDA==", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/private-theming": "^7.0.2", + "@mui/styled-engine": "^7.0.2", + "@mui/types": "^7.4.1", + "@mui/utils": "^7.0.2", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz", + "integrity": "sha512-gUL8IIAI52CRXP/MixT1tJKt3SI6tVv4U/9soFsTtAsHzaJQptZ42ffdHZV3niX1ei0aUgMvOxBBN0KYqdG39g==", + "dependencies": { + "@babel/runtime": "^7.27.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.0.2.tgz", + "integrity": "sha512-72gcuQjPzhj/MLmPHLCgZjy2VjOH4KniR/4qRtXTTXIEwbkgcN+Y5W/rC90rWtMmZbjt9svZev/z+QHUI4j74w==", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/types": "^7.4.1", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.2.0.tgz", + "integrity": "sha512-Onf9ZrZmoTz3awrOKXtMDHqTXroGSdDJismIVQP71MHEcoOB+qvNDaekUJqkx8jGQwldTQnwDpkzXCQaiX9RRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.2", + "@mui/x-charts-vendor": "8.0.0", + "@mui/x-internals": "8.2.0", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.0.0.tgz", + "integrity": "sha512-aXv0QlCTkVxSNX+sHdG92jaQMEWJFw2NuxBx599JyZ5Ij038JwdU9x0dArfPdtpdCX0A19lHKHYgZ8S0I4LpnQ==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@types/d3-color": "^3.1.3", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1", + "delaunator": "^5.0.1", + "robust-predicates": "^3.0.2" + } + }, + "node_modules/@mui/x-internals": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.2.0.tgz", + "integrity": "sha512-qV4Qr+m4sAPBSuqu8/Ofi5m+nMMvIybGno6cp757bHSmwxkqrn5SKaGyFnH5kB58fOhYA9hG1UivFp7mO1dE4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz", + "integrity": "sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-google-maps/api": { + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/@react-google-maps/api/-/api-2.20.6.tgz", + "integrity": "sha512-frxkSHWbd36ayyxrEVopSCDSgJUT1tVKXvQld2IyzU3UnDuqqNA3AZE4/fCdqQb2/zBQx3nrWnZB1wBXDcrjcw==", + "dependencies": { + "@googlemaps/js-api-loader": "1.16.8", + "@googlemaps/markerclusterer": "2.5.3", + "@react-google-maps/infobox": "2.20.0", + "@react-google-maps/marker-clusterer": "2.20.0", + "@types/google.maps": "3.58.1", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19", + "react-dom": "^16.8 || ^17 || ^18 || ^19" + } + }, + "node_modules/@react-google-maps/infobox": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/infobox/-/infobox-2.20.0.tgz", + "integrity": "sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==", + "license": "MIT" + }, + "node_modules/@react-google-maps/marker-clusterer": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz", + "integrity": "sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==", + "license": "MIT" + }, + "node_modules/@react-oauth/google": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", + "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.7.0.tgz", + "integrity": "sha512-XVwolG6eTqwV0N8z/oDlN93ITCIGIop6leXlGJI/4EKy+0POYkR+ABHRSdGXY+0MQvJBP8yAzh+EYFxTuvmBiQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", + "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.38.0.tgz", - "integrity": "sha512-fQgqwKmW0REM4LomQ+87PP8w8xvU9LZfeLBKybeli+0yHT7VKILINzFEuggvnV9M3x1Ed4gUBmGUzCo/ikmFbQ==", + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", + "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "android" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.38.0.tgz", - "integrity": "sha512-hz5oqQLXTB3SbXpfkKHKXLdIp02/w3M+ajp8p4yWOWwQRtHWiEOCKtc9U+YXahrwdk+3qHdFMDWR5k+4dIlddg==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", + "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.38.0.tgz", - "integrity": "sha512-NXqygK/dTSibQ+0pzxsL3r4Xl8oPqVoWbZV9niqOnIHV/J92fe65pOir0xjkUZDRSPyFRvu+4YOpJF9BZHQImw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", + "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ] }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.38.0.tgz", - "integrity": "sha512-GEAIabR1uFyvf/jW/5jfu8gjM06/4kZ1W+j1nWTSSB3w6moZEBm7iBtzwQ3a1Pxos2F7Gz+58aVEnZHU295QTg==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", + "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ] }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.38.0.tgz", - "integrity": "sha512-9EYTX+Gus2EGPbfs+fh7l95wVADtSQyYw4DfSBcYdUEAmP2lqSZY0Y17yX/3m5VKGGJ4UmIH5LHLkMJft3bYoA==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", + "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", "cpu": [ - "riscv64" + "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ] }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.38.0.tgz", - "integrity": "sha512-Mpp6+Z5VhB9VDk7RwZXoG2qMdERm3Jw07RNlXHE0bOnEeX+l7Fy4bg+NxfyN15ruuY3/7Vrbpm75J9QHFqj5+Q==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", + "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", "cpu": [ - "s390x" + "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.38.0.tgz", - "integrity": "sha512-vPvNgFlZRAgO7rwncMeE0+8c4Hmc+qixnp00/Uv3ht2x7KYrJ6ERVd3/R0nUtlE6/hu7/HiiNHJ/rP6knRFt1w==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", + "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", "cpu": [ - "x64" + "arm" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.38.0.tgz", - "integrity": "sha512-q5Zv+goWvQUGCaL7fU8NuTw8aydIL/C9abAVGCzRReuj5h30TPx4LumBtAidrVOtXnlB+RZkBtExMsfqkMfb8g==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", + "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.38.0.tgz", - "integrity": "sha512-u/Jbm1BU89Vftqyqbmxdq14nBaQjQX1HhmsdBWqSdGClNaKwhjsg5TpW+5Ibs1mb8Es9wJiMdl86BcmtUVXNZg==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", + "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.38.0.tgz", - "integrity": "sha512-mqu4PzTrlpNHHbu5qleGvXJoGgHpChBlrBx/mEhTPpnAL1ZAYFlvHD7rLK839LLKQzqEQMFJfGrrOHItN4ZQqA==", + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", + "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", "cpu": [ - "ia32" + "loong64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ - "win32" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", + "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", + "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", + "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", + "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", + "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", + "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", + "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", + "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.38.0.tgz", - "integrity": "sha512-jjqy3uWlecfB98Psxb5cD6Fny9Fupv9LrDSPTQZUROqjvZmcCqNu4UMl7qqhlUUGpwiAkotj6GYu4SZdcr/nLw==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", + "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -1309,11 +1971,10 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } @@ -1323,7 +1984,6 @@ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -1334,57 +1994,158 @@ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "dev": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, + "dev": true + }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "19.0.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", - "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", - "dev": true, - "license": "MIT", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz", + "integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", - "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", + "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "dev": true, - "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "react-refresh": "^0.17.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -1393,12 +2154,23 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1411,17 +2183,23 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1438,7 +2216,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -1453,22 +2230,83 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "dev": true + }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1493,7 +2331,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1507,20 +2344,70 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", - "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", "dev": true, "funding": [ { @@ -1535,15 +2422,25 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1555,12 +2452,39 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", + "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1572,29 +2496,116 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true, - "license": "MIT" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/countup.js": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.8.2.tgz", + "integrity": "sha512-UtRoPH6udaru/MOhhZhI/GZHJKAyAxuKItD2Tr7AbrqrOPBX/uejWBBJt8q86169AMqKkE9h9/24kFWbUk/Bag==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1604,19 +2615,254 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", "license": "MIT" }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -1629,19 +2875,219 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, - "license": "MIT" + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/electron-to-chromium": { - "version": "1.5.128", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz", - "integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==", - "dev": true, - "license": "ISC" + "version": "1.5.139", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.139.tgz", + "integrity": "sha512-GGnRYOTdN5LYpwbIr0rwP/ZHOQSvAF6TG0LSzp28uCBb9JiXHJGmaaKw29qjNJc5bGnnp6kXJqRnGMQoELwi5w==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } }, "node_modules/esbuild": { "version": "0.25.2", @@ -1649,7 +3095,6 @@ "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1689,7 +3134,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -1698,8 +3142,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -1708,20 +3150,19 @@ } }, "node_modules/eslint": { - "version": "9.23.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", - "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", + "version": "9.25.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.0.tgz", + "integrity": "sha512-MsBdObhM4cEwkzCiraDv7A6txFXEqtNXOb877TsSp2FCkBNl8JfVQrmiuDqC1IkejT6JLPzYBXx/xAiYhyzgGA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.2.0", - "@eslint/core": "^0.12.0", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.23.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/js": "9.25.0", + "@eslint/plugin-kit": "^0.2.8", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1773,7 +3214,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -1786,7 +3226,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", "dev": true, - "license": "MIT", "peerDependencies": { "eslint": ">=8.40" } @@ -1796,7 +3235,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -1813,7 +3251,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1826,7 +3263,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", @@ -1844,7 +3280,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -1857,7 +3292,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -1870,7 +3304,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -1880,38 +3313,95 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, - "license": "MIT" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" }, @@ -1919,12 +3409,27 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -1941,7 +3446,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -1954,8 +3458,62 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.3.tgz", + "integrity": "sha512-pQEHGLZjLRyfLCe6r6n8IQGqHEceKfYR5tIf/iUDn5SabaitfVR/pIskxnyvSSl122J63rFY17i68hrfK0BVOA==", "dev": true, - "license": "ISC" + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } }, "node_modules/fsevents": { "version": "2.3.3", @@ -1963,7 +3521,6 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1972,22 +3529,63 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2000,7 +3598,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -2008,32 +3605,205 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/i18next": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz", + "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz", + "integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2050,17 +3820,60 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2070,7 +3883,6 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2078,26 +3890,42 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true + }, + "node_modules/js-sha256": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz", + "integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==", + "license": "MIT" }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2109,8 +3937,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, @@ -2122,29 +3948,30 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -2152,22 +3979,132 @@ "node": ">=6" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/jss": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/jss" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "node_modules/jss-plugin-camel-case": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-default-unit": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-global": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-nested": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-props-sort": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-rule-value-function": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-vendor-prefixer": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.10.0" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -2176,12 +4113,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -2192,29 +4133,150 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2222,12 +4284,23 @@ "node": "*" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/nanoid": { "version": "3.3.11", @@ -2240,7 +4313,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2252,22 +4324,105 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, - "license": "MIT" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -2285,7 +4440,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2301,7 +4455,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -2312,12 +4465,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -2325,12 +4484,27 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2340,7 +4514,19 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "engines": { "node": ">=8" } @@ -2348,9 +4534,19 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, - "license": "ISC" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/postcss": { "version": "8.5.3", @@ -2371,7 +4567,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -2386,68 +4581,573 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "engines": { + "node": ">=8" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-countup": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz", + "integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==", + "license": "MIT", + "dependencies": { + "countup.js": "^2.8.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-funnel-pipeline": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/react-funnel-pipeline/-/react-funnel-pipeline-0.2.0.tgz", + "integrity": "sha512-sjfwaTpk+aDNsUlghkDUrnmlI87vxLljxPcpLgDzMZ0SsVnWXyJ8fPpjIzQbEIjlWszf783u/aU8axRPOGe8Tw==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", + "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-i18next": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz", + "integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", + "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.1.tgz", + "integrity": "sha512-/jjU3fcYNd2bwz9Q0xt5TwyiyoO8XjSEFXJY4O/lMAlkGTHWuHRAbR9Etik+lSDqMC7A7mz3UlXzgYT6Vl58sA==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.1.tgz", + "integrity": "sha512-5DPSPc7ENrt2tlKPq0FtpG80ZbqA9aIKEyqX6hSNJDlol/tr6iqCK4crqdsusmOSSotq6zDsn0y3urX9TuTNmA==", + "dependencies": { + "react-router": "7.5.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-world-flags": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/react-world-flags/-/react-world-flags-1.6.0.tgz", + "integrity": "sha512-eutSeAy5YKoVh14js/JUCSlA6EBk1n4k+bDaV+NkNB50VhnG+f4QDTpYycnTUTsZ5cqw/saPmk0Z4Fa0VVZ1Iw==", + "license": "MIT", + "dependencies": { + "svg-country-flags": "^1.2.10", + "svgo": "^3.0.2", + "world-countries": "^5.0.0" + }, + "peerDependencies": { + "react": ">=0.14" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/recharts": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, "engines": { - "node": ">=0.10.0" + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", "peerDependencies": { - "react": "^19.1.0" + "redux": "^5.0.0" } }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "license": "MIT", + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", "engines": { "node": ">=4" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.38.0.tgz", - "integrity": "sha512-5SsIRtJy9bf1ErAOiFMFzl64Ex9X5V7bnJ+WlFMb+zmP459OSWCEG7b0ERZ+PEU7xPt4OG3RHbrp1LJlXxYTrw==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz", + "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "1.0.7" }, @@ -2459,51 +5159,53 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.38.0", - "@rollup/rollup-android-arm64": "4.38.0", - "@rollup/rollup-darwin-arm64": "4.38.0", - "@rollup/rollup-darwin-x64": "4.38.0", - "@rollup/rollup-freebsd-arm64": "4.38.0", - "@rollup/rollup-freebsd-x64": "4.38.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.38.0", - "@rollup/rollup-linux-arm-musleabihf": "4.38.0", - "@rollup/rollup-linux-arm64-gnu": "4.38.0", - "@rollup/rollup-linux-arm64-musl": "4.38.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.38.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.38.0", - "@rollup/rollup-linux-riscv64-gnu": "4.38.0", - "@rollup/rollup-linux-riscv64-musl": "4.38.0", - "@rollup/rollup-linux-s390x-gnu": "4.38.0", - "@rollup/rollup-linux-x64-gnu": "4.38.0", - "@rollup/rollup-linux-x64-musl": "4.38.0", - "@rollup/rollup-win32-arm64-msvc": "4.38.0", - "@rollup/rollup-win32-ia32-msvc": "4.38.0", - "@rollup/rollup-win32-x64-msvc": "4.38.0", + "@rollup/rollup-android-arm-eabi": "4.40.0", + "@rollup/rollup-android-arm64": "4.40.0", + "@rollup/rollup-darwin-arm64": "4.40.0", + "@rollup/rollup-darwin-x64": "4.40.0", + "@rollup/rollup-freebsd-arm64": "4.40.0", + "@rollup/rollup-freebsd-x64": "4.40.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", + "@rollup/rollup-linux-arm-musleabihf": "4.40.0", + "@rollup/rollup-linux-arm64-gnu": "4.40.0", + "@rollup/rollup-linux-arm64-musl": "4.40.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-musl": "4.40.0", + "@rollup/rollup-linux-s390x-gnu": "4.40.0", + "@rollup/rollup-linux-x64-gnu": "4.40.0", + "@rollup/rollup-linux-x64-musl": "4.40.0", + "@rollup/rollup-win32-arm64-msvc": "4.40.0", + "@rollup/rollup-win32-ia32-msvc": "4.40.0", + "@rollup/rollup-win32-x64-msvc": "4.40.0", "fsevents": "~2.3.2" } }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -2516,27 +5218,153 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "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/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==" + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -2544,12 +5372,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "node_modules/superagent": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.0.tgz", + "integrity": "sha512-IKeoGox6oG9zyDeizaezkJ2/aK0wc5la9st7WsAKyrAkfJ56W3whVbVtF68k6wuc87/y9T85NyON5FLz7Mrzzw==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -2557,12 +5418,120 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-country-flags": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/svg-country-flags/-/svg-country-flags-1.2.10.tgz", + "integrity": "sha512-xrqwo0TYf/h2cfPvGpjdSuSguUbri4vNNizBnwzoZnX0xGo3O5nGJMlbYEp7NOYcnPGBm6LE2axqDWSB847bLw==", + "license": "PD" + }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "engines": { + "node": ">=8" + } + }, + "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==" + }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -2570,6 +5539,26 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -2589,7 +5578,6 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -2606,21 +5594,72 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -2683,12 +5722,40 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/web-admin": { + "resolved": "", + "link": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -2699,35 +5766,130 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/world-countries": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/world-countries/-/world-countries-5.1.0.tgz", + "integrity": "sha512-CXR6EBvTbArDlDDIWU3gfKb7Qk0ck2WNZ234b/A0vuecPzIfzzxH+O6Ejnvg1sT8XuiZjVlzOH0h08ZtaO7g0w==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "dev": true, - "license": "ISC" + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "engines": { + "node": ">=10" + } }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index e3d4f98..e138c38 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,45 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mapbox/polyline": "^1.2.1", + "@microsoft/signalr": "^8.0.7", + "@mui/icons-material": "^7.0.2", + "@mui/material": "^7.0.1", + "@mui/styles": "^6.4.11", + "@mui/x-charts": "^8.2.0", + "@react-google-maps/api": "^2.20.6", + "@react-oauth/google": "^0.12.1", + "@reduxjs/toolkit": "^2.6.1", + "axios": "^1.9.0", + "chart.js": "^4.4.9", + "crypto-js": "^4.2.0", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "i18next": "^25.2.1", + "i18next-browser-languagedetector": "^8.1.0", + "i18next-http-backend": "^3.0.2", + "js-sha256": "^0.11.0", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.487.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-chartjs-2": "^5.3.0", + "react-countup": "^6.5.3", + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", + "react-funnel-pipeline": "^0.2.0", + "react-hot-toast": "^2.5.2", + "react-i18next": "^15.5.2", + "react-icons": "^5.5.0", + "react-redux": "^9.2.0", + "react-router-dom": "^7.4.1", + "react-toastify": "^11.0.5", + "react-world-flags": "^1.6.0", + "recharts": "^2.15.3", + "web-admin": "file:", + "xlsx": "^0.18.5", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -22,6 +59,8 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", - "vite": "^6.2.0" + "prettier": "^3.5.3", + "superagent": "^10.2.0", + "vite": "^6.3.5" } } diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..c269214 --- /dev/null +++ b/public/_headers @@ -0,0 +1,2 @@ +/* + Access-Control-Allow-Origin: * \ No newline at end of file diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..78f7f20 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/public/locales/ba/translation.json b/public/locales/ba/translation.json new file mode 100644 index 0000000..3cef3ec --- /dev/null +++ b/public/locales/ba/translation.json @@ -0,0 +1,270 @@ +{ + "ads.adsManagement": "Upravljanje oglasima", + "ads.adminPanel": "Administratorska ploča", + "ads.advertisements": "Oglasi", + "ads.createAd": "Kreiraj oglas", + "ads.searchAds": "Pretraži oglase", + "categories.categories": "Kategorije", + "categories.addCategory": "Dodaj kategoriju", + "categories.searchCategory": "Pretraži kategoriju", + "common.searchPlaceholder": "Pretraži...", + "common.welcome": "Dobrodošli", + "common.orders": "Narudžbe", + "common.status": "Status", + "common.all": "Sve", + "common.searchOrders": "Pretraži narudžbe", + "common.loginToContinue": "Prijavite se za nastavak", + "common.login": "Prijava", + "common.requests": "Zahtjevi", + "common.searchUser": "Pretraži korisnika", + "common.addToCart": "Dodaj u korpu", + "common.viewDetails": "Pogledaj detalje", + "common.price": "Cijena", + "common.quantity": "Količina", + "common.subtotal": "Međuzbir", + "common.total": "Ukupno", + "common.checkout": "Plaćanje", + "common.continueShopping": "Nastavi kupovinu", + "common.save": "Sačuvaj", + "common.cancel": "Otkaži", + "common.oops": "Ups! Nešto je pošlo po zlu.", + "common.loading": "Učitavanje...", + "common.adminPanel": "Administratorska ploča", + "common.first": "Prva", + "common.last": "Posljednja", + "common.confirm": "Potvrdi", + "common.delete": "Obriši", + "common.edit": "Uredi", + "common.view": "Pogledaj", + "common.close": "Zatvori", + "common.done": "Gotovo", + "common.browseFiles": "Pregledaj fajlove", + "common.dragFilesToUpload": "Prevucite fajlove za učitavanje", + "common.or": "ili", + "common.maxFileSize": "Maksimalna veličina fajla: 50MB — Podržani formati: JPG, PNG, GIF, SVG, WEBP", + "common.languageManagement": "Upravljanje jezicima", + "common.currentLanguage": "Trenutni jezik", + "common.languageCode": "Kod jezika", + "common.languageName": "Naziv jezika", + "common.actions": "Akcije", + "common.addLanguage": "Dodaj jezik", + "common.editLanguage": "Uredi jezik", + "common.deleteLanguage": "Obriši jezik", + "common.availableLanguages": "Dostupni jezici", + "common.addNewLanguage": "Dodaj novi jezik", + "common.saveLanguage": "Sačuvaj jezik", + "common.translations": "Prijevodi", + "common.allRoutes": "Sve rute", + "common.routes": "Rute", + "common.createRoute": "Kreiraj rutu", + "chat.openTicketToViewChat": "Otvorite ovaj tiket da biste vidjeli chat.", + "roles.Buyer": "Kupac", + "roles.Seller": "Prodavač", + "roles.Admin": "Administrator", + "roles.Unknown": "Nepoznata uloga", + "nav.analytics": "Analitika", + "nav.users": "Korisnici", + "nav.requests": "Zahtjevi", + "nav.stores": "Prodavnice", + "nav.categories": "Kategorije", + "nav.orders": "Narudžbe", + "nav.advertisements": "Oglasi", + "nav.chat": "Chat", + "nav.routes": "Rute", + "nav.languages": "Jezici", + "usersPage.username": "Korisničko ime", + "usersPage.email": "Email", + "usersPage.role": "Uloga", + "usersPage.active": "Aktivan", + "usersPage.actions": "Akcije", + "usersPage.addUser": "Dodaj korisnika", + "usersPage.searchUser": "Pretraži korisnika", + "usersPage.title": "Upravljanje korisnicima", + "usersPage.phoneNumber": "Broj telefona", + "usersPage.saveChanges": "Sačuvaj promjene", + "usersPage.deleteUser": "Obriši korisnika", + "usersPage.confirmDeleteUser": "Jeste li sigurni da želite obrisati ovog korisnika?", + "usersPage.noUsers": "Nema dostupnih korisnika", + "usersPage.loadingUsers": "Učitavanje korisnika...", + "analytics.dashboardTitle": "Analitika kontrolne ploče", + "analytics.totalAds": "Ukupno oglasa", + "analytics.totalViews": "Ukupno pregleda", + "analytics.totalClicks": "Ukupno klikova", + "analytics.totalConversions": "Ukupno konverzija", + "analytics.conversionRevenue": "Prihod od konverzija", + "analytics.clicksRevenue": "Prihod od klikova", + "analytics.viewsRevenue": "Prihod od pregleda", + "analytics.totalProducts": "Ukupno proizvoda", + "analytics.productPerformance": "Učinkovitost proizvoda", + "analytics.noProducts": "Nema proizvoda za prikaz ili se još učitavaju...", + "analytics.storeEarnings": "Zarada prodavnice (prošli mjesec)", + "analytics.realtimeEvents": "Događaji u stvarnom vremenu", + "analytics.noEvents": "Još nema primljenih događaja", + "analytics.lastError": "Posljednja greška", + "sellerAnalytics.totalEarnings": "Ukupna zarada", + "sellerAnalytics.sellerProfit": "Profit prodavača", + "sellerAnalytics.clickRevenue": "Prihod od klikova", + "sellerAnalytics.viewRevenue": "Prihod od pregleda", + "sellerAnalytics.conversionRevenue": "Prihod od konverzija", + "sellerAnalytics.clickRevenueOverTime": "Prihod od klikova kroz vrijeme", + "sellerAnalytics.viewRevenueOverTime": "Prihod od pregleda kroz vrijeme", + "sellerAnalytics.conversionRevenueOverTime": "Prihod od konverzija kroz vrijeme", + "routes.availableRoutes": "Dostupne rute", + "routes.noRoutes": "Nema dostupnih ruta", + "routes.loadingMap": "Učitavanje mape za ID rute: {{id}}...", + "routes.selectRoute": "Odaberite rutu sa liste da biste je vidjeli na mapi", + "routes.routeDetails": "Detalji rute", + "routes.directions": "Upute", + "routes.routeId": "ID rute: {{id}}", + "ads.adType": "Tip oglasa", + "ads.triggers": "Okidači", + "ads.startTime": "Vrijeme početka", + "ads.endTime": "Vrijeme završetka", + "ads.isActive": "Aktivan", + "ads.advertisementItems": "Stavke oglasa", + "ads.conversionPrice": "Cijena konverzije", + "ads.storeName": "Naziv prodavnice", + "ads.address": "Adresa", + "ads.description": "Opis", + "ads.place": "Mjesto", + "ads.category": "Kategorija", + "auth.login": "Prijava", + "auth.logout": "Odjava", + "auth.email": "Email", + "auth.password": "Lozinka", + "auth.forgotPassword": "Zaboravili ste lozinku?", + "auth.signIn": "Prijavite se", + "auth.signUp": "Registrujte se", + "auth.rememberMe": "Zapamti me", + "errors.connectionError": "Greška u vezi", + "errors.failedToConnect": "Neuspješno povezivanje", + "errors.authTokenMissing": "Nedostaje autentifikacijski token", + "errors.loadingError": "Neuspješno učitavanje podataka", + "errors.deleteError": "Neuspješno brisanje stavke", + "errors.updateError": "Neuspješno ažuriranje stavke", + "errors.createError": "Neuspješno kreiranje stavke", + "stores.stores": "Prodavnice", + "stores.searchStore": "Pretraži prodavnicu", + "stores.addStore": "Dodaj prodavnicu", + "common.userCreatedSuccessfully": "Korisnik je uspješno kreiran!", + "common.createNewUser": "Kreiraj novog korisnika", + "common.role": "Uloga", + "common.createUser": "Kreiraj korisnika", + "common.loadingUserData": "Učitavanje podataka korisnika...", + "common.userDetails": "Detalji korisnika", + "common.name": "Ime", + "common.email": "Email", + "common.phoneNumber": "Broj telefona", + "common.saveChanges": "Sačuvaj promjene", + "common.deleteUser": "Obriši korisnika", + "common.confirmDeleteUser": "Jeste li sigurni da želite obrisati ovog korisnika?", + "common.noUsers": "Nema dostupnih korisnika", + "common.userManagement": "Upravljanje korisnicima", + "common.addUser": "Dodaj korisnika", + "common.administrator": "Administrator", + "common.logout": "Odjava", + "common.analytics": "Analitika", + "common.users": "Korisnici", + "common.stores": "Prodavnice", + "common.categories": "Kategorije", + "common.advertisements": "Oglasi", + "common.chat": "Razgovori", + "common.languages": "Jezici", + "common.pasteTranslations" : "Zalijepi prijevod", + + "common.translationJsonHint" : "Dodajte tekst u JSON formatu" , + "common.totalAds": "Ukupno oglasa", + "analytics.storePerformance": "Učinkovitost prodavnice", + "analytics.revenueAndProfitAnalysis": "Analiza prihoda i profita", + "analytics.totalRevenue": "Ukupan prihod", + "analytics.fromAllAdvertisingSources": "Iz svih oglasnih izvora", + "analytics.clickRevenue": "Prihod od klikova", + "analytics.fromClicks": "Od {{count}} klikova", + "analytics.viewRevenue": "Prihod od pregleda", + "analytics.fromViews": "Od {{count}} pregleda", + "analytics.fromConversions": "Od {{count}} konverzija", + "analytics.revenueBySourceOverTime": "Prihod po izvoru kroz vrijeme", + "analytics.revenueDistribution": "Distribucija prihoda", + "analytics.totalEarnedProfitFromAds": "Ukupan zarađeni profit od oglasa", + "common.unknownProduct": "Nepoznat proizvod", + "analytics.detailedAnalyticsFor": "Detaljna analitika za {{storeName}}", + "analytics.unknownStore": "Nepoznata prodavnica", + "analytics.storeEarningsPastMonth": "Zarada prodavnice (prošli mjesec)", + "analytics.noProductsToDisplay": "Nema proizvoda za prikaz ili se još učitavaju...", + "analytics.noStoresToDisplay": "Nema prodavnica za prikaz ili se još učitavaju...", + "analytics.conversionsRevenue": "Prihod od konverzija", + "analytics.dashboardAnalytics": "Analitika kontrolne table", + "analytics.paretoChart": "Pareto dijagram", + "analytics.dealsAmount": "Iznos ponuda", + "analytics.deals": "Ponude", + "analytics.storeRevenue": "Prihod prodavnice", + "analytics.adminProfit": "Profit administratora", + "analytics.taxRate": "Poreska stopa (%)", + "analytics.topRated": "Najbolje ocijenjeno", + "analytics.lowestRated": "Najniže ocijenjeno", + "analytics.topStores": "Najbolje prodavnice", + "analytics.topProducts": "Najbolji proizvodi", + "analytics.topCategories": "Najbolje kategorije", + "analytics.topUsers": "Najbolji korisnici", + "analytics.topProductsByAdRevenue": "Najbolji proizvodi po prihodu od oglasa", + "common.noPendingUsersFound": "Nema korisnika na čekanju.", + "analytics.picture": "Slika", + "analytics.pendingUsers": "Korisnici na čekanju", + "analytics.pendingRequests": "Zahtjevi na čekanju", + "analytics.pendingRequestsTitle": "Zahtjevi na čekanju", + "analytics.pendingRequestsDescription": "Zahtjevi na čekanju su zahtjevi koji čekaju obradu od strane administratora.", + "analytics.pendingUsersTitle": "Korisnici na čekanju", + "analytics.pendingUsersDescription": "Korisnici na čekanju su korisnici koji čekaju odobrenje administratora.", + "analytics.latestAdUpdate": "Zadnje ažuriranje oglasa", + "analytics.latestView": "Zadnji pregled", + "analytics.latestClick": "Zadnji klik", + "analytics.latestConversion": "Zadnja konverzija", + "analytics.history": "Historija", + "analytics.views": "Pregledi", + "common.clicks": "Klikovi", + "analytics.active": "Aktivan", + "analytics.status": "Status", + "analytics.conversionPrice": "Cijena konverzije", + "analytics.storeName": "Naziv prodavnice", + "analytics.comparedToLastMonth": "U poređenju s prošlim mjesecom", + "analytics.comparedToLastYear": "U poređenju s prošlom godinom", + "analytics.comparedToPreviousMonth": "U poređenju s prethodnim mjesecom", + "analytics.comparedToPreviousYear": "U poređenju s prethodnom godinom", + "analytics.comparedToNextMonth": "U poređenju sa sljedećim mjesecom", + "analytics.comparedToNextYear": "U poređenju sa sljedećom godinom", + "common.picture": "Slika", + "common.details": "Detalji", + "common.addItem": "Dodaj stavku", + "common.adText": "Tekst oglasa", + "common.product": "Proizvod", + "common.username": "Korisničko ime", + "common.store": "Prodavnica", + "common.deliveryAddress": "Adresa dostave", + "common.deliveryStatus": "Status dostave", + "common.deliveryDate": "Datum dostave", + "common.deliveryTime": "Vrijeme dostave", + "common.deliveryPrice": "Cijena dostave", + "common.active": "Aktivan", + "common.inactive": "Neaktivan", + "common.saveAd": "Spasi oglas", + "common.viewPrice": "Cijena pregleda", + "common.clickPrice": "Cijena klika", + "common.conversionPrice": "Cijena konverzije", + "common.storeName": "Naziv prodavnice", + "common.storeId": "ID prodavnice", + "common.displayingPage": "Prikaz stranice", + "common.tax": "Porez", + "common.totalMonthlyIncome": "Ukupan mjesečni prihod", + "common.taxedMonthlyIncome": "Oporezovani mjesečni prihod", + "common.addProduct": "Dodaj proizvod", + "common.products": "Proizvodi", + "common.tickets": "Tiketi", + "common.searchTickets": "Pretraži tikete...", + "common.noTicketsFound": "Nijedan tiket nije pronađen.", + "common.ticket": "Tiket", + "common.views": "Pregledi", + "common.productCategory": "Kategorija proizvoda", + "common.storeCategory": "Kategorija prodavnice", + "common.orderNumber": "Broj narudžbe", + "common.created": "Kreirano" +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 0000000..5602b58 --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,290 @@ +{ + "ads.adsManagement": "Ads Management", + "ads.adminPanel": "Admin Panel", + "ads.advertisements": "Advertisements", + "ads.createAd": "Create Ad", + "ads.searchAds": "Search Ads", + "categories.categories": "Categories", + "categories.addCategory": "Add Category", + "categories.searchCategory": "Search Category", + "common.searchPlaceholder": "Search...", + "common.welcome": "Welcome", + "common.orders": "Orders", + "common.status": "Status", + "common.all": "All", + "common.searchOrders": "Search Orders", + "common.loginToContinue": "Login to continue", + "common.login": "Login", + "common.requests": "Requests", + "common.searchUser": "Search User", + "common.addToCart": "Add to Cart", + "common.viewDetails": "View Details", + "common.price": "Price", + "common.quantity": "Quantity", + "common.subtotal": "Subtotal", + "common.total": "Total", + "common.checkout": "Checkout", + "common.continueShopping": "Continue Shopping", + "common.save": "Save", + "common.cancel": "Cancel", + "common.oops": "Oops! Something went wrong.", + "common.loading": "Loading...", + "common.adminPanel": "Admin Panel", + "common.first": "First", + "common.last": "Last", + "common.confirm": "Confirm", + "common.delete": "Delete", + "common.edit": "Edit", + "common.view": "View", + "common.close": "Close", + "common.done": "Done", + "common.browseFiles": "Browse Files", + "common.dragFilesToUpload": "Drag files to upload", + "common.or": "or", + "common.maxFileSize": "Max file size: 50MB — Supported types: JPG, PNG, GIF, SVG, WEBP", + "common.languageManagement": "Language Management", + "common.currentLanguage": "Current Language", + "common.languageCode": "Language Code", + "common.languageName": "Language Name", + "common.actions": "Actions", + "common.addLanguage": "Add Language", + "common.editLanguage": "Edit Language", + "common.deleteLanguage": "Delete Language", + "common.availableLanguages": "Available Languages", + "common.addNewLanguage": "Add New Language", + "common.saveLanguage": "Save Language", + "common.translations": "Translations", + "common.allRoutes": "All Routes", + "common.routes": "Routes", + "common.createRoute": "Create Route", + "chat.openTicketToViewChat": "Open this ticket to view the chat.", + "roles.Buyer": "Buyer", + "roles.Seller": "Seller", + "roles.Admin": "Administrator", + "roles.Unknown": "Unknown Role", + "nav.analytics": "Analytics", + "nav.users": "Users", + "nav.requests": "Requests", + "nav.stores": "Stores", + "nav.categories": "Categories", + "nav.orders": "Orders", + "nav.advertisements": "Advertisements", + "nav.chat": "Chat", + "nav.routes": "Routes", + "nav.languages": "Languages", + "usersPage.username": "Username", + "usersPage.email": "Email", + "usersPage.role": "Role", + "usersPage.active": "Active", + "usersPage.actions": "Actions", + "usersPage.addUser": "Add User", + "usersPage.searchUser": "Search User", + "usersPage.title": "User Management", + "usersPage.phoneNumber": "Phone Number", + "usersPage.saveChanges": "Save Changes", + "usersPage.deleteUser": "Delete User", + "usersPage.confirmDeleteUser": "Are you sure you want to delete this user?", + "usersPage.noUsers": "No users available", + "usersPage.loadingUsers": "Loading users...", + "analytics.dashboardTitle": "Dashboard Analytics", + "analytics.totalAds": "Total Ads", + "analytics.totalViews": "Total Views", + "analytics.totalClicks": "Total Clicks", + "analytics.totalConversions": "Total Conversions", + "analytics.conversionRevenue": "Conversion Revenue", + "analytics.clicksRevenue": "Clicks Revenue", + "analytics.viewsRevenue": "Views Revenue", + "analytics.totalProducts": "Total Products", + "analytics.productPerformance": "Product Performance", + "analytics.noProducts": "No products to display or still loading...", + "analytics.storeEarnings": "Store Earnings (Past Month)", + "analytics.realtimeEvents": "Realtime Events", + "analytics.noEvents": "No events received yet", + "analytics.lastError": "Last Error", + "analytics.ordersRevenueByRegions": "Orders Revenue by Regions", + "analytics.ordersByRegions": "Orders by Regions", + "analytics.revenue": "Revenue", + "analytics.orders": "Orders", + "analytics.adTriggersBreakdown": "Ad Triggers Breakdown", + "analytics.search": "Search", + "analytics.order": "Order", + "analytics.view": "View", + "analytics.conversionRate": "Conversion Rate (All Ads)", + "analytics.conversions": "conversions", + "analytics.clicks": "clicks", + "analytics.topStoresByAdRevenue": "Top Stores by Ad Revenue", + "analytics.salesFunnelAnalysis": "Sales Funnel Analysis", + "analytics.viewed": "Viewed", + "analytics.clicked": "Clicked", + "analytics.converted": "Converted", + "analytics.combinationChart": "Combination Chart: Fixed vs PopUp Ads", + "analytics.fixed": "Fixed", + "analytics.popup": "PopUp", + "sellerAnalytics.totalEarnings": "Total Earnings", + "sellerAnalytics.sellerProfit": "Seller Profit", + "sellerAnalytics.clickRevenue": "Click Revenue", + "sellerAnalytics.viewRevenue": "View Revenue", + "sellerAnalytics.conversionRevenue": "Conversion Revenue", + "sellerAnalytics.clickRevenueOverTime": "Click Revenue Over Time", + "sellerAnalytics.viewRevenueOverTime": "View Revenue Over Time", + "sellerAnalytics.conversionRevenueOverTime": "Conversion Revenue Over Time", + "routes.availableRoutes": "Available Routes", + "routes.noRoutes": "No routes available", + "routes.loadingMap": "Loading map for Route ID: {{id}}...", + "routes.selectRoute": "Select a route from the list to view it on the map", + "routes.routeDetails": "Route Details", + "routes.directions": "Directions", + "routes.routeId": "Route ID: {{id}}", + "ads.adType": "Ad Type", + "ads.triggers": "Triggers", + "ads.startTime": "Start Time", + "ads.endTime": "End Time", + "ads.isActive": "Is Active", + "ads.advertisementItems": "Advertisement Items", + "ads.conversionPrice": "Conversion Price", + "ads.storeName": "Store Name", + "ads.address": "Address", + "ads.description": "Description", + "ads.place": "Place", + "ads.category": "Category", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.email": "Email", + "auth.password": "Password", + "auth.forgotPassword": "Forgot Password?", + "auth.signIn": "Sign In", + "auth.signUp": "Sign Up", + "auth.rememberMe": "Remember Me", + "errors.connectionError": "Connection Error", + "errors.failedToConnect": "Failed to connect", + "errors.authTokenMissing": "Auth Token Missing", + "errors.loadingError": "Failed to load data", + "errors.deleteError": "Failed to delete item", + "errors.updateError": "Failed to update item", + "errors.createError": "Failed to create item", + "stores.stores": "Stores", + "stores.searchStore": "Search Store", + "stores.addStore": "Add Store", + "common.userCreatedSuccessfully": "User created successfully!", + "common.createNewUser": "Create New User", + "common.role": "Role", + "common.createUser": "Create User", + "common.loadingUserData": "Loading user data...", + "common.userDetails": "User Details", + "common.name": "Name", + "common.email": "Email", + "common.phoneNumber": "Phone Number", + "common.saveChanges": "Save Changes", + "common.deleteUser": "Delete User", + "common.confirmDeleteUser": "Are you sure you want to delete this user?", + "common.noUsers": "No users available", + "common.userManagement": "User Management", + "common.addUser": "Add User", + "common.administrator": "Administrator", + "common.logout": "Logout", + "common.analytics": "Analytics", + "common.users": "Users", + "common.stores": "Stores", + "common.categories": "Categories", + "common.advertisements": "Advertisements", + "common.chat": "Chat", + "common.languages": "Languages", + "common.pasteTranslations" : "Paste Translations", + "common.translationJsonHint" : "Add a text in JSON format", + "common.totalAds": "Total Ads", + "analytics.storePerformance": "Store Performance", + "analytics.revenueAndProfitAnalysis": "Revenue & Profit Analysis", + "analytics.totalRevenue": "Total Revenue", + "analytics.fromAllAdvertisingSources": "From all advertising sources", + "analytics.clickRevenue": "Click Revenue", + "analytics.fromClicks": "From {{count}} clicks", + "analytics.viewRevenue": "View Revenue", + "analytics.fromViews": "From {{count}} views", + "analytics.fromConversions": "From {{count}} conversions", + "analytics.revenueBySourceOverTime": "Revenue by Source Over Time", + "analytics.revenueDistribution": "Revenue Distribution", + "analytics.totalEarnedProfitFromAds": "Total Earned Profit from Ads", + "common.unknownProduct": "Unknown Product", + "analytics.detailedAnalyticsFor": "Detailed Analytics for {{storeName}}", + "analytics.unknownStore": "Unknown Store", + "analytics.storeEarningsPastMonth": "Store Earnings (Past Month)", + "analytics.noProductsToDisplay": "No products to display or still loading...", + + "analytics.noStoresToDisplay": "No stores to display or still loading...", + "analytics.conversionsRevenue": "Conversions Revenue", + "analytics.dashboardAnalytics": "Dashboard Analytics", + "analytics.paretoChart": "Pareto Chart", + "analytics.dealsAmount": "Deals amount", + "analytics.deals": "Deals", + "analytics.storeRevenue": "Store Revenue", + "analytics.adminProfit": "Admin Profit", + "analytics.taxRate": "Tax Rate (%)", + "analytics.topRated": "Top Rated", + "analytics.lowestRated": "Lowest Rated", + "analytics.topStores": "Top Stores", + "analytics.topProducts": "Top Products", + "analytics.topCategories": "Top Categories", + "analytics.topUsers": "Top Users", + "analytics.topProductsByAdRevenue": "Top Products by Ad Revenue", + "common.noPendingUsersFound": "No pending users found.", + "analytics.picture": "Picture", + "analytics.pendingUsers": "Pending Users", + "analytics.pendingRequests": "Pending Requests", + "analytics.pendingRequestsTitle": "Pending Requests", + "analytics.pendingRequestsDescription": "Pending requests are requests that are waiting to be processed by the administrator.", + "analytics.pendingUsersTitle": "Pending Users", + "analytics.pendingUsersDescription": "Pending users are users that are waiting to be approved by the administrator.", + "analytics.latestAdUpdate": "Latest Ad Update", + "analytics.latestView": "Latest View", + "analytics.latestClick": "Latest Click", + "analytics.latestConversion": "Latest Conversion", + "analytics.history": "History", + "analytics.views": "Views", + "common.clicks": "Clicks", + "analytics.active": "Active", + "analytics.status": "Status", + "analytics.conversionPrice": "Conversion Price", + "analytics.storeName": "Store Name", + "analytics.comparedToLastMonth": "Compared to last month", + "analytics.comparedToLastYear": "Compared to last year", + "analytics.comparedToPreviousMonth": "Compared to previous month", + "analytics.comparedToPreviousYear": "Compared to previous year", + "analytics.comparedToNextMonth": "Compared to next month", + "analytics.comparedToNextYear": "Compared to next year", + "common.picture": "Picture", + "common.details": "Details", + "common.addItem": "Add Item", + "common.adText": "Ad Text", + "common.product": "Product", + "common.username": "Username", + "common.store": "Store", + "common.deliveryAddress": "Delivery Address", + "common.deliveryStatus": "Delivery Status", + "common.deliveryDate": "Delivery Date", + "common.deliveryTime": "Delivery Time", + "common.deliveryPrice": "Delivery Price", + "common.active": "Active", + "common.inactive": "Inactive", + "common.saveAd": "Save Ad", + "common.viewPrice": "View Price", + "common.clickPrice": "Click Price", + "common.conversionPrice": "Conversion Price", + "common.storeName": "Store Name", + "common.storeId": "Store ID", + "common.displayingPage": "Displaying Page", + "common.tax": "Tax", + "common.totalMonthlyIncome": "Total Monthly Income", + "common.taxedMonthlyIncome": "Taxed Monthly Income", + "common.addProduct": "Add Product", + "common.products": "Products", + "common.tickets": "Tickets", + "common.searchTickets": "Search tickets...", + "common.noTicketsFound": "No tickets found.", + "common.ticket": "Ticket", + "common.views": "Views", + "common.productCategory": "Product Category", + "common.storeCategory": "Store Category", + "common.orderNumber": "Order Number", + "common.created": "Created" + +} \ No newline at end of file diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json new file mode 100644 index 0000000..ea4951c --- /dev/null +++ b/public/locales/es/translation.json @@ -0,0 +1,289 @@ +{ + + "ads.adsManagement": "Ads Management", + "ads.adminPanel": "Admin Panel", + "ads.advertisements": "Advertisements", + "ads.createAd": "Create Ad", + "ads.searchAds": "Search Ads", + "categories.categories": "Categories", + "categories.addCategory": "Add Category", + "categories.searchCategory": "Search Category", + "common.searchPlaceholder": "Search...", + "common.welcome": "Welcome", + "common.orders": "Orders", + "common.status": "Status", + "common.all": "All", + "common.searchOrders": "Search Orders", + "common.loginToContinue": "Login to continue", + "common.login": "Login", + "common.requests": "Requests", + "common.searchUser": "Search User", + "common.addToCart": "Add to Cart", + "common.viewDetails": "View Details", + "common.price": "Price", + "common.quantity": "Quantity", + "common.subtotal": "Subtotal", + "common.total": "Total", + "common.checkout": "Checkout", + "common.continueShopping": "Continue Shopping", + "common.save": "Save", + "common.cancel": "Cancel", + "common.oops": "Oops! Something went wrong.", + "common.loading": "Loading...", + "common.adminPanel": "Admin Panel", + "common.first": "First", + "common.last": "Last", + "common.confirm": "Confirm", + "common.delete": "Delete", + "common.edit": "Edit", + "common.view": "View", + "common.close": "Close", + "common.done": "Done", + "common.browseFiles": "Browse Files", + "common.dragFilesToUpload": "Drag files to upload", + "common.or": "or", + "common.maxFileSize": "Max file size: 50MB — Supported types: JPG, PNG, GIF, SVG, WEBP", + "common.languageManagement": "Language Management", + "common.currentLanguage": "Current Language", + "common.languageCode": "Language Code", + "common.languageName": "Language Name", + "common.actions": "Actions", + "common.addLanguage": "Add Language", + "common.editLanguage": "Edit Language", + "common.deleteLanguage": "Delete Language", + "common.availableLanguages": "Available Languages", + "common.addNewLanguage": "Add New Language", + "common.saveLanguage": "Save Language", + "common.translations": "Translations", + "common.allRoutes": "All Routes", + "common.routes": "Routes", + "common.createRoute": "Create Route", + "chat.openTicketToViewChat": "Open this ticket to view the chat.", + "roles.Buyer": "Buyer", + "roles.Seller": "Seller", + "roles.Admin": "Administrator", + "roles.Unknown": "Unknown Role", + "nav.analytics": "Analytics", + "nav.users": "Users", + "nav.requests": "Requests", + "nav.stores": "Stores", + "nav.categories": "Categories", + "nav.orders": "Orders", + "nav.advertisements": "Advertisements", + "nav.chat": "Chat", + "nav.routes": "Routes", + "nav.languages": "Languages", + "usersPage.username": "Username", + "usersPage.email": "Email", + "usersPage.role": "Role", + "usersPage.active": "Active", + "usersPage.actions": "Actions", + "usersPage.addUser": "Add User", + "usersPage.searchUser": "Search User", + "usersPage.title": "User Management", + "usersPage.phoneNumber": "Phone Number", + "usersPage.saveChanges": "Save Changes", + "usersPage.deleteUser": "Delete User", + "usersPage.confirmDeleteUser": "Are you sure you want to delete this user?", + "usersPage.noUsers": "No users available", + "usersPage.loadingUsers": "Loading users...", + "analytics.dashboardTitle": "Dashboard Analytics", + "analytics.totalAds": "Total Ads", + "analytics.totalViews": "Total Views", + "analytics.totalClicks": "Total Clicks", + "analytics.totalConversions": "Total Conversions", + "analytics.conversionRevenue": "Conversion Revenue", + "analytics.clicksRevenue": "Clicks Revenue", + "analytics.viewsRevenue": "Views Revenue", + "analytics.totalProducts": "Total Products", + "analytics.productPerformance": "Product Performance", + "analytics.noProducts": "No products to display or still loading...", + "analytics.storeEarnings": "Store Earnings (Past Month)", + "analytics.realtimeEvents": "Realtime Events", + "analytics.noEvents": "No events received yet", + "analytics.lastError": "Last Error", + "analytics.ordersRevenueByRegions": "Orders Revenue by Regions", + "analytics.ordersByRegions": "Orders by Regions", + "analytics.revenue": "Revenue", + "analytics.orders": "Orders", + "analytics.adTriggersBreakdown": "Ad Triggers Breakdown", + "analytics.search": "Search", + "analytics.order": "Order", + "analytics.view": "View", + "analytics.conversionRate": "Conversion Rate (All Ads)", + "analytics.conversions": "conversions", + "analytics.clicks": "clicks", + "analytics.topStoresByAdRevenue": "Top Stores by Ad Revenue", + "analytics.salesFunnelAnalysis": "Sales Funnel Analysis", + "analytics.viewed": "Viewed", + "analytics.clicked": "Clicked", + "analytics.converted": "Converted", + "analytics.combinationChart": "Combination Chart: Fixed vs PopUp Ads", + "analytics.fixed": "Fixed", + "analytics.popup": "PopUp", + "sellerAnalytics.totalEarnings": "Total Earnings", + "sellerAnalytics.sellerProfit": "Seller Profit", + "sellerAnalytics.clickRevenue": "Click Revenue", + "sellerAnalytics.viewRevenue": "View Revenue", + "sellerAnalytics.conversionRevenue": "Conversion Revenue", + "sellerAnalytics.clickRevenueOverTime": "Click Revenue Over Time", + "sellerAnalytics.viewRevenueOverTime": "View Revenue Over Time", + "sellerAnalytics.conversionRevenueOverTime": "Conversion Revenue Over Time", + "routes.availableRoutes": "Available Routes", + "routes.noRoutes": "No routes available", + "routes.loadingMap": "Loading map for Route ID: {{id}}...", + "routes.selectRoute": "Select a route from the list to view it on the map", + "routes.routeDetails": "Route Details", + "routes.directions": "Directions", + "routes.routeId": "Route ID: {{id}}", + "ads.adType": "Ad Type", + "ads.triggers": "Triggers", + "ads.startTime": "Start Time", + "ads.endTime": "End Time", + "ads.isActive": "Is Active", + "ads.advertisementItems": "Advertisement Items", + "ads.conversionPrice": "Conversion Price", + "ads.storeName": "Store Name", + "ads.address": "Address", + "ads.description": "Description", + "ads.place": "Place", + "ads.category": "Category", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.email": "Email", + "auth.password": "Password", + "auth.forgotPassword": "Forgot Password?", + "auth.signIn": "Sign In", + "auth.signUp": "Sign Up", + "auth.rememberMe": "Remember Me", + "errors.connectionError": "Connection Error", + "errors.failedToConnect": "Failed to connect", + "errors.authTokenMissing": "Auth Token Missing", + "errors.loadingError": "Failed to load data", + "errors.deleteError": "Failed to delete item", + "errors.updateError": "Failed to update item", + "errors.createError": "Failed to create item", + "stores.stores": "Stores", + "stores.searchStore": "Search Store", + "stores.addStore": "Add Store", + "common.userCreatedSuccessfully": "User created successfully!", + "common.createNewUser": "Create New User", + "common.role": "Role", + "common.createUser": "Create User", + "common.loadingUserData": "Loading user data...", + "common.userDetails": "User Details", + "common.name": "Name", + "common.email": "Email", + "common.phoneNumber": "Phone Number", + "common.saveChanges": "Save Changes", + "common.deleteUser": "Delete User", + "common.confirmDeleteUser": "Are you sure you want to delete this user?", + "common.noUsers": "No users available", + "common.userManagement": "User Management", + "common.addUser": "Add User", + "common.administrator": "Administrator", + "common.logout": "Logout", + "common.analytics": "Analytics", + "common.users": "Users", + "common.stores": "Stores", + "common.categories": "Categories", + "common.advertisements": "Advertisements", + "common.chat": "Chat", + "common.languages": "Languages", + "common.pasteTranslations" : "Paste Translations", + "common.translationJsonHint" : "Add a text in JSON format", + "common.totalAds": "Total Ads", + "analytics.storePerformance": "Store Performance", + "analytics.revenueAndProfitAnalysis": "Revenue & Profit Analysis", + "analytics.totalRevenue": "Total Revenue", + "analytics.fromAllAdvertisingSources": "From all advertising sources", + "analytics.clickRevenue": "Click Revenue", + "analytics.fromClicks": "From {{count}} clicks", + "analytics.viewRevenue": "View Revenue", + "analytics.fromViews": "From {{count}} views", + "analytics.fromConversions": "From {{count}} conversions", + "analytics.revenueBySourceOverTime": "Revenue by Source Over Time", + "analytics.revenueDistribution": "Revenue Distribution", + "analytics.totalEarnedProfitFromAds": "Total Earned Profit from Ads", + "common.unknownProduct": "Unknown Product", + "analytics.detailedAnalyticsFor": "Detailed Analytics for {{storeName}}", + "analytics.unknownStore": "Unknown Store", + "analytics.storeEarningsPastMonth": "Store Earnings (Past Month)", + "analytics.noProductsToDisplay": "No products to display or still loading...", + "analytics.noStoresToDisplay": "No stores to display or still loading...", + "analytics.conversionsRevenue": "Conversions Revenue", + "analytics.dashboardAnalytics": "Dashboard Analytics", + "analytics.paretoChart": "Pareto Chart", + "analytics.dealsAmount": "Deals amount", + "analytics.deals": "Deals", + "analytics.storeRevenue": "Store Revenue", + "analytics.adminProfit": "Admin Profit", + "analytics.taxRate": "Tax Rate (%)", + "analytics.topRated": "Top Rated", + "analytics.lowestRated": "Lowest Rated", + "analytics.topStores": "Top Stores", + "analytics.topProducts": "Top Products", + "analytics.topCategories": "Top Categories", + "analytics.topUsers": "Top Users", + "analytics.topProductsByAdRevenue": "Top Products by Ad Revenue", + "common.noPendingUsersFound": "No pending users found.", + "analytics.picture": "Picture", + "analytics.pendingUsers": "Pending Users", + "analytics.pendingRequests": "Pending Requests", + "analytics.pendingRequestsTitle": "Pending Requests", + "analytics.pendingRequestsDescription": "Pending requests are requests that are waiting to be processed by the administrator.", + "analytics.pendingUsersTitle": "Pending Users", + "analytics.pendingUsersDescription": "Pending users are users that are waiting to be approved by the administrator.", + "analytics.latestAdUpdate": "Latest Ad Update", + "analytics.latestView": "Latest View", + "analytics.latestClick": "Latest Click", + "analytics.latestConversion": "Latest Conversion", + "analytics.history": "History", + "analytics.views": "Views", + "common.clicks": "Clicks", + "analytics.active": "Active", + "analytics.status": "Status", + "analytics.conversionPrice": "Conversion Price", + "analytics.storeName": "Store Name", + "analytics.comparedToLastMonth": "Compared to last month", + "analytics.comparedToLastYear": "Compared to last year", + "analytics.comparedToPreviousMonth": "Compared to previous month", + "analytics.comparedToPreviousYear": "Compared to previous year", + "analytics.comparedToNextMonth": "Compared to next month", + "analytics.comparedToNextYear": "Compared to next year", + "common.picture": "Picture", + "common.details": "Details", + "common.addItem": "Add Item", + "common.adText": "Ad Text", + "common.product": "Product", + "common.username": "Username", + "common.store": "Store", + "common.deliveryAddress": "Delivery Address", + "common.deliveryStatus": "Delivery Status", + "common.deliveryDate": "Delivery Date", + "common.deliveryTime": "Delivery Time", + "common.deliveryPrice": "Delivery Price", + "common.active": "Active", + "common.inactive": "Inactive", + "common.saveAd": "Save Ad", + "common.viewPrice": "View Price", + "common.clickPrice": "Click Price", + "common.conversionPrice": "Conversion Price", + "common.storeName": "Store Name", + "common.storeId": "Store ID", + "common.displayingPage": "Displaying Page", + "common.tax": "Tax", + "common.totalMonthlyIncome": "Total Monthly Income", + "common.taxedMonthlyIncome": "Taxed Monthly Income", + "common.addProduct": "Add Product", + "common.products": "Products", + "common.tickets": "Tickets", + "common.searchTickets": "Search tickets...", + "common.noTicketsFound": "No tickets found.", + "common.ticket": "Ticket", + "common.views": "Views", + "common.productCategory": "Product Category", + "common.storeCategory": "Store Category", + "common.orderNumber": "Order Number", + "common.created": "Created" +} diff --git a/src/App.css b/src/App.css index b9d355d..492550d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,8 +1,13 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; + width: 100vw; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; } .logo { @@ -40,3 +45,28 @@ .read-the-docs { color: #888; } + +body.login-background { + background-image: url('/src/assets/images/background.jpg'); /* ili drugi image */ + background-size: cover; + background-color: rgba(0, 0, 0, 0.2); /* zatamnjenje */ + backdrop-filter: blur(2px); + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; + position: relative; +} + +/* ako želiš dodatni dim ili blur preko svega (opciono) */ +body.login-background::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.4); /* zatamnjenje */ + backdrop-filter: blur(6px); /* blur opcionalno */ + z-index: -1; +} + diff --git a/src/App.jsx b/src/App.jsx index f67355a..6f1e226 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,35 +1,18 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import React from 'react'; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import theme from "@styles/theme"; +import AppRoutes from "./routes/Router"; +import "./App.css"; +import './i18n'; // Import i18n configuration function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + + + + + ); } -export default App +export default App; \ No newline at end of file diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js new file mode 100644 index 0000000..89b2563 --- /dev/null +++ b/src/api/ApiClient.js @@ -0,0 +1,583 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import superagent from "superagent"; + +/** +* @module ApiClient +* @version v1 +*/ + +/** +* Manages low level client-server communications, parameter marshalling, etc. There should not be any need for an +* application to use this class directly - the *Api and model classes provide the public API for the service. The +* contents of this file should be regarded as internal but are documented for completeness. +* @alias module:ApiClient +* @class +*/ +export default class ApiClient { + constructor() { + /** + * The base URL against which to resolve every API call's (relative) path. + * @type {String} + * @default / + */ + this.basePath = '/'.replace(/\/+$/, ''); + + /** + * The authentication methods to be included for all API calls. + * @type {Array.} + */ + this.authentications = { + } + + /** + * The default HTTP headers to be included for all API calls. + * @type {Array.} + * @default {} + */ + this.defaultHeaders = {}; + + /** + * The default HTTP timeout for all API calls. + * @type {Number} + * @default 60000 + */ + this.timeout = 60000; + + /** + * If set to false an additional timestamp parameter is added to all API GET calls to + * prevent browser caching + * @type {Boolean} + * @default true + */ + this.cache = true; + + /** + * If set to true, the client will save the cookies from each server + * response, and return them in the next request. + * @default false + */ + this.enableCookies = false; + + /* + * Used to save and return cookies in a node.js (non-browser) setting, + * if this.enableCookies is set to true. + */ + if (typeof window === 'undefined') { + this.agent = new superagent.agent(); + } + + /* + * Allow user to override superagent agent + */ + this.requestAgent = null; + + } + + /** + * Returns a string representation for an actual parameter. + * @param param The actual parameter. + * @returns {String} The string representation of param. + */ + paramToString(param) { + if (param == undefined || param == null) { + return ''; + } + if (param instanceof Date) { + return param.toJSON(); + } + + return param.toString(); + } + + /** + * Builds full URL by appending the given path to the base URL and replacing path parameter place-holders with parameter values. + * NOTE: query parameters are not handled here. + * @param {String} path The path to append to the base URL. + * @param {Object} pathParams The parameter values to append. + * @returns {String} The encoded path with parameter values substituted. + */ + buildUrl(path, pathParams) { + if (!path.match(/^\//)) { + path = '/' + path; + } + + var url = this.basePath + path; + url = url.replace(/\{([\w-]+)\}/g, (fullMatch, key) => { + var value; + if (pathParams.hasOwnProperty(key)) { + value = this.paramToString(pathParams[key]); + } else { + value = fullMatch; + } + + return encodeURIComponent(value); + }); + + return url; + } + + /** + * Checks whether the given content type represents JSON.
+ * JSON content type examples:
+ * + * @param {String} contentType The MIME content type to check. + * @returns {Boolean} true if contentType represents JSON, otherwise false. + */ + isJsonMime(contentType) { + return Boolean(contentType != null && contentType.match(/^application\/json(;.*)?$/i)); + } + + /** + * Chooses a content type from the given array, with JSON preferred; i.e. return JSON if included, otherwise return the first. + * @param {Array.} contentTypes + * @returns {String} The chosen content type, preferring JSON. + */ + jsonPreferredMime(contentTypes) { + for (var i = 0; i < contentTypes.length; i++) { + if (this.isJsonMime(contentTypes[i])) { + return contentTypes[i]; + } + } + + return contentTypes[0]; + } + + /** + * Checks whether the given parameter value represents file-like content. + * @param param The parameter to check. + * @returns {Boolean} true if param represents a file. + */ + isFileParam(param) { + // fs.ReadStream in Node.js and Electron (but not in runtime like browserify) + if (typeof require === 'function') { + let fs; + try { + fs = require('fs'); + } catch (err) {} + if (fs && fs.ReadStream && param instanceof fs.ReadStream) { + return true; + } + } + + // Buffer in Node.js + if (typeof Buffer === 'function' && param instanceof Buffer) { + return true; + } + + // Blob in browser + if (typeof Blob === 'function' && param instanceof Blob) { + return true; + } + + // File in browser (it seems File object is also instance of Blob, but keep this for safe) + if (typeof File === 'function' && param instanceof File) { + return true; + } + + return false; + } + + /** + * Normalizes parameter values: + *
    + *
  • remove nils
  • + *
  • keep files and arrays
  • + *
  • format to string with `paramToString` for other cases
  • + *
+ * @param {Object.} params The parameters as object properties. + * @returns {Object.} normalized parameters. + */ + normalizeParams(params) { + var newParams = {}; + for (var key in params) { + if (params.hasOwnProperty(key) && params[key] != undefined && params[key] != null) { + var value = params[key]; + if (this.isFileParam(value) || Array.isArray(value)) { + newParams[key] = value; + } else { + newParams[key] = this.paramToString(value); + } + } + } + + return newParams; + } + + /** + * Enumeration of collection format separator strategies. + * @enum {String} + * @readonly + */ + static CollectionFormatEnum = { + /** + * Comma-separated values. Value: csv + * @const + */ + CSV: ',', + + /** + * Space-separated values. Value: ssv + * @const + */ + SSV: ' ', + + /** + * Tab-separated values. Value: tsv + * @const + */ + TSV: '\t', + + /** + * Pipe(|)-separated values. Value: pipes + * @const + */ + PIPES: '|', + + /** + * Native array. Value: multi + * @const + */ + MULTI: 'multi' + }; + + /** + * Builds a string representation of an array-type actual parameter, according to the given collection format. + * @param {Array} param An array parameter. + * @param {module:ApiClient.CollectionFormatEnum} collectionFormat The array element separator strategy. + * @returns {String|Array} A string representation of the supplied collection, using the specified delimiter. Returns + * param as is if collectionFormat is multi. + */ + buildCollectionParam(param, collectionFormat) { + if (param == null) { + return null; + } + switch (collectionFormat) { + case 'csv': + return param.map(this.paramToString).join(','); + case 'ssv': + return param.map(this.paramToString).join(' '); + case 'tsv': + return param.map(this.paramToString).join('\t'); + case 'pipes': + return param.map(this.paramToString).join('|'); + case 'multi': + //return the array directly as SuperAgent will handle it as expected + return param.map(this.paramToString); + default: + throw new Error('Unknown collection format: ' + collectionFormat); + } + } + + /** + * Applies authentication headers to the request. + * @param {Object} request The request object created by a superagent() call. + * @param {Array.} authNames An array of authentication method names. + */ + applyAuthToRequest(request, authNames) { + authNames.forEach((authName) => { + var auth = this.authentications[authName]; + switch (auth.type) { + case 'basic': + if (auth.username || auth.password) { + request.auth(auth.username || '', auth.password || ''); + } + + break; + case 'apiKey': + if (auth.apiKey) { + var data = {}; + if (auth.apiKeyPrefix) { + data[auth.name] = auth.apiKeyPrefix + ' ' + auth.apiKey; + } else { + data[auth.name] = auth.apiKey; + } + + if (auth['in'] === 'header') { + request.set(data); + } else { + request.query(data); + } + } + + break; + case 'oauth2': + if (auth.accessToken) { + request.set({'Authorization': 'Bearer ' + auth.accessToken}); + } + + break; + default: + throw new Error('Unknown authentication type: ' + auth.type); + } + }); + } + + /** + * Deserializes an HTTP response body into a value of the specified type. + * @param {Object} response A SuperAgent response object. + * @param {(String|Array.|Object.|Function)} returnType The type to return. Pass a string for simple types + * or the constructor function for a complex type. Pass an array containing the type name to return an array of that type. To + * return an object, pass an object with one property whose name is the key type and whose value is the corresponding value type: + * all properties on data will be converted to this type. + * @returns A value of the specified type. + */ + deserialize(response, returnType) { + if (response == null || returnType == null || response.status == 204) { + return null; + } + + // Rely on SuperAgent for parsing response body. + // See http://visionmedia.github.io/superagent/#parsing-response-bodies + var data = response.body; + if (data == null || (typeof data === 'object' && typeof data.length === 'undefined' && !Object.keys(data).length)) { + // SuperAgent does not always produce a body; use the unparsed response as a fallback + data = response.text; + } + + return ApiClient.convertToType(data, returnType); + } + + /** + * Callback function to receive the result of the operation. + * @callback module:ApiClient~callApiCallback + * @param {String} error Error message, if any. + * @param data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * Invokes the REST service using the supplied settings and parameters. + * @param {String} path The base URL to invoke. + * @param {String} httpMethod The HTTP method to use. + * @param {Object.} pathParams A map of path parameters and their values. + * @param {Object.} queryParams A map of query parameters and their values. + * @param {Object.} headerParams A map of header parameters and their values. + * @param {Object.} formParams A map of form parameters and their values. + * @param {Object} bodyParam The value to pass as the request body. + * @param {Array.} authNames An array of authentication type names. + * @param {Array.} contentTypes An array of request MIME types. + * @param {Array.} accepts An array of acceptable response MIME types. + * @param {(String|Array|ObjectFunction)} returnType The required type to return; can be a string for simple types or the + * constructor for a complex type. + * @param {module:ApiClient~callApiCallback} callback The callback function. + * @returns {Object} The SuperAgent request object. + */ + callApi(path, httpMethod, pathParams, + queryParams, headerParams, formParams, bodyParam, authNames, contentTypes, accepts, + returnType, callback) { + + var url = this.buildUrl(path, pathParams); + var request = superagent(httpMethod, url); + + // apply authentications + this.applyAuthToRequest(request, authNames); + + // set query parameters + if (httpMethod.toUpperCase() === 'GET' && this.cache === false) { + queryParams['_'] = new Date().getTime(); + } + + request.query(this.normalizeParams(queryParams)); + + // set header parameters + request.set(this.defaultHeaders).set(this.normalizeParams(headerParams)); + + // set requestAgent if it is set by user + if (this.requestAgent) { + request.agent(this.requestAgent); + } + + // set request timeout + request.timeout(this.timeout); + + var contentType = this.jsonPreferredMime(contentTypes); + if (contentType) { + // Issue with superagent and multipart/form-data (https://github.com/visionmedia/superagent/issues/746) + if(contentType != 'multipart/form-data') { + request.type(contentType); + } + } else if (!request.header['Content-Type']) { + request.type('application/json'); + } + + if (contentType === 'application/x-www-form-urlencoded') { + request.send(new URLSearchParams(this.normalizeParams(formParams))); + } else if (contentType == 'multipart/form-data') { + var _formParams = this.normalizeParams(formParams); + for (var key in _formParams) { + if (_formParams.hasOwnProperty(key)) { + if (this.isFileParam(_formParams[key])) { + // file field + request.attach(key, _formParams[key]); + } else { + request.field(key, _formParams[key]); + } + } + } + } else if (bodyParam) { + request.send(bodyParam); + } + + var accept = this.jsonPreferredMime(accepts); + if (accept) { + request.accept(accept); + } + + if (returnType === 'Blob') { + request.responseType('blob'); + } else if (returnType === 'String') { + request.responseType('string'); + } + + // Attach previously saved cookies, if enabled + if (this.enableCookies){ + if (typeof window === 'undefined') { + this.agent.attachCookies(request); + } + else { + request.withCredentials(); + } + } + + + + request.end((error, response) => { + if (callback) { + var data = null; + if (!error) { + try { + data = this.deserialize(response, returnType); + if (this.enableCookies && typeof window === 'undefined'){ + this.agent.saveCookies(response); + } + } catch (err) { + error = err; + } + } + + callback(error, data, response); + } + }); + + return request; + } + + /** + * Parses an ISO-8601 string representation of a date value. + * @param {String} str The date value as a string. + * @returns {Date} The parsed date object. + */ + static parseDate(str) { + return new Date(str); + } + + /** + * Converts a value to the specified type. + * @param {(String|Object)} data The data to convert, as a string or object. + * @param {(String|Array.|Object.|Function)} type The type to return. Pass a string for simple types + * or the constructor function for a complex type. Pass an array containing the type name to return an array of that type. To + * return an object, pass an object with one property whose name is the key type and whose value is the corresponding value type: + * all properties on data will be converted to this type. + * @returns An instance of the specified type or null or undefined if data is null or undefined. + */ + static convertToType(data, type) { + if (data === null || data === undefined) + return data + + switch (type) { + case 'Boolean': + return Boolean(data); + case 'Integer': + return parseInt(data, 10); + case 'Number': + return parseFloat(data); + case 'String': + return String(data); + case 'Date': + return ApiClient.parseDate(String(data)); + case 'Blob': + return data; + default: + if (type === Object) { + // generic object, return directly + return data; + } else if (typeof type === 'function') { + // for model type like: User + return type.constructFromObject(data); + } else if (Array.isArray(type)) { + // for array type like: ['String'] + var itemType = type[0]; + + return data.map((item) => { + return ApiClient.convertToType(item, itemType); + }); + } else if (typeof type === 'object') { + // for plain object type like: {'String': 'Integer'} + var keyType, valueType; + for (var k in type) { + if (type.hasOwnProperty(k)) { + keyType = k; + valueType = type[k]; + break; + } + } + + var result = {}; + for (var k in data) { + if (data.hasOwnProperty(k)) { + var key = ApiClient.convertToType(k, keyType); + var value = ApiClient.convertToType(data[k], valueType); + result[key] = value; + } + } + + return result; + } else { + // for unknown type, return the data directly + return data; + } + } + } + + /** + * Constructs a new map or array model from REST data. + * @param data {Object|Array} The REST data. + * @param obj {Object|Array} The target object or array. + */ + static constructFromObject(data, obj, itemType) { + if (Array.isArray(data)) { + for (var i = 0; i < data.length; i++) { + if (data.hasOwnProperty(i)) + obj[i] = ApiClient.convertToType(data[i], itemType); + } + } else { + for (var k in data) { + if (data.hasOwnProperty(k)) + obj[k] = ApiClient.convertToType(data[k], itemType); + } + } + }; +} + +/** +* The default API client implementation. +* @type {module:ApiClient} +*/ +ApiClient.instance = new ApiClient(); diff --git a/src/api/api.js b/src/api/api.js new file mode 100644 index 0000000..c9a5687 --- /dev/null +++ b/src/api/api.js @@ -0,0 +1,1714 @@ +//import axios from "axios"; +import apiClientInstance from './apiClientInstance'; +import TestAuthApi from './api/TestAuthApi'; +import LoginDTO from './model/LoginDTO'; +import users from '../data/users'; +import stores from '../data/stores'; +import categories from '../data/categories'; +import products from '../data/products'; +import pendingUsers from '../data/pendingUsers.js'; +import axios from 'axios'; +import * as XLSX from 'xlsx'; +import ads from '../data/ads.js'; +import sha256 from 'crypto-js/sha256'; +import { format } from 'date-fns'; +//import { GET } from 'superagent'; +const baseApiUrl = import.meta.env.VITE_API_BASE_URL; +const API_FLAG = import.meta.env.VITE_API_FLAG; +const API_ENV_DEV = 'dev'; +const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; + +console.log('Mock', API_FLAG); +console.log('api ', baseApiUrl); + +const apiSetAuthHeader = () => { + const token = localStorage.getItem('token'); + + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } +}; + +// ---------------------- +// AUTH - Prijava korisnika +// ---------------------- + +export const apiLoginUserAsync = async (username, password) => { + if (API_FLAG == API_ENV_DEV) { + const testAuthApi = new TestAuthApi(apiClientInstance); + const loginPayload = new LoginDTO(); + loginPayload.username = username; + loginPayload.password = password; + + console.log('Attempting login via TestAuthApi for:', username); + + localStorage.setItem('auth', true); + } else { + const ret = await axios.post(`${baseApiUrl}/api/Auth/login`, { + email: username, + email: username, + password: password, + app: 'Admin', + }); + const token = ret.data.token; + localStorage.setItem('auth', true); + localStorage.setItem('token', token); + } +}; + +// ---------------------- +// PENDING USERS - Neodobreni korisnici +// ---------------------- + +export const apiFetchPendingUsersAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + try { + return pendingUsers; + } catch (error) { + console.error('Greška pri dohvaćanju korisnika:', error); + throw error; + } + } else { + const token = localStorage.getItem('token'); + + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + const users = await axios.get(`${baseApiUrl}/api/Admin/users`); + return users.data.filter((u) => !u.isApproved); + } +}; + +export const apiApproveUserAsync = async (userId) => { + if (API_ENV_DEV == API_FLAG) { + try { + // pronađi korisnika u "pendingUsers" nizu i označi ga kao odobrenog + const userIndex = pendingUsers.findIndex((user) => user.id === userId); + if (userIndex !== -1) { + const user = pendingUsers[userIndex]; + user.isApproved = true; + // premjesti korisnika iz pendingUsers u users + users.push(user); + pendingUsers.splice(userIndex, 1); + return user; + } else { + throw new Error('User not found in pending users.'); + } + } catch (error) { + console.error('Error approving user:', error); + throw error; + } + } else { + const token = localStorage.getItem('token'); + + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + return axios.post(`${baseApiUrl}/api/Admin/users/approve`, { + userId: userId, + }); + } +}; + +// ---------------------- +// USER MANAGEMENT +// ---------------------- + +export const apiFetchApprovedUsersAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + try { + // dohvati users iz niza koji su odobreni + return users.filter((user) => user.isApproved); + } catch (error) { + console.error('Greška pri dohvaćanju odobrenih korisnika:', error); + throw error; + } + } else { + apiSetAuthHeader(); + const users = await axios.get(`${baseApiUrl}/api/Admin/users`); + return users.data + .filter((u) => u.isApproved) + .filter( + (u) => + (u.roles && u.roles != 'Admin') || (u.roles && u.roles[0] != 'Admin') + ); + } +}; + +export const apiCreateUserAsync = async (newUserPayload) => { + if (API_ENV_DEV == API_FLAG) { + try { + // dodaj novog korisnika u niz "users" + const newUser = { + email: newUserPayload.email, + userName: newUserPayload.userName, + password: newUserPayload.password, + id: users.length + 1, + isApproved: false, + roles: [newUserPayload.role], + }; + //users.push(newUser); + pendingUsers.push(newUser); + return { data: newUser }; + } catch (error) { + console.error('Greška pri kreiranju korisnika:', error); + throw error; + } + } else { + apiSetAuthHeader(); + return axios.post(`${baseApiUrl}/api/Admin/users/create`, { + userName: newUserPayload.userName, + email: newUserPayload.email, + password: newUserPayload.password, + role: newUserPayload.role, + }); + } +}; + +export const apiDeleteUserAsync = async (userId) => { + if (API_ENV_DEV == API_FLAG) { + try { + // Pronađi korisnika u "users" i ukloni ga iz niza + const userIndex = users.findIndex((user) => user.id === userId); + if (userIndex !== -1) { + const user = users[userIndex]; + users.splice(userIndex, 1); + return user; + } else { + throw new Error('User not found.'); + } + } catch (error) { + console.error('Error deleting user:', error); + throw error; + } + } else { + apiSetAuthHeader(); + return axios.delete(`${baseApiUrl}/api/Admin/user/${userId}`); + } +}; + +/* + +// ---------------------- +// PENDING USERS - Neodobreni korisnici +// ---------------------- + + + + + +export const apiFetchPendingUsersAsync = async () => { + try { + const response = await axios.get('/api/Admin/users'); + return response.data.filter((user) => !user.isApproved); + } catch (error) { + console.error("Greška pri dohvaćanju korisnika:", error); + throw error; + } +}; + +export const apiApproveUserAsync = async (userId) => { + try { + const response = await axios.post('/api/Admin/users/approve', { userId }); + return response.data; + } catch (error) { + console.error("Error approving user:", error); + throw error; + } +}; + + + + + +// ---------------------- +// USER MANAGEMENT +// ---------------------- + + + + +export const apiFetchApprovedUsersAsync = async () => { + try { + const response = await axios.get('/api/Admin/users'); + return response.data.filter((user) => user.isApproved && user.roles[0] !== "Admin"); + } catch (error) { + console.error("Greška pri dohvaćanju odobrenih korisnika:", error); + throw error; + } +}; + +export const apiCreateUserAsync = async (newUserPayload) => { + try { + const response = await axios.post('/api/Admin/users/create', newUserPayload); + return response.data; + } catch (error) { + console.error("Greška pri kreiranju korisnika:", error); + throw error; + } +}; + +export const apiDeleteUserAsync = async (userId) => { + try { + const response = await axios.delete(`/api/Admin/user/${userId}`); + return response.data; + } catch (error) { + console.error("Error deleting user:", error); + throw error; + } +};*/ + +// { +// "name": "aa", +// "price": "1", +// "weight": "1", +// "weightunit": "kg", +// "volume": "1", +// "volumeunit": "L", +// "productcategoryid": 1, +// "storeId": 2, +// "photos": [ +// { +// "path": "./maca.jpg", +// "relativePath": "./maca.jpg" +// } +// ] +// } + +/** + * Fetches products for a specific store + * @param {number} storeId - ID of the store + * @returns {Promise<{status: number, data: Array}>} List of products + */ +export const apiGetStoreProductsAsync = async (storeId, categoryId = null) => { + if (API_ENV_DEV === API_FLAG) { + return { + status: 200, + data: products.filter((p) => p.storeId === storeId), + }; + } else { + try { + apiSetAuthHeader(); + const params = new URLSearchParams(); + params.append('storeId', storeId); + if (categoryId !== null) { + params.append('categoryId', categoryId); + } + + const response = await axios.get( + `${baseApiUrl}/api/Catalog/products?${params.toString()}` + ); + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Error fetching store products:', error); + return { status: error.response?.status || 500, data: [] }; + } + } +}; + +/** + * Creates a new product + * @param {Object} productData - Product data to create + * @returns {Promise<{status: number, data: Object}>} Created product + */ +export const apiCreateProductAsync = async (productData) => { + if (API_ENV_DEV === API_FLAG) { + try { + const newProduct = { + id: products.length + 1, + ...productData, + }; + products.push(newProduct); + return { status: 201, data: newProduct }; + } catch (error) { + console.error('Product creation failed:', error); + return { status: 500, data: null }; + } + } else { + console.log('TEST: ', productData); + try { + const formData = new FormData(); + const price = productData.retailPrice || productData.price; + formData.append('RetailPrice', String(price ?? 0)); + formData.append( + 'ProductCategoryId', + String(productData.productcategoryid || productData.productCategory) + ); + formData.append( + 'WholesalePrice', + String(productData.wholesalePrice ?? 0) + ); + formData.append('Name', productData.name); + formData.append('Weight', String(productData.weight ?? 0)); + formData.append('Volume', String(productData.volume ?? 0)); + formData.append('WeightUnit', productData.weightUnit ?? ''); + formData.append('StoreId', String(productData.storeId)); + formData.append('VolumeUnit', productData.volumeUnit ?? ''); + + if (productData.photos?.length > 0) { + productData.photos.forEach((file) => { + if (file instanceof File) { + formData.append('Files', file, file.name); + } + }); + } + + const response = await axios.post( + `${baseApiUrl}/api/Admin/products/create`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Product creation failed:', error); + return { status: error.response?.status || 500, data: null }; + } + } +}; + +/** + * Updates an existing product + * @param {Object} productData - Product data to update + * @returns {Promise<{status: number, data: Object}>} Updated product + */ +export const apiUpdateProductAsync = async (productData) => { + apiSetAuthHeader(); + + try { + const payload = { + name: productData.name, + productCategoryId: Number(productData.productCategoryId), + retailPrice: Number(productData.retailPrice ?? 0), + wholesaleThreshold: Number(productData.wholesaleThreshold ?? 0), + wholesalePrice: Number(productData.wholesalePrice ?? 0), + weight: Number(productData.weight ?? 0), + weightUnit: productData.weightUnit ?? 'kg', + volume: Number(productData.volume ?? 0), + volumeUnit: productData.volumeUnit ?? 'L', + storeId: Number(productData.storeId), + isActive: Boolean(productData.isActive), + files: productData.files ?? [], + }; + + console.log('📦 Product update payload:', payload); + + const response = await axios.put( + `${baseApiUrl}/api/Admin/products/${productData.id}`, + payload, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + return { status: response.status, data: response.data }; + } catch (error) { + console.error('❌ Error updating product:', error.response?.data || error); + return { status: error.response?.status || 500, data: null }; + } +}; + +/** + * Deletes a product + * @param {number} productId - ID of the product to delete + * @returns {Promise<{status: number, data: Object}>} Deletion result + */ +export const apiDeleteProductAsync = async (productId) => { + if (API_ENV_DEV === API_FLAG) { + const index = products.findIndex((p) => p.id === productId); + if (index !== -1) { + products.splice(index, 1); + return { status: 204, data: null }; + } + return { status: 404, data: null }; + } else { + try { + const response = await axios.delete( + `${baseApiUrl}/api/Admin/products/${productId}` + ); + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Error deleting product:', error); + return { status: error.response?.status || 500, data: null }; + } + } +}; + +export const apiGetProductCategoriesAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + return categories.filter((cat) => cat.type === 'product'); + } else { + apiSetAuthHeader(); + const res = await axios.get(`${baseApiUrl}/api/Admin/categories`); + return res.data; + } +}; + +export const apiGetStoreCategoriesAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + return categories.filter((cat) => cat.type === 'store'); + } else { + apiSetAuthHeader(); + const res = await axios.get(`${baseApiUrl}/api/Admin/store/categories`); + return res.data; + } +}; + +// Get store details +export const apiGetStoreByIdAsync = async (storeId) => { + if (API_ENV_DEV == API_FLAG) { + // izbrisati naknadno + const mockStore = { + id: storeId, + name: 'Nova Market', + description: 'Brza i kvalitetna dostava proizvoda.', + isOnline: true, + createdAt: '2024-01-01', + products: [], + }; + + return stores.filter((trgovina) => trgovina.id === storeId); + } else { + apiSetAuthHeader(); + const store = await axios.get(`${baseApiUrl}/api/Admin/stores/${storeId}`); + return store.data; + } +}; + +export const apiUpdateStoreAsync = async (store) => { + if (API_ENV_DEV === API_FLAG) { + const index = stores.indexOf((st) => store.name == st.name); + stores[index] = { + ...store, + }; + return new Promise((resolve) => + setTimeout(() => resolve({ success: true, data: store }), 500) + ); + } else { + apiSetAuthHeader(); + return axios.put(`${baseApiUrl}/api/Admin/store/${store.id}`, { + id: store.id, + name: store.name, + address: store.address, + categoryId: store.categoryId, + description: store.description, + isActive: store.isActive, + tax: store.tax + }); + } +}; + +// Get all stores +export const apiGetAllStoresAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + //izbrisati poslije + return stores; + //return new Promise((resolve) => setTimeout(() => resolve({stores}), 500)); + } else { + apiSetAuthHeader(); + const stores = await axios.get(`${baseApiUrl}/api/Admin/stores`); + return stores.data; + } +}; + +export const apiGetMonthlyStoreRevenueAsync = async (id) => { + apiSetAuthHeader(); + const now = new Date(); + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); // ✅ define it here + const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); // ✅ and here + const from = format(firstDayOfMonth, 'yyyy-MM-dd'); + const to = format(lastDayOfMonth, 'yyyy-MM-dd'); + + const rev = await axios.get(`${baseApiUrl}/api/Admin/store/${id}/income?from=${from}&to=${to}`); + console.log(rev); + return rev.data; + +}; +// DELETE product category +export const apiDeleteProductCategoryAsync = async (categoryId) => { + if (API_ENV_DEV === API_FLAG) { + const rez = categories.filter((cat) => cat.id == categoryId); + const index = categories.indexOf(rez); + if (index > -1) { + categories.splice(index, 1); + console.log('deleted'); + } + return new Promise((resolve) => + setTimeout(() => resolve({ success: true, deletedId: categoryId }), 500) + ); + } else { + apiSetAuthHeader(); + return axios.delete(`${baseApiUrl}/api/Admin/categories/${categoryId}`); + } +}; + +// DELETE store category +export const apiDeleteStoreCategoryAsync = async (categoryId) => { + if (API_ENV_DEV === API_FLAG) { + const rez = categories.filter((cat) => cat.id == categoryId); + const index = categories.indexOf(rez); + if (index > -1) { + categories.splice(index, 1); + console.log('deleted'); + } + return new Promise((resolve) => + setTimeout(() => resolve({ success: true, deletedId: categoryId }), 500) + ); + } else { + apiSetAuthHeader(); + return axios.delete(`${baseApiUrl}/api/Admin/store/category/${categoryId}`); + } +}; + +export const apiAddProductCategoryAsync = async (name) => { + if (API_ENV_DEV === API_FLAG) { + try { + const newCategory = { + id: categories.length + 1, + name: name, + type: 'product', + }; + categories.push(newCategory); + return { data: newCategory }; + } catch (error) { + console.log('Error pri kreiranju kategorije proizvoda!'); + throw error; + } + //return new Promise((resolve) => + //setTimeout( + //() => resolve({ success: true, data: { id: Date.now(), name } }), + //500 + //) + //); + } else { + apiSetAuthHeader(); + try { + const res = await axios.post(`${baseApiUrl}/api/Admin/categories`, { + name, + }); + return { success: true, data: res.data }; + } catch (err) { + console.error('Error creating product category:', err); + return { success: false }; + } + } +}; + +export const apiAddStoreCategoryAsync = async (name) => { + if (API_ENV_DEV === API_FLAG) { + try { + const newCategory = { + id: categories.length + 1, + name: name, + type: 'store', + }; + categories.push(newCategory); + return newCategory; + } catch (error) { + console.log('Error pri kreiranju kategorije trgovine!'); + throw error; + } + // return new Promise((resolve) => + // setTimeout( + // () => resolve({ success: true, data: { id: Date.now(), name } }), + // 500 + // ) + //); + } else { + apiSetAuthHeader(); + try { + const res = await axios.post( + `${baseApiUrl}/api/Admin/store/categories/create`, + { name } + ); + return { success: res.status < 400, data: res.data }; + } catch (err) { + console.error('Error creating store category:', err); + return { success: false }; + } + } +}; + +export const apiUpdateProductCategoryAsync = async (updatedCategory) => { + if (API_ENV_DEV === API_FLAG) { + const index = categories.findIndex((cat) => cat.id === updatedCategory); + categories[index] = { + ...updatedCategory, + }; + //??? + } + apiSetAuthHeader(); + try { + const response = await axios.put( + `${baseApiUrl}/api/Admin/categories/${updatedCategory.id}`, + { name: updatedCategory.name } + ); + return { success: true, data: response.data }; + } catch (error) { + console.error('Error updating product category:', error); + return { success: false, message: error.message }; + } +}; + +export const apiUpdateStoreCategoryAsync = async (updatedCategory) => { + if (API_ENV_DEV === API_FLAG) { + const index = categories.findIndex( + (cat) => cat.name === updatedCategory.name + ); + categories[index] = { + ...updatedCategory, + }; + return { success: true, data: response.data }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.put( + `${baseApiUrl}/api/Admin/store/category/${updatedCategory.id}`, + { name: updatedCategory.name } + ); + return { success: true, data: response.data }; + } catch (error) { + console.error('Error updating store category:', error); + return { success: false, message: error.message }; + } + } +}; + +export const apiAddStoreAsync = async (newStore) => { + if (API_ENV_DEV === API_FLAG) { + return { + status: 201, + data: { ...newStore, id: Date.now() }, + }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.post( + `${baseApiUrl}/api/Admin/store/create`, + { + name: newStore.name, + categoryId: newStore.categoryid, + address: newStore.address, + description: newStore.description, + placeId: newStore.placeId, + } + ); + return response; + } catch (error) { + console.error('Greška pri kreiranju prodavnice:', error); + return { success: false }; + } + } +}; + +export const apiDeleteStoreAsync = async (storeId) => { + if (API_ENV_DEV === API_FLAG) { + const rez = stores.find((store) => store.id == storeId); + const index = stores.indexOf(rez); + //console.log(index); + if (index > -1) { + stores.splice(index, 1); + console.log(storeId); + console.log(rez.id); + } + return new Promise((resolve) => + setTimeout(() => resolve({ success: true, deletedId: storeId }), 500) + ); + } else { + apiSetAuthHeader(); + try { + const res = await axios.delete( + `${baseApiUrl}/api/Admin/store/${storeId}` + ); + return { success: res.status === 204 }; + } catch (error) { + console.error('Greška pri brisanju prodavnice:', error); + return { success: false }; + } + } +}; + +// Mock ažuriranje korisnika +export const apiUpdateUserAsync = async (updatedUser) => { + if (API_ENV_DEV == API_FLAG) { + const index = users.findIndex((us) => us.id === updatedUser.id); + users[index] = { + ...updatedUser, + }; + return new Promise((resolve) => + setTimeout(() => resolve({ success: true, updatedUser }), 500) + ); + } else { + apiSetAuthHeader(); + return axios.put(`${baseApiUrl}/api/Admin/users/update`, { + userName: updatedUser.email, + id: updatedUser.id, + role: updatedUser.roles[0], + lastActive: updatedUser.isActive, + isApproved: updatedUser.isApproved, + email: updatedUser.email, + }); + } +}; + +// Mock promjena statusa korisnika (Online/Offline) +export const apiToggleUserAvailabilityAsync = async (userId, currentStatus) => { + if (API_ENV_DEV == API_FLAG) { + const newStatus = currentStatus === 'Online' ? 'false' : 'true'; + const index = users.find((us) => us.id == userId); + users[index].availability = newStatus; + return new Promise((resolve) => + setTimeout( + () => resolve({ success: true, availability: newStatus }), + 10000 + ) + ); + } else { + apiSetAuthHeader(); + console.log({ + userId: userId, + activationStatus: currentStatus, + }); + return axios.post(`${baseApiUrl}/api/Admin/users/activate`, { + userId: userId, + activationStatus: currentStatus, + }); + } +}; + +/** + * Simulira export proizvoda u Excel formatu. + * @returns {Promise<{status: number, data: Blob}>} Axios-like odgovor sa blobom Excel fajla + */ +export const apiExportProductsToExcelAsync = async (storeId) => { + if (API_ENV_DEV == API_FLAG) { + const mockProducts = [ + { name: 'Product 1', price: 100, description: 'Description 1' }, + { name: 'Product 2', price: 200, description: 'Description 2' }, + { name: 'Product 3', price: 300, description: 'Description 3' }, + ]; + + const ws = XLSX.utils.json_to_sheet(mockProducts); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Products'); + + const excelData = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + + const blob = new Blob([excelData], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + return { + status: 200, + data: blob, + }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.get(`${baseApiUrl}/api/Admin/products`, { + params: { storeId }, + }); + + console.log('Dobio odgovor:', response.data); + const products = response.data; + + // Pretvori podatke u Excel format + const flattenedProducts = products.map((product) => ({ + ...product, + productCategory: product.productCategory?.id ?? null, + photos: product.photos || '', + })); + + const ws = XLSX.utils.json_to_sheet(flattenedProducts); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'Products'); + const excelData = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); + + const blob = new Blob([excelData], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + return { status: 200, data: blob }; + } catch (error) { + return { + status: error.response?.status || 500, + data: error.response?.data || error, + }; + } + } +}; + +/** + * Simulira export proizvoda u CSV formatu. + * @returns {Promise<{status: number, data: Blob}>} Axios-like odgovor sa blobom CSV fajla + */ +export const apiExportProductsToCSVAsync = async (storeId) => { + if (API_ENV_DEV == API_FLAG) { + const csvContent = + 'Product ID,Product Name,Price\n1,Product A,10.99\n2,Product B,19.99'; + const blob = new Blob([csvContent], { type: 'text/csv' }); + + return { + status: 200, + data: blob, + }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.get(`${baseApiUrl}/api/Admin/products`, { + params: { storeId }, + }); + const products = response.data; + + // Pretvaranje objekata u CSV string + const flattenedProducts = products.map((product) => ({ + ...product, + productCategory: product.productCategory?.id ?? null, + photos: product.photos || '', + })); + + const header = Object.keys(flattenedProducts[0] || {}).join(','); + const rows = flattenedProducts.map((product) => + Object.values(product).join(',') + ); + + const csvContent = [header, ...rows].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + + return { status: 200, data: blob }; + } catch (error) { + return { + status: error.response?.status || 500, + data: error.response?.data || error, + }; + } + } +}; + +export const apiFetchOrdersAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/Admin/order`); + const orders = res.data; + return orders.map((order) => ({ + id: order.id, + status: order.status, + buyerName: order.buyerId, + storeName: order.storeId, + buyerId: order.buyerId, + storeId: order.storeId, + addressId: order.addressId, + createdAt: order.time, + totalPrice: order.total, + isCancelled: order.status === 1, + products: order.orderItems, + })); + } catch (err) { + console.error('Error fetching orders:', err); + return []; + } +}; + +const mapOrderStatus = (code) => { + return ( + { + 0: 'active', + 1: 'cancelled', + 2: 'requested', + 3: 'confirmed', + 4: 'ready', + 5: 'sent', + 6: 'delivered', + }[code] || 'unknown' + ); +}; + +export const apiFetchGeographyAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/Geography/geography`, { + headers: { + Accept: 'application/json', + }, + }); + return res.data; + } catch (error) { + console.error('Error fetching geography data:', error); + return { regions: [], places: [] }; + } +}; + +export const apiDeleteOrderAsync = async (orderId) => { + apiSetAuthHeader(); + try { + const res = await axios.delete(`${baseApiUrl}/api/Admin/order/${orderId}`); + return { status: res.status }; + } catch (err) { + console.error('Error deleting order:', err); + return { status: err.response?.status || 500 }; + } +}; + +export const apiUpdateOrderAsync = async (orderId, payload) => { + apiSetAuthHeader(); + + try { + console.log(payload); + const response = await axios.put( + `${baseApiUrl}/api/Admin/order/update/${orderId}`, + { + buyerId: String(payload.buyerId), + storeId: Number(payload.storeId), + status: String(payload.status), + time: new Date(payload.time).toISOString(), + total: Number(payload.total), + orderItems: payload.orderItems.map((item) => ({ + id: Number(item.id), + productId: Number(item.productId), + price: Number(item.price), + quantity: Number(item.quantity), + })), + } + ); + + return { success: response.status === 204 }; + } catch (error) { + console.error('Error updating order:', error.response?.data || error); + return { success: false, message: error.message }; + } +}; + +export const apiUpdateOrderStatusAsync = async (orderId, newStatus) => { + apiSetAuthHeader(); + try { + const response = await axios.put( + `${baseApiUrl}/api/Admin/order/update/status/${orderId}`, + { + newStatus: newStatus === 'active' ? 1 : 0, + } + ); + return { success: response.status === 204 }; + } catch (error) { + console.error( + 'Error updating order status:', + error.response?.data || error + ); + return { success: false, message: error.message }; + } +}; + +export const apiFetchAllUsersAsync = async () => { + if (API_ENV_DEV == API_FLAG) { + try { + return pendingUsers; + } catch (error) { + console.error('Greška pri dohvaćanju korisnika:', error); + throw error; + } + } else { + const token = localStorage.getItem('token'); + + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + const users = await axios.get(`${baseApiUrl}/api/Admin/users`); + return users; + } +}; +/** + * Pretvara listu stringova (["Search", "Buy"]) u bit-flag broj*/ +const convertTriggersToBitFlag = (triggers) => { + const triggerMap = { + search: 1, + buy: 2, + view: 4, + }; + + if (!Array.isArray(triggers)) return 0; + + return triggers.reduce((acc, trigger) => { + const lowerTrigger = trigger.toLowerCase(); + return acc | (triggerMap[lowerTrigger] || 0); + }, 0); +}; +export const apiCreateAdAsync = async (adData) => { + try { + apiSetAuthHeader(); + const formData = new FormData(); + + // Osnovni podaci + formData.append('SellerId', String(adData.sellerId)); + formData.append('StartTime', new Date(adData.startTime).toISOString()); + formData.append('EndTime', new Date(adData.endTime).toISOString()); + formData.append('ClickPrice', parseFloat(adData.clickPrice)); + formData.append('ViewPrice', parseFloat(adData.viewPrice)); + formData.append('ConversionPrice', parseFloat(adData.conversionPrice)); + formData.append('AdType', adData.AdType); + if (Array.isArray(adData.Triggers)) { + adData.Triggers.forEach((item, index) => { + formData.append(`Triggers[${index}]`, String(item)); + }); + } + if (Array.isArray(adData.AdData)) { + console.log(adData.AdData[0].StoreLink); + adData.AdData.forEach((item, index) => { + formData.append( + `AdDataItems[${index}].storeId`, + String(item.StoreLink) + ); + formData.append( + `AdDataItems[${index}].productId`, + String(item.ProductLink) + ); + formData.append( + `AdDataItems[${index}].description`, + item.Description ?? '' + ); + if (item.Image) { + formData.append( + `AdDataItems[${index}].imageFile`, + item.Image, + item.Image.name + ); + } + }); + } + + //ispis + for (const [key, val] of formData.entries()) { + console.log(key, val); + } + console.log(formData); + const response = await axios.post( + `${baseApiUrl}/api/AdminAnalytics/advertisements`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Advertisement creation failed:', error); + return { status: error.response?.status || 500, data: null }; + } +}; + +/** + * Fetches all advertisements + * @returns {Promise<{status: number, data: Array}>} lista reklama + */ +export const apiGetAllAdsAsync = async () => { + if (API_ENV_DEV === API_FLAG) { + // Return mock data for development + const mockAds = ads; + + return { status: 200, data: mockAds }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.get( + `${baseApiUrl}/api/AdminAnalytics/advertisements` + ); + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Error fetching advertisements:', error); + return { status: error.response?.status || 500, data: [] }; + } + } +}; + +/** + * Deletes an advertisement + * @param {number} adId - ID reklame koja se brise + * @returns {Promise<{status: number, data: Object}>} + */ +export const apiDeleteAdAsync = async (adId) => { + if (API_ENV_DEV === API_FLAG) { + // Mock deletion for development + return { status: 204, data: null }; + } else { + apiSetAuthHeader(); + try { + const response = await axios.delete( + `${baseApiUrl}/api/AdminAnalytics/advertisements/${adId}` + ); + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Error deleting advertisement:', error); + return { status: error.response?.status || 500, data: null }; + } + } +}; + +/** + * Updates an existing advertisement + * @param {Object} adData - Advertisement data to update + * @returns {Promise<{status: number, data: Object}>} Updated advertisement + */ +/* +export const apiUpdateAdAsync = async (adData) => { + if (API_ENV_DEV === API_FLAG) { + // Mock update for development + return { + status: 200, + data: { + ...adData, + startTime: new Date(adData.startTime).toISOString(), + endTime: new Date(adData.endTime).toISOString(), + }, + }; + } else { + apiSetAuthHeader(); + try { + const formData = new FormData(); + formData.append('id', adData.id); + formData.append('sellerId', adData.sellerId); + formData.append('startTime', new Date(adData.startTime).toISOString()); + formData.append('endTime', new Date(adData.endTime).toISOString()); + + // Handle the AdData array + adData.AdData.forEach((item, index) => { + formData.append(`AdDataItems[${index}].Description`, item.Description); + formData.append(`AdDataItems[${index}].ProductLink`, item.ProductLink); + formData.append(`AdDataItems[${index}].StoreLink`, item.StoreLink); + // Handle image file if it exists + if (item.Image instanceof File) { + formData.append( + `AdData[${index}].Image`, + item.Image, + item.Image.name + ); + } else if (typeof item.Image === 'string') { + formData.append(`AdData[${index}].ImagePath`, item.Image); + } + }); + + const response = await axios.put( + `${baseApiUrl}/api/AdminAnalytics/advertisements/${adData.id}`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Advertisement update failed:', error); + return { status: error.response?.status || 500, data: null }; + } + } +}; +*/ + +export const apiUpdateAdAsync = async (advertisementId, adData) => { + try { + apiSetAuthHeader(); + + const formData = new FormData(); + formData.append('StartTime', new Date(adData.startTime).toISOString()); + formData.append('EndTime', new Date(adData.endTime).toISOString()); + formData.append('IsActive', adData.isActive); + formData.append('AdType', adData.adType); + formData.append('Triggers', adData.triggers); + + adData.newAdDataItems.forEach((item, index) => { + formData.append(`NewAdDataItems[${index}].storeId`, item.storeId); + formData.append(`NewAdDataItems[${index}].productId`, item.productId); + formData.append( + `NewAdDataItems[${index}].description`, + item.description || '' + ); + if (item.imageFile instanceof File) { + formData.append( + `NewAdDataItems[${index}].imageFile`, + item.imageFile, + item.imageFile.name + ); + } + }); + + const response = await axios.put( + `${baseApiUrl}/api/AdminAnalytics/advertisements/${advertisementId}`, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + } + ); + + return { status: response.status, data: response.data }; + } catch (error) { + console.error('Error updating advertisement:', error); + return { status: error.response?.status || 500, data: null }; + } +}; + +export const apiRemoveAdItemAsync = async (id) => { + apiSetAuthHeader(); + return axios.delete(`${baseApiUrl}/api/AdminAnalytics/data/${id}`); +}; + +export const apiGetRegionsAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/Geography/regions`); + return res.data; // [{ id, name, countryCode }] + } catch (error) { + console.error('Error fetching regions:', error); + return []; + } +}; + +export const apiGetGeographyAsync = async () => { + apiSetAuthHeader(); + try { + const response = await axios.get(`${baseApiUrl}/api/Geography/geography`); + return { + regions: response.data.regions || [], + places: response.data.places || [], + }; + } catch (error) { + console.error('Error fetching geography data:', error); + return { regions: [], places: [] }; + } +}; + +export const apiFetchAdClicksAsync = async (id) => { + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + return axios.get( + `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/clicks` + ); +}; + +export const apiFetchAdViewsAsync = async (id) => { + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + return axios.get( + `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/views` + ); +}; + +export const apiFetchAdConversionsAsync = async (id) => { + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + return axios.get( + `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/conversions` + ); +}; + +export const apiFetchAdsWithProfitAsync = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + + const response = await axios.get( + `${baseApiUrl}/api/AdminAnalytics/advertisements` + ); + const ads = response.data; + + const allProductIds = []; // Za skupljanje svih productId vrednosti + + const adsWithProfit = ads.map((ad) => { + const profit = + ad.clicks * ad.clickPrice + + ad.views * ad.viewPrice + + ad.conversions * ad.conversionPrice; + + // Izdvajanje productId-ova iz adData + const adData = ad.adData ?? []; + const productIds = adData + .filter( + (item) => item.productId !== null && item.productId !== undefined + ) + .map((item) => item.productId); + + // Ispis pojedinačnih productId-ova za svaki oglas + console.log(`📦 Ad #${ad.id} - productId-ovi:`, productIds); + + allProductIds.push(...productIds); // Dodaj u globalni niz + + const fullAd = { + id: ad.id, + sellerId: ad.sellerId, + views: ad.views, + viewPrice: ad.viewPrice, + clicks: ad.clicks, + clickPrice: ad.clickPrice, + conversions: ad.conversions, + conversionPrice: ad.conversionPrice, + startTime: ad.startTime, + endTime: ad.endTime, + isActive: ad.isActive, + adType: ad.adType, + productCategoryId: ad.productCategoryId ?? null, + triggers: ad.triggers, + adData: adData, + profit: parseFloat(profit.toFixed(2)), + }; + + return fullAd; + }); + + // Uklanjanje duplikata + const uniqueProductIds = [...new Set(allProductIds)]; + + // Sačuvaj u localStorage + localStorage.setItem('adProductIds', JSON.stringify(uniqueProductIds)); + + // Ispis svih sačuvanih ID-eva + console.log( + '✅ Svi sačuvani productId-ovi u localStorage:', + uniqueProductIds + ); + + return adsWithProfit; + } catch (error) { + console.error('❌ Greška pri dohvaćanju oglasa:', error); + return []; + } +}; + +export const apiFetchProductsByIdsAsync = async () => { + try { + const token = localStorage.getItem('token'); + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; + } + + const storedProductIds = JSON.parse(localStorage.getItem('adProductIds')); + + if ( + !storedProductIds || + !Array.isArray(storedProductIds) || + storedProductIds.length === 0 + ) { + console.warn('⚠️ Nema productId vrednosti u localStorage.'); + return []; + } + + console.log('📦 Product ID-ovi koji će biti dohvaćeni:', storedProductIds); + + const productRequests = storedProductIds.map(async (productId) => { + try { + const response = await axios.get( + `${baseApiUrl}/api/Admin/products/${productId}` + ); + console.log(`✅ Proizvod ${productId} uspešno dohvaćen.`); + return response.data; + } catch (err) { + console.error(`❌ Greška pri dohvaćanju proizvoda ${productId}:`, err); + return null; + } + }); + + const allProducts = await Promise.all(productRequests); + + // Filtriraj neuspešne (null) odgovore + const validProducts = allProducts.filter((p) => p !== null); + + console.log( + '✅ Ukupno uspešno dohvaćenih proizvoda:', + validProducts.length + ); + return validProducts; + } catch (error) { + console.error('❌ Globalna greška pri dohvaćanju proizvoda:', error); + return []; + } +}; + +//rute +export const createRouteAsync = async (orders, directionsResponse) => { + const rawData = JSON.stringify(directionsResponse); + const hash = sha256(rawData).toString(); + + const payload = { + orderIds: orders.map((o) => o.id), + routeData: { + data: rawData, + hash: hash, + }, + }; + + const response = await axios.post( + `${baseApiUrl}/api/Delivery/routes`, + payload + ); + return response.data; +}; + +export const apiFetchAllTicketsAsync = async ({ + status = '', + pageNumber = 1, + pageSize = 20, +} = {}) => { + apiSetAuthHeader(); + try { + const params = []; + if (status) params.push(`status=${encodeURIComponent(status)}`); + if (pageNumber) params.push(`pageNumber=${pageNumber}`); + if (pageSize) params.push(`pageSize=${pageSize}`); + const query = params.length ? `?${params.join('&')}` : ''; + const res = await axios.get(`${baseApiUrl}/api/Tickets/all${query}`); + return { status: res.status, data: res.data }; + } catch (err) { + console.error('Error fetching tickets:', err); + return { status: err.response?.status || 500, data: [] }; + } +}; + +export const apiUpdateTicketStatusAsync = async (ticketId, newStatus) => { + apiSetAuthHeader(); + try { + const res = await axios.put( + `${baseApiUrl}/api/Tickets/${ticketId}/status`, + { newStatus } + ); + return { status: res.status, data: res.data }; + } catch (err) { + console.error('Error updating ticket status:', err); + return { status: err.response?.status || 500, data: null }; + } +}; + +export const apiFetchAllConversationsAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/Chat/conversations`); + return { status: res.status, data: res.data }; + } catch (err) { + console.error('Error fetching conversations:', err); + return { status: err.response?.status || 500, data: [] }; + } +}; + +export const apiFetchMessagesForConversationAsync = async ( + conversationId, + page = 1, + pageSize = 30 +) => { + apiSetAuthHeader(); + try { + const res = await axios.get( + `${baseApiUrl}/api/Chat/conversations/${conversationId}/messages?page=${page}&pageSize=${pageSize}` + ); + return { status: res.status, data: res.data }; + } catch (err) { + console.error('Error fetching messages:', err); + return { status: err.response?.status || 500, data: [] }; + } +}; + +export const apiDeleteTicketAsync = async (ticketId) => { + apiSetAuthHeader(); + try { + const res = await axios.delete(`${baseApiUrl}/api/Tickets/${ticketId}`); + return { status: res.status }; + } catch (err) { + console.error('Error deleting ticket:', err); + return { status: err.response?.status || 500 }; + } +}; + +export const fetchAdressesAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/user-profile/address`); + return res.data; + } catch (err) { + console.error('Error finding address.', err); + return { status: err.response?.status || 500 }; + } +}; + +export const fetchAdressByIdAsync = async (id) => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/user-profile/address/${id}`); + return res.data; + } catch (err) { + console.error('Error finding address.', err); + return { status: err.response?.status || 500 }; + } +}; + +export const apiGetRoutesAsync = async () => { + apiSetAuthHeader(); + try { + const res = await axios.get(`${baseApiUrl}/api/Delivery/routes`); + return res.data; + } catch (err) { + console.error('Error getting routes.', err); + return { status: err.response?.status || 500 }; + } +}; + +export const apiDeleteRouteAsync = async (id) => { + apiSetAuthHeader(); + try { + const res = await axios.delete(`${baseApiUrl}/api/Delivery/routes/${id}`); + return res.status; + } catch (err) { + console.error('Error getting routes.', err); + return { status: err.response?.status || 500 }; + } +}; + +export const getGoogle = async (origin, destination, waypoints) => { + const apiKey = 'AIzaSyAiW6HWTmBB84hHGcxxUdPHwRcc6vpbPRo'; + + const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent( + origin + )}&destination=${encodeURIComponent( + destination + )}&waypoints=${encodeURIComponent(waypoints)}&key=${apiKey}`; + + try { + const response = await axios.get(url); + const directionsJson = response.data; // Axios automatically parses the JSON + + if (directionsJson.status !== 'OK') { + alert('Google Maps API Error: ' + directionsJson.status); + return null; + } + + return directionsJson; + } catch (err) { + console.error('Error fetching directions:', err); + alert('An error occurred while fetching directions.'); + return null; + } +}; + +/** + * Fetches the optimal route from Google Directions API. + * @async + * @param {string[]} locs - Array of location strings (addresses or lat/lng). + * @param {string} transportMode - 'driving' or 'walking'. + * @returns {Promise} The first route object from the API response or null. + */ +export const apiExternGetOptimalRouteAsync = async (locs, transportMode) => { + if (!locs) return null; + if (locs.length < 2) { + console.warn('Need at least an origin and destination for a route.'); + return null; + } + try { + const origin = locs[0]; + const destination = locs[locs.length - 1]; + // const waypointsParam = locs.slice(1, -1).map(loc => ({ location: loc, stopover: true })); + + const waypointsString = locs + .slice(1, -1) + .map((loc) => encodeURIComponent(loc)) + .join('|'); + if (!origin || !destination) return; + const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&waypoints=optimize:true|${waypointsString}&mode=${transportMode}&key=${googleMapsApiKey}`; + const netlifyFunctionEndpoint = `/api/netlify/directions`; + console.log('Requesting directions URL:', url); + + // const response = await axios.get(netlifyFunctionEndpoint, { + // params: { + // url: url, // Pass the partial URL + // }, + // }); + const response = await fetch(url, { method: 'GET', mode: 'no-cors' }); + + if (!response.ok) + throw new Error(`Error fetching route: ${response.statusText}`); + + const data = await response.json(); + if (data.status !== 'OK') + throw new Error( + `API error: ${data.status} - ${data.error_message || 'Unknown error'}` + ); + + //setOptimalRouteData(data.routes[0]); + //await saveDataToLocalStorage(data, `route-${transportMode}.json`); + console.log(data.routes[0]); + return data.routes[0]; + } catch (error) { + console.error('Error fetching optimal route:', error); + //window.alert(t('Error fetching route. Please check console.')); + return null; + } +}; + +export const apiCreateRouteAsync = async (orders) => { + if (orders.length == 0) return; + apiSetAuthHeader(); + const payload = { + orderIds: orders.map((o) => o.id), + }; + const response = await axios.post( + `${baseApiUrl}/api/Delivery/routes/create`, + JSON.stringify(payload), + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + return response.data; +}; + +export const apiGetOrderAddresses = async (orders) => { + const addresses = []; + for (let index = 0; index < orders.length; index++) { + const addr = await fetchAdressByIdAsync(orders[index].addressId); + if (orders[index].storeId) { + const store = await apiGetStoreByIdAsync(orders[index].storeId); + addresses.push(store.address); + } + addresses.push(addr.address); + } + return addresses; +}; + +export const apiGetAllRoutesAsync = async () => { + apiSetAuthHeader(); + return axios.get(`${baseApiUrl}/api/Delivery/routes`); +}; + + + +export const apiGetStoreIncomeAsync = async (storeId, from, to) => { + try { + const response = await axios.get( + `${baseApiUrl}/api/Admin/store/${storeId}/income`, + { + params: { from, to } + } + ); + return response.data; + } catch (error) { + console.error(`❌ Error fetching store income for ID ${storeId}:`, error); + throw error; + } +}; + + + + +export const apiFetchDeliveryAddressByIdAsync = async (addressId) => { + const res = await axios.get( + `${baseApiUrl}/api/user-profile/address/${addressId}` + ); + return res.data; // Vraća objekat adrese +}; + diff --git a/src/api/api/AdminApi.js b/src/api/api/AdminApi.js new file mode 100644 index 0000000..8355021 --- /dev/null +++ b/src/api/api/AdminApi.js @@ -0,0 +1,210 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from "../ApiClient"; +import ApproveUserDto from '../model/ApproveUserDto'; +import CreateUserDto from '../model/CreateUserDto'; +import ProblemDetails from '../model/ProblemDetails'; +import UserInfoDto from '../model/UserInfoDto'; + +/** +* Admin service. +* @module api/AdminApi +* @version v1 +*/ +export default class AdminApi { + + /** + * Constructs a new AdminApi. + * @alias module:api/AdminApi + * @class + * @param {module:ApiClient} [apiClient] Optional API client implementation to use, + * default to {@link module:ApiClient#instanc + e} if unspecified. + */ + constructor(apiClient) { + this.apiClient = apiClient || ApiClient.instance; + } + + /** + * Callback function to receive the result of the apiAdminUserIdDelete operation. + * @callback moduleapi/AdminApi~apiAdminUserIdDeleteCallback + * @param {String} error Error message, if any. + * @param {'String'{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {String} id + * @param {module:api/AdminApi~apiAdminUserIdDeleteCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiAdminUserIdDelete(id, callback) { + + let postBody = null; + // verify the required parameter 'id' is set + if (id === undefined || id === null) { + throw new Error("Missing the required parameter 'id' when calling apiAdminUserIdDelete"); + } + + let pathParams = { + 'id': id + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = []; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = 'String'; + + return this.apiClient.callApi( + '/api/Admin/user/{id}', 'DELETE', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + /** + * Callback function to receive the result of the apiAdminUsersApprovePost operation. + * @callback moduleapi/AdminApi~apiAdminUsersApprovePostCallback + * @param {String} error Error message, if any. + * @param {'String'{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {Object} opts Optional parameters + * @param {module:model/ApproveUserDto} opts.body + * @param {module:api/AdminApi~apiAdminUsersApprovePostCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiAdminUsersApprovePost(opts, callback) { + opts = opts || {}; + let postBody = opts['body']; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = ['application/json', 'text/json', 'application/_*+json']; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = 'String'; + + return this.apiClient.callApi( + '/api/Admin/users/approve', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + /** + * Callback function to receive the result of the apiAdminUsersCreatePost operation. + * @callback moduleapi/AdminApi~apiAdminUsersCreatePostCallback + * @param {String} error Error message, if any. + * @param {module:model/UserInfoDto{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {Object} opts Optional parameters + * @param {module:model/CreateUserDto} opts.body + * @param {module:api/AdminApi~apiAdminUsersCreatePostCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiAdminUsersCreatePost(opts, callback) { + opts = opts || {}; + let postBody = opts['body']; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = ['application/json', 'text/json', 'application/_*+json']; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = UserInfoDto; + + return this.apiClient.callApi( + '/api/Admin/users/create', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + /** + * Callback function to receive the result of the apiAdminUsersGet operation. + * @callback moduleapi/AdminApi~apiAdminUsersGetCallback + * @param {String} error Error message, if any. + * @param {Array.{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {module:api/AdminApi~apiAdminUsersGetCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiAdminUsersGet(callback) { + + let postBody = null; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = []; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = [UserInfoDto]; + + return this.apiClient.callApi( + '/api/Admin/users', 'GET', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + +} \ No newline at end of file diff --git a/src/api/api/FacebookAuthApi.js b/src/api/api/FacebookAuthApi.js new file mode 100644 index 0000000..4242818 --- /dev/null +++ b/src/api/api/FacebookAuthApi.js @@ -0,0 +1,78 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from "../ApiClient"; + +/** +* FacebookAuth service. +* @module api/FacebookAuthApi +* @version v1 +*/ +export default class FacebookAuthApi { + + /** + * Constructs a new FacebookAuthApi. + * @alias module:api/FacebookAuthApi + * @class + * @param {module:ApiClient} [apiClient] Optional API client implementation to use, + * default to {@link module:ApiClient#instanc + e} if unspecified. + */ + constructor(apiClient) { + this.apiClient = apiClient || ApiClient.instance; + } + + /** + * Callback function to receive the result of the apiFacebookAuthLoginPost operation. + * @callback moduleapi/FacebookAuthApi~apiFacebookAuthLoginPostCallback + * @param {String} error Error message, if any. + * @param data This operation does not return a value. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {Object} opts Optional parameters + * @param {String} opts.body + * @param {module:api/FacebookAuthApi~apiFacebookAuthLoginPostCallback} callback The callback function, accepting three arguments: error, data, response + */ + apiFacebookAuthLoginPost(opts, callback) { + opts = opts || {}; + let postBody = opts['body']; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = ['application/json', 'text/json', 'application/_*+json']; + let accepts = []; + let returnType = null; + + return this.apiClient.callApi( + '/api/FacebookAuth/login', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + +} \ No newline at end of file diff --git a/src/api/api/TestAuthApi.js b/src/api/api/TestAuthApi.js new file mode 100644 index 0000000..ebec88a --- /dev/null +++ b/src/api/api/TestAuthApi.js @@ -0,0 +1,122 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from "../ApiClient"; +import LoginDTO from '../model/LoginDTO'; + +/** +* TestAuth service. +* @module api/TestAuthApi +* @version v1 +*/ +export default class TestAuthApi { + + /** + * Constructs a new TestAuthApi. + * @alias module:api/TestAuthApi + * @class + * @param {module:ApiClient} [apiClient] Optional API client implementation to use, + * default to {@link module:ApiClient#instanc + e} if unspecified. + */ + constructor(apiClient) { + this.apiClient = apiClient || ApiClient.instance; + } + + /** + * Callback function to receive the result of the apiTestAuthLoginPost operation. + * @callback moduleapi/TestAuthApi~apiTestAuthLoginPostCallback + * @param {String} error Error message, if any. + * @param {'String'{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {Object} opts Optional parameters + * @param {module:model/LoginDTO} opts.body + * @param {module:api/TestAuthApi~apiTestAuthLoginPostCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiTestAuthLoginPost(opts, callback) { + opts = opts || {}; + let postBody = opts; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = ['application/json', 'text/json', 'application/_*+json']; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = 'String'; + + console.log(opts); + + return this.apiClient.callApi( + '/api/TestAuth/login', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + /** + * Callback function to receive the result of the apiTestAuthRegisterPost operation. + * @callback moduleapi/TestAuthApi~apiTestAuthRegisterPostCallback + * @param {String} error Error message, if any. + * @param {'String'{ data The data returned by the service call. + * @param {String} response The complete HTTP response. + */ + + /** + * @param {module:api/TestAuthApi~apiTestAuthRegisterPostCallback} callback The callback function, accepting three arguments: error, data, response + * data is of type: {@link <&vendorExtensions.x-jsdoc-type>} + */ + apiTestAuthRegisterPost(callback) { + + let postBody = null; + + let pathParams = { + + }; + let queryParams = { + + }; + let headerParams = { + + }; + let formParams = { + + }; + + let authNames = []; + let contentTypes = []; + let accepts = ['text/plain', 'application/json', 'text/json']; + let returnType = 'String'; + + return this.apiClient.callApi( + '/api/Auth/register', 'POST', + pathParams, queryParams, headerParams, formParams, postBody, + authNames, contentTypes, accepts, returnType, callback + ); + } + +} \ No newline at end of file diff --git a/src/api/apiClientInstance.js b/src/api/apiClientInstance.js new file mode 100644 index 0000000..ba3e137 --- /dev/null +++ b/src/api/apiClientInstance.js @@ -0,0 +1,29 @@ +// src/api/apiClientInstance.js +import ApiClient from './ApiClient'; // Import the generated ApiClient +import request from 'superagent'; // Import superagent directly TO CONFIGURE DEFAULTS + +const apiClientInstance = new ApiClient(); + +// Configure the base path (use HTTP as per your HAR log) +apiClientInstance.basePath = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5054'; +console.log(`ApiClient basePath configured to: ${apiClientInstance.basePath}`); + + +// --- Configure superagent defaults to SEND COOKIES --- +// This tells *all* subsequent superagent requests made through its standard import +// (which the generated ApiClient likely uses) to include credentials (cookies). +try { + // THIS IS THE KEY LINE FOR COOKIE AUTH: + request.defaults({ withCredentials: true }); + console.log("Superagent default withCredentials configured globally."); +} catch (err) { + // This shouldn't normally fail, but good to have a catch block + console.error("Could not configure superagent defaults:", err); +} +// --- End of superagent configuration --- + + +// No need to override callApi or look for ApiClient specific options, +// as applyAuthToRequest only handles spec-defined auth schemes, not cookies. + +export default apiClientInstance; \ No newline at end of file diff --git a/src/api/index.js b/src/api/index.js new file mode 100644 index 0000000..9b93e73 --- /dev/null +++ b/src/api/index.js @@ -0,0 +1,110 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from './ApiClient'; +import ApproveUserDto from './model/ApproveUserDto'; +import CreateUserDto from './model/CreateUserDto'; +import LoginDTO from './model/LoginDTO'; +import ProblemDetails from './model/ProblemDetails'; +import UserInfoDto from './model/UserInfoDto'; +import AdminApi from './api/AdminApi'; +import FacebookAuthApi from './api/FacebookAuthApi'; +import TestAuthApi from './api/TestAuthApi'; + +/** +* Object.
+* The index module provides access to constructors for all the classes which comprise the public API. +*

+* An AMD (recommended!) or CommonJS application will generally do something equivalent to the following: +*

+* var BazaarApi = require('index'); // See note below*.
+* var xxxSvc = new BazaarApi.XxxApi(); // Allocate the API class we're going to use.
+* var yyyModel = new BazaarApi.Yyy(); // Construct a model instance.
+* yyyModel.someProperty = 'someValue';
+* ...
+* var zzz = xxxSvc.doSomething(yyyModel); // Invoke the service.
+* ...
+* 
+* *NOTE: For a top-level AMD script, use require(['index'], function(){...}) +* and put the application logic within the callback function. +*

+*

+* A non-AMD browser application (discouraged) might do something like this: +*

+* var xxxSvc = new BazaarApi.XxxApi(); // Allocate the API class we're going to use.
+* var yyy = new BazaarApi.Yyy(); // Construct a model instance.
+* yyyModel.someProperty = 'someValue';
+* ...
+* var zzz = xxxSvc.doSomething(yyyModel); // Invoke the service.
+* ...
+* 
+*

+* @module index +* @version v1 +*/ +export { + /** + * The ApiClient constructor. + * @property {module:ApiClient} + */ + ApiClient, + + /** + * The ApproveUserDto model constructor. + * @property {module:model/ApproveUserDto} + */ + ApproveUserDto, + + /** + * The CreateUserDto model constructor. + * @property {module:model/CreateUserDto} + */ + CreateUserDto, + + /** + * The LoginDTO model constructor. + * @property {module:model/LoginDTO} + */ + LoginDTO, + + /** + * The ProblemDetails model constructor. + * @property {module:model/ProblemDetails} + */ + ProblemDetails, + + /** + * The UserInfoDto model constructor. + * @property {module:model/UserInfoDto} + */ + UserInfoDto, + + /** + * The AdminApi service constructor. + * @property {module:api/AdminApi} + */ + AdminApi, + + /** + * The FacebookAuthApi service constructor. + * @property {module:api/FacebookAuthApi} + */ + FacebookAuthApi, + + /** + * The TestAuthApi service constructor. + * @property {module:api/TestAuthApi} + */ + TestAuthApi +}; diff --git a/src/api/model/ApproveUserDto.js b/src/api/model/ApproveUserDto.js new file mode 100644 index 0000000..02f76bb --- /dev/null +++ b/src/api/model/ApproveUserDto.js @@ -0,0 +1,54 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from '../ApiClient'; + +/** + * The ApproveUserDto model module. + * @module model/ApproveUserDto + * @version v1 + */ +export default class ApproveUserDto { + /** + * Constructs a new ApproveUserDto. + * @alias module:model/ApproveUserDto + * @class + * @param userId {String} + */ + constructor(userId) { + this.userId = userId; + } + + /** + * Constructs a ApproveUserDto from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/ApproveUserDto} obj Optional instance to populate. + * @return {module:model/ApproveUserDto} The populated ApproveUserDto instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new ApproveUserDto(); + if (data.hasOwnProperty('userId')) + obj.userId = ApiClient.convertToType(data['userId'], 'String'); + } + return obj; + } +} + +/** + * @member {String} userId + */ +ApproveUserDto.prototype.userId = undefined; + diff --git a/src/api/model/CreateUserDto.js b/src/api/model/CreateUserDto.js new file mode 100644 index 0000000..4344a20 --- /dev/null +++ b/src/api/model/CreateUserDto.js @@ -0,0 +1,72 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from '../ApiClient'; + +/** + * The CreateUserDto model module. + * @module model/CreateUserDto + * @version v1 + */ +export default class CreateUserDto { + /** + * Constructs a new CreateUserDto. + * @alias module:model/CreateUserDto + * @class + * @param userName {String} + * @param email {String} + * @param password {String} + */ + constructor(userName, email, password) { + this.userName = userName; + this.email = email; + this.password = password; + } + + /** + * Constructs a CreateUserDto from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/CreateUserDto} obj Optional instance to populate. + * @return {module:model/CreateUserDto} The populated CreateUserDto instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new CreateUserDto(); + if (data.hasOwnProperty('userName')) + obj.userName = ApiClient.convertToType(data['userName'], 'String'); + if (data.hasOwnProperty('email')) + obj.email = ApiClient.convertToType(data['email'], 'String'); + if (data.hasOwnProperty('password')) + obj.password = ApiClient.convertToType(data['password'], 'String'); + } + return obj; + } +} + +/** + * @member {String} userName + */ +CreateUserDto.prototype.userName = undefined; + +/** + * @member {String} email + */ +CreateUserDto.prototype.email = undefined; + +/** + * @member {String} password + */ +CreateUserDto.prototype.password = undefined; + diff --git a/src/api/model/LoginDTO.js b/src/api/model/LoginDTO.js new file mode 100644 index 0000000..d79a3c8 --- /dev/null +++ b/src/api/model/LoginDTO.js @@ -0,0 +1,63 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from '../ApiClient'; + +/** + * The LoginDTO model module. + * @module model/LoginDTO + * @version v1 + */ +export default class LoginDTO { + /** + * Constructs a new LoginDTO. + * @alias module:model/LoginDTO + * @class + * @param username {String} + * @param password {String} + */ + constructor(username, password) { + this.username = username; + this.password = password; + } + + /** + * Constructs a LoginDTO from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/LoginDTO} obj Optional instance to populate. + * @return {module:model/LoginDTO} The populated LoginDTO instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new LoginDTO(); + if (data.hasOwnProperty('username')) + obj.username = ApiClient.convertToType(data['username'], 'String'); + if (data.hasOwnProperty('password')) + obj.password = ApiClient.convertToType(data['password'], 'String'); + } + return obj; + } +} + +/** + * @member {String} username + */ +LoginDTO.prototype.username = undefined; + +/** + * @member {String} password + */ +LoginDTO.prototype.password = undefined; + diff --git a/src/api/model/ProblemDetails.js b/src/api/model/ProblemDetails.js new file mode 100644 index 0000000..be57235 --- /dev/null +++ b/src/api/model/ProblemDetails.js @@ -0,0 +1,46 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from '../ApiClient'; + +/** + * The ProblemDetails model module. + * @module model/ProblemDetails + * @version v1 + */ +export default class ProblemDetails { + /** + * Constructs a new ProblemDetails. + * @alias module:model/ProblemDetails + * @class + * @extends Object + */ + constructor() { + } + + /** + * Constructs a ProblemDetails from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/ProblemDetails} obj Optional instance to populate. + * @return {module:model/ProblemDetails} The populated ProblemDetails instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new ProblemDetails(); + ApiClient.constructFromObject(data, obj, 'Object'); + } + return obj; + } +} diff --git a/src/api/model/UserInfoDto.js b/src/api/model/UserInfoDto.js new file mode 100644 index 0000000..6349074 --- /dev/null +++ b/src/api/model/UserInfoDto.js @@ -0,0 +1,87 @@ +/* + * Bazaar API + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * OpenAPI spec version: v1 + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + * + * Swagger Codegen version: 3.0.68 + * + * Do not edit the class manually. + * + */ +import ApiClient from '../ApiClient'; + +/** + * The UserInfoDto model module. + * @module model/UserInfoDto + * @version v1 + */ +export default class UserInfoDto { + /** + * Constructs a new UserInfoDto. + * @alias module:model/UserInfoDto + * @class + */ + constructor() { + } + + /** + * Constructs a UserInfoDto from a plain JavaScript object, optionally creating a new instance. + * Copies all relevant properties from data to obj if supplied or a new instance if not. + * @param {Object} data The plain JavaScript object bearing properties of interest. + * @param {module:model/UserInfoDto} obj Optional instance to populate. + * @return {module:model/UserInfoDto} The populated UserInfoDto instance. + */ + static constructFromObject(data, obj) { + if (data) { + obj = obj || new UserInfoDto(); + if (data.hasOwnProperty('id')) + obj.id = ApiClient.convertToType(data['id'], 'String'); + if (data.hasOwnProperty('userName')) + obj.userName = ApiClient.convertToType(data['userName'], 'String'); + if (data.hasOwnProperty('email')) + obj.email = ApiClient.convertToType(data['email'], 'String'); + if (data.hasOwnProperty('emailConfirmed')) + obj.emailConfirmed = ApiClient.convertToType(data['emailConfirmed'], 'Boolean'); + if (data.hasOwnProperty('roles')) + obj.roles = ApiClient.convertToType(data['roles'], ['String']); + if (data.hasOwnProperty('isApproved')) + obj.isApproved = ApiClient.convertToType(data['isApproved'], 'Boolean'); + } + return obj; + } +} + +/** + * @member {String} id + */ +UserInfoDto.prototype.id = undefined; + +/** + * @member {String} userName + */ +UserInfoDto.prototype.userName = undefined; + +/** + * @member {String} email + */ +UserInfoDto.prototype.email = undefined; + +/** + * @member {Boolean} emailConfirmed + */ +UserInfoDto.prototype.emailConfirmed = undefined; + +/** + * @member {Array.} roles + */ +UserInfoDto.prototype.roles = undefined; + +/** + * @member {Boolean} isApproved + */ +UserInfoDto.prototype.isApproved = undefined; + diff --git a/src/assets/fonts/.gitkeep b/src/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/icons/.gitkeep b/src/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/icons/admin.svg b/src/assets/icons/admin.svg new file mode 100644 index 0000000..ca80650 --- /dev/null +++ b/src/assets/icons/admin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/.gitkeep b/src/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/images/Bazaar.png b/src/assets/images/Bazaar.png new file mode 100644 index 0000000..cad22e6 Binary files /dev/null and b/src/assets/images/Bazaar.png differ diff --git a/src/assets/images/background.jpg b/src/assets/images/background.jpg new file mode 100644 index 0000000..8f07497 Binary files /dev/null and b/src/assets/images/background.jpg differ diff --git a/src/assets/images/bazaarAd.jpg b/src/assets/images/bazaarAd.jpg new file mode 100644 index 0000000..d265f28 Binary files /dev/null and b/src/assets/images/bazaarAd.jpg differ diff --git a/src/assets/images/edit-icon.png b/src/assets/images/edit-icon.png new file mode 100644 index 0000000..7885e39 Binary files /dev/null and b/src/assets/images/edit-icon.png differ diff --git a/src/assets/images/routing-pointa-ppointb.png b/src/assets/images/routing-pointa-ppointb.png new file mode 100644 index 0000000..e68e775 Binary files /dev/null and b/src/assets/images/routing-pointa-ppointb.png differ diff --git a/src/components/.gitkeep b/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/components/AdCard.jsx b/src/components/AdCard.jsx new file mode 100644 index 0000000..bef9601 --- /dev/null +++ b/src/components/AdCard.jsx @@ -0,0 +1,351 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Paper, + Typography, + Stack, + IconButton, + Tooltip, +} from '@mui/material'; +import { + Eye, + Hand, + Clock, + CheckCircle, + XCircle, + Link as LucideLink, + Store, + Info, + Pencil, + Trash2, +} from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import DeleteConfirmationModal from './DeleteAdConfirmation'; +import EditAdModal from './EditAdModal'; +import { apiFetchApprovedUsersAsync } from '../api/api'; +const baseApiUrl = import.meta.env.VITE_API_BASE_URL; +import defaultAdImage from '@images/bazaarAd.jpg'; +import { useTranslation } from 'react-i18next'; + +const IconStat = ({ icon, value, label, bg }) => ( + + + {icon} + + + + {value} + + + {label} + + + +); + +const AdCard = ({ ad, stores, onDelete, onEdit, onViewDetails }) => { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [sellers, setSellers] = useState([]); + const { t } = useTranslation(); + + useEffect(() => { + const fetchUsers = async () => { + const rez = await apiFetchApprovedUsersAsync(); + setSellers(rez); + }; + fetchUsers(); + }, []); + + const handleDelete = async () => { + try { + await onDelete(ad.id); + toast.success('Ad deleted successfully'); + } catch (err) { + toast.error(err.message || 'Failed to delete ad'); + } finally { + setIsDeleteOpen(false); + } + }; + + const handleEdit = async (adId, payload) => { + try { + await onEdit(adId, payload); + toast.success('Ad updated successfully'); + } catch (err) { + toast.error(err.message || 'Failed to update ad'); + } finally { + setIsEditOpen(false); + } + }; + + const handleDetails = () => { + try { + onViewDetails(ad.id); + } catch (err) { + toast.error(err.message || 'Failed to open details'); + } + }; + + const adItem = ad.adData[0]; + const dateRange = `${new Date(ad.startTime).toLocaleDateString()} - ${new Date(ad.endTime).toLocaleDateString()}`; + + return ( + + + {/* Left: Image + Description */} + + + + + + #{ad.id.toString().padStart(6, '0')} | Seller:{' '} + {sellers.find((s) => s.id == ad.sellerId)?.userName || + 'Unknown'} + + + + {adItem?.description || 'No Description'} + + + {adItem?.productId && ( + + + + + + )} + {adItem?.storeId && ( + + + + + + )} + + + + + {/* Middle: Stats */} + + + } + value={ad.views} + label={t('common.views')} + bg='#0284c7' + /> + + + } + value={ad.clicks} + label={t('common.clicks')} + bg='#0d9488' + /> + + + } + value={dateRange} + label={t('common.active')} + bg='#8b5cf6' + /> + + + + + {t('common.clickPrice')}:{' '} + {ad.clickPrice ?? 'Mock'} + + + {t('common.viewPrice')}:{' '} + {ad.viewPrice ?? 'Mock'} + + + {t('common.conversionPrice')}:{' '} + {ad.conversionPrice ?? 'Mock'} + + + + + + ) : ( + + ) + } + value={ad.isActive ? t('common.active') : t('common.inactive')} + label={t('common.status')} + bg={ad.isActive ? '#22c55e' : '#f87171'} + /> + + + + {/* Right: Actions */} + + + + + + + + setIsEditOpen(true)}> + + + + + setIsDeleteOpen(true)} + > + + + + + {/* ...modals... */} + + setIsEditOpen(false)} + onSave={handleEdit} + /> + + setIsDeleteOpen(false)} + onConfirm={handleDelete} + /> + + ); +}; + + +export default AdCard; diff --git a/src/components/AdContentCard.jsx b/src/components/AdContentCard.jsx new file mode 100644 index 0000000..5a8c898 --- /dev/null +++ b/src/components/AdContentCard.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Box, Typography, Paper, Stack } from '@mui/material'; +import { Store, Package, MessageSquare } from 'lucide-react'; +const baseApiUrl = import.meta.env.VITE_API_BASE_URL; + +const AdContentCard = ({ imageUrl, storeName, productName, description }) => { + return ( + + {/* Left Image */} + + ad + + + {/* Right Content */} + + + + + + Store: {storeName} + + + + + + + Product: {productName} + + + + + + + {description || 'No advertisement text provided.'} + + + + + + ); +}; + +export default AdContentCard; diff --git a/src/components/AdFunnelChart.jsx b/src/components/AdFunnelChart.jsx new file mode 100644 index 0000000..537f38e --- /dev/null +++ b/src/components/AdFunnelChart.jsx @@ -0,0 +1,217 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Card, Typography, Box, Grid } from '@mui/material'; +import FunnelCurved from './FunnelCurved'; +import { + VisibilityOutlined, + MouseOutlined, + CheckCircleOutline, +} from '@mui/icons-material'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; +import { useTranslation } from 'react-i18next'; + +const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +const funnelColors = ['#60a5fa', '#38bdf8', '#0ea5e9']; +const funnelIcons = [ + , + , + , +]; + +export default function AdFunnelChart() { + const { t } = useTranslation(); + const [funnelSteps, setFunnelSteps] = useState([ + { + label: t('analytics.viewed'), + value: 0, + percent: 100, + color: funnelColors[0], + icon: funnelIcons[0], + }, + { + label: t('analytics.clicked'), + value: 0, + percent: 0, + color: funnelColors[1], + icon: funnelIcons[1], + }, + { + label: t('analytics.converted'), + value: 0, + percent: 0, + color: funnelColors[2], + icon: funnelIcons[2], + }, + ]); + + const [ads, setAds] = useState([]); + const connectionRef = useRef(null); + + useEffect(() => { + const fetchData = async () => { + const adsResponse = await apiGetAllAdsAsync(); + const adsData = adsResponse.data || []; + setAds(adsData); + updateFunnelData(adsData); + }; + + fetchData(); + + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) return; + + const newConnection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = newConnection; + + const startConnection = async () => { + try { + await newConnection.start(); + console.log('SignalR Connected to AdvertisementHub!'); + } catch (err) { + console.error('SignalR Connection Error:', err); + } + }; + + startConnection(); + + newConnection.on('ReceiveAdUpdate', (updatedAd) => { + setAds((prevAds) => { + const existingAdIndex = prevAds.findIndex( + (ad) => ad.id === updatedAd.id + ); + const updatedAds = [...prevAds]; + + if (existingAdIndex !== -1) { + updatedAds[existingAdIndex] = updatedAd; + } else { + updatedAds.push(updatedAd); + } + + updateFunnelData(updatedAds); + return updatedAds; + }); + }); + + return () => { + if ( + connectionRef.current && + connectionRef.current.state === 'Connected' + ) { + connectionRef.current + .stop() + .catch((err) => + console.error('Error stopping SignalR connection:', err) + ); + } + }; + }, []); + + const updateFunnelData = (adsData) => { + const totalViews = adsData.reduce((sum, ad) => sum + (ad.views || 0), 0); + const totalClicks = adsData.reduce((sum, ad) => sum + (ad.clicks || 0), 0); + const totalConversions = adsData.reduce( + (sum, ad) => sum + (ad.conversions || 0), + 0 + ); + + setFunnelSteps([ + { + label: t('analytics.viewed'), + value: totalViews, + percent: 100, + color: funnelColors[0], + icon: funnelIcons[0], + }, + { + label: t('analytics.clicked'), + value: totalClicks, + percent: + totalViews > 0 ? Math.round((totalClicks / totalViews) * 100) : 0, + color: funnelColors[1], + icon: funnelIcons[1], + }, + { + label: t('analytics.converted'), + value: totalConversions, + percent: + totalClicks > 0 + ? Math.round((totalConversions / totalClicks) * 100) + : 0, + color: funnelColors[2], + icon: funnelIcons[2], + }, + ]); + }; + + return ( + + + {t('analytics.salesFunnelAnalysis')} + + + + + + + + {funnelSteps.map((step) => ( + + + + {step.icon} + + + {step.value.toLocaleString()} + + + {step.label} + + + {step.percent}% from previous + + + + ))} + + + ); +} diff --git a/src/components/AdRealtimeMonitor.jsx b/src/components/AdRealtimeMonitor.jsx new file mode 100644 index 0000000..dfe7e29 --- /dev/null +++ b/src/components/AdRealtimeMonitor.jsx @@ -0,0 +1,45 @@ +// AdRealtimeMonitor.jsx +import React from 'react'; +import { useAdSignalR } from '../hooks/useAdSignalR'; // putanja do custom hooka +import { useTranslation } from 'react-i18next'; +export default function AdRealtimeMonitor() { + const { + connectionStatus, + latestAdUpdate, + latestClickTime, + latestViewTime, + latestConversionTime, + adUpdatesHistory, + } = useAdSignalR(); + + const { t } = useTranslation(); + + return ( +
+
Status: {connectionStatus}
+
+ {t('common.latestAdUpdate')}:{' '} + {latestAdUpdate ? JSON.stringify(latestAdUpdate) : 'None'} +
+
+ {t('common.latestClick')}: {latestClickTime} +
+
+ {t('common.latestView')}: {latestViewTime} +
+
+ {t('common.latestConversion')}: {latestConversionTime} +
+
+ {t('common.history')}: +
    + {adUpdatesHistory.map((item, idx) => ( +
  • + [{item.type}] {item.data} ({item.time.toLocaleString()}) +
  • + ))} +
+
+
+ ); +} diff --git a/src/components/AdStackedBarChart.jsx b/src/components/AdStackedBarChart.jsx new file mode 100644 index 0000000..16cce3b --- /dev/null +++ b/src/components/AdStackedBarChart.jsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Typography, Box } from '@mui/material'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { format, parseISO } from 'date-fns'; +import { useTranslation } from 'react-i18next'; + +const colors = ['#6366F1', '#F59E0B']; +const labels = ['Fixed', 'PopUp']; + +const barHeight = 50; +const barGap = 45; +const chartWidth = 200; +const yAxisWidth = 90; +const overlapRadius = 20; +const framePadding = 10; + +function groupByMonthAndType(ads) { + const byMonth = {}; + ads.forEach((ad) => { + const date = ad.startTime || ad.endTime; + if (!date) return; + const month = format(parseISO(date), 'yyyy-MM'); + if (!byMonth[month]) byMonth[month] = { year: month, Fixed: 0, PopUp: 0 }; + if (ad.adType === 'Fixed') byMonth[month].Fixed += 1; + if (ad.adType === 'PopUp') byMonth[month].PopUp += 1; + }); + + // Sortiraj po mjesecima + const sortedMonths = Object.values(byMonth).sort((a, b) => + a.year.localeCompare(b.year) + ); + + // Uzmi samo zadnja tri mjeseca + return sortedMonths.slice(-3); +} + + +function StackedBarRow({ + row, + y, + maxTotal, + chartWidth, + overlapRadius, + framePadding, + strokeWidth = 4, +}) { + const keys = ['Fixed', 'PopUp']; + const total = keys.reduce((sum, k) => sum + row[k], 0); + const barWidth = total > 0 ? (total / maxTotal) * chartWidth : 0; + let acc = 0; + const segmentPositions = []; + + keys.forEach((k, idx) => { + const value = row[k]; + const start = acc; + acc += value; + const x = (start / total) * barWidth + yAxisWidth; + const w = (value / total) * barWidth; + segmentPositions.push({ x, w }); + }); + + return ( + + + {['Fixed', 'PopUp'] + .map((k, idx) => { + const { x, w } = segmentPositions[idx]; + return ( + + ); + }) + .reverse()} + + ); +} + +export default function AdStackedBarChart() { + const { t } = useTranslation(); + const [data, setData] = useState([]); + useEffect(() => { + const fetchData = async () => { + const adsResponse = await apiGetAllAdsAsync(); + const ads = adsResponse.data; + const grouped = groupByMonthAndType(ads); + setData(grouped); + }; + fetchData(); + }, []); + + const keys = ['Fixed', 'PopUp']; + const totals = data.map((row) => keys.reduce((sum, k) => sum + row[k], 0)); + const maxTotal = Math.max(...totals, 1); // da ne bude 0 + + const chartHeight = data.length * (barHeight + barGap); + + return ( + + + {t('analytics.combinationChart')} + + + {/* Godine na Y osi */} + {data.map((row, i) => ( + + {format(parseISO(row.year + '-01'), 'MMM yyyy')} + + ))} + {/* Barovi */} + {data.map((row, i) => ( + + ))} + + + {labels.map((label, idx) => ( + + + {t(`analytics.${label.toLowerCase()}`)} + + ))} + + + ); +} diff --git a/src/components/AddAdItemModal.jsx b/src/components/AddAdItemModal.jsx new file mode 100644 index 0000000..bd914c2 --- /dev/null +++ b/src/components/AddAdItemModal.jsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import { + Modal, + Box, + TextField, + MenuItem, + Typography, + Button, +} from '@mui/material'; +import ImageUploader from './ImageUploader'; +import { apiGetStoreProductsAsync } from '@api/api'; + +const AddAdItemModal = ({ open, onClose, onAddItem, stores }) => { + const [formData, setFormData] = useState({ + Image: '', + StoreLink: '', + ProductLink: '', + Description: '' + }); + + const [errors, setErrors] = useState({}); + const [products, setProducts] = useState([]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleStoreChange = async (e) => { + const selectedStoreLink = e.target.value; + setFormData((prev) => ({ + ...prev, + StoreLink: selectedStoreLink, + })); + try { + const result = await apiGetStoreProductsAsync(selectedStoreLink); + setProducts(result.data || []); + } catch (err) { + console.error('Failed to fetch products for store:', err); + } + }; + + const handleImageUpload = (files) => { + const file = files[0]; + if (file) { + setFormData((prev) => ({ ...prev, Image: file })); + } + }; + + const handleSubmit = () => { + const err = {}; + if (!formData.Description.trim()) err.Description = 'Required'; + if (!formData.ProductLink) err.ProductLink = 'Required'; + if (!formData.StoreLink) err.StoreLink = 'Required'; + if (!formData.Image) err.Image = 'Image is required'; + setErrors(err); + if (Object.keys(err).length > 0) return; + + onAddItem(formData); + + setFormData({ + Image: '', + StoreLink: '', + ProductLink: '', + Description: '', + AdType: '', + Triggers: [], + }); + + onClose(); + }; + + return ( + + + {/* Left: Image Uploader */} + + + {errors.Image && ( + + {errors.Image} + + )} + + + {/* Right: Form Fields */} + + + Add Ad Item + + + + + + {products.map((p) => ( + + {p.name} + + ))} + + + + {stores.map((s) => ( + + {s.name} + + ))} + + + {/* Buttons */} + + + + + + + + ); +}; + +export default AddAdItemModal; diff --git a/src/components/AddAdModal.jsx b/src/components/AddAdModal.jsx new file mode 100644 index 0000000..0158760 --- /dev/null +++ b/src/components/AddAdModal.jsx @@ -0,0 +1,411 @@ +import React, { useState, useEffect } from 'react'; +import { Select, Checkbox, ListItemText } from '@mui/material'; +import { + Modal, + Box, + Typography, + TextField, + Button, + MenuItem, + InputAdornment, +} from '@mui/material'; +import SellIcon from '@mui/icons-material/Sell'; +import AddAdItemModal from './AddAdItemModal'; +import { + apiGetAllStoresAsync, + apiFetchApprovedUsersAsync, + apiCreateAdAsync, +} from '@api/api'; +import { useTranslation } from 'react-i18next'; + +const triggerArrayToBitmask = (arr) => { + const triggerMap = { + View: 1, + Search: 2, + Order: 4, + }; + return arr.reduce((sum, val) => sum | (triggerMap[val] || 0), 0); +}; + +const AddAdModal = ({ open, onClose, onAddAd }) => { + const { t } = useTranslation(); + const [formData, setFormData] = useState({ + sellerId: '', + Views: 0, + Clicks: 0, + Conversions: 0, + clickPrice: '', + viewPrice: '', + conversionPrice: '', + startTime: '', + endTime: '', + isActive: true, + AdData: [], + AdType: '', + Triggers: [], + }); + + const [stores, setStores] = useState([]); + const [sellers, setSellers] = useState([]); + const [formErrors, setFormErrors] = useState({}); + const [adItemModalOpen, setAdItemModalOpen] = useState(false); + + useEffect(() => { + if (open) { + apiGetAllStoresAsync().then(setStores); + apiFetchApprovedUsersAsync().then((users) => { + const sellersOnly = users.filter((u) => { + const role = (u.roles?.[0] || 'buyer').toLowerCase(); + return role === 'seller'; + }); + setSellers(sellersOnly); + }); + } + }, [open]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value.toString(), + })); + }; + + const handleAddAdItem = (item) => { + setFormData((prev) => ({ + ...prev, + AdData: [...prev.AdData, item], + })); + }; + const handleAdType = (e) => { + const value = e.target.value.toString(); + setFormData((prev) => ({ + ...prev, + AdType: value, + })); + }; + + const handleTriggers = (e) => { + const value = e.target.value.toString(); + if (!formData.Triggers.includes(value)) { + setFormData((prev) => ({ + ...prev, + Triggers: [...prev.Triggers, value], + })); + } + }; + const handleSubmit = async () => { + const errors = {}; + console.log('[DEBUG] Raw form data before validation:', formData); + if (!formData.sellerId) errors.sellerId = 'Seller is required'; + if (!formData.startTime) errors.startTime = 'Start time is required'; + if (!formData.endTime) { + errors.endTime = 'End time is required'; + } else if (formData.startTime && formData.endTime <= formData.startTime) { + errors.endTime = 'End time must be after start time'; + } + + if (!formData.clickPrice) errors.clickPrice = 'Click price required'; + if (!formData.viewPrice) errors.viewPrice = 'View price required'; + if (!formData.conversionPrice) errors.conversionPrice = 'Conversion price required'; + if (!formData.AdType) errors.AdType = 'Ad Type is required'; + if (formData.Triggers.length === 0) errors.Triggers = 'At least one trigger required'; + if (formData.AdData.length === 0) errors.AdData = 'At least one ad item required'; + + setFormErrors(errors); + if (Object.keys(errors).length > 0) return; + if (Object.keys(errors).length > 0) { + console.warn('[DEBUG] Validation errors:', errors); + return; + + console.log('AdType being sent:', formData.AdType); +} + + const result = { + sellerId: formData.sellerId, + startTime: formData.startTime, + endTime: formData.endTime, + clickPrice: parseFloat(formData.clickPrice), + viewPrice: parseFloat(formData.viewPrice), + conversionPrice: parseFloat(formData.conversionPrice), + AdType: formData.AdType, + Triggers: formData.Triggers, + AdData: formData.AdData, + isActive: formData.isActive, + }; + setFormData({ + sellerId: '', + startTime: '', + endTime: '', + clickPrice: '', + viewPrice: '', + conversionPrice: '', + AdData: [], + AdType: '', + Triggers: [], + isActive: true, + }); + onAddAd(result) + onClose(); + }; + + return ( + + + + + + {t('common.createAd')} + + + + + + + {sellers.map((seller) => ( + + {seller.userName} + + ))} + + + + + + + {formErrors.AdData && ( + + {formErrors.AdData} + + )} + + + + KM, + }} + /> + + KM, + }} + /> + + KM, + }} + /> + + + PopUp + Fixed + + + selected.join(', '), + }} + name="Triggers" + label={t('common.triggers')} + value={Array.isArray(formData.Triggers) ? formData.Triggers : []} + onChange={(e) => { + const { value } = e.target; + setFormData((prev) => ({ + ...prev, + Triggers: typeof value === 'string' ? value.split(',') : value, + })); + }} + fullWidth + margin="dense" + error={!!formErrors.Triggers} + helperText={formErrors.Triggers} +> + {['Search', 'Order', 'View'].map((trigger) => ( + + + + + ))} + + + {formData.AdData.map((item, index) => ( + + + {t('common.adText')}: {item.Description} + + + {t('common.store')}: {item.StoreLink} + + + {t('common.product')}: {item.ProductLink} + + + ))} + + + + + + + + setAdItemModalOpen(false)} + onAddItem={handleAddAdItem} + stores={stores} + /> + + + ); +}; + +export default AddAdModal; diff --git a/src/components/AddCategoryModal.jsx b/src/components/AddCategoryModal.jsx new file mode 100644 index 0000000..b16686e --- /dev/null +++ b/src/components/AddCategoryModal.jsx @@ -0,0 +1,121 @@ +import React, { useState } from "react"; +import { + Modal, + Box, + TextField, + Button, + Typography, + RadioGroup, + FormControlLabel, + Radio, + FormLabel, + Avatar, +} from "@mui/material"; +import CategoryIcon from "@mui/icons-material/Category"; + +const AddCategoryModal = ({ open, onClose, onAddCategory, selectedType }) => { + const [categoryName, setCategoryName] = useState(""); + const [categoryType, setCategoryType] = useState("product"); + + const handleSubmit = () => { + console.log(selectedType); + if (categoryName.trim()) { + const newCategory = { + id: Date.now(), + name: categoryName.trim(), + type: categoryType, + }; + onAddCategory(newCategory); + setCategoryName(""); + onClose(); + } +}; + + return ( + + + {/* Ikonica iznad */} + + + + + {/* Naslov */} + + Add New Category + + + {/* Input za ime */} + setCategoryName(e.target.value)} + sx={{ mb: 3 }} + /> + + {/* Radio grupa za tip */} + + Category Type + setCategoryType(e.target.value)} + > + } + label="Product" + /> + } + label="Store" + /> + + + + {/* Dugmad */} + + + + + + + ); +}; + +export default AddCategoryModal; diff --git a/src/components/AddStoreModal.jsx b/src/components/AddStoreModal.jsx new file mode 100644 index 0000000..593fbba --- /dev/null +++ b/src/components/AddStoreModal.jsx @@ -0,0 +1,175 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Box, + Typography, + TextField, + Button, + MenuItem, +} from '@mui/material'; +import StoreMallDirectoryIcon from '@mui/icons-material/StoreMallDirectory'; +import { apiGetStoreCategoriesAsync, apiFetchGeographyAsync } from '@api/api'; +import { useTranslation } from 'react-i18next'; + + +const AddStoreModal = ({ open, onClose, onAddStore }) => { + const { t } = useTranslation(); + const [formData, setFormData] = useState({ + name: '', + address: '', + description: '', + categoryid: '', + placeId: '', + isActive: true, + }); + + const [categories, setCategories] = useState([]); + const [places, setPlaces] = useState([]); + + useEffect(() => { + if (open) { + apiGetStoreCategoriesAsync().then(setCategories); + apiFetchGeographyAsync().then((geo) => { + setPlaces(geo?.places || []); + }); + } + }, [open]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: name === 'isActive' ? value === 'true' : value, + })); + }; + + const handleSubmit = () => { + onAddStore(formData); + onClose(); + }; + + return ( + + + + + + {t('common.addNewStore')} + + + + + + + + + + + {places.map((place) => ( + + {place.name} ({place.postalCode}) + + ))} + + + + {categories.map((cat) => ( + + {cat.name} + + ))} + + + + + + + + + ); +}; + +export default AddStoreModal; diff --git a/src/components/AddUserModal.jsx b/src/components/AddUserModal.jsx new file mode 100644 index 0000000..ca89c9d --- /dev/null +++ b/src/components/AddUserModal.jsx @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Button, + Box, + Typography, + RadioGroup, + FormControlLabel, + Radio, + Avatar, +} from "@mui/material"; +import PersonIcon from "@mui/icons-material/Person"; + +const AddUserModal = ({ open, onClose, onCreate }) => { + const [formData, setFormData] = useState({ + userName: "", + email: "", + password: "", + role: "Buyer", + }); + + useEffect(() => { + if (open) { + setFormData({ + userName: "", + email: "", + password: "", + role: "Buyer", + }); + } + }, [open]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleSubmit = () => { + if ( + formData.userName.trim() && + formData.email.trim() && + formData.password.trim() + ) { + onCreate(formData); + onClose(); + } + }; + + return ( + + + + + + + Add New User + + + + + + + + + + + + } label="Buyer" /> + } label="Seller" /> + + + + + + + + + ); +}; + +export default AddUserModal; diff --git a/src/components/AdminSearchBar.jsx b/src/components/AdminSearchBar.jsx new file mode 100644 index 0000000..fed898b --- /dev/null +++ b/src/components/AdminSearchBar.jsx @@ -0,0 +1,32 @@ +import React from "react"; +import { TextField, InputAdornment } from "@mui/material"; +import { FiSearch } from "react-icons/fi"; + +const SearchBar = ({ placeholder = "Search", onChange, value }) => { + return ( + + + + ), + }} + /> + ); +}; + +export default SearchBar; diff --git a/src/components/AdvertisementDetailsModal.jsx b/src/components/AdvertisementDetailsModal.jsx new file mode 100644 index 0000000..7f619df --- /dev/null +++ b/src/components/AdvertisementDetailsModal.jsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Box, Typography } from '@mui/material'; +import { + Eye, Hand, CheckCircle, BarChart2, + MousePointerClick, Percent, Activity +} from 'lucide-react'; +import CountUp from 'react-countup'; +import AdContentCard from '@components/AdContentCard'; +import HorizontalScroll from './HorizontalScroll'; +import { apiGetAllStoresAsync, apiGetStoreProductsAsync } from '@api/api'; +import { useAdSignalR } from '@hooks/useAdSignalR'; +import { useTranslation } from 'react-i18next'; + +const AdvertisementDetailsModal = ({ open, onClose, ad, onSave, onDelete }) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [editedData, setEditedData] = useState({ + adData: ad?.adData || [], + startTime: ad?.startTime || '', + endTime: ad?.endTime || '', + isActive: ad?.isActive || false, + }); + + const [stores, setStores] = useState([]); + const [products, setProducts] = useState([]); + + const { + connectionStatus, + latestAdUpdate, + latestClickTime, + latestViewTime, + latestConversionTime, + adUpdatesHistory, + } = useAdSignalR(); + + const adToShow = latestAdUpdate?.id === ad?.id ? latestAdUpdate : ad; + + useEffect(() => { + if (open) { + const fetchData = async () => { + const fetchedStores = await apiGetAllStoresAsync(); + setStores(fetchedStores); + + const allProducts = []; + for (const store of fetchedStores) { + const { data } = await apiGetStoreProductsAsync(store.id); + allProducts.push(...data); + } + setProducts(allProducts); + }; + fetchData(); + } + }, [open]); + + const getStoreName = (storeId) => + stores.find((s) => s.id === storeId)?.name || `Unknown store`; + + const getProductName = (productId) => + products.find((p) => p.id === productId)?.name || `Unknown product`; + + const handleSave = () => { + onSave?.(ad.id, editedData); + setIsEditing(false); + }; + + const handleCancel = () => { + setEditedData({ + adData: ad.adData, + startTime: ad.startTime, + endTime: ad.endTime, + isActive: ad.isActive, + }); + setIsEditing(false); + }; + + const updateAdData = (index, field, value) => { + const newAdData = [...editedData.adData]; + newAdData[index] = { ...newAdData[index], [field]: value }; + setEditedData({ ...editedData, adData: newAdData }); + }; + + if (!adToShow) { + return <>; // siguran render bez hook greške + } + + const cardData = [ + { + icon: , + label: 'Views', + value: adToShow.views.toLocaleString(), + bg: '#e0f2fe', + }, + { + icon: , + label: 'Clicks', + value: adToShow.clicks.toLocaleString(), + bg: '#ccfbf1', + }, + { + icon: , + label: 'CTR', + value: + adToShow.views > 0 + ? ((adToShow.clicks / adToShow.views) * 100).toFixed(1) + '%' + : '0%', + bg: '#fef9c3', + }, + { + icon: , + label: 'Status', + value: adToShow.isActive ? 'Active' : 'Inactive', + bg: adToShow.isActive ? '#dcfce7' : '#fee2e2', + }, + ]; + + return ( + + + {/* Header */} + + + + + Advertisement Overview + + + Advertisement {adToShow.id} + + + Seller ID: {adToShow.sellerId} + + + + + + {/* Stats Cards */} + + {cardData.map((item, i) => ( + + {item.icon} + + {item.label} + + + {item.value} + + + ))} + + + {/* Time Info */} + + {[{ label: 'Start Time', time: adToShow.startTime }, { label: 'End Time', time: adToShow.endTime }].map(({ label, time }, idx) => ( + + + + + + {label} + + + {new Date(time).toLocaleDateString()} + + + {new Date(time).toLocaleTimeString()} + + + ))} + + + {/* Prices */} + + + {t('common.clickPrice')}: {adToShow.clickPrice ?? '1000'} + + + {t('common.viewPrice')}: {adToShow.viewPrice ?? '1000'} + + + {t('common.conversionPrice')}: {adToShow.conversionPrice ?? '1000'} + + + + {/* Content Section */} + + + {t('common.advertisementContent')} + + + {adToShow.adData.map((item, index) => ( + + ))} + + + + + ); +}; + +const styles = { + modal: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '90%', + maxWidth: 1000, + maxHeight: '90vh', + overflowY: 'auto', + bgcolor: '#fff', + borderRadius: 3, + p: 4, + outline: 'none', + }, + headerBox: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + mb: 4, + }, + headerAccent: { + width: 12, + height: 48, + borderRadius: '50px', + background: 'linear-gradient(to bottom, #facc15, #f97316)', + mx: 1, + }, + headerContent: { + flex: 1, + textAlign: 'center', + }, + cardGrid: { + display: 'flex', + justifyContent: 'space-between', + gap: 2, + mb: 4, + }, + card: { + flex: 1, + borderRadius: 2, + p: 2, + textAlign: 'center', + boxShadow: '0 2px 6px rgba(0,0,0,0.06)', + }, + timeCard: { + flex: 1, + backgroundColor: '#fff7ed', + borderRadius: 2, + p: 3, + boxShadow: '0 2px 8px rgba(0,0,0,0.05)', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 0.5, + }, + timeTitle: { + display: 'flex', + alignItems: 'center', + fontWeight: 600, + color: '#FF8000', + mb: 1, + }, +}; + +export default AdvertisementDetailsModal; \ No newline at end of file diff --git a/src/components/AnalyticsChart.jsx b/src/components/AnalyticsChart.jsx new file mode 100644 index 0000000..608242d --- /dev/null +++ b/src/components/AnalyticsChart.jsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Tabs, Tab, Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; + +const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +function getLast12Months() { + const months = []; + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push( + d.toLocaleString('default', { month: 'short', year: 'numeric' }) + ); + } + return months; +} + +function generateTargets(realValues, minOffset = -0.1, maxOffset = 0.15) { + return realValues.map((item) => { + const offset = minOffset + Math.random() * (maxOffset - minOffset); + return Math.round(item * (1 + offset)); + }); +} + +const AdsRevenueChart = () => { + const { t } = useTranslation(); + const [tab, setTab] = useState(0); + const [chartData, setChartData] = useState({ + conversions: [], + clicks: [], + views: [], + }); + const [ads, setAds] = useState([]); + const connectionRef = useRef(null); + + // Helper to recalculate chart data + const calculateChartData = (ads) => { + const months = getLast12Months(); + + const revenueData = { + conversions: Array(12).fill(0), + clicks: Array(12).fill(0), + views: Array(12).fill(0), + }; + + for (const ad of ads) { + const startDate = new Date(ad.startTime); + const endDate = new Date(ad.endTime); + + for (let i = 0; i < 12; i++) { + const monthStart = new Date(); + monthStart.setMonth(monthStart.getMonth() - (11 - i), 1); + const monthEnd = new Date(monthStart); + monthEnd.setMonth(monthEnd.getMonth() + 1); + + if (startDate < monthEnd && endDate >= monthStart) { + revenueData.conversions[i] += + (ad.conversions || 0) * (ad.conversionPrice || 0); + revenueData.clicks[i] += (ad.clicks || 0) * (ad.clickPrice || 0); + revenueData.views[i] += (ad.views || 0) * (ad.viewPrice || 0); + } + } + } + + // Generate targets + const conversionsTargets = generateTargets(revenueData.conversions); + const clicksTargets = generateTargets(revenueData.clicks); + const viewsTargets = generateTargets(revenueData.views); + + // Prepare final chart data + setChartData({ + conversions: months.map((month, i) => ({ + month, + revenue: revenueData.conversions[i], + target: conversionsTargets[i], + })), + clicks: months.map((month, i) => ({ + month, + revenue: revenueData.clicks[i], + target: clicksTargets[i], + })), + views: months.map((month, i) => ({ + month, + revenue: revenueData.views[i], + target: viewsTargets[i], + })), + }); + }; + + useEffect(() => { + const fetchInitialData = async () => { + try { + const adsResponse = await apiGetAllAdsAsync(); + const adsData = adsResponse.data || []; + setAds(adsData); + calculateChartData(adsData); + } catch (error) { + console.error('Error fetching initial ads data:', error); + } + }; + + fetchInitialData(); + + // Initialize SignalR connection + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + console.warn('No JWT token found. SignalR connection not started.'); + return; + } + + const newConnection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = newConnection; + + const startConnection = async () => { + try { + await newConnection.start(); + console.log('SignalR Connected to AdvertisementHub!'); + } catch (err) { + console.error('SignalR Connection Error:', err); + } + }; + + startConnection(); + + // Register event handlers + newConnection.on('ReceiveAdUpdate', (updatedAd) => { + console.log('Received Ad Update:', updatedAd); + setAds((prevAds) => { + const updatedAds = prevAds.map((ad) => + ad.id === updatedAd.id ? updatedAd : ad + ); + + if (!updatedAds.some((ad) => ad.id === updatedAd.id)) { + updatedAds.push(updatedAd); + } + + calculateChartData(updatedAds); + return updatedAds; + }); + }); + + // Cleanup on unmount + return () => { + if ( + connectionRef.current && + connectionRef.current.state === 'Connected' + ) { + console.log('Stopping SignalR connection on component unmount.'); + connectionRef.current + .stop() + .catch((err) => + console.error('Error stopping SignalR connection:', err) + ); + } + }; + }, []); + + const handleChange = (event, newValue) => { + setTab(newValue); + }; + + return ( + + + + {tab === 0 && t('analytics.conversionsRevenue')} + {tab === 1 && t('analytics.clicksRevenue')} + {tab === 2 && t('analytics.viewsRevenue')} + + + + + + + + + + + + + `$${Math.round(v / 1000)}K`} /> + `$${val}`} /> + + + + + + + ); +}; + +export default AdsRevenueChart; diff --git a/src/components/ApproveUserButton.jsx b/src/components/ApproveUserButton.jsx new file mode 100644 index 0000000..0a09060 --- /dev/null +++ b/src/components/ApproveUserButton.jsx @@ -0,0 +1,10 @@ +import { IconButton } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; + +export default function ApproveUserButton({ onClick }) { + return ( + + + + ); +} diff --git a/src/components/Calendar.jsx b/src/components/Calendar.jsx new file mode 100644 index 0000000..a9893d9 --- /dev/null +++ b/src/components/Calendar.jsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react'; +import { + Paper, + Box, + IconButton, + Typography, + styled, + Button, // From HEAD + Grid, + useTheme, +} from '@mui/material'; +import { + ChevronLeft, + ChevronRight, + ZoomIn, + ZoomOut, + Remove, +} from '@mui/icons-material'; +import dayjs from 'dayjs'; + +const CalendarCell = styled(Box)( + ({ theme, isToday, isSelected, isCurrentMonth }) => ({ + width: 36, + height: 36, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + borderRadius: '50%', + transition: 'all 0.2s ease', + color: !isCurrentMonth + ? theme.palette.text.disabled + : isToday + ? theme.palette.primary.main + : theme.palette.text.primary, + backgroundColor: isSelected ? theme.palette.primary.main : 'transparent', + '&:hover': { + backgroundColor: isSelected + ? theme.palette.primary.dark // Slightly darker hover for selected + : isCurrentMonth + ? theme.palette.action.hover + : 'transparent', // Only hover current month days + }, + ...(isSelected && { + color: theme.palette.primary.contrastText, + }), + ...(isToday && + !isSelected && { + border: `1px solid ${theme.palette.primary.main}`, + }), + ...(!isCurrentMonth && { + // Ensure non-current month days are not interactive on hover + pointerEvents: 'none', + }), + }) +); + +const DayHeader = styled(Typography)(({ theme }) => ({ + // Added theme for consistency + fontSize: '0.875rem', + fontWeight: 500, + textAlign: 'center', + color: theme.palette.text.secondary, // Using theme for color +})); + +function Calendar() { + const theme = useTheme(); + const today = dayjs(); + const [currentDate, setCurrentDate] = useState(today); + // Default to no dates selected or just today if that's the desired default + const [selectedDates, setSelectedDates] = useState([ + today.format('YYYY-MM-DD'), + ]); + // const [selectedDates, setSelectedDates] = useState([]); // Alternative: start with no selection + + // Generate calendar days + const generateCalendarDays = () => { + const firstDayOfMonth = currentDate.startOf('month'); + const daysInMonth = currentDate.daysInMonth(); + // Consistent startDay logic (Monday = 0) + const startDay = + firstDayOfMonth.day() === 0 ? 6 : firstDayOfMonth.day() - 1; + + const prevMonthDays = []; + for (let i = 0; i < startDay; i++) { + // Corrected loop for prev month days + const date = firstDayOfMonth.subtract(startDay - i, 'day'); + prevMonthDays.push({ + day: date.date(), + isCurrentMonth: false, + date: date.format('YYYY-MM-DD'), + isToday: date.isSame(today, 'day'), + }); + } + + const currentMonthDays = []; + for (let i = 1; i <= daysInMonth; i++) { + const date = firstDayOfMonth.date(i); // Simpler way to get date in current month + currentMonthDays.push({ + day: i, + isCurrentMonth: true, + date: date.format('YYYY-MM-DD'), + isToday: date.isSame(today, 'day'), + }); + } + + const totalCells = 42; // Standard 6 weeks * 7 days + const remainingCells = + totalCells - (prevMonthDays.length + currentMonthDays.length); + const nextMonthDays = []; + const lastDayOfCurrentMonth = currentDate.endOf('month'); // For calculating next month days + + for (let i = 1; i <= remainingCells; i++) { + const date = lastDayOfCurrentMonth.add(i, 'day'); + nextMonthDays.push({ + day: date.date(), + isCurrentMonth: false, + date: date.format('YYYY-MM-DD'), + isToday: date.isSame(today, 'day'), + }); + } + + return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; + }; + + const days = generateCalendarDays(); + const weekDays = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + + const handleDateClick = (dateStr, isCurrentMonthCell) => { + if (!isCurrentMonthCell) return; // Only allow selecting dates in the current month + + if (selectedDates.includes(dateStr)) { + setSelectedDates(selectedDates.filter((d) => d !== dateStr)); + } else { + // If you want single selection, uncomment next line and comment out the one after + // setSelectedDates([dateStr]); + setSelectedDates([...selectedDates, dateStr]); // For multi-selection + } + }; + + const handlePrevMonth = () => { + setCurrentDate(currentDate.subtract(1, 'month')); + }; + + const handleNextMonth = () => { + setCurrentDate(currentDate.add(1, 'month')); + }; + + // Placeholder for zoom functionality if needed + const handleZoom = (type) => { + console.log(`Zoom ${type} clicked`); + }; + + return ( + + {/* Calendar Header: Month/Year and Navigation */} + + + + + + {currentDate.format('MMMM YYYY')} + + + + + + + {/* Weekday headers */} + + {' '} + {/* Adjusted spacing and padding */} + {weekDays.map((day) => ( + // Each day header takes up 1/7th of the width + + {day} + + ))} + + + {/* Calendar grid */} + + {' '} + {/* Allow grid to take remaining space */} + {days.map((dayInfo, index) => ( + // Each cell takes up 1/7th of the width + + + handleDateClick(dayInfo.date, dayInfo.isCurrentMonth) + } + > + {dayInfo.day} + + + ))} + + + {/* Done button - from HEAD */} + + + + + ); +} + +export default Calendar; diff --git a/src/components/CategoryCard.jsx b/src/components/CategoryCard.jsx new file mode 100644 index 0000000..2ed9185 --- /dev/null +++ b/src/components/CategoryCard.jsx @@ -0,0 +1,189 @@ +import React, { useState } from "react"; +import { + Box, + Typography, + IconButton, + Avatar, + Chip, + TextField, +} from "@mui/material"; +import CategoryIcon from "@mui/icons-material/Category"; +import { FiEdit2, FiTrash } from "react-icons/fi"; +import ConfirmDeleteModal from "@components/ConfirmDeleteModal"; +import { useTranslation } from 'react-i18next'; + +const CategoryCard = ({ category, onUpdateCategory, onDeleteCategory }) => { + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editedName, setEditedName] = useState(category.name); + const { t } = useTranslation(); + + const handleEditToggle = () => setIsEditing(true); + + const handleBlur = () => { + setIsEditing(false); + if (editedName.trim() !== "" && editedName !== category.name) { + onUpdateCategory({ ...category, name: editedName }); + } + }; + + const handleDelete = () => { + setOpenDeleteModal(true); + }; + + const confirmDelete = () => { + onDeleteCategory(category.id); + setOpenDeleteModal(false); + }; + + return ( + <> + + {/* Delete Icon */} + + + + + {/* Header */} + + + + + + + {isEditing ? ( + setEditedName(e.target.value)} + onBlur={handleBlur} + autoFocus + InputProps={{ + disableUnderline: true, + sx: { + padding: 0, + fontSize: "1rem", + fontWeight: "bold", + borderBottom: "2px solid #1976d2", + width: `${editedName.length + 1}ch`, + transition: "border 0.2s", + }, + }} + /> + ) : ( + + {category.name} + + + + + )} + + + {/* Label */} + + + + + {/* Decorative wave */} + + + + + + + + + + + + + + {/* Confirm Delete Modal */} + setOpenDeleteModal(false)} + onConfirm={confirmDelete} + categoryName={category.name} + /> + + ); +}; + +export default CategoryCard; diff --git a/src/components/CategoryEditModal.jsx b/src/components/CategoryEditModal.jsx new file mode 100644 index 0000000..16a7a4c --- /dev/null +++ b/src/components/CategoryEditModal.jsx @@ -0,0 +1,54 @@ +import React, { useState, useEffect } from "react"; +import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, Button } from "@mui/material"; + +const CategoryEditModal = ({ open, onClose, category, onUpdateCategory }) => { + const [categoryName, setCategoryName] = useState(category.name); + const [categoryDescription, setCategoryDescription] = useState(category.description); + + useEffect(() => { + if (category) { + setCategoryName(category.name); + setCategoryDescription(category.description); + } + }, [category]); + + const handleSave = () => { + onUpdateCategory({ ...category, name: categoryName, description: categoryDescription }); + onClose(); + }; + + return ( + + Edit Category + + setCategoryName(e.target.value)} + /> + setCategoryDescription(e.target.value)} + /> + + + + + + + ); +}; + +export default CategoryEditModal; diff --git a/src/components/CategoryTabs.jsx b/src/components/CategoryTabs.jsx new file mode 100644 index 0000000..cf6cde6 --- /dev/null +++ b/src/components/CategoryTabs.jsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Box, ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { FaBoxOpen } from "react-icons/fa"; +import { FaStore } from "react-icons/fa"; +import { useTranslation } from 'react-i18next'; + +const CategoryTabs = ({ selectedType, onChangeType }) => { + const { t } = useTranslation(); + return ( + + value && onChangeType(value)} + sx={{ + backgroundColor: "#fff", + borderRadius: 2, + p: 0.5, + gap: 1.5, // razmak između tabova + "& .MuiToggleButton-root": { + border: "none", + borderRadius: 2, + px: 3, + py: 1.2, + fontWeight: 600, + display: "flex", + alignItems: "center", + gap: 1, + fontSize: "0.95rem", + transition: "all 0.2s ease-in-out", + color: "#555", + "&:hover": { + backgroundColor: "#eaeaea", + }, + "&.Mui-selected": { + color: "#fff", + "&:hover": { + opacity: 0.95, + }, + }, + }, + }} + > + + + {t('common.productCategories')} + + + + + {t('common.storeCategories')} + + + + ); +}; + +export default CategoryTabs; diff --git a/src/components/ChatHeader.jsx b/src/components/ChatHeader.jsx new file mode 100644 index 0000000..92b1679 --- /dev/null +++ b/src/components/ChatHeader.jsx @@ -0,0 +1,29 @@ +// @components/ChatHeader.jsx +import { Box, Typography, Stack, Chip } from '@mui/material'; +import CircleIcon from '@mui/icons-material/Circle'; + +export default function ChatHeader({ username }) { + return ( + + + + {username || 'User'} + + + + Online + + + + + + ); +} diff --git a/src/components/ChatInput.jsx b/src/components/ChatInput.jsx new file mode 100644 index 0000000..dfa5f45 --- /dev/null +++ b/src/components/ChatInput.jsx @@ -0,0 +1,64 @@ +// @components/ChatInput.jsx +import { useState } from 'react'; +import { + Box, + TextField, + IconButton, + Paper, + Switch, + FormControlLabel, +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; + +export default function ChatInput({ disabled, onSendMessage }) { + const [message, setMessage] = useState(''); + + const handleSend = () => { + if (message.trim() && !disabled) { + onSendMessage(message); + setMessage(''); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + return ( + + + + setMessage(e.target.value)} + onKeyPress={handleKeyPress} + sx={{ bgcolor: '#f7f8fa', borderRadius: 2 }} + disabled={disabled} + /> + + + + + + ); +} diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx new file mode 100644 index 0000000..25355bd --- /dev/null +++ b/src/components/ChatMessage.jsx @@ -0,0 +1,100 @@ +// @components/ChatMessage.jsx +import { Box, Paper, Typography, Stack } from '@mui/material'; + +export default function ChatMessage({ + sender, + isAdmin, + text, + time, + attachment, + isPrivate = false, +}) { + return ( + + + {!isAdmin && ( + + {sender} + + )} + + + {text} + + + {attachment && ( + + + + {attachment.name} + + + )} + + + + {time} + + + {isPrivate && ( + + Private + + )} + + + + ); +} diff --git a/src/components/ChatMessages.jsx b/src/components/ChatMessages.jsx new file mode 100644 index 0000000..b06f271 --- /dev/null +++ b/src/components/ChatMessages.jsx @@ -0,0 +1,46 @@ +// @components/ChatMessages.jsx +import { Box } from '@mui/material'; +import ChatMessage from './ChatMessage'; +import { useEffect, useRef } from 'react'; + +export default function ChatMessages({ messages = [], userId }) { + const messagesEndRef = useRef(null); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( + + {messages.map((msg) => ( + + ))} +
+ + ); +} diff --git a/src/components/ConfirmDeleteModal.jsx b/src/components/ConfirmDeleteModal.jsx new file mode 100644 index 0000000..1098663 --- /dev/null +++ b/src/components/ConfirmDeleteModal.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import { + Modal, + Box, + Typography, + Button, + Stack, + IconButton, +} from "@mui/material"; +import { FiTrash2 } from "react-icons/fi"; + +const ConfirmDeleteModal = ({ open, onClose, onConfirm, categoryName }) => { + return ( + + + + + + + + Are you sure? + + + You’re about to delete the category + {categoryName}. This action cannot be undone. + + + + + + + + + ); +}; + +export default ConfirmDeleteModal; diff --git a/src/components/ConfirmDeleteStoreModal.jsx b/src/components/ConfirmDeleteStoreModal.jsx new file mode 100644 index 0000000..95d93a1 --- /dev/null +++ b/src/components/ConfirmDeleteStoreModal.jsx @@ -0,0 +1,83 @@ +import React from "react"; +import { + Modal, + Box, + Typography, + Button, + Stack, + IconButton, +} from "@mui/material"; +import { FiTrash2 } from "react-icons/fi"; + +const ConfirmDeleteStoreModal = ({ open, onClose, onConfirm, storeName }) => { + return ( + + + + + + + + Are you sure? + + + You’re about to delete the store {storeName}. This action cannot be undone. + + + + + + + + + ); +}; + +export default ConfirmDeleteStoreModal; diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..822f7f4 --- /dev/null +++ b/src/components/ConfirmDialog.jsx @@ -0,0 +1,31 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + } from "@mui/material"; + import { useTranslation } from 'react-i18next'; + + export default function ConfirmDialog({ open, onClose, onConfirm, message }) { + const { t } = useTranslation(); + + return ( + + {t('common.confirm')} + + {message || t('common.confirmAction')} + + + + + + + ); + } + \ No newline at end of file diff --git a/src/components/CountryStatsPanel.jsx b/src/components/CountryStatsPanel.jsx new file mode 100644 index 0000000..864bc58 --- /dev/null +++ b/src/components/CountryStatsPanel.jsx @@ -0,0 +1,209 @@ +import React, { useEffect, useState } from 'react'; +import { + Card, + CardContent, + Box, + Typography, + Tabs, + Tab, + LinearProgress, +} from '@mui/material'; +import Flag from 'react-world-flags'; +import { + apiGetAllStoresAsync, + apiFetchOrdersAsync, + apiGetGeographyAsync, +} from '../api/api.js'; +import { useTranslation } from 'react-i18next'; + +const CountryStatsPanel = () => { + const { t } = useTranslation(); + const [tab, setTab] = useState(0); + const [data, setData] = useState({ revenue: [], orders: [] }); + + useEffect(() => { + const fetchData = async () => { + const [stores, orders, geography] = await Promise.all([ + apiGetAllStoresAsync(), + apiFetchOrdersAsync(), + apiGetGeographyAsync(), + ]); + + const { regions, places } = geography; + + const regionMap = {}; + regions.forEach((r) => { + regionMap[r.id] = { + name: r.name, + code: r.countryCode?.toUpperCase() || 'BA', + }; + }); + + const placeNameToRegionId = {}; + places.forEach((p) => { + placeNameToRegionId[p.name] = p.regionId; + }); + + const storeMap = {}; + stores.forEach((store) => { + storeMap[store.id] = store; + }); + + const revenueByRegion = {}; + let totalRevenue = 0; + + const ordersByRegion = {}; + let totalOrders = 0; + + orders.forEach((order) => { + const store = storeMap[order.storeName]; + if (!store) return; + + const regionId = placeNameToRegionId[store.placeName]; + const region = regionMap[regionId]; + + const targetId = region ? regionId : 'others'; + const targetRegion = region || { name: 'Others', code: 'BA' }; + + if (!revenueByRegion[targetId]) { + revenueByRegion[targetId] = { + name: targetRegion.name, + code: targetRegion.code, + value: 0, + count: 0, + }; + } + revenueByRegion[targetId].value += order.totalPrice || 0; + revenueByRegion[targetId].count += 1; + totalRevenue += order.totalPrice || 0; + + if (!ordersByRegion[targetId]) { + ordersByRegion[targetId] = { + name: targetRegion.name, + code: targetRegion.code, + value: 0, + }; + } + ordersByRegion[targetId].value += 1; + totalOrders += 1; + }); + + const revenueSorted = Object.values(revenueByRegion).sort( + (a, b) => b.value - a.value + ); + const ordersSorted = Object.values(ordersByRegion).sort( + (a, b) => b.value - a.value + ); + + const topRevenue = revenueSorted.slice(0, 4); + const otherRevenue = revenueSorted.slice(4).reduce( + (acc, r) => { + acc.value += r.value; + acc.count += r.count; + return acc; + }, + { name: 'Others', code: 'BA', value: 0, count: 0 } + ); + const revenueArr = [...topRevenue]; + if (otherRevenue.value > 0) { + revenueArr.push({ + ...otherRevenue, + percent: Number( + ((otherRevenue.value / totalRevenue) * 100).toFixed(1) + ), + }); + } + revenueArr.forEach((r) => { + r.percent = Number(((r.value / totalRevenue) * 100).toFixed(1)); + }); + + const topOrders = ordersSorted.slice(0, 4); + const otherOrders = ordersSorted.slice(4).reduce( + (acc, o) => { + acc.value += o.value; + return acc; + }, + { name: 'Others', code: 'BA', value: 0 } + ); + const ordersArr = [...topOrders]; + if (otherOrders.value > 0) { + ordersArr.push({ + ...otherOrders, + percent: Number(((otherOrders.value / totalOrders) * 100).toFixed(1)), + }); + } + ordersArr.forEach((o) => { + o.percent = Number(((o.value / totalOrders) * 100).toFixed(1)); + }); + + setData({ revenue: revenueArr, orders: ordersArr }); + }; + + fetchData(); + }, []); + + const labels = [t('analytics.ordersRevenueByRegions'), t('analytics.ordersByRegions')]; + const keys = ['revenue', 'orders']; + const currentData = data[keys[tab]] || []; + + return ( + + + + {labels[tab]} + + setTab(newVal)} + size='small' + textColor='primary' + indicatorColor='primary' + sx={{ mb: 2 }} + > + + + + + {currentData.map((item, index) => ( + + + + + + {item.name} + + + + {item.value.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{' '} + • {item.percent}% + + + + + ))} + + + ); +}; + +export default CountryStatsPanel; diff --git a/src/components/CreateRouteModal.jsx b/src/components/CreateRouteModal.jsx new file mode 100644 index 0000000..e01364e --- /dev/null +++ b/src/components/CreateRouteModal.jsx @@ -0,0 +1,264 @@ +import React, { useState, useEffect } from 'react'; +import { Search, CheckSquare, Square } from 'lucide-react'; +import { + Modal, + Box, + Typography, + TextField, + Button, + InputAdornment, + Chip, +} from '@mui/material'; +import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import sha256 from 'crypto-js/sha256'; +import { + apiFetchOrdersAsync, + createRouteAsync, + fetchAdressesAsync, + fetchAdressByIdAsync, + getGoogle, +} from '@api/api'; +import { + apiCreateRouteAsync, + apiExternGetOptimalRouteAsync, + apiGetOrderAddresses, + apiGetStoreByIdAsync, +} from '../api/api'; + +const CreateRouteModal = ({ open, onClose, onCreateRoute }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedOrders, setSelectedOrders] = useState([]); + const [allOrders, setAllOrders] = useState([]); + const [loading, setLoading] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + + useEffect(() => { + const fetchOrders = async () => { + try { + const fetched = await apiFetchOrdersAsync(); + const addresses = await fetchAdressesAsync(); // all addresses + + const enrichedOrders = await Promise.all( + fetched.map(async (order) => { + const store = await apiGetStoreByIdAsync(order.storeName); // fetch per order + const buyerAddress = await fetchAdressByIdAsync(order.addressId); + console.log(buyerAddress); + return { + ...order, + senderAddress: store.address, + buyerAddress: buyerAddress.address, + }; + }) + ); + + setAllOrders(enrichedOrders); + } catch (err) { + console.error('Greška prilikom učitavanja narudžbi:', err); + } + }; + + if (open) { + fetchOrders(); + } + }, [open]); + + const handleToggleOrder = (order) => { + const exists = selectedOrders.some((o) => o.id === order.id); + if (exists) { + setSelectedOrders(selectedOrders.filter((o) => o.id !== order.id)); + } else { + setSelectedOrders([...selectedOrders, order]); + } + }; + + const filteredOrders = allOrders.filter((order) => + order.id.toString().includes(searchTerm.trim()) + ); + + const handleCreateRoute = async () => { + try { + setLoading(true); + onCreateRoute(selectedOrders); + onClose(); + } catch (err) { + console.error('Greška pri kreiranju rute:', err); + alert('Došlo je do greške.'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + Create Route + + + + { + setSearchTerm(e.target.value); + setShowSearchResults(true); + }} + onFocus={() => setShowSearchResults(true)} + InputProps={{ + startAdornment: ( + + + + ), + sx: { borderRadius: 1 }, + }} + sx={{ mb: 2 }} + /> + + {showSearchResults && searchTerm && ( + + {filteredOrders.map((order) => ( + handleToggleOrder(order)} + sx={{ + p: 2, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + borderBottom: 1, + borderColor: 'divider', + }} + > + {selectedOrders.some((o) => o.id === order.id) ? ( + + ) : ( + + )} + + Order #{order.id} + + {`${order.senderAddress?.toString() || '?'} - ${order.buyerAddress?.toString() || '?'}`} + + + + ))} + + )} + + + + Selected Orders + + + + {selectedOrders.length === 0 ? ( + + + No orders selected. Search to add orders. + + + ) : ( + selectedOrders.map((order) => ( + handleToggleOrder(order)} + sx={{ + p: 2, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + borderBottom: 1, + borderColor: 'divider', + }} + > + + + Order #{order.id} + + {`${order.senderAddress?.toString() || '?'} - ${order.buyerAddress?.toString() || '?'}`} + + + + )) + )} + + + + + + + + + ); +}; + +export default CreateRouteModal; diff --git a/src/components/CustomButton.jsx b/src/components/CustomButton.jsx new file mode 100644 index 0000000..674e68b --- /dev/null +++ b/src/components/CustomButton.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import buttonStyle from './CustomButtonStyles'; + +const CustomButton = ({ children, ...props }) => { + return ( + + ); +}; + +export default CustomButton; diff --git a/src/components/CustomButtonStyles.jsx b/src/components/CustomButtonStyles.jsx new file mode 100644 index 0000000..bd2b102 --- /dev/null +++ b/src/components/CustomButtonStyles.jsx @@ -0,0 +1,25 @@ +const buttonStyle = (theme) => ({ + textTransform: 'none', + borderRadius: '12px', + paddingY: 1.2, + paddingX: 4, + fontWeight: 600, + fontSize: '1rem', + backgroundColor: theme.palette.text.primary, + color: theme.palette.primary.contrastText, + boxShadow: '0 4px 10px rgba(0,0,0,0.1)', + transition: 'all 0.3s ease-in-out', + + '&:hover': { + backgroundColor: '#3b0f0f', + boxShadow: '0 6px 14px rgba(0,0,0,0.2)', + }, + + '&:focus': { + outline: 'none', + boxShadow: `0 0 0 3px rgba(77, 18, 17, 0.3)`, + }, + }); + + export default buttonStyle; + \ No newline at end of file diff --git a/src/components/CustomTextField.jsx b/src/components/CustomTextField.jsx new file mode 100644 index 0000000..db5fec9 --- /dev/null +++ b/src/components/CustomTextField.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { TextField, InputAdornment, IconButton } from '@mui/material'; +import { HiOutlineMail, HiOutlineLockClosed, HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi'; + + +const CustomTextField = ({ label, type, ...props }) => { + const [showPassword, setShowPassword] = useState(false); + const isPassword = type === 'password'; + + return ( + + {label.toLowerCase().includes('email') ? ( + + ) : isPassword ? ( + + ) : null} + + ), + endAdornment: isPassword && ( + + setShowPassword((prev) => !prev)}> + {showPassword ? : } + + + ), + }, + }} + /> + ); +}; + +export default CustomTextField; diff --git a/src/components/CustomTextFieldStyles.jsx b/src/components/CustomTextFieldStyles.jsx new file mode 100644 index 0000000..023c40c --- /dev/null +++ b/src/components/CustomTextFieldStyles.jsx @@ -0,0 +1,17 @@ +const textFieldStyle = { + marginBottom: 2, + '& .MuiOutlinedInput-root': { + borderRadius: '12px', + '& fieldset': { + borderWidth: '2px', + }, + '&:hover fieldset': { + borderColor: '#3C5B66', + }, + '&.Mui-focused fieldset': { + borderColor: '#3C5B66', + }, + }, +}; + +export default textFieldStyle; diff --git a/src/components/DealsChart.jsx b/src/components/DealsChart.jsx new file mode 100644 index 0000000..78895c2 --- /dev/null +++ b/src/components/DealsChart.jsx @@ -0,0 +1,356 @@ +import React, { useState, useRef, useEffect } from 'react'; // Merged: useEffect from develop +import { + Box, + Typography, + IconButton, + Menu, + MenuItem, + Paper, // Ensured Paper is present +} from '@mui/material'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import StoreIcon from '@mui/icons-material/Store'; +import { Bar } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip as ChartTooltip, + Legend, +} from 'chart.js'; +import { apiGetAllStoresAsync, apiGetAllAdsAsync } from '../api/api.js'; // From develop +import { useTranslation } from 'react-i18next'; + + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + ChartTooltip, + Legend +); + +function DealsChart() { + const { t } = useTranslation(); + const [filterType, setFilterType] = useState('topRated'); // 'topRated' or 'lowestRated' + const [anchorEl, setAnchorEl] = useState(null); + const [storesData, setStoresData] = useState({ + topRated: [], + lowestRated: [], + }); // From develop, initialized + const [barPositions, setBarPositions] = useState([]); // From develop + const open = Boolean(anchorEl); + const chartRef = useRef(null); + const containerRef = useRef(null); // From develop + + useEffect(() => { + const fetchData = async () => { + try { + const [storesResponse, adsResponse] = await Promise.all([ + apiGetAllStoresAsync(), + apiGetAllAdsAsync(), + ]); + + const stores = Array.isArray(storesResponse) ? storesResponse : []; + const ads = + adsResponse && Array.isArray(adsResponse.data) + ? adsResponse.data + : []; + + const storeMap = {}; + stores.forEach((store) => { + if (store && store.id) { + // Ensure store and store.id exist + storeMap[store.id] = store.name || `Store ${store.id}`; // Use name or fallback + } + }); + + const revenueByStore = {}; + ads.forEach((ad) => { + if (!ad || !ad.conversionPrice || ad.conversionPrice === 0) return; + if (ad.adData && Array.isArray(ad.adData)) { + ad.adData.forEach((adDataItem) => { + if (!adDataItem || !adDataItem.storeId) return; // Ensure adDataItem and storeId exist + const storeId = adDataItem.storeId; + if (!storeMap[storeId]) return; + const revenue = (ad.conversionPrice || 0) * (ad.conversions || 0); + revenueByStore[storeId] = + (revenueByStore[storeId] || 0) + revenue; + }); + } + }); + + const sortedStoresData = Object.entries(revenueByStore) + .map(([storeId, amount]) => ({ + id: storeId, + name: storeMap[storeId] || `Store ${storeId}`, // Fallback name + amount, + })) + .sort((a, b) => b.amount - a.amount); // Sort descending by amount + + const topRated = sortedStoresData.slice(0, 5); + // For lowest rated, take the last 5 (smallest amounts) and then sort them ascending for display + const lowestRated = sortedStoresData + .slice(-5) + .sort((a, b) => a.amount - b.amount); + + setStoresData({ + topRated, + lowestRated, + }); + } catch (error) { + console.error('Failed to fetch deals data:', error); + setStoresData({ topRated: [], lowestRated: [] }); // Reset on error + } + }; + + fetchData(); + }, []); + + const handleFilterClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleFilterChange = (type) => { + setFilterType(type); + handleClose(); + setBarPositions([]); // Reset bar positions when filter changes + }; + + const currentDisplayData = storesData[filterType] || []; + + const chartData = { + labels: currentDisplayData.map((item) => item.name), // Use store names for labels + datasets: [ + { + data: currentDisplayData.map((item) => item.amount), + backgroundColor: '#353535', // Darker bars from develop + borderWidth: 1, + borderColor: '#000', + borderRadius: 24, // More rounded bars from develop + barThickness: 55, // Bar thickness from develop + }, + ], + }; + + const chartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + callbacks: { + label: function (context) { + return `$${context.raw.toLocaleString()}`; + }, + title: function (context) { + // Tooltip title from develop + return context && context[0] ? context[0].label : ''; + }, + }, + }, + }, + scales: { + x: { + display: false, // Hiding x-axis labels as info is on icons/tooltips + grid: { display: false }, + }, + y: { + display: false, // Hiding y-axis + grid: { display: false }, + beginAtZero: true, // Important for bar charts + }, + }, + animation: { + // For calculating icon positions from develop + onComplete: function () { + if (chartRef.current) { + const chart = chartRef.current; + const meta = chart.getDatasetMeta(0); + const newPositions = []; + + if (meta && meta.data && meta.data.length > 0) { + meta.data.forEach((bar) => { + newPositions.push({ + top: bar.y, // y-coordinate of the top of the bar + left: bar.x, // x-coordinate of the center of the bar + // width: bar.width, // not strictly needed for icon positioning here + }); + }); + // Only update if positions actually changed to prevent potential loops + if ( + newPositions.length !== barPositions.length || + newPositions.some( + (p, i) => + p.top !== barPositions[i]?.top || + p.left !== barPositions[i]?.left + ) + ) { + setBarPositions(newPositions); + } + } else if (barPositions.length > 0) { + // If no data, clear positions + setBarPositions([]); + } + } + }, + duration: 300, // Give a small duration for animation to complete + }, + layout: { + // Padding from HEAD, adjusted + padding: { top: 30, bottom: 10, left: 10, right: 10 }, + }, + }; + + const chartHeight = 250; + + return ( + + + + + Filters + + + + + handleFilterChange('topRated')}> + Top Rated + + handleFilterChange('lowestRated')}> + Lowest Rated + + + + + + + + {/* Icons on top of bars - logic from develop */} + {currentDisplayData.length > 0 && + barPositions.length === currentDisplayData.length && + barPositions.map((pos, index) => { + const item = currentDisplayData[index]; + if (!item) return null; + + let iconBackgroundColor; + if (filterType === 'topRated') { + if (index === 0) + iconBackgroundColor = '#FFD700'; // Gold + else if (index === 1) + iconBackgroundColor = '#C0C0C0'; // Silver + else if (index === 2) + iconBackgroundColor = '#CD7F32'; // Bronze + else iconBackgroundColor = '#B4D4C3'; // Neutral + } else { + // Lowest Rated (currentDisplayData is sorted ascending for 'lowestRated') + if (index === 0) + iconBackgroundColor = '#f44336'; // Lowest + else if (index === 1) + iconBackgroundColor = '#E57373'; // 2nd Lowest + else if (index === 2) + iconBackgroundColor = '#FFB74D'; // 3rd Lowest + else iconBackgroundColor = '#B4D4C3'; // Neutral + } + + return ( + + + + ); + })} + + + {/* Text at the bottom - "by store" from develop */} + + + {t('analytics.dealsAmount')} + + + + by store + + + + + + ); +} + +export default DealsChart; diff --git a/src/components/DeleteAdConfirmation.jsx b/src/components/DeleteAdConfirmation.jsx new file mode 100644 index 0000000..fe01565 --- /dev/null +++ b/src/components/DeleteAdConfirmation.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import { + Modal, + Box, + Typography, + Button, + Stack, + IconButton, +} from "@mui/material"; +import { Trash2 } from "lucide-react"; + +const DeleteConfirmationModal = ({ open, onClose, onConfirm }) => { + return ( + + + + + + + + Are you sure? + + + You’re about to delete this ad. This action cannot be undone. + + + + + + + + + ); +}; + +export default DeleteConfirmationModal; + \ No newline at end of file diff --git a/src/components/DeleteConfirmModal.jsx b/src/components/DeleteConfirmModal.jsx new file mode 100644 index 0000000..3057695 --- /dev/null +++ b/src/components/DeleteConfirmModal.jsx @@ -0,0 +1,43 @@ +// @components/DeleteConfirmModal.jsx +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { useTranslation } from 'react-i18next'; + +export default function DeleteConfirmModal({ + open, + onClose, + onConfirm, + ticketTitle, +}) { + const { t } = useTranslation(); + + return ( + + + + {t('common.delete')} + + + + {t('common.confirmDelete', { item: ticketTitle })} + + + + + + + + ); +} diff --git a/src/components/DeleteRouteConfirmation.jsx b/src/components/DeleteRouteConfirmation.jsx new file mode 100644 index 0000000..11b210e --- /dev/null +++ b/src/components/DeleteRouteConfirmation.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import { + Modal, + Box, + Typography, + Button, + Stack, + IconButton, +} from "@mui/material"; +import { Trash2 } from "lucide-react"; + +const DeleteConfirmationModal = ({ open, onClose, onConfirm }) => { + return ( + + + + + + + + Are you sure? + + + You’re about to delete this route. This action cannot be undone. + + + + + + + + + ); +}; + +export default DeleteConfirmationModal; + \ No newline at end of file diff --git a/src/components/DeleteUserButton.jsx b/src/components/DeleteUserButton.jsx new file mode 100644 index 0000000..4e1ed4d --- /dev/null +++ b/src/components/DeleteUserButton.jsx @@ -0,0 +1,10 @@ +import { IconButton } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; + +export default function DeleteUserButton({ onClick }) { + return ( + + + + ); +} diff --git a/src/components/EditAdModal.jsx b/src/components/EditAdModal.jsx new file mode 100644 index 0000000..e2f369a --- /dev/null +++ b/src/components/EditAdModal.jsx @@ -0,0 +1,286 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Box, + Typography, + TextField, + Checkbox, + Button, + IconButton, + Stack, + MenuItem, + Autocomplete, +} from '@mui/material'; +import { Edit3, Trash2 } from 'lucide-react'; +import { + apiRemoveAdItemAsync, + apiGetStoreProductsAsync, +} from '../api/api'; + +const EditAdModal = ({ open, ad, stores, onClose, onSave }) => { + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [isActive, setIsActive] = useState(false); + const [adType, setAdType] = useState(''); + const [triggers, setTriggers] = useState([]); + const [adContentItems, setAdContentItems] = useState([]); + const [products, setProducts] = useState([]); + + useEffect(() => { + console.log(ad); + if (ad) { + setStartTime(ad.startTime || ''); + setEndTime(ad.endTime || ''); + setIsActive(ad.isActive || false); + setAdType(ad.adType || ''); + setTriggers(ad.triggers); + setProducts([]); + setAdContentItems( + (ad.adData || []).map((item) => ({ + ...item, + imageFile: null, // novo uploadovan file (ako bude) + existingImageUrl: item.imageUrl, // postojeca slika iz GET-a + })) + ); + } + }, [ad]); + + const handleFieldChange = async (index, field, value) => { + const updatedItems = [...adContentItems]; + updatedItems[index][field] = value; + setAdContentItems(updatedItems); + + if (field == "storeId") { + const products = await apiGetStoreProductsAsync(value); + setProducts(products.data); + } + }; + + const handleFileChange = (index, file) => { + const updatedItems = [...adContentItems]; + updatedItems[index].imageFile = file; + setAdContentItems(updatedItems); + }; + + const handleRemoveItem = async (index) => { + const updatedItems = [...adContentItems]; + updatedItems.splice(index, 1); + + const id = ad.adData[index].id; + const res = await apiRemoveAdItemAsync(id); + + setAdContentItems(updatedItems); + }; + + const handleSave = () => { + const cleanedItems = adContentItems.map((item) => ({ + storeId: Number(item.storeId), + productId: Number(item.productId), + description: item.description, + imageFile: item.imageFile || null, // ako nema novog file-a, backend koristi stari + })); + + onSave?.(ad.id, { + startTime, + endTime, + isActive, + adType, + triggers, + newAdDataItems: cleanedItems, + }); + + onClose(); + }; + + return ( + + + + + + Edit Advertisement + + + + {/* Time + Active */} + + setStartTime(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setEndTime(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + setIsActive(e.target.checked)} + sx={{ mr: 1 }} + /> + Is Active + + setAdType(e.target.value)} + InputLabelProps={{ shrink: true }} + > + Fixed + PopUp + + setTriggers(newValue)} + disableCloseOnSelect + getOptionLabel={(option) => option} + renderOption={(props, option, { selected }) => ( +
  • + + {option} +
  • + )} + style={{ width: '100%', marginBottom: 16 }} + renderInput={(params) => ( + + )} + /> +
    + + {/* Ad Items Section */} + + Advertisement Items + + + + + {adContentItems.map((item, index) => ( + + + handleFieldChange(index, 'description', e.target.value) + } + sx={{ mb: 1 }} + /> + { + handleFieldChange(index, 'storeId', e.target.value); + } + } + sx={{ mb: 1 }} + > + {stores.map((store) => ( + + {store.name} + + ))} + + { + handleFieldChange(index, 'productId', e.target.value); + } + } + sx={{ mb: 1 }} + > + {products.map((product) => ( + + {product.name} + + ))} + + + handleRemoveItem(index)} + size='small' + > + + + + ))} + + + + {/* Buttons */} + + + + +
    +
    + ); +}; + +export default EditAdModal; diff --git a/src/components/EditProductModal.jsx b/src/components/EditProductModal.jsx new file mode 100644 index 0000000..5cdaf5e --- /dev/null +++ b/src/components/EditProductModal.jsx @@ -0,0 +1,215 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Box, + TextField, + Button, + Typography, + MenuItem, +} from '@mui/material'; +import { HiOutlineCube } from 'react-icons/hi'; +import { apiUpdateProductAsync, apiGetProductCategoriesAsync } from '@api/api'; + +const weightUnits = ['kg', 'g', 'lbs']; +const volumeUnits = ['L', 'ml', 'oz']; + +const EditProductModal = ({ open, onClose, product, onSave }) => { + const [formData, setFormData] = useState({ + name: '', + retailPrice: '', + weight: '', + weightUnit: 'kg', + volume: '', + volumeUnit: 'L', + productCategoryId: '', + isActive: true, + }); + + const [productCategories, setProductCategories] = useState([]); + + useEffect(() => { + if (open) { + apiGetProductCategoriesAsync().then(setProductCategories); + if (product) { + setFormData({ + name: product.name || '', + retailPrice: product.retailPrice || '', + weight: product.weight || '', + weightUnit: product.weightUnit || 'kg', + volume: product.volume || '', + volumeUnit: product.volumeUnit || 'L', + productCategoryId: product.productCategoryId || '', + isActive: product.isActive ?? true, + }); + } + } + }, [open, product]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: name === 'isActive' ? JSON.parse(value) : value, + })); + }; + + const handleSubmit = async () => { + try { + const response = await apiUpdateProductAsync({ + id: product.id, + storeId: product.storeId, + photos: product.photos, + ...formData, + }); + if (response.status >= 200 && response.status < 300) { + onSave(response.data || {}); + onClose(); + window.location.reload(); + } + } catch (error) { + console.error('Error updating product:', error); + } + }; + + return ( + + + + + + Edit Product + + + + + + + + + + + {weightUnits.map((unit) => ( + + {unit} + + ))} + + + + + + + {volumeUnits.map((unit) => ( + + {unit} + + ))} + + + + + {productCategories.map((cat) => ( + + {cat.name} + + ))} + + + + Active + Inactive + + + + + + + + + + ); +}; + +export default EditProductModal; diff --git a/src/components/EditStoreModal.jsx b/src/components/EditStoreModal.jsx new file mode 100644 index 0000000..ddf5ce1 --- /dev/null +++ b/src/components/EditStoreModal.jsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Box, + TextField, + Button, + MenuItem, + Select, + InputLabel, + FormControl, + Typography, + Avatar, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import { apiGetStoreCategoriesAsync, apiUpdateStoreAsync } from '../api/api'; + +const StoreEditModal = ({ open, onClose, store, onStoreUpdated }) => { + const [storeName, setStoreName] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [description, setDescription] = useState(''); + const [address, setAddress] = useState(''); + const [tax, setTax] = useState(''); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + apiGetStoreCategoriesAsync().then(setCategories); + } + }, [open]); + + useEffect(() => { + if (store) { + setStoreName(store.name || ''); + setCategoryId(store.categoryId || ''); + setDescription(store.description || ''); + setAddress(store.address || ''); + setTax(store.tax?.toString() || ''); + } + }, [store]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + const updatedData = { + id: store.id, + name: storeName, + address, + categoryId, + description, + tax: parseFloat(tax)/100, + isActive: store.isOnline ?? true, + }; + + await onStoreUpdated(updatedData); + onClose(); + setLoading(false); + }; + + return ( + + + + + + + + + + Edit Store + + +
    + setStoreName(e.target.value)} + margin='normal' + required + /> + + + Category + + + + setDescription(e.target.value)} + margin='normal' + required + /> + + setAddress(e.target.value)} + margin='normal' + required + /> + setTax(e.target.value)} + margin='normal' + required + inputProps={{ + min: 0, + step: 0.01, + style: { + backgroundColor: 'white', + color: 'black', + }, + }} + /> + + +
    +
    + ); +}; + +export default StoreEditModal; diff --git a/src/components/EditStoreModalStyle.jsx b/src/components/EditStoreModalStyle.jsx new file mode 100644 index 0000000..449ca8d --- /dev/null +++ b/src/components/EditStoreModalStyle.jsx @@ -0,0 +1,20 @@ +const styles = { + modalBox: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + backgroundColor: 'white', + padding: 3, + minWidth: 400, + boxShadow: 24, + borderRadius: 2, + }, + buttonsContainer: { + display: 'flex', + justifyContent: 'space-between', + marginTop: 2, + }, + }; + + export default styles; \ No newline at end of file diff --git a/src/components/FunnelCurved.jsx b/src/components/FunnelCurved.jsx new file mode 100644 index 0000000..bb7a6fc --- /dev/null +++ b/src/components/FunnelCurved.jsx @@ -0,0 +1,85 @@ +import React from 'react'; + +function getWidth(percent, maxWidth) { + return (percent / 100) * maxWidth; +} + +const FunnelCurved = ({ steps, width = 700, height = 200 }) => { + const stepHeight = height / steps.length; + const maxWidth = width; + + return ( + + {steps.map((step, i) => { + const isLast = i === steps.length - 1; + const next = isLast ? step : steps[i + 1]; + const y1 = i * stepHeight; + const y2 = (i + 1) * stepHeight; + const w1 = getWidth(step.percent, maxWidth); + const w2 = getWidth(next.percent, maxWidth); + const x1 = (maxWidth - w1) / 2; + const x2 = (maxWidth - w2) / 2; + + // Ako je zadnji segment, dodaj zaobljeni donji kraj + if (isLast) { + const radius = 10; + return ( + + ); + } + + // Standardni segmenti sa zakrivljenjem + const c1 = y1 + stepHeight * 0.9; + const c2 = y2 - stepHeight * 0.8; + return ( + + ); + })} + + {/* Tekst u sredini svakog stepa */} + {steps.map((step, i) => { + const y = i * stepHeight + stepHeight / 2 + 6; + return ( + + {step.value.toLocaleString()} ({step.percent}%) + + ); + })} + + ); +}; + +export default FunnelCurved; diff --git a/src/components/HorizontalScroll.jsx b/src/components/HorizontalScroll.jsx new file mode 100644 index 0000000..8675291 --- /dev/null +++ b/src/components/HorizontalScroll.jsx @@ -0,0 +1,61 @@ +import { Box, IconButton } from '@mui/material'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useRef } from 'react'; + +const HorizontalScroll = ({ children }) => { + const scrollRef = useRef(); + + const scroll = (offset) => { + scrollRef.current.scrollBy({ left: offset, behavior: 'smooth' }); + }; + + return ( + + scroll(-600)} + sx={{ + position: 'absolute', + top: '50%', + left: -20, + transform: 'translateY(-50%)', + zIndex: 1, + backgroundColor: '#fff', + boxShadow: 1, + }} + > + + + + + {children} + + + scroll(600)} + sx={{ + position: 'absolute', + top: '50%', + right: -20, + transform: 'translateY(-50%)', + zIndex: 1, + backgroundColor: '#fff', + boxShadow: 1, + }} + > + + + + ); +}; + +export default HorizontalScroll; diff --git a/src/components/ImageUploader.jsx b/src/components/ImageUploader.jsx new file mode 100644 index 0000000..125c2d2 --- /dev/null +++ b/src/components/ImageUploader.jsx @@ -0,0 +1,178 @@ +import React, { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { + Box, + Typography, + Button, + LinearProgress, + IconButton, +} from '@mui/material'; +import { CloudUpload, Cancel } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; + +const MAX_SIZE_MB = 50; + +const ImageUploader = ({ onFilesSelected }) => { + const [files, setFiles] = useState([]); + const { t } = useTranslation(); + + const onDrop = useCallback( + (acceptedFiles) => { + setFiles((prev) => [ + ...prev, + ...acceptedFiles.map((file) => ({ + name: file.name, + size: file.size, + status: file.size > MAX_SIZE_MB * 1024 * 1024 ? 'error' : 'success', + })), + ]); + + onFilesSelected(acceptedFiles); + }, + [onFilesSelected] + ); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'image/*': [], + }, + multiple: true, + maxSize: MAX_SIZE_MB * 1024 * 1024, + }); + + const formatSize = (bytes) => `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + + const removeFile = (name) => { + setFiles((prev) => prev.filter((f) => f.name !== name)); + }; + + return ( + + {/* Dropzone */} + + + + + {t('common.dragFilesToUpload')} + + + {t('common.or')} + + + + {t('common.maxFileSize')} + + + + {/* File Preview List */} + + {files.map((f, i) => ( + + removeFile(f.name)} + sx={{ position: 'absolute', top: 4, right: 4 }} + > + + + + + + + {f.name} + + + + + {formatSize(f.size)} + + + + + {f.status === 'error' && ( + + File too large + + )} + + ))} + + + ); +}; + +export default ImageUploader; diff --git a/src/components/KpiCard.jsx b/src/components/KpiCard.jsx new file mode 100644 index 0000000..a377f05 --- /dev/null +++ b/src/components/KpiCard.jsx @@ -0,0 +1,92 @@ +import { Card, CardContent, Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + PackageOpen, + Users, + Store, + Boxes, + DollarSign, + CheckCircle, + ShieldCheck, + UserPlus, + TrendingUp, + TrendingDown, + Eye, // Added Eye for views + MousePointer, // Added MousePointer for clicks + Repeat, // Added Repeat for conversions + BarChart, // Added BarChart for totalAds (changed from generic) + PieChart, // Added PieChart for distribution/summary (generic) + CreditCard, // Used for one revenue type + Wallet, // Used for another revenue type + Banknote, // Used for the third revenue type + LineChart, // Another option for totalAds or general analytics +} from 'lucide-react'; + +const iconMap = { + orders: , + users: , + stores: , + products: , + income: , + activeStores: , + approvedUsers: , + newUsers: , + totalAds: , // Updated icon for totalAds + views: , // Icon for views + clicks: , // Icon for clicks + conversions: , // Icon for conversions + conversionRevenue: , // Updated icon for conversion revenue + clicksRevenue: , // Updated icon for clicks revenue + viewsRevenue: , // Updated icon for views revenue +}; + +const KpiCard = ({ label, value, percentageChange = 0, type = 'orders' }) => { + const isPositive = percentageChange >= 0; + const { t } = useTranslation(); + return ( + + + {iconMap[type]} + + + + + {label} + + + {Number(value) % 1 === 0 + ? Number(value) + : Number(value).toFixed(2)}{' '} + + + + + {isPositive ? : } + + {Math.abs(Number(percentageChange)).toFixed(2)}% {t('analytics.comparedToLastMonth')} + + + + ); +}; + +export default KpiCard; diff --git a/src/components/LockOverlay.jsx b/src/components/LockOverlay.jsx new file mode 100644 index 0000000..55d5921 --- /dev/null +++ b/src/components/LockOverlay.jsx @@ -0,0 +1,25 @@ +// @components/LockOverlay.jsx +import { Box, Typography } from '@mui/material'; +import LockIcon from '@mui/icons-material/Lock'; + +export default function LockOverlay({ message = 'Open this ticket' }) { + return ( + + + + {message} + + + ); +} diff --git a/src/components/MetricCard.jsx b/src/components/MetricCard.jsx new file mode 100644 index 0000000..532eef8 --- /dev/null +++ b/src/components/MetricCard.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Card, CardContent, Typography, Box, Tooltip } from '@mui/material'; +import { Info } from 'lucide-react'; + + +const MetricCard = ({ title, value, subtitle, icon, color, tooltipText, trend, trendValue }) => { + return ( + + + + + {title} + {tooltipText && ( + + + + + + )} + + {icon && ( + + {icon} + + )} + + + + {value} + + + {subtitle && ( + + {subtitle} + + )} + + {trend && ( + + {trend === 'up' ? '↑' : trend === 'down' ? '↓' : '•'} + + {trendValue} + + + )} + + + ); +}; + +export default MetricCard; \ No newline at end of file diff --git a/src/components/NewProductModal.jsx b/src/components/NewProductModal.jsx new file mode 100644 index 0000000..600ca12 --- /dev/null +++ b/src/components/NewProductModal.jsx @@ -0,0 +1,322 @@ +import React, { useState, useEffect } from 'react'; +import { + Modal, + Box, + TextField, + Button, + Typography, + MenuItem, + useTheme, +} from '@mui/material'; +import ImageUploader from './ImageUploader'; +import SuccessMessage from './SuccessMessage'; +import { HiOutlineCube } from 'react-icons/hi'; +import style from './NewProductModalStyle'; +import { + apiCreateProductAsync, + apiGetProductCategoriesAsync, +} from '../api/api'; + +const weightUnits = ['kg', 'g', 'lbs']; +const volumeUnits = ['L', 'ml', 'oz']; + +const AddProductModal = ({ open, onClose, storeID }) => { + const theme = useTheme(); + + const [productCategories, setProductCategories] = useState([]); + const [formData, setFormData] = useState({ + name: '', + price: '', + weight: '', + weightunit: 'kg', + volume: '', + volumeunit: 'L', + productcategoryname: '', + photos: [], + }); + + const [successModal, setSuccessModal] = useState({ + open: false, + isSuccess: true, + message: '', + }); + + useEffect(() => { + if (open) { + apiGetProductCategoriesAsync().then(setProductCategories); + } + }, [open]); + + useEffect(() => { + if (successModal.open) { + const timer = setTimeout(() => { + setSuccessModal((prev) => ({ ...prev, open: false })); + }, 1500); + return () => clearTimeout(timer); + } + }, [successModal.open]); + + const handleChange = (e) => { + const { name, value } = e.target; + + if (name === 'productcategoryid') { + const selectedCategory = productCategories.find( + (cat) => cat.name === value + ); + + setFormData((prev) => ({ + ...prev, + productcategoryid: selectedCategory ? selectedCategory.id : 0, + })); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } + }; + + const handlePhotosChange = (files) => { + setFormData((prev) => ({ ...prev, photos: files })); + }; + + const handleSubmit = async () => { + const selectedCategory = productCategories.find( + (cat) => cat.name === formData.productcategoryname + ); + + if (!selectedCategory) { + alert('Please select a valid product category.'); + return; + } + + const productData = { + name: formData.name, + price: formData.price, + weight: formData.weight, + weightunit: formData.weightunit, + volume: formData.volume, + volumeunit: formData.volumeunit, + productcategoryid: selectedCategory.id, + storeId: storeID, + photos: formData.photos, + }; + + try { + const response = await apiCreateProductAsync(productData); + if (response?.status >= 200 && response?.status < 300) { + setSuccessModal({ + open: true, + isSuccess: true, + message: 'Product has been successfully assigned to the store.', + }); + } else { + throw new Error('API returned failure.'); + } + } catch (err) { + setSuccessModal({ + open: true, + isSuccess: false, + message: 'Failed to assign product to the store.', + }); + } finally { + onClose(); + } + }; + + return ( + <> + + + + + + Add New Product + + + + + {/* Product Form */} + + + + + + + + + + + + {weightUnits.map((unit) => ( + + {unit} + + ))} + + + + + + + + + + + {volumeUnits.map((unit) => ( + + {unit} + + ))} + + + + + + {productCategories.map((cat) => ( + + {cat.name} + + ))} + + + + + + + + + + + setSuccessModal((prev) => ({ ...prev, open: false }))} + isSuccess={successModal.isSuccess} + message={successModal.message} + /> + + ); +}; + +export default AddProductModal; diff --git a/src/components/NewProductModalStyle.jsx b/src/components/NewProductModalStyle.jsx new file mode 100644 index 0000000..c952f9a --- /dev/null +++ b/src/components/NewProductModalStyle.jsx @@ -0,0 +1,14 @@ +const style = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: 500, + bgcolor: "background.paper", + color: "black", + boxShadow: 24, + p: 4, + borderRadius: 4, + }; + + export default style; \ No newline at end of file diff --git a/src/components/OrderComponent.jsx b/src/components/OrderComponent.jsx new file mode 100644 index 0000000..883fbc1 --- /dev/null +++ b/src/components/OrderComponent.jsx @@ -0,0 +1,411 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { + Dialog, + DialogContent, + IconButton, + Typography, + Box, + Button, + Divider, + TextField, + Chip, + MenuItem, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { FaPen, FaCheck } from 'react-icons/fa'; +import OrderItemCard from './OrderItemCard'; +import { + apiUpdateOrderAsync, + apiGetAllStoresAsync, + apiFetchApprovedUsersAsync, +} from '@api/api'; + +const statusOptions = [ + 'Requested', + 'Confirmed', + 'Rejected', + 'Ready', + 'Sent', + 'Delivered', + 'Cancelled', +]; + +const getStatusColor = (status) => { + switch (status.toLowerCase()) { + case 'confirmed': + return '#0288d1'; // plava + case 'rejected': + return '#d32f2f'; // crvena + case 'ready': + return '#388e3c'; // zelena + case 'sent': + return '#fbc02d'; // žuta + case 'delivered': + return '#1976d2'; // tamno plava + case 'cancelled': + return '#b71c1c'; // tamno crvena + case 'requested': + return '#757575'; // siva + default: + return '#9e9e9e'; // fallback siva + } +}; + +const OrderComponent = ({ open, onClose, narudzba, onOrderUpdated }) => { + const [editMode, setEditMode] = useState(false); + const [status, setStatus] = useState(narudzba.status); + const [buyerId, setBuyerId] = useState(null); + const [storeId, setStoreId] = useState(null); + const [buyerName] = useState(narudzba.buyerId); + const [storeName] = useState(narudzba.storeId); + const [storeAddress] = useState(narudzba.storeAddress); + const [deliveryAddress] = useState(narudzba.deliveryAddress); + + const [date, setDate] = useState( + new Date(narudzba.time).toISOString().slice(0, 16) + ); + const [products, setProducts] = useState(narudzba.proizvodi || []); +deliveryAddress + useEffect(() => { + const fetchMappings = async () => { + const [stores, users] = await Promise.all([ + apiGetAllStoresAsync(), + apiFetchApprovedUsersAsync(), + ]); + + const storeEntry = stores.find((s) => s.name === narudzba.storeId); + const userEntry = users.find( + (u) => u.userName === narudzba.buyerId || u.email === narudzba.buyerId + ); + + if (storeEntry) { + setStoreId(storeEntry.id); + } + + if (userEntry) { + setBuyerId(userEntry.id); + } + }; + + fetchMappings(); + }, [narudzba.buyerId, narudzba.storeId]); + + const handleProductChange = (index, changes) => { + setProducts((prev) => + prev.map((item, i) => (i === index ? { ...item, ...changes } : item)) + ); + }; + + const total = useMemo(() => { + return products.reduce( + (sum, p) => sum + parseFloat(p.price || 0) * parseInt(p.quantity || 0), + 0 + ); + }, [products]); + + const handleSaveChanges = async () => { + const originalOrderItems = narudzba.orderItems || []; + + if (originalOrderItems.length !== products.length) { + alert('Greška: broj proizvoda se ne poklapa.'); + return; + } + + if (!storeId) { + alert('Greška: Store ID nije validan.'); + console.log(storeId); + return; + } + + if (!buyerId) { + alert('Greška: Buyer ID nije validan.'); + return; + } + + const payload = { + buyerId: String(buyerId), + storeId, + status, + time: new Date(date).toISOString(), + total, + orderItems: products.map((p, i) => { + const original = originalOrderItems[i]; + return { + id: Number(original.id), + productId: Number(original.productId), + price: Number(p.price), + quantity: Number(p.quantity), + }; + }), + }; + + const res = await apiUpdateOrderAsync(narudzba.id, payload); + + if (res.success) { + setEditMode(false); + onClose(); + window.location.reload(); + } else { + alert('Neuspješno ažuriranje narudžbe.'); + } + }; + + return ( + + {/* Bubble Background */} + + + + + + + + {/* Bubble Background */} + + + + + + + + {`${buyerName}'s Order`} + + setEditMode(!editMode)} + sx={{ color: '#6b7280', p: 0.5 }} + > + {editMode ? : } + + + + + {products.map((item, idx) => ( + + handleProductChange(idx, updated)} + /> + + ))} + + + + + Order Info + + + + Order ID: + {narudzba.id} + + + + Buyer: + {buyerName} + + + + Store: + {storeName} + + + + Store address: + {storeAddress} + + + + Delivery address: + {deliveryAddress} + + + + Status: + {editMode ? ( + setStatus(e.target.value)} + variant='standard' + sx={{ minWidth: 120 }} + > + {statusOptions.map((option) => ( + + {option} + + ))} + + ) : ( + + )} + + + + Date: + {editMode ? ( + setDate(e.target.value)} + sx={{ ml: 2 }} + /> + ) : ( + + {new Date(narudzba.time).toLocaleString()} + + )} + + + + + + + + Total + + + ${total.toFixed(2)} + + + + + + + ); +}; + +export default OrderComponent; diff --git a/src/components/OrderItemCard.jsx b/src/components/OrderItemCard.jsx new file mode 100644 index 0000000..3d939c0 --- /dev/null +++ b/src/components/OrderItemCard.jsx @@ -0,0 +1,132 @@ +import { Box, Typography, Avatar, TextField } from '@mui/material'; + +const OrderItemCard = ({ + imageUrl, + name, + price, + quantity, + tagIcon = '🏷️', + tagLabel = 'General', + isEditable = false, + onChange = () => {}, +}) => { + return ( + + {/* Left: Image */} + + + {/* Right: Info */} + + {/* Name & Tag */} + + + {name} + + + + + {tagIcon} + + {tagLabel} + + + + {/* Quantity and Price */} + + {isEditable ? ( + <> + + onChange({ + quantity: parseInt(e.target.value) || 0, + }) + } + sx={{ width: 50 }} + /> + + onChange({ + price: parseFloat(e.target.value) || 0, + }) + } + sx={{ width: 80 }} + /> + + ) : ( + <> + + {quantity} + + + ${parseFloat(price).toFixed(2)} + + + )} + + + + ); +}; + +export default OrderItemCard; diff --git a/src/components/OrdersByStatus.jsx b/src/components/OrdersByStatus.jsx new file mode 100644 index 0000000..0271e41 --- /dev/null +++ b/src/components/OrdersByStatus.jsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import { Card, CardContent, Typography, Box } from '@mui/material'; +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { useTranslation } from 'react-i18next'; + +// Dodijeli boje svakom triggeru +const triggerColors = { + Search: '#6366F1', + Order: '#F59E0B', + View: '#10B981', +}; + +const triggerLabels = ['Search', 'Order', 'View']; + +const OrdersBystatus = () => { + const { t } = useTranslation(); + const [data, setData] = useState([]); + + useEffect(() => { + const fetchAds = async () => { + const adsRepsonse = await apiGetAllAdsAsync(); + const ads = adsRepsonse.data; + // Broji koliko reklama ima svaki trigger + const triggerCounts = { Search: 0, Order: 0, View: 0 }; + ads.forEach((ad) => { + if (Array.isArray(ad.triggers)) { + ad.triggers.forEach((trigger) => { + if (Object.prototype.hasOwnProperty.call(triggerCounts, trigger)) { + triggerCounts[trigger]++; + } + }); + } + }); + console.log('TREGER: ', triggerCounts); + // Pripremi podatke za PieChart + const chartData = triggerLabels.map((trigger) => ({ + name: trigger, + value: triggerCounts[trigger], + color: triggerColors[trigger], + })); + + setData(chartData); + }; + + fetchAds(); + }, []); + + return ( + + + + {t('analytics.adTriggersBreakdown')} + + + + + + + {data.map((entry, idx) => ( + + ))} + + + + + + {data.map((entry) => ( + + + + {entry.name} ({entry.value}) + + + ))} + + + ); +}; + +export default OrdersBystatus; diff --git a/src/components/OrdersTable.jsx b/src/components/OrdersTable.jsx new file mode 100644 index 0000000..e4eea7e --- /dev/null +++ b/src/components/OrdersTable.jsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + IconButton, + Tooltip, +} from '@mui/material'; +import { FaTrash } from 'react-icons/fa6'; +import CircleIcon from '@mui/icons-material/FiberManualRecord'; +import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import { useTranslation } from 'react-i18next'; + +const getStatusColor = (status) => { + switch (status) { + case 'confirmed': + return '#0288d1'; // plava + case 'rejected': + return '#d32f2f'; // crvena + case 'ready': + return '#388e3c'; // zelena + case 'sent': + return '#fbc02d'; // žuta + case 'delivered': + return '#1976d2'; // tamno plava + case 'cancelled': + return '#b71c1c'; // tamno crvena + case 'requested': // Dodaj boju i za requested + return '#03e8fc'; + default: + return '#9e9e9e'; // siva + } +}; + +const OrdersTable = ({ + orders, + sortField, + sortOrder, + onSortChange, + onOrderClick, + onDelete, +}) => { + const handleSort = (field) => { + const order = field === sortField && sortOrder === 'asc' ? 'desc' : 'asc'; + onSortChange(field, order); + }; + + const formatOrderId = (id) => `#${String(id).padStart(5, '0')}`; + const { t } = useTranslation(); + const columns = [ + { label: t('common.orderNumber'), field: 'id' }, + { label: t('common.buyer'), field: 'buyerName' }, + { label: t('common.store'), field: 'storeName' }, + { label: t('common.deliveryAddress'), field: 'deliveryAddress' }, // NOVA KOLONA + { label: t('common.storeAddress'), field: 'storeAddress' }, // NOVA KOLONA + { label: t('common.status'), field: 'status' }, + { label: t('common.total'), field: 'totalPrice' }, + { label: t('common.created'), field: 'createdAt' }, + { label: '', field: 'actions' }, + ]; + + return ( + + + + + {columns.map((col) => ( + col.field !== 'actions' && handleSort(col.field)} + sx={{ + fontWeight: 'bold', + color: '#000', + cursor: col.field !== 'actions' ? 'pointer' : 'default', + userSelect: 'none', + whiteSpace: 'nowrap', + '&:hover': { + color: col.field !== 'actions' ? '#444' : undefined, + '.sort-icon': { opacity: 1, color: '#444' }, + }, + }} + > + + {col.label} + {col.field !== 'actions' && ( + + {sortOrder === 'asc' ? ( + + ) : ( + + )} + + )} + + + ))} + + + + {orders.map((order) => ( + onOrderClick(order)} + > + + {formatOrderId(order.id)} + + {order.buyerName} + {order.storeName} + {order.deliveryAddress} {/* NOVA ĆELIJA */} + {order.storeAddress} {/* NOVA ĆELIJA */} + + + + ${order.totalPrice} + + {order.createdAt ? // Provjeri da createdAt postoji + new Date(order.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) : 'N/A'} + + + + { + e.stopPropagation(); + onDelete(order.id); + }} + > + + + + + + ))} + +
    +
    + ); +}; + +export default OrdersTable; diff --git a/src/components/ParetoChart.jsx b/src/components/ParetoChart.jsx new file mode 100644 index 0000000..623ac26 --- /dev/null +++ b/src/components/ParetoChart.jsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { + ResponsiveContainer, + ComposedChart, + Bar, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Area, + Legend, +} from 'recharts'; +import { Box, Typography } from '@mui/material'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { format, parseISO } from 'date-fns'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; +import { useTranslation } from 'react-i18next'; + +const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +function groupByMonth(ads) { + const byMonth = {}; + ads.forEach((ad) => { + const date = ad.startTime || ad.endTime; + if (!date) return; + const month = format(parseISO(date), 'yyyy-MM'); + if (!byMonth[month]) + byMonth[month] = { month, clicks: 0, views: 0, conversions: 0 }; + byMonth[month].clicks += ad.clicks || 0; + byMonth[month].views += ad.views || 0; + byMonth[month].conversions += ad.conversions || 0; + }); + return Object.values(byMonth).sort((a, b) => a.month.localeCompare(b.month)); +} + +const ParetoChart = () => { + const [data, setData] = useState([]); + const [ads, setAds] = useState([]); + const connectionRef = useRef(null); + const { t } = useTranslation(); + + useEffect(() => { + const fetchData = async () => { + const adsResponse = await apiGetAllAdsAsync(); + const adsData = adsResponse.data; + setAds(adsData); + updateChartData(adsData); + }; + + const updateChartData = (ads) => { + const chartData = groupByMonth(ads).map((d) => ({ + time: format(parseISO(d.month + '-01'), 'MMM yyyy'), + clicks: d.clicks, + views: d.views, + conversions: d.conversions, + })); + setData(chartData); + }; + + fetchData(); + + // SignalR Setup + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + console.warn('No JWT token found. SignalR connection not started.'); + return; + } + + const connection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = connection; + + const startConnection = async () => { + try { + await connection.start(); + console.log('SignalR Connected to AdvertisementHub!'); + } catch (err) { + console.error('SignalR Connection Error:', err); + } + }; + + startConnection(); + + // Register event handlers + connection.on('ReceiveAdUpdate', (updatedAd) => { + setAds((prevAds) => { + const updatedAds = prevAds.map((ad) => + ad.id === updatedAd.id ? updatedAd : ad + ); + updateChartData(updatedAds); + return updatedAds; + }); + }); + + // Cleanup on unmount + return () => { + if ( + connectionRef.current && + connectionRef.current.state === 'Connected' + ) { + connectionRef.current + .stop() + .catch((err) => + console.error('Error stopping SignalR connection:', err) + ); + } + }; + }, []); + + return ( + + + {t('analytics.paretoChart')} + + + + + + + + + + + + + + + ); +}; + +export default ParetoChart; diff --git a/src/components/PendingUsersTable.jsx b/src/components/PendingUsersTable.jsx new file mode 100644 index 0000000..0f2f036 --- /dev/null +++ b/src/components/PendingUsersTable.jsx @@ -0,0 +1,223 @@ +// PendingUsersTable.jsx +import React, { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Avatar, + TableSortLabel, + Box, + Typography, + Chip, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import ApproveUserButton from "./ApproveUserButton"; +import DeleteUserButton from "./DeleteUserButton"; +import axios from 'axios'; +import { apiApproveUserAsync, apiDeleteUserAsync } from "../api/api"; +import { useTranslation } from 'react-i18next'; + +var baseURL = import.meta.env.VITE_API_BASE_URL + + +const StyledTableContainer = styled(TableContainer)(({ theme }) => ({ + maxHeight: 840, + overflow: "auto", + borderRadius: 8, + boxShadow: "0 2px 8px #800000", +})); + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + transition: "background-color 0.2s ease", + cursor: "pointer", + "&:hover": { + backgroundColor: "rgba(0, 0, 0, 0.04)", + }, +})); + +const PendingUsersTable = ({ + users = [], + onApprove, + onDelete, + onView, + currentPage, + usersPerPage, +}) => { + const { t } = useTranslation(); + const [orderBy, setOrderBy] = useState("submitDate"); + const [order, setOrder] = useState("desc"); + + const handleRequestSort = (property) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; + + const compareValues = (a, b, orderBy) => { + if (!a[orderBy]) return 1; + if (!b[orderBy]) return -1; + if (typeof a[orderBy] === "string") { + return a[orderBy].toLowerCase().localeCompare(b[orderBy].toLowerCase()); + } + return a[orderBy] < b[orderBy] ? -1 : 1; + }; + + const sortedUsers = [...users].sort((a, b) => { + return order === "asc" + ? compareValues(a, b, orderBy) + : compareValues(b, a, orderBy); + }); + + const formatDate = (dateString) => { + if (!dateString) return "N/A"; + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }); + } catch { + return dateString; + } + }; + + return ( + + + + + # + {t('common.picture')} + + handleRequestSort("name")} + > + {t('common.name')} + + + + handleRequestSort("email")} + > + {t('common.email')} + + + + handleRequestSort("role")} + > + {t('common.role')} + + + {t('common.actions')} + + + + {sortedUsers.map((user, index) => ( + onView(user.id)}> + + {(currentPage - 1) * usersPerPage + index + 1} + + + + + + + {user.userName} + + + {user.email} + {user.roles ? user.roles[0] : "?"} + + + + + { + e.preventDefault(); + onApprove(user.id); + + //await apiApproveUserAsync(user.id); + // const token = localStorage.getItem("token"); + + // if (token) { + // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + // } + + // const Payload = { + // userId:user.id + // }; + + // axios + + // .post(`${baseURL}/api/Admin/users/approve`, Payload) + + // .then((response) => { + // console.log("User approved successfully:", response.data); + // // optionally redirect or clear form inputs + // }) + // .catch((error) => { + // console.error("Error approving user:", error); + // }); + } + } + /> + { + e.stopPropagation(); + onDelete(user.id); + await apiDeleteUserAsync(user.id) + + // const token = localStorage.getItem("token"); + + // if (token) { + // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + // } + + // const Payload = { + // userId:user.id + // }; + // axios + // .delete(`http://localhost:5054/api/Admin/users/${user.id}`) + // .then((response) => { + // console.log("User deleted successfully:", response.data); + // // optionally redirect or clear form inputs + // }) + // .catch((error) => { + // console.error("Error deleting user:", error); + // }); + }} + /> + + + + ))} + {users.length === 0 && ( + + + {t('common.noPendingUsersFound')} + + + )} + +
    +
    + ); +}; + +export default PendingUsersTable; diff --git a/src/components/ProductDetailsModal.jsx b/src/components/ProductDetailsModal.jsx new file mode 100644 index 0000000..73ac2e8 --- /dev/null +++ b/src/components/ProductDetailsModal.jsx @@ -0,0 +1,233 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + Typography, + Box, + Divider, + Chip, + useTheme, +} from '@mui/material'; +import StoreIcon from '@mui/icons-material/Store'; +import CategoryIcon from '@mui/icons-material/Category'; +import ScaleIcon from '@mui/icons-material/MonitorWeight'; +import VolumeUpIcon from '@mui/icons-material/Opacity'; +import { apiGetAllStoresAsync, apiGetProductCategoriesAsync } from '@api/api'; + +const ProductDetailsModal = ({ open, onClose, product }) => { + const theme = useTheme(); + const [activeImage, setActiveImage] = useState(null); + const [storeName, setStoreName] = useState(''); + const [categoryName, setCategoryName] = useState(''); + + useEffect(() => { + if (product?.photos?.length) { + const first = + typeof product.photos[0] === 'string' + ? product.photos[0] + : product.photos[0]?.path; + setActiveImage(resolveImage(first)); + } + }, [product]); + + useEffect(() => { + if (open && product) { + loadStoreAndCategory(); + } + }, [open, product]); + + const loadStoreAndCategory = async () => { + try { + const [stores, categories] = await Promise.all([ + apiGetAllStoresAsync(), + apiGetProductCategoriesAsync(), + ]); + + const foundStore = stores.find((s) => s.id === product.storeId); + const foundCategory = categories.find( + (c) => c.id === product.productCategory?.id + ); + + setStoreName(foundStore?.name || 'Unknown Store'); + setCategoryName(foundCategory?.name || 'Unknown Category'); + } catch (err) { + console.error('Greška prilikom učitavanja store/kategorije:', err); + } + }; + + const resolveImage = (path) => { + if (!path) return ''; + return path.startsWith('http') + ? path + : `${import.meta.env.VITE_API_BASE_URL}${path}`; + }; + + if (!product) return null; + + const { + name, + retailPrice, + weight, + weightUnit, + volume, + volumeUnit, + isActive, + photos = [], + } = product; + + const normalizedPhotos = photos.map((p) => + resolveImage(typeof p === 'string' ? p : p?.path) + ); + + return ( + + + {/* Left - Thumbnails */} + + {normalizedPhotos.map((img, idx) => ( + setActiveImage(img)} + sx={{ + width: 60, + height: 60, + objectFit: 'cover', + borderRadius: 2, + border: + activeImage === img ? '2px solid #4a0404' : '1px solid #ccc', + cursor: 'pointer', + transition: 'all 0.2s ease', + }} + onError={(e) => { + e.target.onerror = null; + e.target.src = '/fallback.png'; + }} + /> + ))} + + + {/* Main image */} + + { + e.target.onerror = null; + e.target.src = '/fallback.png'; + }} + /> + + + {/* Info */} + + + {name} + + + + + + {storeName} + + + + + + + {categoryName} + + + + + + + + Weight: + + + {weight} {weightUnit || ''} + + + + + + + Volume: + + + {volume} {volumeUnit || ''} + + + + + + + + + + + + {retailPrice} KM + + + + + ); +}; + +export default ProductDetailsModal; diff --git a/src/components/ProductsSummary.jsx b/src/components/ProductsSummary.jsx new file mode 100644 index 0000000..b480849 --- /dev/null +++ b/src/components/ProductsSummary.jsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { Paper, Typography, Box, Grid, Divider } from '@mui/material'; +import { Megaphone, ShoppingBag, CheckCircle, TrendingUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { apiFetchAdsWithProfitAsync } from '@api/api'; + +const ProductSummary = ({ product, ads }) => { + const { t } = useTranslation(); + const [adsData, setAdsData] = useState([]); + + useEffect(() => { + const loadAds = async () => { + if (!ads) { + try { + const ads = await apiFetchAdsWithProfitAsync(); + console.log('✅ Fetched ads with profit:', ads); + setAdsData(ads); + } catch (error) { + console.error('❌ Error loading ads:', error); + } + } else { + setAdsData(ads); + } + }; + + loadAds(); + }, []); + + const totalViews = adsData.reduce((sum, ad) => sum + ad.views, 0); + const totalClicks = adsData.reduce((sum, ad) => sum + ad.clicks, 0); + const totalConversions = adsData.reduce((sum, ad) => sum + ad.conversions, 0); + const totalProfit = adsData.reduce((sum, ad) => sum + ad.profit, 0); + console.log(product); + return ( + + + + + + + {product?.name.length > 54 + ? `${product.name.substring(0, 54)}...` + : product.name || t('common.unknownProduct')}{' '} + + + + + + + + + + + {t('analytics.totalViews')} + + + + + {totalViews > 0 ? totalViews : 0} + + + + + + + + + {t('analytics.totalClicks')} + + + + + {totalClicks > 0 ? totalClicks : 0} + + + + + + + + + {t('analytics.totalConversions')} + + + + + {totalConversions > 0 ? totalConversions : 0} + + + + + + + + + + {t('analytics.totalEarnedProfitFromAds')} + + + ${totalProfit > 0 ? totalProfit.toFixed(2) : 0} + + + + + + + + ); +}; + +export default ProductSummary; diff --git a/src/components/RevenueByStore.jsx b/src/components/RevenueByStore.jsx new file mode 100644 index 0000000..572f69a --- /dev/null +++ b/src/components/RevenueByStore.jsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react'; +import { Card, CardContent, Typography, Box } from '@mui/material'; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; +import { apiGetAllStoresAsync, apiGetAllAdsAsync } from '../api/api.js'; +import { useTranslation } from 'react-i18next'; + +const barColor = '#6366F1'; +const TOP_N = 5; + +const RevenueByStore = () => { + const { t } = useTranslation(); + const [data, setData] = useState([]); + + useEffect(() => { + const fetchData = async () => { + const [stores, adsResponse] = await Promise.all([ + apiGetAllStoresAsync(), + apiGetAllAdsAsync(), + ]); + const ads = adsResponse.data; + console.log('Ads: ', ads); + + // Mapiraj storeId na ime prodavnice + const storeMap = {}; + stores.forEach((store) => { + storeMap[store.id] = store.name; + }); + console.log('STOREMAP: ', storeMap); + // Grupiraj zaradu po storeId iz adData + const revenueByStore = {}; + ads.forEach((ad) => { + if (!ad.conversionPrice || ad.conversionPrice === 0) return; + // Za svaki adData sa storeId, dodaj cijelu conversionPrice toj prodavnici + ad.adData.forEach((adDataItem) => { + if (!adDataItem.storeId) return; + const storeId = adDataItem.storeId; + if (!storeMap[storeId]) return; + revenueByStore[storeId] = + (revenueByStore[storeId] || 0) + ad.conversionPrice; + }); + }); + console.log('RevenueByStore: ', revenueByStore); + + const chartData = Object.entries(revenueByStore) + .map(([storeId, value]) => ({ + name: storeMap[storeId] || `Store #${storeId}`, + value, + })) + .sort((a, b) => b.value - a.value) + .slice(0, TOP_N); + + setData(chartData); + }; + + fetchData(); + }, []); + + return ( + + + + {t('analytics.topStoresByAdRevenue')} + + + + + + `$${v.toFixed(0)}`} + axisLine={false} + tickLine={false} + tick={{ fontSize: 13, dy: 2 }} + /> + + `$${val}`} /> + + {data.map((entry, idx) => ( + + ))} + + + + + + ); +}; + +export default RevenueByStore; diff --git a/src/components/RevenueMetrics.jsx b/src/components/RevenueMetrics.jsx new file mode 100644 index 0000000..bbdc3e2 --- /dev/null +++ b/src/components/RevenueMetrics.jsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from 'react'; +import { Grid, Paper, Typography, Box } from '@mui/material'; +import { LineChart } from '@mui/x-charts/LineChart'; +import { PieChart } from '@mui/x-charts/PieChart'; +import { DollarSign, Eye, MousePointerClick, ShoppingCart } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import MetricCard from './MetricCard'; +import { apiFetchAdsWithProfitAsync } from '@api/api'; + +const formatCurrency = (value, currency = 'USD') => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 2, + }).format(value); + +const groupByDay = (ads, eventType) => { + const days = Array(30).fill(0); + const today = new Date(); + + ads.forEach((ad) => { + const eventCount = ad[eventType]; + const price = ad[`${eventType.slice(0, -1)}Price`]; + + const date = new Date(ad.startTime); + const diffDays = Math.floor((today - date) / (1000 * 60 * 60 * 24)); + if (diffDays >= 0 && diffDays < 30) { + days[29 - diffDays] += eventCount * price; + } + }); + + return days; +}; + +const RevenueMetrics = () => { + const { t } = useTranslation(); + const [ads, setAds] = useState([]); + // const { t } = useTranslation(); + useEffect(() => { + const fetchData = async () => { + const adsData = await apiFetchAdsWithProfitAsync(); + setAds(adsData); + }; + fetchData(); + }, []); + + const totalRevenue = ads.reduce( + (acc, ad) => + acc + + ad.clicks * ad.clickPrice + + ad.views * ad.viewPrice + + ad.conversions * ad.conversionPrice, + 0 + ); + + const clickRevenue = ads.reduce( + (sum, ad) => sum + ad.clicks * ad.clickPrice, + 0 + ); + const viewRevenue = ads.reduce((sum, ad) => sum + ad.views * ad.viewPrice, 0); + const conversionRevenue = ads.reduce( + (sum, ad) => sum + ad.conversions * ad.conversionPrice, + 0 + ); + + const revenueBySource = [ + { id: 0, value: clickRevenue, label: 'Click Revenue', color: '#3B82F6' }, + { id: 1, value: viewRevenue, label: 'View Revenue', color: '#0D9488' }, + { + id: 2, + value: conversionRevenue, + label: 'Conversion Revenue', + color: '#10B981', + }, + ]; + + const clickRevenueByDay = groupByDay(ads, 'clicks'); + const viewRevenueByDay = groupByDay(ads, 'views'); + const conversionRevenueByDay = groupByDay(ads, 'conversions'); + + const dateLabels = Array(30) + .fill() + .map((_, i) => { + const d = new Date(); + d.setDate(d.getDate() - (29 - i)); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }); + + const xAxisLabels = dateLabels.map((label, i) => (i % 5 === 0 ? label : '')); + + return ( + + + {t('analytics.revenueAndProfitAnalysis')} + + + + + } + color='success' + tooltipText={t('analytics.fromAllAdvertisingSources')} + /> + + + + s + a.clicks, 0).toLocaleString(), + })} + icon={} + color='info' + tooltipText={t('analytics.clickRevenue')} + /> + + + + s + a.views, 0).toLocaleString(), + })} + icon={} + color='secondary' + tooltipText={t('analytics.viewRevenue')} + /> + + + + s + a.conversions, 0) + .toLocaleString(), + })} + icon={} + color='success' + tooltipText={t('analytics.conversionRevenue')} + /> + + + + + + + + {t('analytics.revenueBySourceOverTime')} + + + + + + + + {t('analytics.revenueDistribution')} + + + + + + + + + ); +}; + +export default RevenueMetrics; diff --git a/src/components/RouteCard.jsx b/src/components/RouteCard.jsx new file mode 100644 index 0000000..09818df --- /dev/null +++ b/src/components/RouteCard.jsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { Box, Typography, Button } from '@mui/material'; +import mapa from '@images/routing-pointa-ppointb.png'; +import DeleteConfirmationModal from './DeleteRouteConfirmation'; +import RouteDetailsModal from './RouteDetailsModal'; +import { useTranslation } from 'react-i18next'; +const RouteCard = ({route, onViewDetails, onDelete, googleMapsApiKey}) => { + const { t } = useTranslation(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + + const handleDelete = async() => { + try{ + await onDelete(route.id); + }catch(err){ + console.log("Delete unsuccessful"); + }finally { + setDeleteOpen(false); + } + } + const handleViewDetails = () => { + try{ + onViewDetails(route.id); + }catch(err){ + console.log("Failed to open route details"); + } + } + return ( + + {/* Top-center text */} + + Ruta {route?.id} + + + {/* Buttons */} + + + + + setDetailsOpen(false)} + routeData={route} + /> + + setDeleteOpen(false)} + onConfirm={handleDelete} + /> + + + ); +}; + +export default RouteCard; \ No newline at end of file diff --git a/src/components/RouteDetailsModal.jsx b/src/components/RouteDetailsModal.jsx new file mode 100644 index 0000000..d4d38ae --- /dev/null +++ b/src/components/RouteDetailsModal.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { + Box, + Modal, + Typography, + IconButton, + Divider, + List, + ListItem, + ListItemText, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import DirectionsIcon from '@mui/icons-material/Directions'; +import RouteMap from './RouteMap'; // prilagodi ako je u drugom folderu + +const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '90%', + height: '80%', + bgcolor: 'background.paper', + boxShadow: 24, + p: 2, + borderRadius: 2, + display: 'flex', + flexDirection: 'row', + overflow: 'hidden', +}; + +const RouteDetailsModal = ({ open, onClose, routeData }) => { + if (!routeData) return null; + + const steps = + routeData.routeData?.data?.routes?.[0]?.legs?.[0]?.steps || []; + + return ( + + + {/* LEFT SIDE: Map */} + + + + + {/* RIGHT SIDE: Details */} + + {/* Close Button */} + + + + + + Route ID: {routeData.id} + + + + + + + Directions + + + {steps.length === 0 ? ( + No directions available. + ) : ( + + {steps.map((step, index) => ( + + + } + /> + + ))} + + )} + + + + ); +}; + +export default RouteDetailsModal; diff --git a/src/components/RouteDisplayModal.jsx b/src/components/RouteDisplayModal.jsx new file mode 100644 index 0000000..12633b3 --- /dev/null +++ b/src/components/RouteDisplayModal.jsx @@ -0,0 +1,366 @@ +// RouteDisplayModal.js +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { + GoogleMap, + LoadScript, + Polyline, + Marker, + InfoWindow, +} from '@react-google-maps/api'; +import mapboxPolyline from '@mapbox/polyline'; +//import { useTranslation } from 'react-i18next'; + +// Helper: Calculates geographical bounds for points (same as before) +const getBoundingBox = (points) => { + if (!points || points.length === 0) return null; + + // THIS LINE NEEDS `window.google` to be defined + const bounds = new google.maps.LatLngBounds(); + points.forEach((point) => { + // THIS LINE ALSO NEEDS `window.google` + bounds.extend(new google.maps.LatLng(point.latitude, point.longitude)); + }); + return bounds; +}; + +/** + * @typedef {object} Point + * @property {number} latitude + * @property {number} longitude + * @property {string} [duration] + * @property {string} [address] + */ + +/** + * RouteDisplayModal component. + * Displays a pre-calculated route on a Google Map within a modal-like view. + * + * @param {object} props + * @param {boolean} props.open - Whether the modal is visible. + * @param {function} props.onClose - Callback function to close the modal. + * @param {object} props.routeData - The Google Directions API route object (e.g., data.routes[0]). + * @param {string} props.googleMapsApiKey - Your Google Maps API Key. + * @returns {JSX.Element|null} The rendered modal component or null if not open. + */ +function RouteDisplayModal({ open, onClose, routeData, googleMapsApiKey }) { + const [routePath, setRoutePath] = useState([]); + const [waypoints, setWaypoints] = useState([]); + const [mapCenter, setMapCenter] = useState({ + lat: 43.8665216, + lng: 18.3926784, + }); // Default + const [zoom, setZoom] = useState(10); // Default + const [activeMarker, setActiveMarker] = useState(null); + // const { t } = useTranslation(); + const mapRef = useRef(null); + const t = (s) => s; + + console.log(routeData); + + /** + * Processes the provided route data to set map path and waypoints. + */ + const processDisplayRouteData = useCallback( + (currentRouteData) => { + if (!currentRouteData) { + setRoutePath([]); + setWaypoints([]); + return; + } + + let overviewPolyline = currentRouteData.overview_polyline?.points; + if (!overviewPolyline) { + overviewPolyline = currentRouteData.routes[0].overview_polyline?.points; + if (!overviewPolyline) { + console.error('Overview polyline missing from provided route data.'); + setRoutePath([]); + setWaypoints([]); + return; + } + } + + const decodedPath = mapboxPolyline + .decode(overviewPolyline) + .map(([lat, lng]) => ({ lat, lng })); + setRoutePath(decodedPath); + + const newWaypoints = []; + let accumulatedTime = 0; // in seconds + + if (currentRouteData.legs[0]?.start_location) { + newWaypoints.push({ + latitude: currentRouteData.legs[0].start_location.lat, + longitude: currentRouteData.legs[0].start_location.lng, + address: currentRouteData.legs[0].start_address, + duration: t('Start Location'), + }); + } + + currentRouteData.legs.forEach((leg) => { + accumulatedTime += leg.duration.value; + let durationText = ''; + if (accumulatedTime < 60) { + durationText = `< 1 ${t('min')}`; + } else if (accumulatedTime < 3600) { + durationText = `${Math.round(accumulatedTime / 60)} ${t('min')}`; + } else { + const hours = Math.floor(accumulatedTime / 3600); + const minutes = Math.round((accumulatedTime % 3600) / 60); + durationText = `${hours}h ${minutes}${t('min')}`; + } + + newWaypoints.push({ + latitude: leg.end_location.lat, + longitude: leg.end_location.lng, + address: leg.end_address, + duration: durationText, + }); + }); + + setWaypoints(newWaypoints); + + // Defer fitting bounds until map is loaded + if ( + mapRef.current && + (decodedPath.length > 0 || newWaypoints.length > 0) + ) { + const pointsToBound = + newWaypoints.length > 0 + ? newWaypoints + : decodedPath.map((p) => ({ latitude: p.lat, longitude: p.lng })); + const bounds = getBoundingBox(pointsToBound); + if (bounds) { + mapRef.current.fitBounds(bounds); + // Optional: Get center and zoom after fitting bounds if needed for state, + // but usually fitBounds is enough. + // const newCenter = bounds.getCenter(); + // setMapCenter({ lat: newCenter.lat(), lng: newCenter.lng() }); + // setZoom(mapRef.current.getZoom()); + } + } else if (decodedPath.length > 0) { + setMapCenter({ lat: decodedPath[0].lat, lng: decodedPath[0].lng }); + setZoom(12); + } + }, + [t] + ); // mapRef is not a dependency for useCallback here + + useEffect(() => { + if (open && routeData) { + processDisplayRouteData(routeData); + } else if (!open) { + // Optionally clear when closed if desired, or let it persist + // setRoutePath([]); + // setWaypoints([]); + } + }, [open, routeData, processDisplayRouteData]); + + const handleMapLoad = useCallback( + (map) => { + mapRef.current = map; + // If routeData is already present when map loads, fit bounds + if (open && routeData && (routePath.length > 0 || waypoints.length > 0)) { + const pointsToBound = + waypoints.length > 0 + ? waypoints + : routePath.map((p) => ({ latitude: p.lat, longitude: p.lng })); + const bounds = getBoundingBox(pointsToBound); + if (bounds && mapRef.current) { + mapRef.current.fitBounds(bounds); + } + } + }, + [open, routeData, routePath, waypoints] + ); // Add dependencies that affect bounding + + const handleMarkerClick = (point) => { + setActiveMarker(point); + if (mapRef.current) { + // Center on marker click + mapRef.current.panTo({ lat: point.latitude, lng: point.longitude }); + } + }; + + if (!open) { + return null; + } + + if (!googleMapsApiKey) { + return ( +
    +
    +

    {t('Error: Google Maps API Key is missing.')}

    + +
    +
    + ); + } + if (!routeData) { + return ( +
    +
    +

    {t('No route data to display.')}

    + +
    +
    + ); + } + + return ( +
    +
    +
    +

    {t('Route Details')}

    + +
    + {/* */} + + {routePath.length > 0 && ( + + )} + {waypoints.map((point, index) => ( + handleMarkerClick(point)} + label={ + index === 0 + ? 'S' + : index === waypoints.length - 1 + ? 'E' + : `${index}` + } + /> + ))} + {activeMarker && ( + setActiveMarker(null)} + > +
    +

    {activeMarker.address}

    +

    + {activeMarker.duration?.includes(t('Start')) || + activeMarker.duration?.includes(t('End')) + ? activeMarker.duration + : `${t('ETA')}: ${activeMarker.duration || t('Unknown ETA')}`} +

    +
    +
    + )} +
    + {/*
    */} +
    +

    + {t('Total Distance')}:{' '} + {routeData?.legs?.reduce( + (acc, leg) => acc + leg.distance.value, + 0 + ) / 1000}{' '} + km +

    +

    + {t('Total Duration')}:{' '} + {Math.round( + routeData?.legs?.reduce( + (acc, leg) => acc + leg.duration.value, + 0 + ) / 60 + )}{' '} + {t('min')} +

    +
    +
    + +
    + ); +} + +RouteDisplayModal.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + routeData: PropTypes.object, // Can be null if no route is selected yet + googleMapsApiKey: PropTypes.string.isRequired, +}; + +const styles = { + modalOverlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + modalContent: { + backgroundColor: 'white', + padding: '20px', + borderRadius: '8px', + width: '90%', + maxWidth: '800px', // Max width for the modal + maxHeight: '90vh', + overflowY: 'auto', + boxShadow: '0 4px 6px rgba(0,0,0,0.1)', + }, + mapContainer: { + width: '100%', + height: '400px', // Or any appropriate height for the modal map + marginBottom: '10px', + }, + closeButton: { + background: 'none', + border: 'none', + fontSize: '1.5rem', + cursor: 'pointer', + padding: '5px', + lineHeight: '1', + }, + summary: { + marginTop: '15px', + paddingTop: '10px', + borderTop: '1px solid #eee', + }, +}; + +export default RouteDisplayModal; diff --git a/src/components/RouteMap.jsx b/src/components/RouteMap.jsx new file mode 100644 index 0000000..e772ec8 --- /dev/null +++ b/src/components/RouteMap.jsx @@ -0,0 +1,217 @@ +// src/components/RouteMap.jsx +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + GoogleMap, + useJsApiLoader, + Polyline, + MarkerF, +} from '@react-google-maps/api'; +import polylineUtil from '@mapbox/polyline'; +import { CircularProgress, Alert, Box, Typography } from '@mui/material'; + +const MAP_LIBRARIES = ['geometry', 'places']; + +const RouteMap = ({ backendResponse }) => { + const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; + + const { isLoaded, loadError } = useJsApiLoader({ + googleMapsApiKey: apiKey, + libraries: MAP_LIBRARIES, + // id: 'google-map-script', // Optional: useful for multiple maps or specific loading strategies + }); + + const [map, setMap] = useState(null); + const [decodedPath, setDecodedPath] = useState([]); + const [startLocation, setStartLocation] = useState(null); + const [endLocation, setEndLocation] = useState(null); + const [mapBoundsObject, setMapBoundsObject] = useState(null); // Stores the actual LatLngBounds object + + // Effect to process backendResponse and create map elements + useEffect(() => { + // Only proceed if the API is loaded and we have valid backend data + if (!isLoaded || !backendResponse?.routeData?.data?.routes?.[0]) { + // Clear out data if not ready or no valid response + setDecodedPath([]); + setStartLocation(null); + setEndLocation(null); + setMapBoundsObject(null); + return; + } + + const route = backendResponse.routeData.data.routes[0]; + + // Decode polyline + if (route.overview_polyline?.points) { + try { + const decoded = polylineUtil + .decode(route.overview_polyline.points) + .map((point) => ({ + lat: point[0], + lng: point[1], + })); + setDecodedPath(decoded); + } catch (e) { + console.error('Error decoding polyline:', e); + setDecodedPath([]); + } + } else { + setDecodedPath([]); + } + + // Set start/end locations for markers + if (route.legs?.[0]) { + setStartLocation(route.legs[0].start_location); + setEndLocation(route.legs[0].end_location); + } else { + setStartLocation(null); + setEndLocation(null); + } + + // Create LatLngBounds object (THIS IS LIKELY WHERE THE ERROR WAS) + // Now this block is guarded by `isLoaded` + if (route.bounds && window.google && window.google.maps) { + // Double check window.google just in case, though isLoaded should cover it + try { + const newBounds = new window.google.maps.LatLngBounds( + new window.google.maps.LatLng( + route.bounds.southwest.lat, + route.bounds.southwest.lng + ), + new window.google.maps.LatLng( + route.bounds.northeast.lat, + route.bounds.northeast.lng + ) + ); + setMapBoundsObject(newBounds); + } catch (e) { + console.error( + 'Error creating LatLngBounds from route.bounds:', + e, + route.bounds + ); + setMapBoundsObject(null); // Fallback if creation fails + } + } else { + setMapBoundsObject(null); // If no route.bounds, clear any existing mapBoundsObject + } + }, [backendResponse, isLoaded]); // Key dependencies: backendResponse and isLoaded + + const onMapLoad = useCallback((mapInstance) => { + setMap(mapInstance); + }, []); + + // Effect to fit bounds once map is loaded and bounds/path are ready + useEffect(() => { + if (!map || !isLoaded) return; // Ensure map and API are ready + + if (mapBoundsObject && !mapBoundsObject.isEmpty()) { + map.fitBounds(mapBoundsObject); + } else if (decodedPath.length > 0) { + // Fallback to fitting bounds from the decoded path + console.log( + 'Fitting bounds to decoded path as mapBoundsObject not available or empty.' + ); + try { + const pathBounds = new window.google.maps.LatLngBounds(); + decodedPath.forEach((point) => { + if ( + point && + typeof point.lat === 'number' && + typeof point.lng === 'number' + ) { + pathBounds.extend( + new window.google.maps.LatLng(point.lat, point.lng) + ); + } else { + console.warn('Invalid point in decodedPath:', point); + } + }); + if (!pathBounds.isEmpty()) { + map.fitBounds(pathBounds); + } else { + console.warn('Path bounds are empty, cannot fit.'); + } + } catch (e) { + console.error( + 'Error creating LatLngBounds from decodedPath:', + e, + decodedPath + ); + } + } + }, [map, mapBoundsObject, decodedPath, isLoaded]); // Key dependencies + + const mapContainerStyle = { + width: '100%', + height: '100%', // Crucial: map needs explicit height from parent + }; + + const defaultCenter = useMemo(() => ({ lat: 43.8563, lng: 18.4131 }), []); + + if (loadError) { + console.error('Google Maps API load error:', loadError); + return ( + + Error loading Google Maps: {loadError.message} + + ); + } + + if (!apiKey) { + return ( + Error: Google Maps API Key is missing. + ); + } + + // Show loading spinner while API is loading + if (!isLoaded) { + return ( + + + Loading Map... + + ); + } + + // API is loaded, now render the map + return ( + setMap(null)} // Good practice for cleanup + options={ + { + // streetViewControl: false, + // mapTypeControl: false, + // You can add more options here + } + } + > + {decodedPath.length > 0 && ( + + )} + {startLocation && ( + + )} + {endLocation && } + + ); +}; + +export default RouteMap; diff --git a/src/components/SalesChart.jsx b/src/components/SalesChart.jsx new file mode 100644 index 0000000..2713220 --- /dev/null +++ b/src/components/SalesChart.jsx @@ -0,0 +1,305 @@ +import React, { useState, useEffect } from 'react'; +import { + Paper, + Box, + Typography, + IconButton, + Menu, + MenuItem, + Stack, + useTheme, +} from '@mui/material'; +import FilterListIcon from '@mui/icons-material/FilterList'; +// Using ShoppingCartIcon from develop as it's generic for product sales +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +// API imports from develop +import { + apiGetAllAdsAsync, + apiGetAllStoresAsync, + apiGetStoreProductsAsync, +} from '../api/api.js'; + +function SalesChart() { + const theme = useTheme(); + const [filterType, setFilterType] = useState('topRated'); // 'topRated' or 'lowestRated' + const [anchorEl, setAnchorEl] = useState(null); + const [productSalesData, setProductSalesData] = useState([]); // Changed from productData to be more specific + const open = Boolean(anchorEl); + + useEffect(() => { + const fetchData = async () => { + try { + // Fetch all ads + const adsResponse = await apiGetAllAdsAsync(); + const ads = + adsResponse && Array.isArray(adsResponse.data) + ? adsResponse.data + : []; + + // Fetch all stores + const storesResponse = await apiGetAllStoresAsync(); + const stores = Array.isArray(storesResponse) ? storesResponse : []; + + // Create a map of all products from all stores + const allProductsMap = {}; + for (const store of stores) { + if (store && store.id) { + const productsResponse = await apiGetStoreProductsAsync(store.id); + const storeProducts = + productsResponse && Array.isArray(productsResponse.data) + ? productsResponse.data + : []; + for (const product of storeProducts) { + if (product && product.id && !allProductsMap[product.id]) { + allProductsMap[product.id] = { + id: product.id, + name: product.name || `Product ${product.id}`, + // imageUrl: product.imageUrl || 'https://via.placeholder.com/40', // If you have image URLs + // Placeholder for product-specific icon/color if needed later + // icon: ShoppingCartIcon, + // color: theme.palette.text.secondary, + clicks: 0, + conversions: 0, + revenue: 0, + }; + } + } + } + } + + // Process all ads to aggregate sales data per product + for (const ad of ads) { + if (ad && Array.isArray(ad.adData)) { + for (const adDataItem of ad.adData) { + if ( + adDataItem && + adDataItem.productId && + allProductsMap[adDataItem.productId] + ) { + const productEntry = allProductsMap[adDataItem.productId]; + productEntry.clicks += ad.clicks || 0; + productEntry.conversions += ad.conversions || 0; + productEntry.revenue += + (ad.conversions || 0) * (ad.conversionPrice || 0); + } + } + } + } + + // Sort products based on filterType (revenue) + let sortedProductsArray = Object.values(allProductsMap); + + if (filterType === 'topRated') { + sortedProductsArray.sort((a, b) => b.revenue - a.revenue); + } else { + // 'lowestRated' + // Filter out products with zero revenue for "lowest rated" to make it meaningful + sortedProductsArray = sortedProductsArray.filter( + (p) => p.revenue > 0 + ); + sortedProductsArray.sort((a, b) => a.revenue - b.revenue); + } + + // Limit to top/lowest 4 products (or adjust as needed) + setProductSalesData(sortedProductsArray.slice(0, 4)); + } catch (error) { + console.error('Error fetching product sales data:', error); + setProductSalesData([]); // Reset on error + } + }; + + fetchData(); + }, [filterType]); // Re-fetch when filterType changes + + const handleFilterClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleFilterChange = (type) => { + setFilterType(type); + handleClose(); // Also close the menu + }; + + const totalRevenueAllDisplayed = productSalesData.reduce( + (sum, p) => sum + p.revenue, + 0 + ); + + return ( + + + {/* Filter Button and Menu - structure from develop is fine */} + + + + Filters + + + + + handleFilterChange('topRated')}> + Top Rated + + handleFilterChange('lowestRated')}> + Lowest Rated + + + + + + + {productSalesData.length === 0 && ( + + No product sales data to display for this filter. + + )} + {productSalesData.map((item) => { + // Dynamic percentage calculation from develop + const percentage = + totalRevenueAllDisplayed > 0 + ? ((item.revenue / totalRevenueAllDisplayed) * 100).toFixed(1) + : '0.0'; + + return ( + + + {/* Using generic ShoppingCartIcon from develop. + If item had specific icon data, could use React.createElement here. */} + + + + + + {item.name.length > 16 + ? `${item.name.substring(0, 16)}...` + : item.name}{' '} + + + + + + ${item.revenue.toLocaleString()} + + + {percentage}% + + + + ); + })} + + + ); +} + +export default SalesChart; diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx new file mode 100644 index 0000000..330c63d --- /dev/null +++ b/src/components/SearchBar.jsx @@ -0,0 +1,14 @@ +import { TextField } from "@mui/material"; + +export default function SearchBar({ value, onChange }) { + return ( + + ); +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx new file mode 100644 index 0000000..f42a689 --- /dev/null +++ b/src/components/Sidebar.jsx @@ -0,0 +1,214 @@ +import React from 'react'; +import icon from '@icons/admin.svg'; +import { + Box, + Avatar, + Typography, + IconButton, + Divider, + Button, +} from '@mui/material'; +import { HiOutlineBell } from 'react-icons/hi'; +import { HiOutlineUserGroup } from 'react-icons/hi'; +import { + sidebarContainer, + profileBox, + navItem, + iconBox, + footerBox, +} from './SidebarStyles'; +import AdminSearchBar from '@components/AdminSearchBar'; +import ThemeToggle from '@components/ThemeToggle'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { usePendingUsers } from '@context/PendingUsersContext'; +import LogoutIcon from '@mui/icons-material/Logout'; +import { FiShoppingBag } from 'react-icons/fi'; +import { FiGrid } from 'react-icons/fi'; +import { FiClipboard } from 'react-icons/fi'; +import { FiBarChart2 } from 'react-icons/fi'; +import { HiOutlineMegaphone } from 'react-icons/hi2'; +import { FiMessageCircle } from 'react-icons/fi'; +import { FaRoute } from 'react-icons/fa'; +import { FaLanguage } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; + + +const Sidebar = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { pendingUsers } = usePendingUsers(); + const menuItems = [ + { + icon: , + label: t('common.analytics'), + path: '/analytics', + badge: null, + }, + { + icon: , + label: t('common.users'), + path: '/users', + badge: null, + }, + { + icon: , + label: t('common.requests'), + path: '/requests', + badge: pendingUsers.length, + }, + { + icon: , + label: t('common.stores'), + path: '/stores', + badge: null, + }, + { + icon: , + label: t('common.categories'), + path: '/categories', + badge: null, + }, + { + icon: , + label: t('common.orders'), + path: '/orders', + badge: null, + }, + { + icon: , + label: t('common.advertisements'), + path: '/ads', + badge: null, + }, + { + icon: , + label: t('common.chat'), + path: '/chat', + badge: null, + }, + { + icon: , + label: t('common.routes'), + path: '/routes', + badge: null, + }, + { + icon: , + label: t('common.languages'), + path: '/languages', + badge: null, + }, + ]; + const [isDark, setIsDark] = useState(false); + const toggleTheme = () => setIsDark(!isDark); + + const handleLogout = () => { + console.log('Logging out...'); + + // 1. Clear authentication artifacts from local storage + // (Add/remove items based on what you actually store) + localStorage.removeItem('token'); + localStorage.removeItem('auth'); // From your AppRoutes example + // localStorage.removeItem('user'); // Example: if you store user info + + // 2. Redirect to the login page + // 'replace: true' prevents the user from navigating back to the protected page + navigate('/login', { replace: true }); + + // Optional: Force reload if state isn't clearing properly (useNavigate is usually sufficient) + // window.location.reload(); + + // Optional: Call a backend logout endpoint if needed + // try { + // await axios.post('/api/auth/logout'); + // } catch (error) { + // console.error("Backend logout failed:", error); + // } + + // Optional: Clear any global state (Context, Redux, Zustand) if necessary + // authContext.logout(); + }; + + return ( + + + + + Bazaar + + {t('common.administrator')} + + + + + + + {/* Search */} + {/* Menu */} + {menuItems.map((item, index) => ( + navigate(item.path)} + style={{ cursor: 'pointer' }} + > + {item.icon} + {item.label} + {item.badge && ( + + {item.badge} + + )} + + ))} + + + + { + /* Footer toggle */ + + } + + + ); +}; + +export default Sidebar; diff --git a/src/components/SidebarStyles.jsx b/src/components/SidebarStyles.jsx new file mode 100644 index 0000000..8ce94a6 --- /dev/null +++ b/src/components/SidebarStyles.jsx @@ -0,0 +1,47 @@ +export const sidebarContainer = { + position: "fixed", // Zalijepi za lijevu stranu + top: 0, + left: 0, + height: "100vh", // Cijela visina prozora + width: 260, + backgroundColor: "#f9f9f9", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + padding: "24px 16px", + boxShadow: "2px 0 8px rgba(0,0,0,0.05)", + zIndex: 1000, // Iznad ostalog sadržaja +}; + +export const profileBox = { + display: "flex", + alignItems: "center", + gap: 1.5, + mb: 3, +}; + +export const navItem = { + display: "flex", + alignItems: "center", + gap: 1.5, + p: 1, + borderRadius: 2, + cursor: "pointer", + "&:hover": { + backgroundColor: "#eef2f5", + }, + mb: 1, +}; + +export const iconBox = { + fontSize: 20, + color: "#555", +}; + +export const footerBox = { + display: "flex", + justifyContent: "center", + alignItems: "center", + mt: "auto", + gap: 1, +}; diff --git a/src/components/SocialLoginButton.jsx b/src/components/SocialLoginButton.jsx new file mode 100644 index 0000000..8344fb8 --- /dev/null +++ b/src/components/SocialLoginButton.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import socialButtonStyle from './SocialLoginButtonStyles'; + +const SocialLoginButton = ({ icon, label, onClick }) => { + return ( + + ); +}; + +export default SocialLoginButton; diff --git a/src/components/SocialLoginButtonStyles.jsx b/src/components/SocialLoginButtonStyles.jsx new file mode 100644 index 0000000..de6aa0f --- /dev/null +++ b/src/components/SocialLoginButtonStyles.jsx @@ -0,0 +1,31 @@ +const socialButtonStyle = (theme) => ({ + borderRadius: '12px', + paddingY: 1.2, + paddingX: 3, + fontWeight: 500, + minWidth: 150, + justifyContent: 'flex-start', + gap: 1.5, + borderColor: theme.palette.primary.light, + color: theme.palette.primary.main, + transition: 'all 0.3s ease', + + '& .MuiButton-startIcon': { + margin: 0, + }, + + '&:hover': { + backgroundColor: '#f8f8f8', + borderColor: theme.palette.primary.main, + transform: 'translateY(-2px)', + boxShadow: `0 6px 12px ${theme.palette.primary.light}`, + }, + + '&:focus': { + outline: 'none', + boxShadow: `0 0 0 3px ${theme.palette.primary.light}`, + }, + }); + + export default socialButtonStyle; + \ No newline at end of file diff --git a/src/components/StoreCard.jsx b/src/components/StoreCard.jsx new file mode 100644 index 0000000..dab9153 --- /dev/null +++ b/src/components/StoreCard.jsx @@ -0,0 +1,418 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + Box, + Typography, + Button, + IconButton, + Avatar, + Menu, + MenuItem, +} from '@mui/material'; +import StoreIcon from '@mui/icons-material/Store'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import { FiEdit2, FiTrash } from 'react-icons/fi'; +import { FaPaperclip } from 'react-icons/fa6'; +import { + apiUpdateStoreAsync, + apiDeleteStoreAsync, + apiGetStoreCategoriesAsync, + apiExportProductsToCSVAsync, + apiExportProductsToExcelAsync, + apiCreateProductAsync, + apiGetMonthlyStoreRevenueAsync +} from '@api/api'; +import AddProductModal from '@components/NewProductModal'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import EditStoreModal from '@components/EditStoreModal'; +import ConfirmDeleteStoreModal from '@components/ConfirmDeleteStoreModal'; +import StoreProductsList from '@components/StoreProductsList'; +import * as XLSX from 'xlsx'; +import { apiGetStoreByIdAsync } from '../api/api'; +import { useTranslation } from 'react-i18next'; + + +const StoreCard = ({ store }) => { + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const [menuAnchor, setMenuAnchor] = useState(null); + const [storeData, setStoreData] = useState(store); + const [isOnline, setIsOnline] = useState(storeData.isActive); + const [openModal, setOpenModal] = useState(false); + const [openEditModal, setOpenEditModal] = useState(false); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [categories, setCategories] = useState([]); + const [updating, setUpdating] = useState(false); + const fileInputRef = useRef(); + const [parsedProducts, setParsedProducts] = useState([]); + const [revenue, setRevenue] = useState(0); + const openStatus = Boolean(anchorEl); + const openMenu = Boolean(menuAnchor); + + useEffect(() => { + apiGetStoreCategoriesAsync().then(setCategories); + const fetchRevenue = async () => { + try { + const rez = await apiGetMonthlyStoreRevenueAsync(storeData.id); + console.log(rez.taxedIncome); // ✅ This will now work + setRevenue(rez); + } catch (error) { + console.error("Failed to fetch revenue:", error); + } + }; + + fetchRevenue(); + }, []); + + const handleStatusClick = (e) => setAnchorEl(e.currentTarget); + const handleStatusChange = async (newStatus) => { + setUpdating(true); + const matchedCategory = categories.find( + (cat) => cat.name === storeData.categoryName + ); + if (!matchedCategory) return; + + const updatedStore = { + ...storeData, + isActive: newStatus, + categoryId: matchedCategory.id, + }; + + const res = await apiUpdateStoreAsync(updatedStore); + if (res?.success || res?.status === 201) setIsOnline(newStatus); + setUpdating(false); + setAnchorEl(null); + }; + + const handleExportCSV = async () => { + const response = await apiExportProductsToCSVAsync(storeData.id); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'Proizvodi.csv'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleExportExcel = async () => { + const response = await apiExportProductsToExcelAsync(storeData.id); + console.log('PREOVJERA', response.data); + + const blob = response.data; + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', 'Proizvodi.xlsx'); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleMenuClick = (e) => setMenuAnchor(e.currentTarget); + const handleMenuClose = () => setMenuAnchor(null); + + const handleFileUpload = (e) => { + const file = e.target.files[0]; + if (!file) return; + + const fileName = file.name.toLowerCase(); + const isCSV = fileName.endsWith('.csv'); + const reader = new FileReader(); + + reader.onload = (evt) => { + const fileContent = evt.target.result; + let workbook; + + if (isCSV) { + workbook = XLSX.read(fileContent, { type: 'string' }); + } else { + workbook = XLSX.read(fileContent, { type: 'binary' }); + } + + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const jsonData = XLSX.utils.sheet_to_json(sheet); + setParsedProducts(jsonData); + handleBulkCreate(jsonData); + }; + + if (isCSV) { + reader.readAsText(file); // CSV kao tekst + } else { + reader.readAsBinaryString(file); // Excel kao binarni + } + }; + + const handleBulkCreate = async (products) => { + let success = 0; + let fail = 0; + + for (const product of products) { + console.log('Creating product:', product); + try { + const res = await apiCreateProductAsync({ + ...product, + storeId: storeData.id, + }); + console.log('Response from apiCreateProductAsync:', res); + + // Ovo je sad ispravno + res?.status === 201 ? success++ : fail++; + } catch (error) { + console.error('Error in bulk create:', error); + fail++; + } + } + window.location.reload(); + + console.log(`✅ ${success} created, ❌ ${fail} failed`); + }; + + const onUpdate = async (targetstore) =>{ + try{ + const rez = await apiUpdateStoreAsync(targetstore); + const newstore = await apiGetStoreByIdAsync(targetstore.id); + setStoreData(newstore); + }catch(err){ + console.log(err); + } + + }; + return ( + <> + + {/* Status & Delete */} + + setOpenDeleteModal(true)} sx={{ p: 0.5 }}> + + + + + + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + > + handleStatusChange(true)}> + 🟢 Online + + handleStatusChange(false)}> + 🔴 Offline + + + + {/* Header */} + + + + + + + {storeData.name} + setOpenEditModal(true)} + sx={{ p: 0, opacity: 0, transition: 'opacity 0.2s' }} + > + + + + + + + {storeData.address} + + + + + + {/* Description */} + + {storeData.description} + + + + {t('common.tax')}: {(storeData.tax*100).toFixed(2)} + + + + {t('common.totalMonthlyIncome')}: {revenue.totalIncome} + + + + {t('common.taxedMonthlyIncome')}: {revenue.taxedIncome} + + + {/* Buttons */} + + + + + + + + + + + + fileInputRef.current.click()}> + 📥 Import (CSV/Excel) + + 📤 Export CSV + 📤 Export Excel + + {/* Products List */} + + + + setOpenModal(false)} + storeID={storeData.id} + /> + setOpenEditModal(false)} + store={store} + onStoreUpdated={onUpdate} + /> + setOpenDeleteModal(false)} + storeName={storeData.name} + onConfirm={async () => { + const res = await apiDeleteStoreAsync(storeData.id); + if (res.success) window.location.reload(); + }} + /> + + ); +}; + +export default StoreCard; diff --git a/src/components/StoreEarningsTable.jsx b/src/components/StoreEarningsTable.jsx new file mode 100644 index 0000000..64cd179 --- /dev/null +++ b/src/components/StoreEarningsTable.jsx @@ -0,0 +1,92 @@ +import React, { useState, useMemo } from 'react'; +import { + Table, TableHead, TableRow, TableCell, TableBody, + TablePagination, TableSortLabel, Paper, Box +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +const StoreEarningsTable = ({ data }) => { + const { t } = useTranslation(); + const [page, setPage] = useState(0); + const rowsPerPage = 5; + const [orderBy, setOrderBy] = useState('storeRevenue'); + const [order, setOrder] = useState('desc'); + + const handleSort = (property) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const sortedData = useMemo(() => { + return [...data].sort((a, b) => + (order === 'asc' ? a[orderBy] - b[orderBy] : b[orderBy] - a[orderBy]) + ); + }, [data, order, orderBy]); + + const paginatedData = sortedData.slice(page * rowsPerPage, (page + 1) * rowsPerPage); + + return ( + + + + + + + {t('common.storeName')} + + + handleSort('storeRevenue')} + > + {t('analytics.storeRevenue')} + + + + handleSort('adminProfit')} + > + {t('analytics.adminProfit')} + + + + handleSort('taxRate')} + > + {t('analytics.taxRate')} + + + + + + {paginatedData.map((row) => ( + + {row.name} + {(row.storeRevenue ?? 0).toFixed(2)} $ + {(row.adminProfit ?? 0).toFixed(2)} $ + {(row.taxRate ?? 0).toFixed(2)} % + + + ))} + +
    +
    + setPage(newPage)} + /> +
    + ); +}; + +export default StoreEarningsTable; diff --git a/src/components/StoreProductsList.jsx b/src/components/StoreProductsList.jsx new file mode 100644 index 0000000..5a967fa --- /dev/null +++ b/src/components/StoreProductsList.jsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Typography, IconButton, Tooltip } from '@mui/material'; +import { FiEdit2, FiTrash } from 'react-icons/fi'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import { + apiGetStoreProductsAsync, + apiDeleteProductAsync, + apiUpdateProductAsync, +} from '@api/api'; +import EditProductModal from './EditProductModal'; +import ProductDetailsModal from './ProductDetailsModal'; +import { useTranslation } from 'react-i18next'; + +const StoreProductsList = ({ storeId }) => { + const [products, setProducts] = useState([]); + const [openEditModal, setOpenEditModal] = useState(false); + const [openDetailsModal, setOpenDetailsModal] = useState(false); + const [selectedProduct, setSelectedProduct] = useState(null); + const { t } = useTranslation(); + + useEffect(() => { + const fetchProducts = async () => { + const response = await apiGetStoreProductsAsync(storeId); + if (response.status === 200) { + setProducts(response.data); + } + }; + fetchProducts(); + }, [storeId]); + + const handleEditClick = (product, e) => { + e.stopPropagation(); + setSelectedProduct(product); + setOpenEditModal(true); + }; + + const handleDeleteClick = async (productId, e) => { + e.stopPropagation(); + const response = await apiDeleteProductAsync(productId); + if (response.status === 204) { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + } + }; + + const handleStatusClick = async (product, e) => { + e.stopPropagation(); + console.log('🟡 Selected product before toggle:', product); + + const updatedProduct = { + id: product.id, + name: product.name, + retailPrice: Number(product.retailPrice ?? product.price ?? 0), + wholesaleThreshold: 0, + wholesalePrice: Number(product.wholesalePrice ?? product.price ?? 0), + productCategoryId: + product.productCategory?.id ?? product.productCategoryId ?? 1, + weight: product.weight ?? 0, + volume: product.volume ?? 0, + weightUnit: product.weightUnit ?? 'kg', + volumeUnit: product.volumeUnit ?? 'L', + storeId: product.storeId, + isActive: !product.isActive, + files: product.photos ?? [], + }; + + console.log('📦 Sending updated product to API:', updatedProduct); + + const response = await apiUpdateProductAsync(updatedProduct); + if (response.status >= 200 && response.status < 300) { + setProducts((prev) => + prev.map((p) => + p.id === product.id ? { ...p, isActive: !p.isActive } : p + ) + ); + } + }; + + const renderPlaceholderItems = () => { + const itemHeight = 40; + const minItems = 3; + const placeholdersNeeded = Math.max(0, minItems - products.length); + + return Array(placeholdersNeeded) + .fill(null) + .map((_, index) => ( + + )); + }; + + return ( + + + {t('common.products')} + + + {products.map((product) => ( + { + setSelectedProduct(product); + setOpenDetailsModal(true); + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + p: 1, + height: '40px', + cursor: 'pointer', + '&:hover': { + backgroundColor: '#f5f5f5', + '& .edit-icon': { opacity: 1 }, + }, + }} + > + + + handleStatusClick(product, e)} + sx={{ p: 0 }} + > + + + + + {product.name} + + + + handleEditClick(product, e)} + > + + + handleDeleteClick(product.id, e)} + > + + + + + ))} + {renderPlaceholderItems()} + + + setOpenEditModal(false)} + product={selectedProduct} + onSave={(updatedProduct) => { + setProducts((prev) => + prev.map((p) => (p.id === updatedProduct.id ? updatedProduct : p)) + ); + setOpenEditModal(false); + }} + /> + + setOpenDetailsModal(false)} + product={selectedProduct} + /> + + ); +}; + +export default StoreProductsList; diff --git a/src/components/SuccessMessage.jsx b/src/components/SuccessMessage.jsx new file mode 100644 index 0000000..0f85190 --- /dev/null +++ b/src/components/SuccessMessage.jsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Modal, Box, Typography } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "@mui/icons-material/Error"; + +const SuccessMessage = ({ open, onClose, isSuccess, message }) => { + const Icon = isSuccess ? CheckCircleIcon : ErrorIcon; + const iconColor = isSuccess ? "#4caf50" : "#f44336"; + const title = isSuccess ? "Success" : "Error"; + + return ( + + + + + {title} + + + {message} + + + + ); +}; + +export default SuccessMessage; diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..53f2cb1 --- /dev/null +++ b/src/components/ThemeToggle.jsx @@ -0,0 +1,75 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { Sun, Moon } from "lucide-react"; + +const ThemeToggle = ({ isDark, toggleTheme }) => { + return ( + + {/* Tekst: Light */} + + Light + + + {/* Tekst: Dark */} + + Dark + + + {/* Klizeća ikona */} + + {isDark ? ( + + ) : ( + + )} + + + ); +}; + +export default ThemeToggle; diff --git a/src/components/TicketCard.jsx b/src/components/TicketCard.jsx new file mode 100644 index 0000000..b48c8fa --- /dev/null +++ b/src/components/TicketCard.jsx @@ -0,0 +1,129 @@ +// @components/TicketCard.jsx +import React from 'react'; +import { + Card, + CardContent, + Typography, + Box, + IconButton, + Chip, + Stack, +} from '@mui/material'; +import ChatIcon from '@mui/icons-material/Chat'; +import DeleteIcon from '@mui/icons-material/Delete'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; + +export default function TicketCard({ + ticket, + selected, + unlocked, + onClick, + onOpenChat, + onDelete, + onResolve, +}) { + const { title, description, createdAt, status } = ticket; + + const chatIconColor = + status === 'Requested' + ? '#bdbdbd' + : status === 'Open' + ? '#43a047' + : '#bdbdbd'; + + const statusColor = + status === 'Requested' + ? 'warning' + : status === 'Open' + ? '#4CAF50' + : status === 'Resolved' + ? '#2196F3' + : 'default'; + + return ( + + + + {title} + + + {description} + + + + {new Date(createdAt).toLocaleString()} + + + + + e.stopPropagation()} // spriječi bubbling na card + > + + + + onDelete(ticket)} size='large'> + + + onResolve(ticket)} + size='large' + disabled={status === 'Resolved'} + > + + + + + ); +} diff --git a/src/components/TicketListSection.jsx b/src/components/TicketListSection.jsx new file mode 100644 index 0000000..b1f6bec --- /dev/null +++ b/src/components/TicketListSection.jsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { Box, TextField, Typography } from '@mui/material'; +import TicketCard from './TicketCard'; +import SearchIcon from '@mui/icons-material/Search'; +import InputAdornment from '@mui/material/InputAdornment'; +import DeleteConfirmModal from '@components/DeleteConfirmModal'; +import { apiUpdateTicketStatusAsync } from '../api/api.js'; // prilagodi path +import { apiDeleteTicketAsync } from '../api/api.js'; // prilagodi path +import { useTranslation } from 'react-i18next'; + +export default function TicketListSection({ + tickets, + selectedTicketId, + setSelectedTicketId, + unlockedTickets, + onUnlockChat, + refreshTickets, + setTickets, // Dodaj ovaj prop iz ChatPage.jsx +}) { + const [search, setSearch] = useState(''); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [ticketToDelete, setTicketToDelete] = useState(null); + const { t } = useTranslation(); + const handleOpenChat = async (ticketId) => { + const ticket = tickets.find((t) => t.id === ticketId); + if (ticket.status === 'Requested') { + await apiUpdateTicketStatusAsync(ticketId, 'Open'); + if (refreshTickets) await refreshTickets(); + } + setSelectedTicketId(ticketId); // Samo selektuj ticket + }; + + const handleDelete = (ticket) => { + setTicketToDelete(ticket); + setDeleteModalOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!ticketToDelete) return; + const { status } = await apiDeleteTicketAsync(ticketToDelete.id); + if (status === 204) { + setTickets((prev) => prev.filter((t) => t.id !== ticketToDelete.id)); + if (selectedTicketId === ticketToDelete.id) setSelectedTicketId(null); + } else { + alert('Failed to delete ticket.'); + } + setDeleteModalOpen(false); + setTicketToDelete(null); + }; + + const handleResolve = async (ticket) => { + if (ticket.status !== 'Resolved') { + await apiUpdateTicketStatusAsync(ticket.id, 'Resolved'); + if (refreshTickets) refreshTickets(); + } + }; + + const filteredTickets = tickets.filter( + (t) => + t.title.toLowerCase().includes(search.toLowerCase()) || + t.description.toLowerCase().includes(search.toLowerCase()) + ); + + return ( + <> + + + {t('common.tickets')} + + setSearch(e.target.value)} + sx={{ + mb: 2, + ml: 1, + width: 335, + borderRadius: 3, + background: '#fff', + boxShadow: '0 2px 8px 0 rgba(60,72,88,0.08)', + '& .MuiOutlinedInput-root': { + borderRadius: 3, + background: '#fff', + }, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: '#e0e0e0', + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: '#bdbdbd', + }, + '& .MuiInputAdornment-root': { + color: '#bdbdbd', + }, + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {filteredTickets.length === 0 ? ( + + {t('common.noTicketsFound')} + + ) : ( + filteredTickets.map((ticket) => ( + setSelectedTicketId(ticket.id)} + onOpenChat={() => handleOpenChat(ticket.id)} + onDelete={handleDelete} + onResolve={handleResolve} + /> + )) + )} + + + setDeleteModalOpen(false)} + onConfirm={handleConfirmDelete} + ticketTitle={ticketToDelete?.title} + /> + + ); +} diff --git a/src/components/UserAvatar.jsx b/src/components/UserAvatar.jsx new file mode 100644 index 0000000..76513c0 --- /dev/null +++ b/src/components/UserAvatar.jsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Avatar } from "@mui/material"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; + +const UserAvatar = () => ( + + + +); + +export default UserAvatar; \ No newline at end of file diff --git a/src/components/UserDetailsModal.jsx b/src/components/UserDetailsModal.jsx new file mode 100644 index 0000000..f242a02 --- /dev/null +++ b/src/components/UserDetailsModal.jsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + IconButton, + Typography, + Button, + Box, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +import UserAvatar from "../components/UserAvatar.jsx"; +import UserName from "../components/UserName.jsx"; +import UserEmail from "../components/UserEmail.jsx"; +import UserPhone from "../components/UserPhone.jsx"; +import UserRoles from "../components/UserRoles.jsx"; +import UserEditForm from "../components/UserEditForm.jsx"; + +const UserDetailsModal = ({ open, onClose, user, readOnly = false }) => { + const [selectedUser, setSelectedUser] = useState(null); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + if (user) { + setSelectedUser(user); + setIsEditing(false); + } + }, [user]); + + if (!selectedUser) return null; + + const handleEditToggle = () => { + setIsEditing((prev) => !prev); + }; + + const handleUserSave = (updatedUser) => { + setSelectedUser(updatedUser); + setIsEditing(false); + }; + + return ( + + + + User Details + + + + + + + + + + + + + + + + + Username:{" "} + {selectedUser.userName} + + + Role:{" "} + {selectedUser.roles[0]} + + + + {/* {!readOnly && ( + <> + + + {isEditing && ( + + + Edit User Details + + + + )} + + )} */} + + + + ); +}; + +export default UserDetailsModal; diff --git a/src/components/UserDistribution.jsx b/src/components/UserDistribution.jsx new file mode 100644 index 0000000..ad63309 --- /dev/null +++ b/src/components/UserDistribution.jsx @@ -0,0 +1,208 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Card, CardContent, Typography, Box } from '@mui/material'; +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; +import { apiGetAllAdsAsync } from '../api/api.js'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; +import { useTranslation } from 'react-i18next'; + +const gaugeColor = '#0F766E'; +const bgColor = '#E5E7EB'; +const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +const UserDistribution = () => { + const { t } = useTranslation(); + const [conversionRate, setConversionRate] = useState(0); + const [totalConversions, setTotalConversions] = useState(0); + const [totalClicks, setTotalClicks] = useState(0); + const [ads, setAds] = useState([]); + const connectionRef = useRef(null); + + useEffect(() => { + const fetchInitialData = async () => { + try { + const adsResponse = await apiGetAllAdsAsync(); + const adsData = adsResponse.data; + setAds(adsData); + + // Calculate initial conversions and clicks + const totalConversions = adsData.reduce( + (sum, ad) => sum + (ad.conversions || 0), + 0 + ); + const totalClicks = adsData.reduce( + (sum, ad) => sum + (ad.clicks || 0), + 0 + ); + + setTotalConversions(totalConversions); + setTotalClicks(totalClicks); + setConversionRate( + totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0 + ); + } catch (error) { + console.error('Error fetching initial ads data:', error); + } + }; + + fetchInitialData(); + + // Initialize SignalR connection + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + console.warn('No JWT token found. SignalR connection not started.'); + return; + } + + const newConnection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = newConnection; + + const startConnection = async () => { + try { + await newConnection.start(); + console.log('SignalR Connected to AdvertisementHub!'); + } catch (err) { + console.error('SignalR Connection Error:', err); + } + }; + + startConnection(); + + // Register event handlers + newConnection.on('ReceiveAdUpdate', (updatedAd) => { + console.log('Received Ad Update:', updatedAd); + setAds((prevAds) => { + const updatedAds = prevAds.map((ad) => + ad.id === updatedAd.id ? updatedAd : ad + ); + + // If the ad is new, add it + if (!updatedAds.some((ad) => ad.id === updatedAd.id)) { + updatedAds.push(updatedAd); + } + + // Recalculate conversions and clicks + const totalConversions = updatedAds.reduce( + (sum, ad) => sum + (ad.conversions || 0), + 0 + ); + const totalClicks = updatedAds.reduce( + (sum, ad) => sum + (ad.clicks || 0), + 0 + ); + + setTotalConversions(totalConversions); + setTotalClicks(totalClicks); + setConversionRate( + totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0 + ); + + return updatedAds; + }); + }); + + newConnection.on('ReceiveClickTimestamp', () => { + console.log('Received Click Timestamp'); + setTotalClicks((prev) => { + const newTotalClicks = prev + 1; + setConversionRate( + newTotalClicks > 0 ? (totalConversions / newTotalClicks) * 100 : 0 + ); + return newTotalClicks; + }); + }); + + newConnection.on('ReceiveConversionTimestamp', () => { + console.log('Received Conversion Timestamp'); + setTotalConversions((prev) => { + const newTotalConversions = prev + 1; + setConversionRate( + totalClicks > 0 ? (newTotalConversions / totalClicks) * 100 : 0 + ); + return newTotalConversions; + }); + }); + + // Cleanup on unmount + return () => { + if ( + connectionRef.current && + connectionRef.current.state === 'Connected' + ) { + console.log('Stopping SignalR connection on component unmount.'); + connectionRef.current + .stop() + .catch((err) => + console.error('Error stopping SignalR connection:', err) + ); + } + }; + }, [totalClicks, totalConversions]); + + const gaugeData = [ + { name: 'Conversion Rate', value: conversionRate, color: gaugeColor }, + { name: 'Remaining', value: 100 - conversionRate, color: bgColor }, + ]; + + return ( + + + + {t('analytics.conversionRate')} + + + {totalConversions} {t('analytics.conversions')} / {totalClicks} {t('analytics.clicks')} + + + + + + + {gaugeData.map((entry, idx) => ( + + ))} + + + + + + {totalClicks > 0 ? conversionRate.toFixed(1) : 0}% + + + + + ); +}; + +export default UserDistribution; diff --git a/src/components/UserEditForm.jsx b/src/components/UserEditForm.jsx new file mode 100644 index 0000000..cae8169 --- /dev/null +++ b/src/components/UserEditForm.jsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { TextField, Button, Box, Typography, MenuItem, Select, FormControl, InputLabel } from "@mui/material"; +import { updateUser } from '../data/usersDetails.js'; // Importuj funkciju za ažuriranje korisnika + +const UserEditForm = ({ user, onSave }) => { + const [name, setName] = useState(user.name); + const [email, setEmail] = useState(user.email); + const [role, setRole] = useState(user.role); + const [phoneNumber, setPhoneNumber] = useState(user.phoneNumber || ''); // Dodano polje za broj telefona + + // Funkcija za obradu promene u imenu + const handleNameChange = (e) => setName(e.target.value); + + // Funkcija za obradu promene u emailu + const handleEmailChange = (e) => setEmail(e.target.value); + + // Funkcija za obradu promene u roli + const handleRoleChange = (e) => setRole(e.target.value); + + // Funkcija za obradu promene u broju telefona + const handlePhoneNumberChange = (e) => setPhoneNumber(e.target.value); + + // Funkcija za sačuvanje promena + const handleSave = () => { + const updatedUser = { name, email, role, phoneNumber }; + updateUser(user.id, updatedUser); // Ažuriraj korisnika u bazi podataka + onSave(updatedUser); // Osvježi roditeljsku komponentu + }; + + return ( + + + + + + + + + Role + + + + + + ); +}; + +export default UserEditForm; \ No newline at end of file diff --git a/src/components/UserEmail.jsx b/src/components/UserEmail.jsx new file mode 100644 index 0000000..92fde5c --- /dev/null +++ b/src/components/UserEmail.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { Typography } from "@mui/material"; + +const UserEmail = ({ email }) => ( + + {email} + +); + +export default UserEmail; \ No newline at end of file diff --git a/src/components/UserInfoSidebar.jsx b/src/components/UserInfoSidebar.jsx new file mode 100644 index 0000000..6a6c72c --- /dev/null +++ b/src/components/UserInfoSidebar.jsx @@ -0,0 +1,45 @@ +// @components/UserInfoSidebar.jsx +import { + Box, + Paper, + Typography, + Stack, + Divider, + Avatar, + List, + ListItem, + ListItemIcon, + ListItemText, +} from '@mui/material'; +import StoreIcon from '@mui/icons-material/Store'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; + +export default function UserInfoSidebar({ username, storeName }) { + return ( + + + + {username || 'User'}{' '} + {storeName && ( + + + + {storeName} + + + )} + + + + ); +} diff --git a/src/components/UserList.jsx b/src/components/UserList.jsx new file mode 100644 index 0000000..4f50278 --- /dev/null +++ b/src/components/UserList.jsx @@ -0,0 +1,344 @@ +import React, { useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Avatar, + TableSortLabel, + Typography, + Chip, + Box, + IconButton, + TextField, + Select, + MenuItem, + Tooltip, +} from '@mui/material'; +import DeleteUserButton from './DeleteUserButton'; +import { FiEdit2 } from 'react-icons/fi'; +import { FaUser, FaUserSlash } from 'react-icons/fa'; +import { MdDone } from 'react-icons/md'; +import { useTranslation } from 'react-i18next'; + +const getStatus = (user) => { + if (user.isApproved === true) return 'Approved'; + if (user.isApproved === false) return 'Rejected'; + return 'Pending'; +}; + +const StatusChip = ({ status }) => { + let color = '#800000'; + let bg = '#e6f7ff'; + if (status === 'Approved') bg = '#e6f7ed'; + if (status === 'Rejected') bg = '#ffe6e6'; + + return ( + + ); +}; + +const ActiveChip = ({ value }) => { + const isActive = value === true; + return ( + + ); +}; + +export default function UserList({ + users, + onDelete, + onEdit, + onView, + currentPage, + usersPerPage, +}) { + const [orderBy, setOrderBy] = useState('name'); + const [order, setOrder] = useState('asc'); + const [editingUserId, setEditingUserId] = useState(null); + const [editedUser, setEditedUser] = useState({}); + const { t } = useTranslation(); + const handleSort = (field) => { + const isAsc = orderBy === field && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(field); + }; + + const handleEditClick = (user) => { + setEditingUserId(user.id); + setEditedUser({ ...user }); + }; + + const handleSaveEdit = () => { + onEdit(editedUser); + setEditingUserId(null); + }; + + const handleFieldChange = (e) => { + const { name, value } = e.target; + setEditedUser((prev) => ({ ...prev, [name]: value })); + }; + + const sortUsers = [...users].sort((a, b) => { + const valA = orderBy === 'status' ? getStatus(a) : a[orderBy]; + const valB = orderBy === 'status' ? getStatus(b) : b[orderBy]; + + if (!valA) return 1; + if (!valB) return -1; + + if (typeof valA === 'string') { + return order === 'asc' + ? valA.localeCompare(valB) + : valB.localeCompare(valA); + } + + return order === 'asc' ? valA - valB : valB - valA; + }); + + return ( + + + + + # + {t('common.picture')} + + handleSort('userName')} + > + {t('common.username')} + + + + handleSort('email')} + > + {t('common.email')} + + + + handleSort('role')} + > + {t('common.role')} + + + + handleSort('isActive')} + > + {t('common.active')} + + + {/* + handleSort("lastActive")} + > + Last Active + + */} + {/* + handleSort('status')} + > + Status + + */} + {t('common.actions')} + + + + + {sortUsers.map((user, index) => { + const isEditing = editingUserId === user.id; + return ( + + + {(currentPage - 1) * usersPerPage + index + 1} + + + + + + + {isEditing ? ( + + ) : ( + user.userName + )} + + + + {isEditing ? ( + + ) : ( + user.email + )} + + + + {isEditing ? ( + + ) : ( + user.roles[0] + )} + + + + {isEditing ? ( + + ) : ( + + )} + + + {/* {user.lastActive} + + + */} + + + + + { + e.stopPropagation(); + onEdit({ + ...user, + isActive: !user.isActive, + toggleAvailabilityOnly: true, // da backend zna + }); + }} + > + {user.isActive ? ( + + ) : ( + + )} + + + + { + e.stopPropagation(); + if (isEditing) { + handleSaveEdit(); + } else { + handleEditClick(user); + } + }} + > + {isEditing ? ( + + ) : ( + + )} + + + + + { + e.stopPropagation(); + onDelete(user.id); + }} + /> + + + + + ); + })} + +
    +
    + ); +} diff --git a/src/components/UserManagementPagination.jsx b/src/components/UserManagementPagination.jsx new file mode 100644 index 0000000..2f68827 --- /dev/null +++ b/src/components/UserManagementPagination.jsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Box, Typography, IconButton, Button } from "@mui/material"; +import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { useTranslation } from 'react-i18next'; + +const UserManagementPagination = ({ + currentPage, + totalPages, + onPageChange, +}) => { + const getPages = () => { + const pages = []; + const maxVisible = 8; + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, startPage + maxVisible - 1); + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return pages; + }; + + const { t } = useTranslation(); + + return ( + + + {t('common.displayingPage')} + + + + + + onPageChange(currentPage - 1)} + disabled={currentPage === 1} + > + + + + {getPages().map((page) => ( + + ))} + + {currentPage + 2 < totalPages && ( + + ... + + )} + + onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + > + + + + + + + ); +}; + +export default UserManagementPagination; + diff --git a/src/components/UserName.jsx b/src/components/UserName.jsx new file mode 100644 index 0000000..0668a47 --- /dev/null +++ b/src/components/UserName.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { Typography } from "@mui/material"; + +const UserName = ({ userName }) => ( + + {userName} + +); + +export default UserName; \ No newline at end of file diff --git a/src/components/UserPhone.jsx b/src/components/UserPhone.jsx new file mode 100644 index 0000000..85e8f3e --- /dev/null +++ b/src/components/UserPhone.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { Typography } from "@mui/material"; + +const UserPhone = ({ phoneNumber }) => ( + + Telefon: {phoneNumber || "N/A"} + +); + +export default UserPhone; \ No newline at end of file diff --git a/src/components/UserRoles.jsx b/src/components/UserRoles.jsx new file mode 100644 index 0000000..368c08f --- /dev/null +++ b/src/components/UserRoles.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Typography } from '@mui/material'; + +const UserRoles = ({ roles }) => { + return ( + {roles} + ); +}; + +export default UserRoles; \ No newline at end of file diff --git a/src/components/ValidatedTextField.jsx b/src/components/ValidatedTextField.jsx new file mode 100644 index 0000000..5b8f374 --- /dev/null +++ b/src/components/ValidatedTextField.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import CustomTextField from './CustomTextField'; + +const ValidatedTextField = ({ error, helperText, sx, ...props }) => { + return ( + + ); +}; + +export default ValidatedTextField; diff --git a/src/context/PendingUsersContext.jsx b/src/context/PendingUsersContext.jsx new file mode 100644 index 0000000..37fc940 --- /dev/null +++ b/src/context/PendingUsersContext.jsx @@ -0,0 +1,40 @@ +// src/context/PendingUsersContext.js +import React, { createContext, useContext, useState, useEffect } from "react"; +import { apiFetchPendingUsersAsync } from "../api/api.js"; + +export const PendingUsersContext = createContext(); + +export const usePendingUsers = () => useContext(PendingUsersContext); + +export const PendingUsersProvider = ({ children }) => { + const [pendingUsers, setPendingUsers] = useState([]); + + useEffect(() => { + async function fetchData() { + try { + const users = await apiFetchPendingUsersAsync(); + setPendingUsers(users); + console.log("Fetched users:", users); + } catch (error) { + console.error("Neuspješno dohvaćanje korisnika:", error); + } + } + fetchData(); + }, []); + + const approveUser = (id) => { + setPendingUsers((prev) => prev.filter((u) => u.id !== id)); + }; + + const deleteUser = (id) => { + setPendingUsers((prev) => prev.filter((u) => u.id !== id)); + }; + + return ( + + {children} + + ); +}; diff --git a/src/data/.gitkeep b/src/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/data/ads.js b/src/data/ads.js new file mode 100644 index 0000000..a8986ec --- /dev/null +++ b/src/data/ads.js @@ -0,0 +1,36 @@ +const Ads = [ + { + sellerId: '1', + startTime: '2025-05-01T08:00:00Z', + endTime: '2025-05-10T23:59:59Z', + AdData: [ + { + Description: 'Super ponuda - 50% popusta na sve patike!', + Image: 'https://example.com/images/ad1.jpg', + ProductLink: '1', + StoreLink: '1' + }, + { + Description: 'Kupite jedan, drugi gratis! Akcija traje do isteka zaliha.', + Image: 'https://example.com/images/ad2.jpg', + ProductLink: '2', + StoreLink: '2' + } + ] + }, + { + sellerId: 'seller456', + startTime: '2025-05-05T00:00:00Z', + endTime: '2025-05-15T23:59:59Z', + AdData: [ + { + Description: 'Nova kolekcija proljeće/ljeto 2025. Pogledajte sada!', + Image: 'https://example.com/images/ad3.jpg', + ProductLink: '2', + StoreLink: '2' + } + ] + } + ]; + + export default Ads; \ No newline at end of file diff --git a/src/data/categories.js b/src/data/categories.js new file mode 100644 index 0000000..28744a3 --- /dev/null +++ b/src/data/categories.js @@ -0,0 +1,33 @@ +let categories = [ + { + id: 1, + name: "kategorija1", + type: "product" + }, + { + id: 2, + name: "kategorija2", + type: "store" + }, + { + id: 3, + name: "kategorija3", + type: "product" + }, + { + id: 4, + name: "kategorija4", + type: "store" + }, + { + id: 5, + name: "kategorija5", + type: "product" + }, + { + id: 6, + name: "kategorija6", + type: "store" + }, +] +export default categories; \ No newline at end of file diff --git a/src/data/mockAds.js b/src/data/mockAds.js new file mode 100644 index 0000000..f0736de --- /dev/null +++ b/src/data/mockAds.js @@ -0,0 +1,21 @@ +/*export const mockAds = [ + { + id: "AD-001245", + sellerId: "SELLER-001", + Views: 541200, + Clicks: 46250, + startTime: "2021-01-25T00:00:00Z", + endTime: "2021-12-31T00:00:00Z", + isActive: true, + AdData: [ + { + id: 1, + Description: "50% OFF Floor Lamp Get it Now!", + Image: "https://via.placeholder.com/150", + ProductLink: "https://example.com/product/floor-lamp", + StoreLink: "https://example.com/store", + }, + ], + }, +];*/ + \ No newline at end of file diff --git a/src/data/pendingUsers.js b/src/data/pendingUsers.js new file mode 100644 index 0000000..ea34ff2 --- /dev/null +++ b/src/data/pendingUsers.js @@ -0,0 +1,5 @@ +import users from "./users.js" + +let pendingUsers = users.filter(u=> !u.isApproved); + +export default pendingUsers; \ No newline at end of file diff --git a/src/data/products.js b/src/data/products.js new file mode 100644 index 0000000..204854a --- /dev/null +++ b/src/data/products.js @@ -0,0 +1,1239 @@ +let products = [ + { + id: 1, + name: 'Proizvod 1 - Nova Market', + price: 98.15, + weight: 3.16, + weightunit: 'lbs', + volume: 1.69, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 1, + isActive: true, + photos: [], + }, + { + id: 2, + name: 'Proizvod 2 - Nova Market', + price: 15.97, + weight: 1.6, + weightunit: 'kg', + volume: 1.46, + volumeunit: 'L', + productcategoryid: 2, + storeId: 1, + isActive: true, + photos: [], + }, + { + id: 3, + name: 'Proizvod 3 - Nova Market', + price: 81.78, + weight: 1.01, + weightunit: 'kg', + volume: 1.53, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 1, + isActive: true, + photos: [], + }, + { + id: 4, + name: 'Proizvod 4 - Nova Market', + price: 33.99, + weight: 3.67, + weightunit: 'lbs', + volume: 1.41, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 1, + isActive: true, + photos: [], + }, + { + id: 5, + name: 'Proizvod 5 - Nova Market', + price: 14.45, + weight: 4.9, + weightunit: 'lbs', + volume: 2.68, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 1, + isActive: true, + photos: [], + }, + { + id: 6, + name: 'Proizvod 1 - Tech World', + price: 41.53, + weight: 4.82, + weightunit: 'lbs', + volume: 0.83, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 2, + isActive: true, + photos: [], + }, + { + id: 7, + name: 'Proizvod 2 - Tech World', + price: 20.78, + weight: 4.95, + weightunit: 'kg', + volume: 2.61, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 2, + isActive: true, + photos: [], + }, + { + id: 8, + name: 'Proizvod 3 - Tech World', + price: 60.7, + weight: 0.21, + weightunit: 'g', + volume: 1.86, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 2, + isActive: true, + photos: [], + }, + { + id: 9, + name: 'Proizvod 4 - Tech World', + price: 61.91, + weight: 2.65, + weightunit: 'kg', + volume: 1.54, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 2, + isActive: true, + photos: [], + }, + { + id: 10, + name: 'Proizvod 5 - Tech World', + price: 76.28, + weight: 0.5, + weightunit: 'kg', + volume: 2.24, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 2, + isActive: true, + photos: [], + }, + { + id: 11, + name: 'Proizvod 1 - BioShop', + price: 90.87, + weight: 3.65, + weightunit: 'lbs', + volume: 2.94, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 3, + isActive: true, + photos: [], + }, + { + id: 12, + name: 'Proizvod 2 - BioShop', + price: 43.25, + weight: 3.75, + weightunit: 'lbs', + volume: 0.14, + volumeunit: 'L', + productcategoryid: 6, + storeId: 3, + isActive: true, + photos: [], + }, + { + id: 13, + name: 'Proizvod 3 - BioShop', + price: 22.33, + weight: 0.33, + weightunit: 'lbs', + volume: 2.76, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 3, + isActive: true, + photos: [], + }, + { + id: 14, + name: 'Proizvod 4 - BioShop', + price: 84.77, + weight: 2.97, + weightunit: 'g', + volume: 0.28, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 3, + isActive: true, + photos: [], + }, + { + id: 15, + name: 'Proizvod 5 - BioShop', + price: 23.74, + weight: 4.3, + weightunit: 'kg', + volume: 2.38, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 3, + isActive: true, + photos: [], + }, + { + id: 16, + name: 'Proizvod 1 - Fashion Spot', + price: 34.13, + weight: 4.67, + weightunit: 'g', + volume: 0.77, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 4, + isActive: true, + photos: [], + }, + { + id: 17, + name: 'Proizvod 2 - Fashion Spot', + price: 46.35, + weight: 2.24, + weightunit: 'g', + volume: 1.35, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 4, + isActive: true, + photos: [], + }, + { + id: 18, + name: 'Proizvod 3 - Fashion Spot', + price: 70.68, + weight: 2.06, + weightunit: 'kg', + volume: 2.42, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 4, + isActive: true, + photos: [], + }, + { + id: 19, + name: 'Proizvod 4 - Fashion Spot', + price: 67.49, + weight: 3.99, + weightunit: 'lbs', + volume: 1.94, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 4, + isActive: true, + photos: [], + }, + { + id: 20, + name: 'Proizvod 5 - Fashion Spot', + price: 86.13, + weight: 1.58, + weightunit: 'kg', + volume: 2.44, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 4, + isActive: true, + photos: [], + }, + { + id: 21, + name: 'Proizvod 1 - Office Plus', + price: 31.87, + weight: 1.66, + weightunit: 'kg', + volume: 0.26, + volumeunit: 'L', + productcategoryid: 4, + storeId: 5, + isActive: true, + photos: [], + }, + { + id: 22, + name: 'Proizvod 2 - Office Plus', + price: 23.21, + weight: 3.64, + weightunit: 'g', + volume: 1.14, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 5, + isActive: true, + photos: [], + }, + { + id: 23, + name: 'Proizvod 3 - Office Plus', + price: 55.47, + weight: 2.71, + weightunit: 'lbs', + volume: 0.83, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 5, + isActive: true, + photos: [], + }, + { + id: 24, + name: 'Proizvod 4 - Office Plus', + price: 32.69, + weight: 1.19, + weightunit: 'g', + volume: 2.29, + volumeunit: 'L', + productcategoryid: 4, + storeId: 5, + isActive: true, + photos: [], + }, + { + id: 25, + name: 'Proizvod 5 - Office Plus', + price: 85.67, + weight: 2.23, + weightunit: 'g', + volume: 2.24, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 5, + isActive: true, + photos: [], + }, + { + id: 26, + name: 'Proizvod 1 - Auto Centar', + price: 28.39, + weight: 2.23, + weightunit: 'lbs', + volume: 1.75, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 6, + isActive: true, + photos: [], + }, + { + id: 27, + name: 'Proizvod 2 - Auto Centar', + price: 69.33, + weight: 2.8, + weightunit: 'kg', + volume: 1.33, + volumeunit: 'L', + productcategoryid: 6, + storeId: 6, + isActive: true, + photos: [], + }, + { + id: 28, + name: 'Proizvod 3 - Auto Centar', + price: 21.79, + weight: 0.78, + weightunit: 'g', + volume: 2.87, + volumeunit: 'L', + productcategoryid: 6, + storeId: 6, + isActive: true, + photos: [], + }, + { + id: 29, + name: 'Proizvod 4 - Auto Centar', + price: 58.72, + weight: 2.24, + weightunit: 'lbs', + volume: 0.94, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 6, + isActive: true, + photos: [], + }, + { + id: 30, + name: 'Proizvod 5 - Auto Centar', + price: 11.48, + weight: 4.07, + weightunit: 'lbs', + volume: 2.18, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 6, + isActive: true, + photos: [], + }, + { + id: 31, + name: 'Proizvod 1 - Pet Planet', + price: 36.24, + weight: 0.43, + weightunit: 'lbs', + volume: 1.46, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 7, + isActive: true, + photos: [], + }, + { + id: 32, + name: 'Proizvod 2 - Pet Planet', + price: 10.93, + weight: 1.67, + weightunit: 'kg', + volume: 2.84, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 7, + isActive: true, + photos: [], + }, + { + id: 33, + name: 'Proizvod 3 - Pet Planet', + price: 30.42, + weight: 2.76, + weightunit: 'lbs', + volume: 1.0, + volumeunit: 'L', + productcategoryid: 2, + storeId: 7, + isActive: true, + photos: [], + }, + { + id: 34, + name: 'Proizvod 4 - Pet Planet', + price: 96.84, + weight: 4.39, + weightunit: 'kg', + volume: 0.33, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 7, + isActive: true, + photos: [], + }, + { + id: 35, + name: 'Proizvod 5 - Pet Planet', + price: 19.83, + weight: 4.5, + weightunit: 'kg', + volume: 2.16, + volumeunit: 'L', + productcategoryid: 2, + storeId: 7, + isActive: true, + photos: [], + }, + { + id: 36, + name: 'Proizvod 1 - Green Garden', + price: 26.15, + weight: 1.44, + weightunit: 'g', + volume: 1.98, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 8, + isActive: true, + photos: [], + }, + { + id: 37, + name: 'Proizvod 2 - Green Garden', + price: 57.42, + weight: 1.61, + weightunit: 'kg', + volume: 1.91, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 8, + isActive: true, + photos: [], + }, + { + id: 38, + name: 'Proizvod 3 - Green Garden', + price: 8.21, + weight: 4.83, + weightunit: 'kg', + volume: 0.22, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 8, + isActive: true, + photos: [], + }, + { + id: 39, + name: 'Proizvod 4 - Green Garden', + price: 50.65, + weight: 4.81, + weightunit: 'kg', + volume: 0.69, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 8, + isActive: true, + photos: [], + }, + { + id: 40, + name: 'Proizvod 5 - Green Garden', + price: 75.39, + weight: 1.66, + weightunit: 'lbs', + volume: 0.33, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 8, + isActive: true, + photos: [], + }, + { + id: 41, + name: 'Proizvod 1 - Kids Toys', + price: 72.01, + weight: 2.09, + weightunit: 'lbs', + volume: 2.5, + volumeunit: 'L', + productcategoryid: 6, + storeId: 9, + isActive: true, + photos: [], + }, + { + id: 42, + name: 'Proizvod 2 - Kids Toys', + price: 93.6, + weight: 3.67, + weightunit: 'kg', + volume: 0.29, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 9, + isActive: true, + photos: [], + }, + { + id: 43, + name: 'Proizvod 3 - Kids Toys', + price: 97.21, + weight: 0.55, + weightunit: 'kg', + volume: 0.41, + volumeunit: 'L', + productcategoryid: 6, + storeId: 9, + isActive: true, + photos: [], + }, + { + id: 44, + name: 'Proizvod 4 - Kids Toys', + price: 87.46, + weight: 1.72, + weightunit: 'g', + volume: 2.34, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 9, + isActive: true, + photos: [], + }, + { + id: 45, + name: 'Proizvod 5 - Kids Toys', + price: 63.83, + weight: 2.05, + weightunit: 'kg', + volume: 2.15, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 9, + isActive: true, + photos: [], + }, + { + id: 46, + name: 'Proizvod 1 - Mega Market', + price: 72.02, + weight: 0.74, + weightunit: 'kg', + volume: 1.0, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 10, + isActive: true, + photos: [], + }, + { + id: 47, + name: 'Proizvod 2 - Mega Market', + price: 67.61, + weight: 0.22, + weightunit: 'g', + volume: 0.35, + volumeunit: 'L', + productcategoryid: 2, + storeId: 10, + isActive: true, + photos: [], + }, + { + id: 48, + name: 'Proizvod 3 - Mega Market', + price: 47.57, + weight: 1.04, + weightunit: 'lbs', + volume: 1.99, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 10, + isActive: true, + photos: [], + }, + { + id: 49, + name: 'Proizvod 4 - Mega Market', + price: 31.82, + weight: 1.61, + weightunit: 'g', + volume: 1.72, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 10, + isActive: true, + photos: [], + }, + { + id: 50, + name: 'Proizvod 5 - Mega Market', + price: 15.47, + weight: 2.2, + weightunit: 'g', + volume: 2.19, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 10, + isActive: true, + photos: [], + }, + { + id: 51, + name: 'Proizvod 1 - Green Garden', + price: 43.8, + weight: 0.65, + weightunit: 'g', + volume: 1.36, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 11, + isActive: true, + photos: [], + }, + { + id: 52, + name: 'Proizvod 2 - Green Garden', + price: 64.01, + weight: 4.12, + weightunit: 'kg', + volume: 2.98, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 11, + isActive: true, + photos: [], + }, + { + id: 53, + name: 'Proizvod 3 - Green Garden', + price: 56.9, + weight: 1.63, + weightunit: 'g', + volume: 1.65, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 11, + isActive: true, + photos: [], + }, + { + id: 54, + name: 'Proizvod 4 - Green Garden', + price: 81.11, + weight: 2.52, + weightunit: 'lbs', + volume: 0.48, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 11, + isActive: true, + photos: [], + }, + { + id: 55, + name: 'Proizvod 5 - Green Garden', + price: 96.97, + weight: 4.15, + weightunit: 'kg', + volume: 2.64, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 11, + isActive: true, + photos: [], + }, + { + id: 56, + name: 'Proizvod 1 - Kids Toys', + price: 24.87, + weight: 2.4, + weightunit: 'g', + volume: 0.9, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 12, + isActive: true, + photos: [], + }, + { + id: 57, + name: 'Proizvod 2 - Kids Toys', + price: 23.0, + weight: 0.34, + weightunit: 'kg', + volume: 1.8, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 12, + isActive: true, + photos: [], + }, + { + id: 58, + name: 'Proizvod 3 - Kids Toys', + price: 64.82, + weight: 4.21, + weightunit: 'kg', + volume: 2.17, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 12, + isActive: true, + photos: [], + }, + { + id: 59, + name: 'Proizvod 4 - Kids Toys', + price: 81.12, + weight: 4.26, + weightunit: 'kg', + volume: 1.79, + volumeunit: 'L', + productcategoryid: 6, + storeId: 12, + isActive: true, + photos: [], + }, + { + id: 60, + name: 'Proizvod 5 - Kids Toys', + price: 15.55, + weight: 1.11, + weightunit: 'lbs', + volume: 1.31, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 12, + isActive: true, + photos: [], + }, + { + id: 61, + name: 'Proizvod 1 - Mega Market', + price: 43.55, + weight: 1.39, + weightunit: 'g', + volume: 2.83, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 13, + isActive: true, + photos: [], + }, + { + id: 62, + name: 'Proizvod 2 - Mega Market', + price: 95.59, + weight: 1.12, + weightunit: 'kg', + volume: 0.47, + volumeunit: 'L', + productcategoryid: 2, + storeId: 13, + isActive: true, + photos: [], + }, + { + id: 63, + name: 'Proizvod 3 - Mega Market', + price: 72.76, + weight: 2.67, + weightunit: 'g', + volume: 2.31, + volumeunit: 'L', + productcategoryid: 2, + storeId: 13, + isActive: true, + photos: [], + }, + { + id: 64, + name: 'Proizvod 4 - Mega Market', + price: 55.62, + weight: 4.23, + weightunit: 'lbs', + volume: 2.19, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 13, + isActive: true, + photos: [], + }, + { + id: 65, + name: 'Proizvod 5 - Mega Market', + price: 29.55, + weight: 2.81, + weightunit: 'lbs', + volume: 2.97, + volumeunit: 'L', + productcategoryid: 2, + storeId: 13, + isActive: true, + photos: [], + }, + { + id: 66, + name: 'Proizvod 1 - Green Garden', + price: 71.32, + weight: 1.71, + weightunit: 'g', + volume: 0.29, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 14, + isActive: true, + photos: [], + }, + { + id: 67, + name: 'Proizvod 2 - Green Garden', + price: 37.28, + weight: 2.48, + weightunit: 'g', + volume: 1.15, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 14, + isActive: true, + photos: [], + }, + { + id: 68, + name: 'Proizvod 3 - Green Garden', + price: 53.29, + weight: 4.85, + weightunit: 'lbs', + volume: 0.52, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 14, + isActive: true, + photos: [], + }, + { + id: 69, + name: 'Proizvod 4 - Green Garden', + price: 54.9, + weight: 4.31, + weightunit: 'lbs', + volume: 2.09, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 14, + isActive: true, + photos: [], + }, + { + id: 70, + name: 'Proizvod 5 - Green Garden', + price: 6.86, + weight: 3.49, + weightunit: 'kg', + volume: 2.9, + volumeunit: 'L', + productcategoryid: 4, + storeId: 14, + isActive: true, + photos: [], + }, + { + id: 71, + name: 'Proizvod 1 - Kids Toys', + price: 87.8, + weight: 1.93, + weightunit: 'lbs', + volume: 0.27, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 15, + isActive: true, + photos: [], + }, + { + id: 72, + name: 'Proizvod 2 - Kids Toys', + price: 73.06, + weight: 2.18, + weightunit: 'kg', + volume: 2.15, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 15, + isActive: true, + photos: [], + }, + { + id: 73, + name: 'Proizvod 3 - Kids Toys', + price: 33.27, + weight: 4.64, + weightunit: 'kg', + volume: 2.64, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 15, + isActive: true, + photos: [], + }, + { + id: 74, + name: 'Proizvod 4 - Kids Toys', + price: 68.53, + weight: 2.69, + weightunit: 'g', + volume: 1.49, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 15, + isActive: true, + photos: [], + }, + { + id: 75, + name: 'Proizvod 5 - Kids Toys', + price: 31.55, + weight: 0.76, + weightunit: 'kg', + volume: 0.73, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 15, + isActive: true, + photos: [], + }, + { + id: 76, + name: 'Proizvod 1 - Mega Market', + price: 43.07, + weight: 2.92, + weightunit: 'g', + volume: 2.0, + volumeunit: 'L', + productcategoryid: 2, + storeId: 16, + isActive: true, + photos: [], + }, + { + id: 77, + name: 'Proizvod 2 - Mega Market', + price: 70.38, + weight: 2.2, + weightunit: 'kg', + volume: 1.75, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 16, + isActive: true, + photos: [], + }, + { + id: 78, + name: 'Proizvod 3 - Mega Market', + price: 69.44, + weight: 3.04, + weightunit: 'kg', + volume: 0.59, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 16, + isActive: true, + photos: [], + }, + { + id: 79, + name: 'Proizvod 4 - Mega Market', + price: 83.14, + weight: 4.55, + weightunit: 'kg', + volume: 0.95, + volumeunit: 'L', + productcategoryid: 2, + storeId: 16, + isActive: true, + photos: [], + }, + { + id: 80, + name: 'Proizvod 5 - Mega Market', + price: 91.73, + weight: 1.42, + weightunit: 'g', + volume: 1.98, + volumeunit: 'oz', + productcategoryid: 2, + storeId: 16, + isActive: true, + photos: [], + }, + { + id: 81, + name: 'Proizvod 1 - Green Garden', + price: 24.71, + weight: 0.48, + weightunit: 'g', + volume: 1.66, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 17, + isActive: true, + photos: [], + }, + { + id: 82, + name: 'Proizvod 2 - Green Garden', + price: 94.13, + weight: 2.36, + weightunit: 'kg', + volume: 1.75, + volumeunit: 'ml', + productcategoryid: 4, + storeId: 17, + isActive: true, + photos: [], + }, + { + id: 83, + name: 'Proizvod 3 - Green Garden', + price: 34.28, + weight: 3.54, + weightunit: 'lbs', + volume: 2.42, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 17, + isActive: true, + photos: [], + }, + { + id: 84, + name: 'Proizvod 4 - Green Garden', + price: 51.48, + weight: 4.33, + weightunit: 'g', + volume: 0.13, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 17, + isActive: true, + photos: [], + }, + { + id: 85, + name: 'Proizvod 5 - Green Garden', + price: 6.2, + weight: 2.18, + weightunit: 'g', + volume: 2.23, + volumeunit: 'oz', + productcategoryid: 4, + storeId: 17, + isActive: true, + photos: [], + }, + { + id: 86, + name: 'Proizvod 1 - Kids Toys', + price: 5.95, + weight: 4.27, + weightunit: 'lbs', + volume: 0.54, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 18, + isActive: true, + photos: [], + }, + { + id: 87, + name: 'Proizvod 2 - Kids Toys', + price: 88.59, + weight: 3.56, + weightunit: 'g', + volume: 2.99, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 18, + isActive: true, + photos: [], + }, + { + id: 88, + name: 'Proizvod 3 - Kids Toys', + price: 50.45, + weight: 0.34, + weightunit: 'g', + volume: 2.75, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 18, + isActive: true, + photos: [], + }, + { + id: 89, + name: 'Proizvod 4 - Kids Toys', + price: 28.4, + weight: 3.65, + weightunit: 'g', + volume: 0.34, + volumeunit: 'ml', + productcategoryid: 6, + storeId: 18, + isActive: true, + photos: [], + }, + { + id: 90, + name: 'Proizvod 5 - Kids Toys', + price: 99.99, + weight: 2.91, + weightunit: 'g', + volume: 0.81, + volumeunit: 'oz', + productcategoryid: 6, + storeId: 18, + isActive: true, + photos: [], + }, + { + id: 91, + name: 'Proizvod 1 - Mega Market', + price: 40.21, + weight: 2.1, + weightunit: 'kg', + volume: 1.77, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 19, + isActive: true, + photos: [], + }, + { + id: 92, + name: 'Proizvod 2 - Mega Market', + price: 26.51, + weight: 1.9, + weightunit: 'g', + volume: 2.62, + volumeunit: 'L', + productcategoryid: 2, + storeId: 19, + isActive: true, + photos: [], + }, + { + id: 93, + name: 'Proizvod 3 - Mega Market', + price: 69.04, + weight: 3.45, + weightunit: 'lbs', + volume: 1.45, + volumeunit: 'ml', + productcategoryid: 2, + storeId: 19, + isActive: true, + photos: [], + }, + { + id: 94, + name: 'Proizvod 4 - Mega Market', + price: 26.02, + weight: 4.57, + weightunit: 'kg', + volume: 1.27, + volumeunit: 'L', + productcategoryid: 2, + storeId: 19, + isActive: true, + photos: [], + }, + { + id: 95, + name: 'Proizvod 5 - Mega Market', + price: 94.53, + weight: 0.5, + weightunit: 'lbs', + volume: 0.64, + volumeunit: 'L', + productcategoryid: 2, + storeId: 19, + isActive: true, + photos: [], + }, +]; + +export default products; diff --git a/src/data/stores.js b/src/data/stores.js new file mode 100644 index 0000000..1141c71 --- /dev/null +++ b/src/data/stores.js @@ -0,0 +1,155 @@ +const stores = [ + { + id: 1, + name: 'Nova Market', + description: 'Brza i kvalitetna dostava proizvoda.', + address: 'Sarajevo', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 2, + name: 'Tech World', + description: 'Elektronika i gadgeti.', + address: 'Mostar', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 3, + name: 'BioShop', + description: 'Prirodna kozmetika i hrana.', + address: 'Banja Luka', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 4, + name: 'Fashion Spot', + description: 'Savremena garderoba.', + address: 'Tuzla', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 5, + name: 'Office Plus', + description: 'Kancelarijski materijal i oprema.', + address: 'Sarajevo', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 6, + name: 'Auto Centar', + description: 'Dijelovi i oprema za automobile.', + address: 'Zenica', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 7, + name: 'Pet Planet', + description: 'Hrana i oprema za kućne ljubimce.', + address: 'Mostar', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 8, + name: 'Green Garden', + description: 'Sve za vašu baštu.', + address: 'Sarajevo', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 9, + name: 'Kids Toys', + description: 'Igračke i oprema za djecu.', + address: 'Sarajevo', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 10, + name: 'Mega Market', + description: 'Vaš svakodnevni supermarket.', + address: 'Banja Luka', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 11, + name: 'Green Garden', + description: 'Sve za vašu baštu.', + address: 'Sarajevo', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 12, + name: 'Kids Toys', + description: 'Igračke i oprema za djecu.', + address: 'Mostar', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 13, + name: 'Mega Market', + description: 'Vaš svakodnevni supermarket.', + address: 'Zenica', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 14, + name: 'Green Garden', + description: 'Sve za vašu baštu.', + address: 'Sarajevo', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 15, + name: 'Kids Toys', + description: 'Igračke i oprema za djecu.', + address: 'Tuzla', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 16, + name: 'Mega Market', + description: 'Vaš svakodnevni supermarket.', + address: 'Banja Luka', + categoryId: 2, + categoryName: "kategorija2", + }, + { + id: 17, + name: 'Green Garden', + description: 'Sve za vašu baštu.', + address: 'Sarajevo', + categoryId: 4, + categoryName: "kategorija4", + }, + { + id: 18, + name: 'Kids Toys', + description: 'Igračke i oprema za djecu.', + address: 'Mostar', + categoryId: 6, + categoryName: "kategorija6", + }, + { + id: 19, + name: 'Mega Market', + description: 'Vaš svakodnevni supermarket.', + address: 'Tuzla', + categoryId: 2, + categoryName: "kategorija2", + }, + ]; + export default stores; \ No newline at end of file diff --git a/src/data/users.js b/src/data/users.js new file mode 100644 index 0000000..db73851 --- /dev/null +++ b/src/data/users.js @@ -0,0 +1,185 @@ +// data/users.js + +import { fetchAdminUsers } from "../utils/users"; + +let users = [ + { + id: 1, + userName: "John Doe", + email: "john.doe@example.com", + roles: ["Seller"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 2, + userName: "Jane Smith", + email: "jane.smith@example.com", + roles: ["Buyer"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 3, + userName: "Alice Johnson", + email: "alice.johnson@example.com", + roles: ["Seller"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 4, + userName: "Bob Brown", + email: "bob.brown@example.com", + roles: ["Buyer"], + availability: "Online", + lastActive: "2024-04-09, 14:30:00", + isApproved: true, + }, + { + id: 5, + userName: "John Doe", + email: "john.doe@example.com", + roles: ["Seller"], + availability: "Offline", + lastActive: "2024-04-08, 13:15:00", + isApproved: true, + }, + { + id: 6, + userName: "Jane Smith", + email: "jane.smith@example.com", + roles: ["Buyer"], + availability: "Offline", + lastActive: "2024-04-09, 14:30:00", + isApproved: true, + }, + { + id: 7, + userName: "Alice Johnson", + email: "alice.johnson@example.com", + roles: ["Seller"], + availability: "Offline", + lastActive: "2024-04-07, 10:00:00", + isApproved: true, + }, + { + id: 8, + userName: "Bob Brown", + email: "bob.brown@example.com", + roles: ["Buyer"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 9, + userName: "John Doe", + email: "john.doe@example.com", + roles: ["Seller"], + availability: "Online", + lastActive: "2024-04-09, 09:45:00", + isApproved: true, + }, + { + id: 10, + userName: "Jane Smith", + email: "jane.smith@example.com", + roles: ["Buyer"], + availability: "Offline", + lastActive: "2024-04-06, 17:20:00", + isApproved: true, + }, + { + id: 11, + userName: "Alice Johnson", + email: "alice.johnson@example.com", + roles: ["Seller"], + availability: "Online", + lastActive: "2024-04-05, 14:00:00", + isApproved: true, + }, + { + id: 12, + userName: "Bob Brown", + email: "bob.brown@example.com", + roles: ["Buyer"], + availability: "Offline", + lastActive: "2024-04-03, 11:00:00", + isApproved: true, + }, + { + id: 13, + userName: "John Doe", + email: "john.doe@example.com", + roles: ["Seller"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 14, + userName: "Jane Smith", + email: "jane.smith@example.com", + roles: ["Buyer"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 15, + userName: "Alice Johnson", + email: "alice.johnson@example.com", + roles: ["Seller"], + availability: "Offline", + lastActive: "2024-04-01, 15:30:00", + isApproved: true, + }, + { + id: 16, + userName: "Bob Brown", + email: "bob.brown@example.com", + roles: ["Buyer"], + availability: "Online", + lastActive: "Now", + isApproved: true, + }, + { + id: 17, + userName: "Bob Brown", + email: "bob.brown@example.com", + roles: ["Buyer"], + availability: "Offline", + lastActive: "2024-04-02, 13:00:00", + isApproved: true, + }, +]; + + +// Funkcija za vraćanje svih korisnika +export async function getUsers() { + console.log("getUsers pozvan"); + //console.log("Trenutni users array:", users); + const users = await fetchAdminUsers(); + return [...users]; +} + +// Funkcija za brisanje korisnika +export function deleteUser(userId) { + users = users.filter((user) => user.id !== userId); +} + +// Funkcija za pretragu korisnika +export function searchUsers(searchTerm) { + return users.filter( + (user) => + user.name.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); +} + + +export default users; \ No newline at end of file diff --git a/src/data/usersDetails.js b/src/data/usersDetails.js new file mode 100644 index 0000000..69e04dd --- /dev/null +++ b/src/data/usersDetails.js @@ -0,0 +1,16 @@ +import UserPhone from "../components/UserPhone"; + +let users = [ + { id: 1, name: "John Doe", email: "john.doe@example.com", role: "buyer" , phoneNumber: "060312589"}, + { id: 2, name: "Jane Smith", email: "jane.smith@example.com", role: "seller", phoneNumber: "062312589"}, + { id: 3, name: "Alice Johnson", email: "alice.johnson@example.com", role: "buyer", phoneNumber: "06031569"}, + { id: 4, name: "Bob Brown", email: "bob.brown@example.com", role: "seller", phoneNumber: "061312589"} + ]; + + + // Funkcija za ažuriranje korisnika + export function updateUser(userId, updatedUser) { + users = users.map(user => + user.id === userId ? { ...user, ...updatedUser } : user + ); + } \ No newline at end of file diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/useAdSignalR.js b/src/hooks/useAdSignalR.js new file mode 100644 index 0000000..7dc360b --- /dev/null +++ b/src/hooks/useAdSignalR.js @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from 'react'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; + +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const baseUrl = import.meta.env.VITE_API_BASE_URL; // ili tvoj base url +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +export function useAdSignalR() { + const connectionRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState('Disconnected'); + const [latestAdUpdate, setLatestAdUpdate] = useState(null); + const [latestClickTime, setLatestClickTime] = useState(null); + const [latestViewTime, setLatestViewTime] = useState(null); + const [latestConversionTime, setLatestConversionTime] = useState(null); + const [adUpdatesHistory, setAdUpdatesHistory] = useState([]); + + useEffect(() => { + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + setConnectionStatus('Auth Token Missing'); + return; + } + + + const newConnection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = newConnection; + setConnectionStatus('Connecting...'); + + const startConnection = async () => { + try { + await newConnection.start(); + setConnectionStatus('Connected'); + } catch (err) { + setConnectionStatus('Error'); + } + }; + + startConnection(); + + // Handlers + newConnection.on('ReceiveAdUpdate', (advertisement) => { + setLatestAdUpdate(advertisement); + setAdUpdatesHistory(prev => [ + { type: 'Ad Update', data: advertisement, time: new Date() }, + ...prev.slice(0, 9) + ]); + }); + + newConnection.on('ReceiveClickTimestamp', (timestamp) => { + setLatestClickTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'Click', data: timestamp, time: new Date() }, + ...prev.slice(0, 9) + ]); + }); + + newConnection.on('ReceiveViewTimestamp', (timestamp) => { + setLatestViewTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'View', data: timestamp, time: new Date() }, + ...prev.slice(0, 9) + ]); + }); + + newConnection.on('ReceiveConversionTimestamp', (timestamp) => { + setLatestConversionTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'Conversion', data: timestamp, time: new Date() }, + ...prev.slice(0, 9) + ]); + }); + + newConnection.onclose(() => setConnectionStatus('Disconnected')); + newConnection.onreconnecting(() => setConnectionStatus('Reconnecting...')); + newConnection.onreconnected(() => setConnectionStatus('Connected')); + + return () => { + if (connectionRef.current && connectionRef.current.state === 'Connected') { + connectionRef.current.stop(); + } + }; + }, []); + + return { + connectionStatus, + latestAdUpdate, + latestClickTime, + latestViewTime, + latestConversionTime, + adUpdatesHistory, + }; +} + + +export function useAdSignalRwithId(adId) { + const connectionRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState('Disconnected'); + const [latestAdUpdate, setLatestAdUpdate] = useState(null); + const [latestClickTime, setLatestClickTime] = useState(null); + const [latestViewTime, setLatestViewTime] = useState(null); + const [latestConversionTime, setLatestConversionTime] = useState(null); + const [adUpdatesHistory, setAdUpdatesHistory] = useState([]); + + useEffect(() => { + if (!adId) return; + + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + setConnectionStatus('Auth Token Missing'); + return; + } + + const connection = new HubConnectionBuilder() + .withUrl(HUB_URL, { + accessTokenFactory: () => jwtToken, + }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + + connectionRef.current = connection; + setConnectionStatus('Connecting...'); + + const startConnection = async () => { + try { + await connection.start(); + setConnectionStatus('Connected'); + } catch (err) { + console.error('SignalR Connection Error:', err); + setConnectionStatus('Error'); + } + }; + + startConnection(); + + // === Filteruj po adId u svakom handleru === + connection.on('ReceiveAdUpdate', (adUpdate) => { + if (adUpdate.id !== adId) return; + setLatestAdUpdate(adUpdate); + setAdUpdatesHistory(prev => [ + { type: 'Ad Update', data: adUpdate, time: new Date() }, + ...prev.slice(0, 9), + ]); + }); + + connection.on('ReceiveClickTimestamp', ({ adId: clickAdId, timestamp }) => { + if (clickAdId !== adId) return; + setLatestClickTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'Click', data: timestamp, time: new Date() }, + ...prev.slice(0, 9), + ]); + }); + + connection.on('ReceiveViewTimestamp', ({ adId: viewAdId, timestamp }) => { + if (viewAdId !== adId) return; + setLatestViewTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'View', data: timestamp, time: new Date() }, + ...prev.slice(0, 9), + ]); + }); + + connection.on('ReceiveConversionTimestamp', ({ adId: convAdId, timestamp }) => { + if (convAdId !== adId) return; + setLatestConversionTime(timestamp); + setAdUpdatesHistory(prev => [ + { type: 'Conversion', data: timestamp, time: new Date() }, + ...prev.slice(0, 9), + ]); + }); + + connection.onclose(() => setConnectionStatus('Disconnected')); + connection.onreconnecting(() => setConnectionStatus('Reconnecting...')); + connection.onreconnected(() => setConnectionStatus('Connected')); + + return () => { + connection.stop(); + }; + }, [adId]); + + return { + connectionStatus, + latestAdUpdate, + latestClickTime, + latestViewTime, + latestConversionTime, + adUpdatesHistory, + }; +} + diff --git a/src/hooks/useSignalR.js b/src/hooks/useSignalR.js new file mode 100644 index 0000000..318b2ba --- /dev/null +++ b/src/hooks/useSignalR.js @@ -0,0 +1,78 @@ +// @hooks/useSignalR.js +import { useEffect, useRef, useState } from 'react'; +import * as signalR from '@microsoft/signalr'; + +export const useSignalR = (conversationId, userId) => { + const [messages, setMessages] = useState([]); + const connectionRef = useRef(null); + + useEffect(() => { + // Connect to SignalR + const connect = async () => { + const storedToken = localStorage.getItem('token'); + + if (!conversationId) { + console.error('Conversation ID is required for SignalR connection.'); + return; + } + + const connection = new signalR.HubConnectionBuilder() + .withUrl(`https://bazaar-system.duckdns.org/chathub`, { + accessTokenFactory: () => storedToken, + }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Information) + .build(); + + connection.serverTimeoutInMilliseconds = 60000; + + // Listen for new messages + connection.on('ReceiveMessage', (receivedMessage) => { + setMessages((prevMessages) => [ + ...prevMessages, + { + id: receivedMessage.id, + senderUserId: receivedMessage.senderUserId, + senderUsername: receivedMessage.senderUsername, + content: receivedMessage.content, + sentAt: receivedMessage.sentAt, + isOwnMessage: receivedMessage.senderUserId === userId, + }, + ]); + }); + + try { + await connection.start(); + console.log('SignalR connected'); + connectionRef.current = connection; + + // Join conversation-specific group + connection + .invoke('JoinConversation', conversationId) + .catch((err) => console.error('Error joining group:', err)); + } catch (err) { + console.error('SignalR connection error:', err); + } + }; + + connect(); + + return () => { + connectionRef.current?.stop(); + }; + }, [conversationId, userId]); + + // Send message to the SignalR hub + const sendMessage = (content) => { + if (connectionRef.current) { + connectionRef.current + .invoke('SendMessage', { + ConversationId: conversationId, + Content: content, + }) + .catch((err) => console.error('Send failed:', err)); + } + }; + + return { messages, sendMessage }; +}; diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..a64ba49 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,43 @@ + import i18n from 'i18next'; + import { initReactI18next } from 'react-i18next'; + import HttpBackend from 'i18next-http-backend'; + import LanguageDetector from 'i18next-browser-languagedetector'; + + // Predefined language codes + const PREDEFINED_LANG_CODES = ['en', 'es']; + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + + i18n + .use(HttpBackend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + ns: ['translation'], + defaultNS: 'translation', + interpolation: { + escapeValue: false, // React already escapes values + }, + backend: { + loadPath: `${API_BASE_URL}/api/translations/{{lng}}` + } + }); + + // Function to fetch and set supported languages + async function fetchAndSetSupportedLanguages() { + try { + const response = await fetch(`${API_BASE_URL}/api/translations/languages`); + const allLangsFromServer = await response.json(); + i18n.options.supportedLngs = allLangsFromServer.map(l => l.code); + } catch (error) { + console.error('Failed to fetch supported languages:', error); + // Fallback to predefined languages if API call fails + i18n.options.supportedLngs = PREDEFINED_LANG_CODES; + } + } + + // Call the function on startup + fetchAndSetSupportedLanguages(); + + export default i18n; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index b9a1a6d..91296b9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,21 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' +import React, { StrictMode } from "react"; +import AppRoutes from "@routes/Router"; +import { createRoot } from "react-dom/client"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import theme from "@styles/theme"; +import "./App.css"; +import "./index.css"; +import { PendingUsersProvider } from "./context/PendingUsersContext"; +import './i18n'; -createRoot(document.getElementById('root')).render( +createRoot(document.getElementById("root")).render( - - , -) + + + + + + + +); \ No newline at end of file diff --git a/src/models/chatModels.js b/src/models/chatModels.js new file mode 100644 index 0000000..5523d2b --- /dev/null +++ b/src/models/chatModels.js @@ -0,0 +1,36 @@ +// @models/chatModels.js +export const ChatMessageType = { + SENT: 'sent', + RECEIVED: 'received', +}; + +export class ConversationDto { + id = 0; + otherParticipantName = ''; + lastMessageSnippet = ''; + lastMessageTimestamp = ''; + unreadMessagesCount = 0; + orderId = null; + storeId = null; +} + +export class MessageDto { + id = 0; + senderUserId = ''; + senderUsername = ''; + content = ''; + sentAt = ''; + readAt = null; + isPrivate = false; +} + +export class ChatMessage { + id = 0; + senderUserId = ''; + senderUsername = ''; + content = ''; + sentAt = ''; + readAt = null; + isPrivate = false; + isOwnMessage = false; +} diff --git a/src/pages/.gitkeep b/src/pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/AdPage.jsx b/src/pages/AdPage.jsx new file mode 100644 index 0000000..9041fce --- /dev/null +++ b/src/pages/AdPage.jsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect } from 'react'; +import { Box } from '@mui/material'; +import AdCard from '@components/AdCard'; +import AdsManagementHeader from '@sections/AdsManagementHeader'; +import UserManagementPagination from '@components/UserManagementPagination'; +import AddAdModal from '@components/AddAdModal'; +import AdvertisementDetailsModal from '@components/AdvertisementDetailsModal'; +import { useAdSignalR } from '../hooks/useAdSignalR'; // ili stvarna putanja +import { + apiCreateAdAsync, + apiGetAllAdsAsync, + apiDeleteAdAsync, + apiUpdateAdAsync, + apiGetAllStoresAsync, + apiGetProductCategoriesAsync, +} from '../api/api'; +import products from '../data/products'; +const generateMockAds = () => { + return Array.from({ length: 26 }, (_, i) => ({ + id: i + 1, + sellerId: 42 + i, + Views: 1200 + i * 10, + Clicks: 300 + i * 5, + startTime: '2024-05-01T00:00:00Z', + endTime: '2024-06-01T00:00:00Z', + isActive: i % 2 === 0, + AdData: [ + { + Description: `Ad Campaign #${i + 1}`, + Image: 'https://via.placeholder.com/150', + ProductLink: 'https://example.com/product', + StoreLink: 'https://example.com/store', + }, + ], + })); +}; + +const AdPage = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [isModalOpen, setIsModalOpen] = useState(false); + const [ads, setAds] = useState([]); + const [stores, setStores] = useState([]); + const [selectedAd, setSelectedAd] = useState(null); + + const { latestAdUpdate } = useAdSignalR(); + + const [isLoading, setIsLoading] = useState(true); + + const adsPerPage = 5; + + const filteredAds = ads.filter((ad) => + ad.adData != undefined && ad.adData[0].description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const totalPages = Math.ceil(filteredAds.length / adsPerPage); + const paginatedAds = filteredAds.slice( + (currentPage - 1) * adsPerPage, + currentPage * adsPerPage + ); + + useEffect(() => { + async function fetchAllAdData() { + setIsLoading(true); + try{ + const rez = await apiGetAllAdsAsync(); + const stores = await apiGetAllStoresAsync(); + const productCategories = await apiGetProductCategoriesAsync(); + setAds(rez.data); + setStores(stores); + } catch (err) { + console.error("Greška pri dohvaćanju reklama:", err); + } + setIsLoading(false); + }; + + fetchAllAdData(); + }, []); + + // === 2. Real-time update preko SignalR === + useEffect(() => { + if (latestAdUpdate) { + setAds((prevAds) => + prevAds.map((ad) => + ad.id === latestAdUpdate.id + ? { + ...ad, + views: latestAdUpdate.views, + clicks: latestAdUpdate.clicks, + } + : ad + ) + ); + } + }, [latestAdUpdate]); + + const handleDelete = async (id) => { + const response = await apiDeleteAdAsync(id); + console.log("nesto"); + const res = await apiGetAllAdsAsync(); + setAds(res.data); + + }; + + const handleEdit = async (adId, payload) => { + try { + const response = await apiUpdateAdAsync(adId, payload); + if (response.status < 400) { + const updated = await apiGetAllAdsAsync(); + setAds(updated.data); + } else { + console.error('Failed to update advertisement'); + } + } catch (error) { + console.error('Error updating ad:', error); + } + }; + + const handleViewDetails = (id) => { + const found = ads.find((a) => a.id === id); + console.log("detalji"); + setSelectedAd(found); + }; + + const handleCreateAd = () => { + setIsModalOpen(true); + }; + +const handleAddAd = async (newAd) => { + try { + const response = await apiCreateAdAsync(newAd); + if (response.status < 400 && response.data) { + setAds(prev => [...prev, response.data]); + console.log("Uradjeno"); + setIsModalOpen(false); + } else { + console.error('Greška pri kreiranju oglasa:', response); + } + } catch (error) { + console.error('API error:', error); + } +}; + + const handlePageChange = (page) => { + setCurrentPage(page); + }; + + return ( + + + + + {paginatedAds.map((ad) => ( + + + + ))} + + + + + setIsModalOpen(false)} + onAddAd={handleAddAd} + /> + + setSelectedAd(null)} + onDelete={handleDelete} + onSave={handleEdit} + /> + + ); +}; + +export default AdPage; diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx new file mode 100644 index 0000000..e71ec91 --- /dev/null +++ b/src/pages/AnalyticsPage.jsx @@ -0,0 +1,874 @@ +import React from 'react'; +import { Grid, Typography, Box, Pagination } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import KpiCard from '@components/KpiCard'; +import AnalyticsChart from '@components/AnalyticsChart'; +import CountryStatsPanel from '@components/CountryStatsPanel'; +import OrdersByStatus from '@components/OrdersByStatus'; +import UserDistribution from '@components/UserDistribution'; +import RevenueByStore from '@components/RevenueByStore'; +// --- Merged Imports --- +import ProductsSummary from '@components/ProductsSummary'; // From HEAD +import RevenueMetrics from '@components/RevenueMetrics'; // From HEAD +import ParetoChart from '@components/ParetoChart'; // From develop +import AdFunnelChart from '@components/AdFunnelChart'; // From develop +import AdStackedBarChart from '@components/AdStackedBarChart'; // From develop +import Calendar from '@components/Calendar'; // From develop +import DealsChart from '@components/DealsChart'; // From develop +import SalesChart from '@components/SalesChart'; // From develop +import { useState, useEffect, useRef } from 'react'; // useRef from develop +import StoreEarningsTable from '@components/StoreEarningsTable'; + +import { + apiGetAllAdsAsync, + apiFetchOrdersAsync, // Used in develop's fetchInitialData, not in HEAD's kpis + apiFetchAllUsersAsync, // Used in develop's fetchInitialData, not in HEAD's kpis + apiGetAllStoresAsync, + apiGetStoreProductsAsync, + apiFetchAdsWithProfitAsync, + apiFetchAdClicksAsync, + apiFetchAdViewsAsync, + apiFetchAdConversionsAsync, // From HEAD, for ProductsSummary + apiGetStoreIncomeAsync, + apiGetMonthlyStoreRevenueAsync +} from '../api/api.js'; +// format and parseISO were in develop but not used in the conflicting part, subMonths is used by both +import { subMonths, format, parseISO } from 'date-fns'; // Added format, parseISO from develop imports +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; // From develop +import SellerAnalytics from './SellerAnalyticsPage.jsx'; + +// --- SignalR Setup (from develop) --- +const baseUrl = import.meta.env.VITE_API_BASE_URL || ''; +const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub'; +const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`; + +const AnalyticsPage = () => { + const { t } = useTranslation(); + // --- State from develop --- + const [totalAdminProfit, setTotalAdminProfit] = useState(0); + const [ads, setAds] = useState([]); // For general ad data, updated by SignalR + const [kpi, setKpi] = useState({ + totalViews: 0, + totalClicks: 0, + totalConversions: 0, + totalConversionRevenue: 0, + totalAds: 0, + activeAds: 0, + topAds: [], + totalClicksRevenue: 0, + totalViewsRevenue: 0, + totalProducts: 0, + viewsChange: 0, + clicksChange: 0, + conversionsChange: 0, + conversionRevenueChange: 0, + clicksRevenueChange: 0, + viewsRevenueChange: 0, + productsChange: 0, // Will be set based on logic + totalAdsChange: 0, + }); + const [connectionStatus, setConnectionStatus] = useState('Disconnected'); + const [lastError, setLastError] = useState(''); + const connectionRef = useRef(null); + const [clickTimeStamps, setClickTimeStamps] = useState([]); + const [viewTimeStamps, setViewTimeStamps] = useState([]); + const [conversionTimeStamps, setConversionTimeStamps] = useState([]); + const [realtimeEvents, setRealtimeEvents] = useState([]); + + // --- State from HEAD (for product pagination and summary) --- + const [products, setProducts] = useState([]); // For paginated product list + const [adsDataForSummary, setAdsDataForSummary] = useState([]); // Specifically for ProductsSummary + const [currentProductPage, setCurrentProductPage] = useState(1); + const PRODUCTS_PER_PAGE = 5; // Or your desired number + + const [stores, setStores] = useState([]); + // const [adsDataForStoreSummary, setAdsDataForStoreSummary] = useState([]); // This might not be needed if ads state is comprehensive + const [currentStorePage, setCurrentStorePage] = useState(1); + const STORES_PER_PAGE = 1; + + const [storeSpecificClickData, setStoreSpecificClickData] = useState([]); + const [storeSpecificViewData, setStoreSpecificViewData] = useState([]); + const [storeSpecificConversionData, setStoreSpecificConversionData] = + useState([]); + + const [storeStats, setStoreStats] = useState([]); + + + // --- Pagination Logic (from HEAD) --- + const handlePageChange = (event, value) => { + setCurrentProductPage(value); + console.log(`curr:${currentProductPage} value${value}`); + console.log(paginatedProducts); + paginatedProducts = products.slice( + (value - 1) * PRODUCTS_PER_PAGE, + value * PRODUCTS_PER_PAGE + ); + }; + + var paginatedProducts = products.slice( + (currentProductPage - 1) * PRODUCTS_PER_PAGE, + currentProductPage * PRODUCTS_PER_PAGE + ); + const pageCount = Math.ceil(products.length / PRODUCTS_PER_PAGE); + + const handleStorePageChange = (event, value) => { + setCurrentStorePage(value); + paginatedStores = [stores[value - 1]]; + // paginatedStores will be derived directly in render or useEffect based on currentStorePage + }; + + let paginatedStores = [stores[0]]; + const storePageCount = Math.ceil(stores.length / STORES_PER_PAGE); + + // --- SignalR useEffect (from develop) --- + useEffect(() => { + const jwtToken = localStorage.getItem('token'); + if (!jwtToken) { + console.warn( + 'AnalyticsPage: No JWT token found. SignalR connection not started.' + ); + setConnectionStatus('Auth Token Missing'); + return; + } + const newConnection = new HubConnectionBuilder() + .withUrl(HUB_URL, { accessTokenFactory: () => jwtToken }) + .withAutomaticReconnect([0, 2000, 10000, 30000]) + .configureLogging(LogLevel.Information) + .build(); + connectionRef.current = newConnection; + setConnectionStatus('Connecting...'); + + const startConnection = async () => { + try { + await newConnection.start(); + console.log('SignalR Connected to AdvertisementHub!'); + setConnectionStatus('Connected'); + setLastError(''); + } catch (err) { + console.error('SignalR Connection Error: ', err); + setConnectionStatus( + `Error: ${err.message ? err.message.substring(0, 150) : 'Unknown'}` + ); + setLastError(err.message || 'Failed to connect'); + } + }; + startConnection(); + + newConnection.on('ReceiveAdUpdate', (advertisement) => { + console.log('Received Ad Update:', advertisement); + setAds((prevAds) => { + const adIndex = prevAds.findIndex((ad) => ad.id === advertisement.id); + const updatedAds = [...prevAds]; + if (adIndex !== -1) updatedAds[adIndex] = advertisement; + else updatedAds.unshift(advertisement); + calculateKpis(updatedAds, kpi.totalProducts); // Recalculate KPIs + return updatedAds; + }); + setRealtimeEvents((prev) => [ + { type: 'Ad Update', data: advertisement, time: new Date() }, + ...prev.slice(0, 19), + ]); + }); + newConnection.on('ReceiveClickTimestamp', (timestamp) => { + console.log('Received Click Timestamp:', timestamp); + setClickTimeStamps((prev) => [...prev, timestamp]); + setRealtimeEvents((prev) => [ + { + type: 'Click', + data: new Date(timestamp).toLocaleTimeString(), + time: new Date(), + }, + ...prev.slice(0, 19), + ]); + }); + newConnection.on('ReceiveViewTimestamp', (timestamp) => { + console.log('Received View Timestamp:', timestamp); + setViewTimeStamps((prev) => [...prev, timestamp]); + setRealtimeEvents((prev) => [ + { + type: 'View', + data: new Date(timestamp).toLocaleTimeString(), + time: new Date(), + }, + ...prev.slice(0, 19), + ]); + }); + newConnection.on('ReceiveConversionTimestamp', (timestamp) => { + console.log('Received Conversion Timestamp:', timestamp); + setConversionTimeStamps((prev) => [...prev, timestamp]); + setRealtimeEvents((prev) => [ + { + type: 'Conversion', + data: new Date(timestamp).toLocaleTimeString(), + time: new Date(), + }, + ...prev.slice(0, 19), + ]); + }); + newConnection.onclose((error) => { + console.warn('SignalR connection closed.', error); + setConnectionStatus('Disconnected'); + if (error) setLastError(`Connection closed: ${error.message}`); + }); + newConnection.onreconnecting((error) => { + console.warn('SignalR attempting to reconnect...', error); + setConnectionStatus('Reconnecting...'); + setLastError(error ? `Reconnect failed: ${error.message}` : ''); + }); + newConnection.onreconnected((connectionId) => { + console.log('SignalR reconnected with ID:', connectionId); + setConnectionStatus('Connected'); + setLastError(''); + }); + return () => { + if ( + connectionRef.current && + connectionRef.current.state === 'Connected' + ) { + console.log('Stopping SignalR connection.'); + connectionRef.current + .stop() + .catch((err) => console.error('Error stopping SignalR:', err)); + } + }; + }, []); // Run once + + // --- Initial Data Fetch (combining logic from both branches) --- + useEffect(() => { + fetchInitialData(); + }, []); + + const fetchInitialData = async () => { + try { + // Fetch stores first to get products + const stores = await apiGetAllStoresAsync(); + setStores(stores); + let allFetchedProducts = []; + let productsThisMonthCount = 0; + let productsPrevMonthCount = 0; + const now = new Date(); + const lastMonthDate = subMonths(now, 1); + const prevMonthDate = subMonths(now, 2); + + if (stores && stores.length > 0) { + const productPromises = stores.map((store) => + store && store.id + ? apiGetStoreProductsAsync(store.id) + : Promise.resolve({ data: [] }) + ); + const productResults = await Promise.all(productPromises); + productResults.forEach((result) => { + if (result && result.data && Array.isArray(result.data)) { + allFetchedProducts.push(...result.data); + result.data.forEach((p) => { + const createdAt = p.createdAt + ? parseISO(p.createdAt) + : new Date(0); + if (createdAt >= lastMonthDate) productsThisMonthCount++; + if (createdAt >= prevMonthDate && createdAt < lastMonthDate) + productsPrevMonthCount++; + }); + } + }); + } + setProducts(allFetchedProducts); // For product pagination + + const calculatedProductsChange = + productsPrevMonthCount > 0 + ? ((productsThisMonthCount - productsPrevMonthCount) / + productsPrevMonthCount) * + 100 + : productsThisMonthCount > 0 + ? 100 + : 0; + + // Fetch ads for general KPIs (from develop) + const adsResponse = await apiGetAllAdsAsync(); + const initialAdsData = + adsResponse && adsResponse.data && Array.isArray(adsResponse.data) + ? adsResponse.data + : []; + console.log('Initial Ads Data:', initialAdsData); + setAds(initialAdsData); + + const clickidk = []; + for (const ad of ads) { + const r = (await apiFetchAdClicksAsync(ad.id)).data; + clickidk.push({ id: ad.id, clicks: r }); + } + setStoreSpecificClickData(clickidk); + const viewidk = []; + for (const ad of ads) { + const r = (await apiFetchAdViewsAsync(ad.id)).data; + viewidk.push({ id: ad.id, views: r }); + } + setStoreSpecificViewData(viewidk); + const ccidk = []; + for (const ad of ads) { + const r = (await apiFetchAdConversionsAsync(ad.id)).data; + ccidk.push({ id: ad.id, conversions: r }); + } + setStoreSpecificConversionData(ccidk); + + // Fetch ads with profit for ProductsSummary (from HEAD) + const adsWithProfitResponse = await apiFetchAdsWithProfitAsync(); + const adsForSummaryData = + adsWithProfitResponse && Array.isArray(adsWithProfitResponse) + ? adsWithProfitResponse + : adsWithProfitResponse && Array.isArray(adsWithProfitResponse.data) + ? adsWithProfitResponse.data + : []; + console.log('✅ Fetched ads with profit for summary:', adsForSummaryData); + setAdsDataForSummary(adsForSummaryData); + + // Calculate KPIs with fetched data + // Pass allFetchedProducts.length for totalProductsCount + // Pass calculatedProductsChange for productsChange KPI + calculateKpis( + initialAdsData, + allFetchedProducts.length, + calculatedProductsChange + ); + + + + const earningsStats = await Promise.all( + stores.map(async (store) => { + try { + const income = await apiGetMonthlyStoreRevenueAsync(store.id); + console.log("ispis "+income.storeId+" total income "+income.totalIncome+" taxed income "+income.taxedIncome);// 👈 nova funkcija + return { + storeId: income.storeId, + name: income.storeName, + storeRevenue: income.totalIncome, + adminProfit: income.taxedIncome, + taxRate: (store.tax) * 100, + }; + } catch (err) { + console.error(`❌ Error fetching income for store ${store.id}`, err); + return { + storeId: store.id, + name: store.name, + storeRevenue: 999, + adminProfit: 999, + taxRate: (store.tax) * 100, + }; + } + }) + ); + + setStoreStats(earningsStats); + + + // Other initial data if needed (orders, users - not directly used for KPIs in develop's version) + // const ordersData = await apiFetchOrdersAsync(); + // const usersResponse = await apiFetchAllUsersAsync(); + // const users = usersResponse.data; + } catch (error) { + console.error('Error fetching initial data:', error); + // Set error states or default values if needed + } + }; + + // --- KPI Calculation (from develop, adapted) --- + const calculateKpis = ( + currentAdsData, + totalProductsCount, + productsChangeValue = kpi.productsChange + ) => { + const now = new Date(); + const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const previousMonthStart = subMonths(currentMonthStart, 1); // Correctly get first day of previous month + const previousMonthEnd = subMonths(now, 1); // End of previous month is last day of previous month + previousMonthEnd.setDate(0); // Set to last day of previous month. Example: if now is July 10, this becomes June 30. + // More robust way: const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); + + const adsThisMonth = currentAdsData.filter((ad) => { + const startTime = ad.startTime ? parseISO(ad.startTime) : new Date(0); + return startTime >= currentMonthStart && startTime <= now; + }); + const adsPrevMonth = currentAdsData.filter((ad) => { + const startTime = ad.startTime ? parseISO(ad.startTime) : new Date(0); + return startTime >= previousMonthStart && startTime <= previousMonthEnd; + }); + + const calculateMetricAndChange = (metricExtractor, priceField = null) => { + const currentMonthTotal = adsThisMonth.reduce( + (sum, ad) => + sum + + (priceField + ? (ad[metricExtractor] || 0) * (ad[priceField] || 0) + : ad[metricExtractor] || 0), + 0 + ); + const prevMonthTotal = adsPrevMonth.reduce( + (sum, ad) => + sum + + (priceField + ? (ad[metricExtractor] || 0) * (ad[priceField] || 0) + : ad[metricExtractor] || 0), + 0 + ); + const change = + prevMonthTotal > 0 + ? ((currentMonthTotal - prevMonthTotal) / prevMonthTotal) * 100 + : currentMonthTotal > 0 + ? 100 + : 0; + return { total: currentMonthTotal, change }; + }; + + const viewsStats = calculateMetricAndChange('views'); + const clicksStats = calculateMetricAndChange('clicks'); + const conversionsStats = calculateMetricAndChange('conversions'); + const conversionRevenueStats = calculateMetricAndChange( + 'conversions', + 'conversionPrice' + ); // Assuming conversionPrice is per conversion + const clicksRevenueStats = calculateMetricAndChange('clicks', 'clickPrice'); + const viewsRevenueStats = calculateMetricAndChange('views', 'viewPrice'); + + const totalAdsChange = + adsPrevMonth.length > 0 + ? ((adsThisMonth.length - adsPrevMonth.length) / adsPrevMonth.length) * + 100 + : adsThisMonth.length > 0 + ? 100 + : 0; + + const activeAds = currentAdsData.filter((ad) => ad.isActive).length; + const topAds = [...currentAdsData] + .sort( + (a, b) => + (b.conversions || 0) * (b.conversionPrice || 0) - + (a.conversions || 0) * (a.conversionPrice || 0) + ) // Sort by total conversion revenue + .slice(0, 5); + + setKpi({ + totalViews: viewsStats.total, + totalClicks: clicksStats.total, + totalConversions: conversionsStats.total, + totalConversionRevenue: conversionRevenueStats.total.toFixed(2), + totalAds: currentAdsData.length, + activeAds: activeAds, + topAds: topAds, + totalClicksRevenue: clicksRevenueStats.total.toFixed(2), + totalViewsRevenue: viewsRevenueStats.total.toFixed(2), + totalProducts: totalProductsCount, + viewsChange: viewsStats.change.toFixed(2), + clicksChange: clicksStats.change.toFixed(2), + conversionsChange: conversionsStats.change.toFixed(2), + conversionRevenueChange: conversionRevenueStats.change.toFixed(2), + clicksRevenueChange: clicksRevenueStats.change.toFixed(2), + viewsRevenueChange: viewsRevenueStats.change.toFixed(2), + productsChange: productsChangeValue.toFixed(2), + totalAdsChange: totalAdsChange.toFixed(2), + }); + }; + + // RealtimeEventsList component (from develop, optional to render) + const RealtimeEventsList = () => ( + + + Realtime Events ({connectionStatus}) + + {lastError && ( + + Last Error: {lastError} + + )} + {realtimeEvents.length === 0 ? ( + + No events received yet + + ) : ( + realtimeEvents.map((event, index) => ( + + + {event.type} + + + {new Date(event.time).toLocaleTimeString()} + + {/* {JSON.stringify(event.data)} */} + + )) + )} + + ); + + return ( + + + {t('analytics.dashboardAnalytics')} + + ({connectionStatus}) + + + + {/* KPI sekcija (from develop) */} + + {' '} + {/* Adjusted spacing and maxWidth */} + {[ + { + label: t('analytics.totalAds'), + value: kpi.totalAds, + change: kpi.totalAdsChange, + type: 'totalAds', + }, + { + label: t('analytics.totalViews'), + value: kpi.totalViews, + change: kpi.viewsChange, + type: 'views', + }, + { + label: t('analytics.totalClicks'), + value: kpi.totalClicks, + change: kpi.clicksChange, + type: 'clicks', + }, + { + label: t('analytics.totalConversions'), + value: kpi.totalConversions, + change: kpi.conversionsChange, + type: 'conversions', + }, + { + label: t('analytics.conversionRevenue'), + value: kpi.totalConversionRevenue, + change: kpi.conversionRevenueChange, + type: 'conversionRevenue', + }, + { + label: t('analytics.clicksRevenue'), + value: kpi.totalClicksRevenue, + change: kpi.clicksRevenueChange, + type: 'clicksRevenue', + }, + { + label: t('analytics.viewsRevenue'), + value: kpi.totalViewsRevenue, + change: kpi.viewsRevenueChange, + type: 'viewsRevenue', + }, + { + label: t('analytics.totalProducts'), + value: kpi.totalProducts, + change: kpi.productsChange, + type: 'products', + }, + ].map((item, i) => ( + + {' '} + {/* Responsive grid items for KPIs */} + + + ))} + + + {/* Glavni graf + countries */} + + + + {' '} + {/* Responsive width */} + + + + + + {' '} + {/* Responsive width */} + + {/* You might want to place RealtimeEventsList here or elsewhere */} + {/* */} + + + + + + {' '} + {/* Responsive width */} + + + {' '} + {/* Responsive width */} + + + + + + {' '} + {/* Responsive width */} + + + + + + {' '} + {/* Responsive width */} + + + + + + {/* Charts from develop */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Product List with Pagination (from HEAD) */} + + {' '} + {/* Responsive width */} + + {t('analytics.productPerformance')} + + {products.length === 0 && ( + + {t('analytics.noProductsToDisplay')} + + )} + {paginatedProducts.map((product, i) => ( + + + {/* Ensure adsDataForSummary is correctly populated and passed */} + + a.adData.map((b) => b.productId).includes(product.id) + )} + /> + + + ))} + {pageCount > 1 && ( + + + + )} + + + {/* Store List with Pagination (from HEAD) */} + + + {t('analytics.storePerformance')} + + {stores.length === 0 && ( + + {t('analytics.noStoresToDisplay')} + + )} + {stores.length && + [stores[currentStorePage - 1]].map((store, i) => ( + + + {/* Ensure adsDataForSummary is correctly populated and passed */} + ad.adData?.[0]?.storeId === store.id)} + products={products.filter((p) => p.storeId == store.id)} + allClicks={storeSpecificClickData.filter((c) => + ads + .filter((ad) => ad.adData[0].storeId == store.id) + .map((ad) => ad.id) + .includes(c.id) + )} + allViews={storeSpecificViewData.filter((c) => + ads + .filter((ad) => ad.adData[0].storeId == store.id) + .map((ad) => ad.id) + .includes(c.id) + )} + allConversions={storeSpecificConversionData.filter((c) => + ads + .filter((ad) => ad.adData[0].storeId == store.id) + .map((ad) => ad.id) + .includes(c.id) + )} + /> + + + ))} + {pageCount > 1 && ( + + + + )} + + + + + {t('analytics.storeEarningsPastMonth')} + + + + + + + {/* //jel ovo ima smisla ovd? (Comment from HEAD) + // If RevenueMetrics is global, it should be outside this map. + // If it's per-product, it should receive 'product' as a prop. */} + {/* Assuming it might be per product */} + + + ); +}; + +export default AnalyticsPage; diff --git a/src/pages/CategoriesPage.jsx b/src/pages/CategoriesPage.jsx new file mode 100644 index 0000000..c6381f5 --- /dev/null +++ b/src/pages/CategoriesPage.jsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from "react"; +import { Box } from "@mui/material"; +import CategoriesHeader from "../sections/CategoriesHeader.jsx"; +import CategoryCard from "../components/CategoryCard.jsx"; +import UserManagementPagination from "../components/UserManagementPagination.jsx"; +import AddCategoryModal from "../components/AddCategoryModal"; +import { + apiGetProductCategoriesAsync, + apiGetStoreCategoriesAsync, + apiDeleteProductCategoryAsync, + apiDeleteStoreCategoryAsync, + apiAddProductCategoryAsync, + apiAddStoreCategoryAsync, + apiUpdateProductCategoryAsync, + apiUpdateStoreCategoryAsync, +} from "@api/api.js"; +import CategoryTabs from "@components/CategoryTabs"; + +const CategoriesPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [allCategories, setAllCategories] = useState([]); + const [openModal, setOpenModal] = useState(false); + const [selectedType, setSelectedType] = useState("product"); + const categoriesPerPage = 20; + + useEffect(() => { + const fetchCategories = async () => { + const data = + selectedType === "product" + ? await apiGetProductCategoriesAsync() + : await apiGetStoreCategoriesAsync(); + + const enriched = data.map((cat) => ({ ...cat, type: selectedType })); + + console.log(enriched) + + setAllCategories(enriched); + }; + + fetchCategories(); +}, [selectedType]); + + + const handleOpenModal = () => { + setOpenModal(true); + }; + + const handleCloseModal = () => { + setOpenModal(false); + }; + + const handleAddCategory = async (newCategory) => { + let response; + console.log(newCategory.type) + if (newCategory.type === "product") { + response = await apiAddProductCategoryAsync(newCategory.name); + } else { + response = await apiAddStoreCategoryAsync(newCategory.name); + } + + if (response?.success) { + if (newCategory.type === selectedType) { + setAllCategories((prev) => [...prev, response.data]); + } + } +}; + + + const handleUpdateCategory = async (updatedCategory) => { + const response = selectedType === "product" + ? await apiUpdateProductCategoryAsync(updatedCategory) + : await apiUpdateStoreCategoryAsync(updatedCategory); + + if (response?.success) { + setAllCategories((prevCategories) => + prevCategories.map((category) => + category.id === updatedCategory.id ? updatedCategory : category + ) + ); + } +}; + + + const handleDeleteCategory = async (categoryId) => { + let response; + if (selectedType === "product") { + response = await apiDeleteProductCategoryAsync(categoryId); + } else { + response = await apiDeleteStoreCategoryAsync(categoryId); + } + + if (response?.success || response?.status === 204) { + setAllCategories((prev) => prev.filter((cat) => cat.id !== categoryId)); + } +}; + + const filteredCategories = allCategories.filter((category) => + category.name.toLowerCase().includes(searchTerm.toLowerCase()) +); + + const totalPages = Math.ceil(filteredCategories.length / categoriesPerPage); + const indexOfLastCategory = currentPage * categoriesPerPage; + const indexOfFirstCategory = indexOfLastCategory - categoriesPerPage; + const currentCategories = filteredCategories.slice( + indexOfFirstCategory, + indexOfLastCategory + ); + + return ( + + + + + + {currentCategories.map((category) => ( + + ))} + + + + + + + + + ); +}; + +export default CategoriesPage; diff --git a/src/pages/ChatPage.jsx b/src/pages/ChatPage.jsx new file mode 100644 index 0000000..79fde63 --- /dev/null +++ b/src/pages/ChatPage.jsx @@ -0,0 +1,81 @@ +// ChatPage.jsx +import React, { useState, useEffect } from 'react'; +import { Box } from '@mui/material'; +import TicketListSection from '@components/TicketListSection'; +import AdminChatSection from '@sections/AdminChatSection'; +import { + apiFetchAllTicketsAsync, + apiFetchAllConversationsAsync, +} from '../api/api.js'; + +export default function ChatPage() { + const [tickets, setTickets] = useState([]); + const [selectedTicketId, setSelectedTicketId] = useState(null); + const [unlockedTickets, setUnlockedTickets] = useState([]); + const [conversations, setConversations] = useState([]); + + const fetchTickets = async () => { + const { data } = await apiFetchAllTicketsAsync(); + setTickets(data); + }; + + useEffect(() => { + const fetchData = async () => { + const { data: ticketsData } = await apiFetchAllTicketsAsync(); + const { data: conversationsData } = await apiFetchAllConversationsAsync(); + setTickets(ticketsData); + setConversations(conversationsData); + }; + fetchData(); + }, []); + + const selectedTicket = tickets.find((t) => t.id === selectedTicketId); + const selectedConversation = conversations.find( + (c) => c.id === selectedTicket?.conversationId + ); + + const handleUnlockChat = (ticketId) => { + setUnlockedTickets((prev) => [...new Set([...prev, ticketId])]); + }; + + return ( + + + + + selectedTicketId && handleUnlockChat(selectedTicketId) + } + /> + + + ); +} diff --git a/src/pages/CreateUserPage.jsx b/src/pages/CreateUserPage.jsx new file mode 100644 index 0000000..c82e781 --- /dev/null +++ b/src/pages/CreateUserPage.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import UserCreateSection from '../sections/UserCreateSection'; + +const CreateUserPage = () => { + return ( +
    + +
    + ); +}; + +export default CreateUserPage; diff --git a/src/pages/DelRoutePage.jsx b/src/pages/DelRoutePage.jsx new file mode 100644 index 0000000..8fa116c --- /dev/null +++ b/src/pages/DelRoutePage.jsx @@ -0,0 +1,319 @@ +// src/pages/RoutesPage.jsx +import React, { useState, useEffect, useCallback } from 'react'; +import RouteMap from '../components/RouteMap'; // Assuming RouteMap is in src/components/ +import RoutesHeader from '@sections/RoutesHeader'; +import CreateRouteModal from "../components/CreateRouteModal"; +// MUI Imports +import { + Box, + Grid, + List, + ListItem, + ListItemButton, + ListItemText, + Typography, + CircularProgress, + Paper, + Alert, + Divider, + Container, +} from '@mui/material'; +import MapIcon from '@mui/icons-material/Map'; // Example icon +import DirectionsIcon from '@mui/icons-material/Directions'; +import ListAltIcon from '@mui/icons-material/ListAlt'; +import { apiGetRoutesAsync, apiCreateRouteAsync } from '../api/api'; + +// You might want to wrap your App in a ThemeProvider in App.jsx or main.jsx +// import { ThemeProvider, createTheme } from '@mui/material/styles'; +// const theme = createTheme(); -> Then wrap around your app + +function RoutesPage2() { + const [routeList, setRouteList] = useState([]); + const [selectedRouteId, setSelectedRouteId] = useState(null); + const [selectedRouteData, setSelectedRouteData] = useState(null); + const [isLoadingList, setIsLoadingList] = useState(false); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + const fetchRouteList = async () => { + setIsLoadingList(true); + setError(null); + try { + //const response = await fetch('/api/routes'); // EXAMPLE: /api/routes + const response = await apiGetRoutesAsync(); + // if (!response.ok) { + // throw new Error(`HTTP error! status: ${response.status}`); + // } + console.log(response.data); + setRouteList(response.data); + } catch (e) { + console.error('Failed to fetch route list:', e); + setError('Failed to load route list. ' + e.message); + setRouteList([]); + } finally { + setIsLoadingList(false); + } + }; + fetchRouteList(); + }, []); + + useEffect(() => { + if (!selectedRouteId) { + setSelectedRouteData(null); + return; + } + const fetchRouteDetails = async () => { + setIsLoadingDetails(true); + setSelectedRouteData(null); + setError(null); + try { + // const response = await fetch(`/api/routes/${selectedRouteId}`); // EXAMPLE: /api/routes/12 + // if (!response.ok) { + // throw new Error(`HTTP error! status: ${response.status}`); + // } + const data = routeList.find((r) => r.id == selectedRouteId); + console.log(data); + setSelectedRouteData(data); + } catch (e) { + console.error( + `Failed to fetch details for route ${selectedRouteId}:`, + e + ); + setError( + `Failed to load details for route ${selectedRouteId}. ` + e.message + ); + setSelectedRouteData(null); + } finally { + setIsLoadingDetails(false); + } + }; + fetchRouteDetails(); + }, [selectedRouteId]); + + const handleRouteClick = useCallback((routeId) => { + setSelectedRouteId(routeId); + }, []); + + const handleCreate = () => { + setIsCreateModalOpen(true); + }; + + const handleCreateRoute = async (orders) => { + try { + + const rez = await apiCreateRouteAsync(orders); + const rute = await apiGetRoutesAsync(); + setRouteList(rute); + console.log('Uradjeno'); + setIsCreateModalOpen(false); + } catch (error) { + console.error('API error:', error); + } + }; + + return ( + + + {' '} + {/* Overall page container */} + + {/* Adjust height as needed */} + {/* Routes List Panel */} + + + + + + Available Routes + + + {isLoadingList && ( + + + + )} + {error && !isLoadingList && ( + + {error} + + )} + {!isLoadingList && routeList.length === 0 && !error && ( + + No routes available. + + )} + {!isLoadingList && routeList.length > 0 && ( + + {routeList.map((route) => ( + handleRouteClick(route.id)} + > + + + ))} + + )} + + + {/* Route Map and Details Panel */} + + + {isLoadingDetails && ( + + + + Loading map for Route ID: {selectedRouteId}... + + + )} + {error && !isLoadingDetails && selectedRouteId && ( + {error} + )} + + {!selectedRouteId && !isLoadingDetails && !error && ( + + + + Select a route from the list to view it on the map. + + + )} + + {selectedRouteData && !isLoadingDetails && !error && ( + <> + + + + Map for Route ID: {selectedRouteData.id} + + + + {' '} + {/* Ensure map has space */} + + + + {selectedRouteData.routeData?.data?.routes?.[0]?.legs?.[0] + ?.steps && ( + <> + + + + + Directions: + + + + {' '} + {/* Scrollable directions */} + + {selectedRouteData.routeData.data.routes[0].legs[0].steps.map( + (step, index) => ( + + + } + /> + + ) + )} + + + + )} + + )} + + + + + setIsCreateModalOpen(false)} + onCreateRoute={handleCreateRoute} + /> + + ); +} + +export default RoutesPage2; diff --git a/src/pages/LanguageManagementPage.jsx b/src/pages/LanguageManagementPage.jsx new file mode 100644 index 0000000..5ab3b40 --- /dev/null +++ b/src/pages/LanguageManagementPage.jsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Card, + CardContent, + Button, + Grid, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + IconButton, + Alert, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; + +const LanguageManagementPage = () => { + const { t, i18n } = useTranslation(); + const [languages, setLanguages] = useState([]); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [newLanguage, setNewLanguage] = useState({ + code: '', + name: '', + translations: {}, + }); + const [error, setError] = useState(''); + const [translationsText, setTranslationsText] = useState(''); + const [jsonError, setJsonError] = useState(''); + + useEffect(() => { + fetchLanguages(); + }, []); + + const fetchLanguages = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages`); + const data = await response.json(); + setLanguages(data); + const masterkeys = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/master-keys`); + const keydata = await masterkeys.json(); + console.log(keydata); + let obj = "{\n"; + for (let index = 0; index < keydata.length; index++) { + obj += `\t "${keydata[index]}": "",\n` + } + obj += "}" + setTranslationsText(obj); + } catch (error) { + console.error('Failed to fetch languages:', error); + setError('Failed to load languages'); + } + }; + + const handleAddLanguage = () => { + setIsAddModalOpen(true); + }; + + const handleCloseModal = () => { + setIsAddModalOpen(false); + setNewLanguage({ + code: '', + name: '', + translations: {}, + }); + setTranslationsText(''); + setJsonError(''); + setError(''); + }; + + const handleSaveLanguage = async () => { + try { + if (!newLanguage.code || !newLanguage.name) { + setError('Language code and name are required'); + return; + } + + if (jsonError) { + setError('Fix translation JSON errors before saving.'); + return; + } + console.log(JSON.stringify(newLanguage)); + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newLanguage), + }); + + if (!response.ok) { + throw new Error('Failed to add language'); + } + + await fetchLanguages(); + handleCloseModal(); + } catch (error) { + console.error('Error adding language:', error); + setError('Failed to add language'); + } + }; + + const handleDeleteLanguage = async (code) => { + try { + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages/${code}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete language'); + } + + await fetchLanguages(); + } catch (error) { + console.error('Error deleting language:', error); + setError('Failed to delete language'); + } + }; + + const handleChangeLanguage = (code) => { + i18n.changeLanguage(code); + }; + + return ( + + + + {t('common.languageManagement')} + + + + + + {t('common.currentLanguage')} + + + { + languages.find(lang => lang.code === i18n.language)?.name + ? `${languages.find(lang => lang.code === i18n.language).name} (${i18n.language})` + : `English (${i18n.language})` + } + + + + + + + {t('common.availableLanguages')} + + + + + {languages.map((language) => ( + + + + + + {language.name} ({language.code}) + + + handleChangeLanguage(language.code)} + disabled={language.code === i18n.language} + > + + + handleDeleteLanguage(language.code)} + disabled={language.code === 'en'} + > + + + + + + + + ))} + + + + + {t('common.addNewLanguage')} + + {error && ( + + {error} + + )} + + setNewLanguage({ ...newLanguage, code: e.target.value })} + placeholder="e.g., fr, de, es" + sx={{ mb: 2 }} + /> + setNewLanguage({ ...newLanguage, name: e.target.value })} + placeholder="e.g., French, German, Spanish" + sx={{ mb: 2 }} + /> + { + const text = e.target.value; + setTranslationsText(text); + try { + const parsed = JSON.parse(text); + setNewLanguage({ ...newLanguage, translations: parsed }); + setJsonError(''); + } catch { + setJsonError('Invalid JSON'); + } + }} + placeholder={t('common.pasteTranslations')} + error={!!jsonError} + helperText={jsonError || t('common.translationJsonHint')} + /> + + + + + + + + + + ); +}; + +export default LanguageManagementPage; diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx new file mode 100644 index 0000000..a8d6f53 --- /dev/null +++ b/src/pages/LoginPage.jsx @@ -0,0 +1,84 @@ +import React, { useEffect } from "react"; +import { Box } from "@mui/material"; +import LoginFormSection from "../sections/LoginFormSection"; +import backgroundImg from "@images/Bazaar.png"; + +const LoginPage = () => { + useEffect(() => { + document.body.classList.add("login-background"); + return () => { + document.body.classList.remove("login-background"); + }; + }, []); + + return ( + + + {/* Lijevi box sa slikom */} + + + {/* Desni box sa formom */} + + + + + + ); +}; + +export default LoginPage; diff --git a/src/pages/OrdersPage.jsx b/src/pages/OrdersPage.jsx new file mode 100644 index 0000000..154037d --- /dev/null +++ b/src/pages/OrdersPage.jsx @@ -0,0 +1,310 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import { Box } from '@mui/material'; +import Sidebar from '@components/Sidebar'; +import OrdersTable from '../components/OrdersTable'; +import OrderDetailsPopup from '../components/OrderComponent'; +import OrdersHeader from '@sections/OrdersHeader'; +import UserManagementPagination from '@components/UserManagementPagination'; +import { + apiFetchOrdersAsync, + apiFetchApprovedUsersAsync, + apiGetAllStoresAsync, + apiDeleteOrderAsync, + apiGetProductCategoriesAsync, + apiGetStoreProductsAsync, + apiFetchDeliveryAddressByIdAsync, + apiGetStoreByIdAsync, +} from '@api/api'; + +const OrdersPage = () => { + const [tabValue, setTabValue] = useState('all'); + const [selectedOrder, setSelectedOrder] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortField, setSortField] = useState('id'); + const [sortOrder, setSortOrder] = useState('asc'); + const [statusFilter, setStatusFilter] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [orders, setOrders] = useState([]); + const ordersPerPage = 10; + + useEffect(() => { + const fetchData = async () => { + try { + // 1. Dohvati osnovne podatke (paralelno) + const [ordersData, users, stores, categories] = await Promise.all([ + apiFetchOrdersAsync(), // Vraća ordersData, koji treba da sadrži addressId i storeId + apiFetchApprovedUsersAsync(), // Vraća listu korisnika + apiGetAllStoresAsync(), // Vraća listu svih prodavnica (možda bez adrese detalja) + apiGetProductCategoriesAsync(), // Vraća kategorije proizvoda + ]); + + const usersMap = Object.fromEntries( + users.map((u) => [u.id, u.userName || u.email]) + ); + const storesMap = Object.fromEntries(stores.map((s) => [s.id, s.name])); + const categoryMap = Object.fromEntries( + categories.map((c) => [c.id, c.name]) + ); + + const productsMap = {}; + const allProducts = []; + + for (const store of stores) { + try { + const res = await apiGetStoreProductsAsync(store.id); + if (res.status === 200 && res.data) { + allProducts.push(...res.data); + } + } catch (error) { + console.error( + `Failed to fetch products for store ID ${store.id}:`, + error + ); + } + } + allProducts.forEach((p) => (productsMap[p.id] = p)); + + const uniqueStoreIds = [ + ...new Set( + ordersData + .map((order) => order.storeId) + .filter((id) => id !== undefined && id !== null) + ), + ]; + const uniqueAddressIds = [ + ...new Set( + ordersData + .map((order) => order.addressId) + .filter((id) => id !== undefined && id !== null) + ), + ]; + + const storeDetailsPromises = uniqueStoreIds.map((storeId) => + apiGetStoreByIdAsync(storeId).catch((err) => { + console.error( + `Failed to fetch store details for ID ${storeId}:`, + err + ); + return { data: { address: 'N/A', id: storeId } }; + }) + ); + + const deliveryAddressPromises = uniqueAddressIds.map((addressId) => + apiFetchDeliveryAddressByIdAsync(addressId).catch((err) => { + console.error( + `Failed to fetch delivery address for ID ${addressId}:`, + err + ); + return { address: 'N/A', id: addressId }; + }) + ); + + const [storeDetailsResponses, deliveryAddressResponses] = + await Promise.all([ + Promise.all(storeDetailsPromises), + Promise.all(deliveryAddressPromises), + ]); + + const storeDetailsMap = Object.fromEntries( + storeDetailsResponses.map((res) => [ + res.data?.id || res.id, + res.data || res, + ]) + ); + const deliveryAddressesMap = Object.fromEntries( + deliveryAddressResponses.map((res) => [ + res.data?.id || res.id, + res.data || res, + ]) + ); + + console.log('USERS', users); + console.log('ORDERSData: ', ordersData); + console.log( + 'ORDERS with Address ID:', + ordersData.map((o) => ({ id: o.id, addressId: o.addressId })) + ); + + const enrichedOrders = ordersData.map((order) => { + const storeDetails = storeDetailsMap[order.storeId]; + const deliveryAddressDetails = deliveryAddressesMap[order.addressId]; + + const storeName = storesMap[parseInt(order.storeId)] ?? order.storeId; // Koristi storeName iz svih stores lookup-a + const storeAddress = storeDetails?.address ?? 'N/A'; // Koristi 'address' iz detalja prodavnice + + const deliveryAddress = deliveryAddressDetails?.address ?? 'N/A'; // Koristi 'address' iz detalja adrese dostave + + const enrichedOrderItems = (order.orderItems ?? []).map((item) => { + const prod = productsMap[item.productId] ?? {}; + const productCategory = + categoryMap[prod.productCategoryId] ?? 'Unknown Category'; + + return { + ...item, + name: prod.name ?? `Product ${item.productId}`, + imageUrl: prod.photos?.[0]?.relativePath + ? `${import.meta.env.VITE_API_BASE_URL}${prod.photos[0].relativePath}` + : 'https://via.placeholder.com/80', + tagIcon: '🏷️', + tagLabel: productCategory, + }; + }); + + return { + ...order, + buyerName: usersMap[order.buyerId] ?? order.buyerId, + storeName: storeName, + storeAddress: storeAddress, + deliveryAddress: deliveryAddress, + _productDetails: enrichedOrderItems, + // ...ostali property-ji koje već imaš (status, time, total...) + }; + }); + + // 6. Postavi obogaćene narudžbe u state + setOrders(enrichedOrders); + } catch (error) { + console.error('Failed to fetch initial data for OrdersPage:', error); + } + }; + + fetchData(); + }, []); + + const handleDeleteOrder = async (orderId) => { + const res = await apiDeleteOrderAsync(orderId); + if (res.status === 204) { + setOrders((prev) => prev.filter((o) => o.id !== orderId)); + } else { + alert('Failed to delete order.'); + } + }; + + const filteredOrders = useMemo(() => { + const filteredByTab = + tabValue === 'all' + ? orders + : orders.filter((order) => + tabValue === 'cancelled' ? order.isCancelled : !order.isCancelled + ); + + return filteredByTab + .filter((order) => + [order.buyerName, order.storeName].some((field) => + field?.toLowerCase().includes(searchTerm.toLowerCase()) + ) + ) + .filter((order) => + statusFilter ? order.status?.toLowerCase() === statusFilter : true + ); + }, [orders, tabValue, searchTerm, statusFilter]); + + const sortedOrders = useMemo(() => { + return [...filteredOrders].sort((a, b) => { + if (sortField === 'createdAt') { + return sortOrder === 'asc' + ? new Date(a.createdAt) - new Date(b.createdAt) + : new Date(b.createdAt) - new Date(a.createdAt); + } + if (sortField === 'id') { + return sortOrder === 'asc' ? a.id - b.id : b.id - a.id; + } + return 0; + }); + }, [filteredOrders, sortField, sortOrder]); + + const totalPages = Math.max( + 1, + Math.ceil(sortedOrders.length / ordersPerPage) + ); + const indexOfLastOrder = currentPage * ordersPerPage; + const indexOfFirstOrder = indexOfLastOrder - ordersPerPage; + const currentOrders = sortedOrders.slice(indexOfFirstOrder, indexOfLastOrder); + + const handlePageChange = (newPage) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + } + }; + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm, tabValue, statusFilter]); + + return ( + + + + + + + { + setSortField(field); + setSortOrder(order); + }} + onOrderClick={(order) => setSelectedOrder(order)} + onDelete={handleDeleteOrder} + /> + + + + + {selectedOrder && ( + setSelectedOrder(null)} + narudzba={{ + id: selectedOrder.id, + buyerId: selectedOrder.buyerName, + storeId: selectedOrder.storeName, + status: selectedOrder.status, + time: selectedOrder.createdAt, + total: selectedOrder.totalPrice, + proizvodi: selectedOrder._productDetails, + deliveryAddress: selectedOrder.deliveryAddress, + storeAddress: selectedOrder.storeAddress, + orderItems: selectedOrder.products.map((p) => ({ + id: p.id, + productId: p.productId, + price: p.price, + quantity: p.quantity, + name: p.name, + })), + }} + /> + )} + + + ); +}; + +export default OrdersPage; diff --git a/src/pages/PendingUsersPage.jsx b/src/pages/PendingUsersPage.jsx new file mode 100644 index 0000000..932909d --- /dev/null +++ b/src/pages/PendingUsersPage.jsx @@ -0,0 +1,143 @@ +import React, { useContext, useState } from "react"; +import PendingUsersHeader from "@sections/PendingUsersHeader"; +import PendingUsersTable from "@components/PendingUsersTable"; +import UserManagementPagination from "@components/UserManagementPagination"; +import ConfirmDialog from "@components/ConfirmDialog"; +import UserDetailsModal from "@components/UserDetailsModal"; +import { Box } from "@mui/material"; +import { PendingUsersContext } from "@context/PendingUsersContext"; +import { apiApproveUserAsync } from "@api/api"; +import { apiDeleteUserAsync } from "@api/api"; + + +var baseURL = import.meta.env.VITE_API_BASE_URL + +const PendingUsers = () => { + const usersPerPage = 8; + + const { pendingUsers, setPendingUsers ,deleteUser} = useContext(PendingUsersContext); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [confirmOpen, setConfirmOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + + const [selectedUser, setSelectedUser] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const filteredUsers = pendingUsers.filter( + (u) => + u.userName.toLowerCase().includes(searchTerm.toLowerCase()) || + u.email.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const totalPages = Math.max( + 1, + Math.ceil(filteredUsers.length / usersPerPage) + ); + const indexOfLastUser = currentPage * usersPerPage; + const indexOfFirstUser = indexOfLastUser - usersPerPage; + const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser); + + const handleApprove = async (id) => { + try { + deleteUser(id); + await apiApproveUserAsync(id); + console.log(`User with ID ${id} approved successfully.`); + } catch (error) { + console.error("Greška pri odobravanju korisnika:", error); + } + }; + + const handleDelete = (id) => { + setUserToDelete(id); + setConfirmOpen(true); + }; + + const confirmDelete = async () => { + setConfirmOpen(false); + setUserToDelete(null); + try { + await apiDeleteUserAsync(userToDelete); + deleteUser(userToDelete); + console.log(`User with ID ${userToDelete} deleted successfully.`); + } catch (error) { + console.error("Greška pri brisanju korisnika:", error); + } + }; + + const cancelDelete = () => { + setConfirmOpen(false); + setUserToDelete(null); + }; + + const handlePageChange = (newPage) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + } + }; + + const handleViewUser = (userId) => { + const user = pendingUsers.find((u) => u.id === userId); + setSelectedUser(user); + setModalOpen(true); + }; + + return ( + + + {}} + searchTerm={searchTerm} + setSearchTerm={setSearchTerm} + /> + + + + + + + + setModalOpen(false)} + user={selectedUser} + readOnly + /> + + + ); +}; + +export default PendingUsers; diff --git a/src/pages/RoutesPage.jsx b/src/pages/RoutesPage.jsx new file mode 100644 index 0000000..c0abf57 --- /dev/null +++ b/src/pages/RoutesPage.jsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react'; +import { Box, Typography, Grid } from '@mui/material'; +import RouteCard from '@components/RouteCard'; +import UserManagementPagination from '@components/UserManagementPagination'; +import RoutesHeader from '@sections/RoutesHeader'; +import RouteDetailsModal from '@components/RouteDetailsModal'; +import { sha256 } from 'js-sha256'; +import CreateRouteModal from '@components/CreateRouteModal'; +import { + apiCreateRouteAsync, + apiGetRoutesAsync, + apiDeleteRouteAsync, +} from '../api/api'; +import { + GoogleMap, + LoadScript, + Polyline, + Marker, + InfoWindow, +} from '@react-google-maps/api'; + +const API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; + +const getBoundingBox = (points) => { + if (!points || points.length === 0) return null; + + // THIS LINE NEEDS `window.google` to be defined + const bounds = new google.maps.LatLngBounds(); + points.forEach((point) => { + // THIS LINE ALSO NEEDS `window.google` + bounds.extend(new google.maps.LatLng(point.latitude, point.longitude)); + }); + return bounds; +}; + +const generateMockRoutes = (page, perPage) => { + const totalRoutes = 42; + const routes = Array.from({ length: totalRoutes }, (_, i) => { + const numOrders = Math.floor(Math.random() * 6) + 1; + const orderIds = Array.from({ length: numOrders }, () => + Math.floor(1000 + Math.random() * 9000) + ); + + const mockData = { + routes: [ + { + legs: [ + { + start_location: { lat: 43.85 + 0.01 * i, lng: 18.38 + 0.01 * i }, + end_location: { lat: 43.86 + 0.01 * i, lng: 18.4 + 0.01 * i }, + }, + ], + }, + ], + }; + + const hash = sha256(JSON.stringify(mockData)); + + return { + id: i + 1, + name: `Route ${i + 1}`, + orderIds, + ownerId: 1, + routeData: { + data: mockData, + hash, + routeId: `route-${i + 1}`, + }, + }; + }); + + const start = (page - 1) * perPage; + const end = start + perPage; + return { + data: routes.slice(start, end), + total: totalRoutes, + }; +}; + +const RoutesPage = () => { + const [selectedRoute, setSelectedRoute] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [routes, setRoutes] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const perPage = 8; + + useEffect(() => { + const fetchRoutes = async () => { + //const response = generateMockRoutes(currentPage, perPage); + const response = await apiGetRoutesAsync(); + console.log(response); +const totalItems = response.length; +setTotalPages(Math.ceil(totalItems / perPage)); + + // Clamp currentPage to stay within valid bounds + const safePage = Math.max(0, Math.min(currentPage - 1, totalPages - 1)); + + const start = safePage * perPage; + const end = start + perPage; + + setRoutes(response.slice(start, end)); + }; + fetchRoutes(); + }, [currentPage,perPage]); + + + const handleCreate = () => { + setIsCreateModalOpen(true); + }; + + const handleCreateRoute = async (orders) => { + try { + + const rez = await apiCreateRouteAsync(orders); + const rute = await apiGetRoutesAsync(); + setRoutes(rute); + console.log('Uradjeno'); + setIsCreateModalOpen(false); + } catch (error) { + console.error('API error:', error); + } + }; + const handleDelete = async (id) => { + try { + const rez = await apiDeleteRouteAsync(id); + const newroutes = await apiGetRoutesAsync(); + setRoutes(newroutes); + } catch (err) { + console.log('Greska pri brisanju', err); + } + }; + + const handleViewDetails = (id) => { + const selected = routes.find((r) => r.id === id); + console.log('Selected route:', selected); + setSelectedRoute(selected); + setIsModalOpen(true); + }; + + return ( + + + + + + {routes.map((route) => ( + + + + ))} + + + + + setIsModalOpen(false)} + /> + + + setIsCreateModalOpen(false)} + onCreateRoute={handleCreateRoute} + /> + + ); +}; + +export default RoutesPage; diff --git a/src/pages/SellerAnalyticsPage.jsx b/src/pages/SellerAnalyticsPage.jsx new file mode 100644 index 0000000..c453501 --- /dev/null +++ b/src/pages/SellerAnalyticsPage.jsx @@ -0,0 +1,502 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Typography, Grid, Card, CardContent } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { DollarSign, TrendingUp, BarChart2, Store } from 'lucide-react'; + +// mock data (will be used if props don't provide enough data for calculation) +const mockStats = [ + { + month: 'Jan', + earningsFromClicks: 80, + earningsFromViews: 20, + earningsFromConversions: 100000, + }, + { + month: 'Feb', + earningsFromClicks: 90, + earningsFromViews: 25, + earningsFromConversions: 105, + }, + { + month: 'Mar', + earningsFromClicks: 110, + earningsFromViews: 30, + earningsFromConversions: 120, + }, + { + month: 'Apr', + earningsFromClicks: 130, + earningsFromViews: 40, + earningsFromConversions: 150, + }, + { + month: 'May', + earningsFromClicks: 145, + earningsFromViews: 50, + earningsFromConversions: 160, + }, + { + month: 'Jun', + earningsFromClicks: 160, + earningsFromViews: 60, + earningsFromConversions: 175, + }, +]; +const mockRealtimeStats = { + sellerName: 'N/A (Using Mock)', + earningsFromClicks: 106.0, + earningsFromClicksOverTime: [10, 20, 30, 46, 55, 63], + earningsFromViews: 33.6, + earningsFromViewsOverTime: [5, 8, 10, 10.6, 12, 14], + earningsFromConversions: 220.0, + earningsFromConversionsOverTime: [40, 60, 50, 70, 85, 100000], + totalEarnings: 359.6, + sellerProfit: 287.68, +}; + +const iconMap = { + 'Total Earnings': , + 'Seller Profit': , + 'View Revenue': , // Note: Duplicate key with 'Conversion Revenue', consider unique keys if icons differ + 'Conversion Revenue': , + // Added for Click Revenue to avoid undefined icon + 'Click Revenue': , +}; + +const storeToStats = (store, ads, clickData, viewData, conversionData) => { + if (!store || !ads || !clickData || !viewData || !conversionData) + return mockStats; // Fallback + const monthlyAggregatedStats = {}; + + const getMonthStats = (monthKey) => { + if (!monthlyAggregatedStats[monthKey]) { + monthlyAggregatedStats[monthKey] = { + earningsFromClicks: 0, + earningsFromViews: 0, + earningsFromConversions: 0, + }; + } + return monthlyAggregatedStats[monthKey]; + }; + + clickData.forEach((entry) => { + const ad = ads.find((a) => a.id === entry.id); + if (!ad || typeof ad.clickPrice !== 'number') return; + (entry.clicks || []).forEach((clickTimestamp) => { + const timestamp = new Date(clickTimestamp); + const start = new Date(ad.startTime); + const end = new Date(ad.endTime); + if (timestamp >= start && timestamp <= end) { + const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`; + const stats = getMonthStats(monthKey); + stats.earningsFromClicks += ad.clickPrice; + } + }); + }); + + viewData.forEach((entry) => { + const ad = ads.find((a) => a.id === entry.id); + if (!ad || typeof ad.viewPrice !== 'number') return; + (entry.views || []).forEach((viewTimestamp) => { + const timestamp = new Date(viewTimestamp); + const start = new Date(ad.startTime); + const end = new Date(ad.endTime); + if (timestamp >= start && timestamp <= end) { + const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`; + const stats = getMonthStats(monthKey); + stats.earningsFromViews += ad.viewPrice; + } + }); + }); + + conversionData.forEach((entry) => { + const ad = ads.find((a) => a.id === entry.id); + if (!ad || typeof ad.conversionPrice !== 'number') return; + (entry.conversions || []).forEach((conversionTimestamp) => { + const timestamp = new Date(conversionTimestamp); + const start = new Date(ad.startTime); + const end = new Date(ad.endTime); + if (timestamp >= start && timestamp <= end) { + const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`; + const stats = getMonthStats(monthKey); + stats.earningsFromConversions += ad.conversionPrice; + } + }); + }); + + const result = []; + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const sortedMonthKeys = Object.keys(monthlyAggregatedStats).sort(); + + if (sortedMonthKeys.length === 0) return mockStats; // Fallback if no data processed + + sortedMonthKeys.forEach((monthKey) => { + const [year, monthNumStr] = monthKey.split('-'); + const monthIndex = parseInt(monthNumStr, 10) - 1; + const monthName = monthNames[monthIndex]; + result.push({ + month: monthName, + earningsFromClicks: parseFloat( + monthlyAggregatedStats[monthKey].earningsFromClicks.toFixed(2) + ), + earningsFromViews: parseFloat( + monthlyAggregatedStats[monthKey].earningsFromViews.toFixed(2) + ), + earningsFromConversions: parseFloat( + monthlyAggregatedStats[monthKey].earningsFromConversions.toFixed(2) + ), + }); + }); + return result; // Ensure we don't return empty if processing happened but yielded no months +}; + +const storeToSummary = (store, ads, products, clicks, views, conversions) => { + if (!store || !ads || !products || !clicks || !views || !conversions) + return { + sellerName: 'N/A (Using default)', + earningsFromClicks: 0.0, + earningsFromClicksOverTime: [0], + earningsFromViews: 0, + earningsFromViewsOverTime: [0], + earningsFromConversions: 0, + earningsFromConversionsOverTime: [0], + totalEarnings: 0, + sellerProfit: 0, + }; // Fallback + + // Calculate revenues based on ad properties (assuming these are aggregated counts on ad objects) + const clickrev = ads.reduce( + (acc, ad) => acc + (ad.clicks || 0) * (ad.clickPrice || 0), + 0 + ); + const convrev = ads.reduce( + (acc, ad) => acc + (ad.conversions || 0) * (ad.conversionPrice || 0), + 0 + ); + const viewrev = ads.reduce( + (acc, ad) => acc + (ad.views || 0) * (ad.viewPrice || 0), + 0 + ); + + const sellerrev = ads.reduce((acc, ad) => { + if (!ad.adData || !ad.adData[0] || !ad.adData[0].productId) return acc; + const product = products.find((p) => p.id === ad.adData[0].productId); + return acc + (ad.conversions || 0) * (product ? product.retailPrice : 0); + }, 0); + + // Process detailed click/view/conversion data for "OverTime" arrays + // These expect `clicks`, `views`, `conversions` to be arrays of { id: adId, clicks/views/conversions: [timestamps] } + // The .fill() part is tricky; it replaces timestamps with prices. If the goal is a list of earnings per event: + const c_processed = clicks + .map((adDetail) => { + const adConfig = ads.find((ad) => ad.id === adDetail.id); + if (!adConfig) return []; + return (adDetail.clicks || []).map(() => adConfig.clickPrice); // Array of prices, one for each click + }) + .flat(); + + const v_processed = views + .map((adDetail) => { + const adConfig = ads.find((ad) => ad.id === adDetail.id); + if (!adConfig) return []; + return (adDetail.views || []).map(() => adConfig.viewPrice); + }) + .flat(); + + const cc_processed = conversions + .map((adDetail) => { + const adConfig = ads.find((ad) => ad.id === adDetail.id); + if (!adConfig) return []; + return (adDetail.conversions || []).map(() => adConfig.conversionPrice); + }) + .flat(); + + const totalEarnings = convrev + viewrev + clickrev; + const sellerProfit = sellerrev - totalEarnings; + + return { + sellerName: store.name || 'Unknown Store', + earningsFromClicks: parseFloat(clickrev.toFixed(2)), + earningsFromClicksOverTime: c_processed.length > 0 ? c_processed : [0], + + earningsFromViews: parseFloat(viewrev.toFixed(2)), + earningsFromViewsOverTime: v_processed.length > 0 ? v_processed : [0], + earningsFromConversions: parseFloat(convrev.toFixed(2)), + earningsFromConversionsOverTime: + cc_processed.length > 0 ? cc_processed : [0], + totalEarnings: parseFloat(totalEarnings.toFixed(2)), + sellerProfit: parseFloat(sellerProfit.toFixed(2)), + }; +}; + +const SellerAnalytics = ({ + store, + ads, + products, + allClicks, // Expected: [{ id: adId, clicks: [timestamp1, ...] }, ...] + allViews, // Expected: [{ id: adId, views: [timestamp1, ...] }, ...] + allConversions, // Expected: [{ id: adId, conversions: [timestamp1, ...] }, ...] +}) => { + const { t } = useTranslation(); + const [stats, setStats] = useState(mockStats); + const [summary, setSummary] = useState(mockRealtimeStats); + + useEffect(() => { + // console.log("SellerAnalytics useEffect triggered. Store:", store); + // console.log("Ads for store:", ads); + // console.log("Products for store:", products); + // console.log("AllClicks for store:", allClicks); + + if (store && ads && products && allClicks && allViews && allConversions) { + const calculatedStats = storeToStats( + store, + ads, + allClicks, + allViews, + allConversions + ); + const calculatedSummary = storeToSummary( + store, + ads, + products, + allClicks, + allViews, + allConversions + ); + + // console.log("Calculated Stats:", calculatedStats); + // console.log("Calculated Summary:", calculatedSummary); + + setStats(calculatedStats); + setSummary(calculatedSummary); + console.log(store); + } else { + console.log('SellerAnalytics: Missing some props, using mock data.'); + // Fallback to ensure summary always has a sellerName if store is somehow undefined briefly + setSummary((prev) => ({ + ...mockRealtimeStats, + sellerName: store?.name || mockRealtimeStats.sellerName, + })); + setStats(mockStats); + } + }, [store, ads, products, allClicks, allViews, allConversions]); + + const topStats = [ + { + label: t('sellerAnalytics.totalEarnings'), + value: `${summary.totalEarnings.toFixed(2)} €`, + change: -5.2, + }, + { + label: t('sellerAnalytics.sellerProfit'), + value: `${summary.sellerProfit.toFixed(2)} €`, + change: 2.1, + }, + { + label: t('sellerAnalytics.clickRevenue'), + value: `${summary.earningsFromClicks.toFixed(2)} €`, + change: 1.5, + }, + { + label: t('sellerAnalytics.viewRevenue'), + value: `${summary.earningsFromViews.toFixed(2)} €`, + change: -3.6, + }, + { + label: t('sellerAnalytics.conversionRevenue'), + value: `${summary.earningsFromConversions.toFixed(2)} €`, + change: 4.9, + }, + ]; + + return ( + + + {t('analytics.storePerformance')}: {summary.sellerName} + + + + + {t('analytics.detailedAnalyticsFor', { storeName: summary.sellerName })} + + + + + {' '} + {/* Adjusted spacing */} + {topStats.map((item, idx) => ( + + {' '} + {/* More responsive grid items */} + + + + + {item.label} + + + {iconMap[item.label] || } + + + + {' '} + {/* Adjusted font size */} + {item.value} + + {item.change !== undefined && ( + + {/* {item.change < 0 ? '↓' : '↑'}{' '} + {Math.abs(item.change).toFixed(1)}% vs last month */} + + )} + + + + ))} + + + + {' '} + {/* Adjusted spacing */} + {[ + { + title: t('analytics.clickRevenueOverTime'), + color: '#0f766e', + data: summary.earningsFromClicksOverTime, + }, + { + title: t('analytics.viewRevenueOverTime'), + color: '#f59e0b', + data: summary.earningsFromViewsOverTime, + }, + { + title: t('analytics.conversionRevenueOverTime'), + color: '#ef4444', + data: summary.earningsFromConversionsOverTime, + }, + ].map((graph, idx) => ( + + {' '} + {/* Responsive charts */} + + + {' '} + {/* Adjusted font size */} + {graph.title} + + + {' '} + {/* Adjusted height */} + ({ + // Ensure stats is not null and handle slice if fewer than 5 data points + month: s.month, + // Ensure graph.data is an array and access it safely + value: + Array.isArray(graph.data) && graph.data.length > i + ? graph.data.slice(-Math.min(5, graph.data.length))[i] + : 0, + }))} + > + + + + + + + + + + + ))} + + + ); +}; + +export default SellerAnalytics; diff --git a/src/pages/StoresPage.jsx b/src/pages/StoresPage.jsx new file mode 100644 index 0000000..a0ac162 --- /dev/null +++ b/src/pages/StoresPage.jsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import { Box } from '@mui/material'; +import StoresHeader from '@sections/StoresHeader'; +import StoreCard from '@components/StoreCard'; +import UserManagementPagination from '@components/UserManagementPagination'; +import { apiGetAllStoresAsync, apiAddStoreAsync } from '@api/api'; +import AddStoreModal from '@components/AddStoreModal'; + +const StoresPage = () => { + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(''); + const [openModal, setOpenModal] = useState(false); + const storesPerPage = 8; + + const [allStores, setAllStores] = useState([]); + + useEffect(() => { + const fetchStores = async () => { + const data = await apiGetAllStoresAsync(); + const mapped = data.map((store) => ({ + ...store, + categoryId: store.categoryId || store.category?.id || 0, + })); + setAllStores(mapped); + }; + + fetchStores(); + }, []); + + const handleAddStore = async (newStoreData) => { + console.log('data', newStoreData); + const response = await apiAddStoreAsync(newStoreData); + console.log(response); + if (response.status < 400) { + const data = await apiGetAllStoresAsync(); + setAllStores(data); + } + }; + + const filteredStores = allStores.filter( + (store) => + store.name.toLowerCase().includes(searchTerm.toLowerCase()) || + store.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const totalPages = Math.ceil(filteredStores.length / storesPerPage); + const indexOfLastStore = currentPage * storesPerPage; + const indexOfFirstStore = indexOfLastStore - storesPerPage; + const currentStores = filteredStores.slice( + indexOfFirstStore, + indexOfLastStore + ); + + return ( + + + setOpenModal(true)} + /> + + {/* Grid layout */} + + {currentStores.map((store) => ( + + ))} + + + + + + + setOpenModal(false)} + onAddStore={handleAddStore} + /> + + ); +}; + +export default StoresPage; diff --git a/src/pages/UsersManagement.jsx b/src/pages/UsersManagement.jsx new file mode 100644 index 0000000..4052526 --- /dev/null +++ b/src/pages/UsersManagement.jsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from "react"; +import UserManagementHeader from "@sections/UserManagementHeader"; +import UserManagementPagination from "@components/UserManagementPagination"; +import UserManagementSection from "@sections/UserManagementSection"; +import UserDetailsModal from "@components/UserDetailsModal"; +import { Box } from "@mui/material"; +import AddUserModal from "@components/AddUserModal"; + +import { + apiFetchApprovedUsersAsync, + apiDeleteUserAsync, + apiCreateUserAsync, +} from "../api/api.js"; + +const UsersManagements = () => { + const usersPerPage = 8; + const [allUsers, setAllUsers] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [roleFilter, setRoleFilter] = useState(""); + const [availabilityFilter, setAvailabilityFilter] = useState(""); + const [addModalOpen, setAddModalOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + useEffect(() => { + async function fetchData() { + setIsLoading(true); + try { + const users = await apiFetchApprovedUsersAsync(); + console.log(users); + setAllUsers(users); + } catch (err) { + console.error("Greška pri dohvaćanju korisnika:", err); + } + setIsLoading(false); + } + fetchData(); + }, []); + + const filteredUsers = allUsers.filter((user) => { + const matchesSearch = + user.userName.toLowerCase().includes(searchTerm.toLowerCase()) || + user.email.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesRole = + roleFilter === "" || user.role?.toLowerCase() === roleFilter.toLowerCase(); + + const matchesAvailability = + availabilityFilter === "" || + user.availability?.toLowerCase() === availabilityFilter.toLowerCase(); + + return matchesSearch && matchesRole && matchesAvailability; + }); + + const totalPages = Math.max(1, Math.ceil(filteredUsers.length / usersPerPage)); + const indexOfLastUser = currentPage * usersPerPage; + const indexOfFirstUser = indexOfLastUser - usersPerPage; + const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser); + + const handleDelete = async (userId) => { + try { + await apiDeleteUserAsync(userId); + console.log(`User with ID ${userId} deleted successfully.`); + setAllUsers(allUsers.filter((u) => u.id !== userId)); + if (currentPage > 1 && currentUsers.length === 1) { + setCurrentPage(currentPage - 1); + } + } catch (error) { + console.error(`Failed to delete user ${userId}:`, error); + } + }; + + const handleAddUser = () => setAddModalOpen(true); + + const handleSaveUser = async (newUser) => { + try { + const createdUser = await apiCreateUserAsync({ + email: newUser.email, + password: newUser.password, + userName: newUser.userName, + }); + setAllUsers((prev) => [...prev, createdUser.data]); + } catch (error) { + console.error("Error creating user:", error); + } + }; + + const handlePageChange = (newPage) => { + if (newPage >= 1 && newPage <= totalPages) { + setCurrentPage(newPage); + } + }; + + const handleViewUser = (userId) => { + const user = allUsers.find((u) => u.id === userId); + setSelectedUser(user); + setModalOpen(true); + }; + + const handleEditUser = (updatedUser) => { + setAllUsers((prevUsers) => + prevUsers.map((user) => (user.id === updatedUser.id ? updatedUser : user)) + ); + }; + + if (isLoading) return Loading...; + + return ( + + + + + + + + + setModalOpen(false)} + user={selectedUser} + /> + + setAddModalOpen(false)} + onCreate={handleSaveUser} + /> + + + ); +}; + +export default UsersManagements; diff --git a/src/routes/.gitkeep b/src/routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/Router.jsx b/src/routes/Router.jsx new file mode 100644 index 0000000..f882e09 --- /dev/null +++ b/src/routes/Router.jsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { + BrowserRouter as Router, + Routes, + Route, + Navigate, +} from 'react-router-dom'; +import LoginPage from '@pages/LoginPage'; +import UsersManagement from '@pages/UsersManagement'; +import PendingUsersPage from '@pages/PendingUsersPage'; +import { ThemeProvider } from '@mui/material/styles'; +import StoresPage from '@pages/StoresPage'; +import CssBaseline from '@mui/material/CssBaseline'; +import theme from '@styles/theme'; +import Sidebar from '@components/Sidebar'; +import CategoriesPage from '@pages/CategoriesPage'; +import OrdersPage from '@pages/OrdersPage'; +import AnalyticsPage from '@pages/AnalyticsPage'; +import AdPage from '@pages/AdPage'; +import SellerAnalyticsPage from '@pages/SellerAnalyticsPage'; +import ChatPage from '@pages/ChatPage'; +import RoutesPage from '@pages/RoutesPage'; +import RoutesPage2 from '../pages/DelRoutePage'; +import LanguageManagementPage from '../pages/LanguageManagementPage'; + +const isAuthenticated = () => { + console.log(localStorage.getItem('auth')); + return localStorage.getItem('auth'); +}; + +const ProtectedRoute = ({ children }) => { + return isAuthenticated() ? children : ; +}; + +const Layout = ({ children }) => ( +
    + +
    {children}
    +
    +); + +const AppRoutes = () => { + return ( + + + + + +
    + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + + + + + } + /> + + + + + {/* */} + + + + + } + /> + + + + + + + + + } + /> + + } + /> + } /> + + + ); +}; + +export default AppRoutes; diff --git a/src/sections/.gitkeep b/src/sections/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/sections/AdminChatSection.jsx b/src/sections/AdminChatSection.jsx new file mode 100644 index 0000000..4f86094 --- /dev/null +++ b/src/sections/AdminChatSection.jsx @@ -0,0 +1,122 @@ +import { Box, Paper } from '@mui/material'; +import { useState, useEffect } from 'react'; +import ChatHeader from '@components/ChatHeader'; +import ChatMessages from '@components/ChatMessages'; +import ChatInput from '@components/ChatInput'; +import UserInfoSidebar from '@components/UserInfoSidebar'; +import LockOverlay from '@components/LockOverlay'; +import { useSignalR } from '@hooks/useSignalR'; +import { apiFetchMessagesForConversationAsync } from '../api/api.js'; +import { useTranslation } from 'react-i18next'; + +export default function AdminChatSection({ ticket, conversation }) { + const [messages, setMessages] = useState([]); + const { t } = useTranslation(); + const adminUserId = conversation?.adminUserId; + + // Prikaz korisnika + let userLabel = ''; + if ( + conversation?.buyerUserId && + ticket?.userId === conversation.buyerUserId + ) { + userLabel = conversation.buyerUsername; + } else if ( + conversation?.sellerUserId && + ticket?.userId === conversation.sellerUserId + ) { + userLabel = conversation.sellerUsername; + } + + // Dohvati poruke + useEffect(() => { + const fetchMessages = async () => { + if (!conversation?.id || ticket?.status !== 'Open') return; + const { data } = await apiFetchMessagesForConversationAsync( + conversation.id + ); + setMessages( + data.map((msg) => ({ + ...msg, + isOwnMessage: msg.senderUserId === adminUserId, + })) + ); + }; + fetchMessages(); + }, [conversation, ticket?.status, adminUserId]); + + // SignalR za real-time poruke + const { messages: signalRMessages, sendMessage } = useSignalR( + ticket?.status === 'Open' && conversation ? conversation.id : null, + adminUserId + ); + + // Dodaj nove SignalR poruke u listu + useEffect(() => { + if ( + signalRMessages && + signalRMessages.length > 0 && + ticket?.status === 'Open' + ) { + setMessages((prev) => [ + ...prev, + signalRMessages[signalRMessages.length - 1], + ]); + } + }, [signalRMessages, ticket?.status]); + + // Kada pošalješ poruku, odmah je dodaj u listu (optimistic update) + const handleSendMessage = (content) => { + if (content.trim() && ticket?.status === 'Open') { + sendMessage(content); + } + }; + + const locked = ticket?.status !== 'Open'; + + return ( + + + + + + {locked && ( + + )} + + + + + + ); +} diff --git a/src/sections/AdsManagementHeader.jsx b/src/sections/AdsManagementHeader.jsx new file mode 100644 index 0000000..08c11e8 --- /dev/null +++ b/src/sections/AdsManagementHeader.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { + Box, + Typography, + Button, + TextField, + InputAdornment, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useTranslation } from 'react-i18next'; + +const AdsManagementHeader = ({ onCreateAd, searchTerm, setSearchTerm }) => { + const { t } = useTranslation(); + return ( + + + + {t('ads.adsManagement')} + + + {t('ads.adminPanel')} > {t('ads.advertisements')} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + borderRadius: 2, + backgroundColor: '#f9f9f9', + minWidth: { xs: '100%', sm: '240px' }, + }} + /> + + + + ); +}; + +export default AdsManagementHeader; diff --git a/src/sections/CategoriesHeader.jsx b/src/sections/CategoriesHeader.jsx new file mode 100644 index 0000000..cbd564c --- /dev/null +++ b/src/sections/CategoriesHeader.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + Box, + Typography, + TextField, + InputAdornment, + Button, +} from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from 'react-i18next'; + +const CategoriesHeader = ({ searchTerm, setSearchTerm, onAddCategory }) => { + const { t } = useTranslation(); + return ( + + + + {t('categories.categories')} + + + {t('common.adminPanel')} > {t('categories.categories')} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + borderRadius: 2, + backgroundColor: "#f9f9f9", + minWidth: { xs: "100%", sm: "240px" }, + }} + /> + + + + + ); +}; + +export default CategoriesHeader; diff --git a/src/sections/LoginFormSection.jsx b/src/sections/LoginFormSection.jsx new file mode 100644 index 0000000..eb00a53 --- /dev/null +++ b/src/sections/LoginFormSection.jsx @@ -0,0 +1,95 @@ +import React, { useEffect } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import CustomTextField from "../components/CustomTextField"; +import CustomButton from "../components/CustomButton"; +import SocialLoginButton from "../components/SocialLoginButton"; +import { formContainer } from "./LoginFormSectionStyles"; +import { FcGoogle } from "react-icons/fc"; +import { FaFacebookF } from "react-icons/fa"; +import { validateEmail } from "../utils/validation"; +import { useState } from "react"; +// import apiClientInstance from '../api/apiClientInstance'; // Import configured client +// import { AdminApi, TestAuthApi } from '../api/api/AdminApi'; +import { apiLoginUserAsync } from "../api/api.js"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; +import { api } from "../utils/apiroutes"; +import { useTranslation } from 'react-i18next'; + +const LoginFormSection = () => { + const { t } = useTranslation(); + var baseURL = import.meta.env.VITE_API_BASE_URL; + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const { isValid, error } = validateEmail(email); + + const navigate = useNavigate(); + + const handleLogIn = () => { + const status = apiLoginUserAsync(email, password); + if (status !== false) navigate("/users"); + }; + + async function handleSubmit(event) { + event.preventDefault(); + + // const loginPayload = { + // email: email, + // password: password, + // }; + + // console.log(baseURL) + // console.log(import.meta.env); + // axios + // .post(`${baseURL}/api/Auth/login`, loginPayload) + + // .then((response) => { + // const token = response.data.token; + + // localStorage.setItem("token", token); + // localStorage.setItem("auth", true); + // if (token) { + // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; + // } + + // navigate("/users"); + // }) + // .catch((err) => console.log(err)); + apiLoginUserAsync(email, password).then(() => { + navigate("/users"); + }); + } + + return ( + + + {t('common.welcome')} + + + {t('common.loginToContinue')} + + setEmail(e.target.value)} + error={email.length > 0 && !isValid} + helperText={email.length > 0 && error} + /> + setPassword(p.target.value)} + /> + + + {t('common.login')} + + + ); +}; + +export default LoginFormSection; diff --git a/src/sections/LoginFormSectionStyles.jsx b/src/sections/LoginFormSectionStyles.jsx new file mode 100644 index 0000000..4baf633 --- /dev/null +++ b/src/sections/LoginFormSectionStyles.jsx @@ -0,0 +1,16 @@ +export const formContainer = { + padding: 4, + width: '100%', + maxWidth: 400, + margin: 'auto', + backgroundColor: '#FAF9F6', + borderRadius: 3, + boxShadow: 3, + }; + + export const socialButtonsWrapper = { + display: 'flex', + gap: 1, + justifyContent: 'center', + }; + \ No newline at end of file diff --git a/src/sections/OrdersHeader.jsx b/src/sections/OrdersHeader.jsx new file mode 100644 index 0000000..e323f2a --- /dev/null +++ b/src/sections/OrdersHeader.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { + Box, + Typography, + TextField, + InputAdornment, + Button, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +const OrdersHeader = ({ + searchTerm, + setSearchTerm, + statusFilter, + setStatusFilter, +}) => { + const { t } = useTranslation(); + return ( + + + + {t('common.orders')} + + + {t('common.adminPanel')} > {t('common.orders')} + + + + + {/* 🔽 Filter by Status */} + + {t('common.status')} + + + + {/* 🔍 Search */} + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + borderRadius: 2, + backgroundColor: '#f9f9f9', + minWidth: { xs: '100%', sm: '240px' }, + }} + /> + + + ); +}; + +export default OrdersHeader; diff --git a/src/sections/PendingUsersHeader.jsx b/src/sections/PendingUsersHeader.jsx new file mode 100644 index 0000000..d4be3df --- /dev/null +++ b/src/sections/PendingUsersHeader.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import { Box, Typography, TextField, InputAdornment } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from 'react-i18next'; + +const PendingUsersHeader = ({ onAddUser, searchTerm, setSearchTerm }) => { + const { t } = useTranslation(); + return ( + + + + {t('common.requests')} + + + {t('common.adminPanel')} > {t('common.requests')} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ borderRadius: 2, backgroundColor: "#f9f9f9" }} + /> + + + ); +}; + +export default PendingUsersHeader; diff --git a/src/sections/PendingUsersSection.jsx b/src/sections/PendingUsersSection.jsx new file mode 100644 index 0000000..4ffd572 --- /dev/null +++ b/src/sections/PendingUsersSection.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import PendingUsersTable from '@components/PendingUsersTable'; + +const PendingUsersSection = ({ + users, + onApprove, + onDelete, + currentPage, + usersPerPage, +}) => { + return ( + <> + + + ); +}; + +export default PendingUsersSection; diff --git a/src/sections/RoutesHeader.jsx b/src/sections/RoutesHeader.jsx new file mode 100644 index 0000000..015de01 --- /dev/null +++ b/src/sections/RoutesHeader.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { + Box, + Typography, + Button, + TextField, + InputAdornment, +} from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from 'react-i18next'; + +const RoutesHeader = ({ onAddRoute }) => { + const { t } = useTranslation(); + return ( + + + + {t('common.allRoutes')} + + + {t('common.adminPanel')} > {t('common.routes')} + + + + + ); +}; + +export default RoutesHeader; diff --git a/src/sections/StoresHeader.jsx b/src/sections/StoresHeader.jsx new file mode 100644 index 0000000..2cbe1a5 --- /dev/null +++ b/src/sections/StoresHeader.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import { + Box, + Typography, + TextField, + InputAdornment, + Button, +} from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from 'react-i18next'; + +const StoresHeader = ({ searchTerm, setSearchTerm, onAddStore }) => { + const { t } = useTranslation(); + return ( + + + + {t('stores.stores')} + + + {t('common.adminPanel')} > {t('stores.stores')} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + borderRadius: 2, + backgroundColor: "#f9f9f9", + minWidth: { xs: "100%", sm: "240px" }, + }} + /> + + + + + ); +}; + +export default StoresHeader; diff --git a/src/sections/UserCreateSection.jsx b/src/sections/UserCreateSection.jsx new file mode 100644 index 0000000..aae85c5 --- /dev/null +++ b/src/sections/UserCreateSection.jsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import { + Box, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + Snackbar, + Alert +} from '@mui/material'; +import ValidatedTextField from '../components/ValidatedTextField'; +import CustomButton from '../components/CustomButton'; +import { useTranslation } from 'react-i18next'; + +const UserCreateSection = () => { + const { t } = useTranslation(); + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + role: 'buyer', + }); + + const [errors, setErrors] = useState({}); + const [snackbarOpen, setSnackbarOpen] = useState(false); + + const validate = () => { + const newErrors = {}; + if (!formData.name.trim()) newErrors.name = 'Name is required'; + if (!formData.email.trim()) newErrors.email = 'Email is required'; + else if (!/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = 'Invalid email'; + if (!formData.password.trim()) newErrors.password = 'Password is required'; + else if (formData.password.length < 6) newErrors.password = 'Minimum 6 characters'; + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = (e) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + setErrors({ ...errors, [e.target.name]: '' }); + }; + + const handleSubmit = () => { + if (!validate()) return; + + //temporary + console.log('User created:', formData); + setSnackbarOpen(true); + setFormData({ name: '', email: '', password: '', role: 'buyer' }); + }; + + return ( + + + {t('common.createNewUser')} + + + + + + + + + + {t('common.role')} + + + + {t('common.createUser')} + + setSnackbarOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbarOpen(false)} + severity="success" + sx={{ width: '100%' }} + > + {t('common.userCreatedSuccessfully')} + + + + ); +}; + +export default UserCreateSection; diff --git a/src/sections/UserDetailsSection.jsx b/src/sections/UserDetailsSection.jsx new file mode 100644 index 0000000..1293188 --- /dev/null +++ b/src/sections/UserDetailsSection.jsx @@ -0,0 +1,80 @@ +import { Card, CardContent, Typography, Button, Box } from "@mui/material"; +import React, { useState, useEffect } from "react"; + +import UserAvatar from "../components/UserAvatar.jsx"; +import UserName from "../components/UserName.jsx"; +import UserEmail from "../components/UserEmail.jsx"; +import UserPhone from "../components/UserPhone.jsx"; +import UserRoles from "../components/UserRoles.jsx"; +import UserEditForm from "../components/userEditForm.jsx"; + +import { getUsers, updateUser } from "../data/usersDetails.js"; +import { useTranslation } from 'react-i18next'; + +const UserDetailsSection = () => { + const [selectedUser, setSelectedUser] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + const user = getUsers()[0]; + setSelectedUser(user); + }, []); + + const handleEditToggle = () => { + setIsEditing(!isEditing); + }; + + const handleUserSave = (updatedUser) => { + setSelectedUser(updatedUser); + setIsEditing(false); + }; + + if (!selectedUser) { + return {t('common.loadingUserData')}; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + {isEditing && ( + + )} + + + + ); +}; + +export default UserDetailsSection; diff --git a/src/sections/UserManagementHeader.jsx b/src/sections/UserManagementHeader.jsx new file mode 100644 index 0000000..806d134 --- /dev/null +++ b/src/sections/UserManagementHeader.jsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Box, + Typography, + Button, + TextField, + InputAdornment, +} from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from 'react-i18next'; + +const UserManagementHeader = ({ onAddUser, searchTerm, setSearchTerm }) => { + const { t } = useTranslation(); + return ( + + + + {t('common.userManagement')} + + + {t('common.adminPanel')} > {t('common.userManagement')} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + borderRadius: 2, + backgroundColor: "#f9f9f9", + minWidth: { xs: "100%", sm: "240px" }, + }} + /> + + + + ); +}; + +export default UserManagementHeader; diff --git a/src/sections/UserManagementSection.jsx b/src/sections/UserManagementSection.jsx new file mode 100644 index 0000000..7dc68e3 --- /dev/null +++ b/src/sections/UserManagementSection.jsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { Box } from '@mui/material'; +import UserList from '../components/UserList.jsx'; +import ConfirmDialog from '../components/ConfirmDialog.jsx'; +import UserDetailsModal from '@components/UserDetailsModal'; +import { apiUpdateUserAsync, apiToggleUserAvailabilityAsync } from '@api/api'; +import { useTranslation } from 'react-i18next'; + +export default function UserManagementSection({ + allUsers, + currentPage, + usersPerPage, + onDelete, + onView, + onEdit, + setAllUsers, +}) { + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); + const { t } = useTranslation(); + + const handleDelete = (userId) => { + setUserToDelete(userId); + setConfirmDialogOpen(true); + }; + + const confirmDelete = () => { + onDelete(userToDelete); + setConfirmDialogOpen(false); + setUserToDelete(null); + }; + + const cancelDelete = () => { + setConfirmDialogOpen(false); + setUserToDelete(null); + }; + + const handleEdit = async (updatedUser) => { + try { + if (updatedUser.toggleAvailabilityOnly) { + onEdit(updatedUser); + + const response = await apiToggleUserAvailabilityAsync( + updatedUser.id, + updatedUser.isActive + ); + console.log('RESP:', response); + + if (response.statusText === 'OK') { + onEdit({ ...updatedUser, isActive: updatedUser.isActive }); + } + } else { + const response = await apiUpdateUserAsync(updatedUser); + if (response.status !== 400) { + onEdit(response.data); + } + } + } catch (err) { + console.error('Error editing user:', err); + } + }; + + return ( + + + + + + ); +} diff --git a/src/services/.gitkeep b/src/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/store/.gitkeep b/src/store/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/styles/.gitkeep b/src/styles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/styles/theme.js b/src/styles/theme.js new file mode 100644 index 0000000..d2b916e --- /dev/null +++ b/src/styles/theme.js @@ -0,0 +1,41 @@ +// theme.js +import { createTheme } from '@mui/material/styles'; + +const theme = createTheme({ + palette: { + primary: { + main: "#3C5B66", + contrastText: "#FFFFFF", + }, + secondary: { + main: "#D7A151", + contrastText: "#FFFFFF", + }, + error: { + main: "#923330", + }, + text: { + primary: "#4D1211", + secondary: "#3C5B66", + }, + }, + typography: { + fontFamily: "Manrope, sans-serif", + h1: { fontWeight: 700 }, + h2: { fontWeight: 700 }, + h3: { fontWeight: 600 }, + h4: { fontWeight: 600 }, + h5: { fontWeight: 500 }, + h6: { fontWeight: 500 }, + body1: { fontWeight: 400 }, + button: { + textTransform: "none", + fontWeight: 600, + }, + }, + shape: { + borderRadius: 5, + }, +}); + +export default theme; diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..eeb464b --- /dev/null +++ b/src/theme.js @@ -0,0 +1,149 @@ +import { createTheme } from '@mui/material/styles'; + +const theme = createTheme({ + palette: { + primary: { + main: '#2563EB', + light: '#93C5FD', + dark: '#1E40AF', + contrastText: '#FFFFFF', + }, + secondary: { + main: '#0D9488', + light: '#99F6E4', + dark: '#0F766E', + contrastText: '#FFFFFF', + }, + success: { + main: '#10B981', + light: '#A7F3D0', + dark: '#047857', + }, + warning: { + main: '#F59E0B', + light: '#FDE68A', + dark: '#B45309', + }, + error: { + main: '#EF4444', + light: '#FCA5A5', + dark: '#B91C1C', + }, + info: { + main: '#3B82F6', + light: '#BFDBFE', + dark: '#1E40AF', + }, + grey: { + 50: '#F9FAFB', + 100: '#F3F4F6', + 200: '#E5E7EB', + 300: '#D1D5DB', + 400: '#9CA3AF', + 500: '#6B7280', + 600: '#4B5563', + 700: '#374151', + 800: '#1F2937', + 900: '#111827', + }, + background: { + default: '#F9FAFB', + paper: '#FFFFFF', + }, + text: { + primary: '#111827', + secondary: '#4B5563', + disabled: '#9CA3AF', + }, + }, + typography: { + fontFamily: '"Inter", "Helvetica", "Arial", sans-serif', + h1: { + fontSize: '2.5rem', + fontWeight: 700, + lineHeight: 1.2, + }, + h2: { + fontSize: '2rem', + fontWeight: 700, + lineHeight: 1.2, + }, + h3: { + fontSize: '1.75rem', + fontWeight: 600, + lineHeight: 1.2, + }, + h4: { + fontSize: '1.5rem', + fontWeight: 600, + lineHeight: 1.2, + }, + h5: { + fontSize: '1.25rem', + fontWeight: 600, + lineHeight: 1.2, + }, + h6: { + fontSize: '1rem', + fontWeight: 600, + lineHeight: 1.2, + }, + body1: { + fontSize: '1rem', + lineHeight: 1.5, + }, + body2: { + fontSize: '0.875rem', + lineHeight: 1.5, + }, + subtitle1: { + fontSize: '1rem', + fontWeight: 500, + lineHeight: 1.5, + }, + subtitle2: { + fontSize: '0.875rem', + fontWeight: 500, + lineHeight: 1.5, + }, + }, + shape: { + borderRadius: 8, + }, + shadows: [ + 'none', + '0px 1px 2px rgba(0, 0, 0, 0.06), 0px 1px 3px rgba(0, 0, 0, 0.1)', + '0px 2px 4px rgba(0, 0, 0, 0.06), 0px 4px 6px rgba(0, 0, 0, 0.1)', + '0px 4px 6px rgba(0, 0, 0, 0.05), 0px 10px 15px rgba(0, 0, 0, 0.1)', + '0px 10px 15px rgba(0, 0, 0, 0.04), 0px 20px 25px rgba(0, 0, 0, 0.1)', + // ... rest of the shadows + ], + components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + padding: '8px 16px', + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + boxShadow: '0px 4px 6px rgba(0, 0, 0, 0.05), 0px 10px 15px rgba(0, 0, 0, 0.1)', + borderRadius: '12px', + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + borderRadius: '12px', + }, + }, + }, + }, +}); + +export default theme; \ No newline at end of file diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/apiroutes.js b/src/utils/apiroutes.js new file mode 100644 index 0000000..bfb2e5c --- /dev/null +++ b/src/utils/apiroutes.js @@ -0,0 +1,10 @@ +const server = import.meta.env.VITE_API_URL; + + +export const api = { + login: `${server}/api/Auth/login`, + users: `${server}/api/Admin/users`, + delete_user: `${server}/api/Admin/user/`, + create_user: `${server}/api/Admin/users/create`, + approve_user: `${server}/api/Admin/users/approve` +} \ No newline at end of file diff --git a/src/utils/axios.js b/src/utils/axios.js new file mode 100644 index 0000000..a6740c4 --- /dev/null +++ b/src/utils/axios.js @@ -0,0 +1,11 @@ +import axios from 'axios'; + +const token = localStorage.getItem('token'); + +if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; +} + +axios.defaults.baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5054'; + +export default axios; \ No newline at end of file diff --git a/src/utils/login.js b/src/utils/login.js new file mode 100644 index 0000000..4547fc1 --- /dev/null +++ b/src/utils/login.js @@ -0,0 +1,41 @@ +/*// src/services/authService.js +import apiClientInstance from '../api/apiClientInstance'; +// *** IMPORTANT: Use the correct API class name based on your HAR log *** +import TestAuthApi from '../api/api/TestAuthApi'; // <<< Make sure this matches your generated file name +import LoginDTO from '../api/model/LoginDTO'; + +export const loginUser = async (username, password) => { + // Use the correct API class + const testAuthApi = new TestAuthApi(apiClientInstance); // <<< Use correct class instance + + const loginPayload = new LoginDTO(); + loginPayload.username = username; + loginPayload.password = password; + + console.log("Attempting login via TestAuthApi for:", username); + + return new Promise((resolve, reject) => { + try { + // *** IMPORTANT: Check the method name in generated TestAuthApi.js *** + // It might be testAuthLoginPost, apiTestAuthLoginPost, etc. + testAuthApi.apiTestAuthLoginPost(loginPayload, (error, data, response) => { // <<< Use correct method name + if (error) { + console.error('Login API Error:', error); + console.error('Login API Response:', response); + const status = error?.status || response?.status || null; + let message = 'Login failed. Please check your credentials.'; + return false; + //reject({ message: message, status: status }); // TODO: Lijepo prikazati + } else { + console.log('Login successful via API.'); + console.log('API Response Data:', data); + localStorage.setItem('auth', true); + resolve(data); + } + }); + } catch (err) { + console.error('Synchronous error calling login API:', err); + reject({ message: err.message || 'An unexpected error occurred.', status: null }); + } + }); +};*/ \ No newline at end of file diff --git a/src/utils/users.js b/src/utils/users.js new file mode 100644 index 0000000..9e2af5a --- /dev/null +++ b/src/utils/users.js @@ -0,0 +1,68 @@ +// src/services/adminService.js + +import apiClientInstance from '../api/apiClientInstance'; // Your configured instance +// Adjust the import path based on your generation +import AdminApi from '../api/api/AdminApi'; + +/** + * Fetches the list of all users from the admin endpoint. + * Assumes the user is authenticated via HttpOnly cookie. + * Returns the array of user data on success. + * Throws an error object { message: string, status: number | null } on failure. + * + * @returns {Promise>} A promise that resolves with the array of users on success. + * @throws {object} An error object with message and status on failure (e.g., 401, 403, 500). + */ +export const fetchAdminUsers = async () => { + // Instantiate the specific API class using the configured client + const adminApi = new AdminApi(apiClientInstance); + + console.log("Attempting to fetch admin users..."); // Debug log + + // Wrap the generated callback method in a Promise + return new Promise((resolve, reject) => { + try { + // Call the generated method for GET /api/Admin/users. + // *** IMPORTANT: Check your generated AdminApi.js *** + // The method name might be getUsers, apiAdminUsersGet, listAdminUsers, etc. + // It typically only takes the callback function as an argument for a simple GET. + adminApi.apiAdminUsersGet((error, data, response) => { // <<< Use correct method name + if (error) { + // Handle API errors (e.g., 401 Unauthorized, 403 Forbidden, 500 Server Error) + console.error('Fetch Admin Users API Error:', error); + console.error('Fetch Admin Users API Response:', response); + + const status = error?.status || response?.status || null; + let message = 'Failed to fetch users.'; // Default message + + // Customize message based on status + if (status === 401) { + message = 'Unauthorized. Please log in again.'; + } else if (status === 403) { + message = 'Forbidden. You do not have permission to view users.'; + } else if (status >= 500) { + message = 'Server error fetching users. Please try again later.'; + } else if (error?.message) { + message = error.message; + } else if (response?.text) { + try { message = JSON.parse(response.text).detail || JSON.parse(response.text).title || message } catch (e) { message = response.text.substring(0, 100) || message } + } + + reject({ message: message, status: status }); + } else { + // Request successful! + console.log('Successfully fetched admin users.'); + console.log('API Response Data:', data); + // Resolve the promise with the user data array + resolve(data || []); // Ensure we return an array even if API returns null/undefined + } + }); + } catch (err) { + // Catch synchronous errors during API call setup + console.error('Synchronous error calling fetch users API:', err); + reject({ message: err.message || 'An unexpected error occurred.', status: null }); + } + }); +}; + +// Add other admin-related service functions here (e.g., approveUser, deleteUser) \ No newline at end of file diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..be445dd --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +export const emailSchema = z.object({ + email: z.string() + .min(1, { message: 'Email is required' }) + .email({ message: 'Please enter a valid email address' }) + .refine((email) => { + return true; + }, { message: 'Invalid email format' }) +}); + +export const validateEmail = (email) => { + try { + emailSchema.parse({ email }); + return { isValid: true, error: null }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + isValid: false, + error: error.errors[0].message + }; + } + return { + isValid: false, + error: 'An unexpected error occurred' + }; + } +}; \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 8b0f57b..59fa629 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,4 +4,27 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@src": "/src", + "@assets": "/src/assets", + "@fonts": "/src/assets/fonts", + "@icons": "/src/assets/icons", + "@images": "/src/assets/images", + "@components": "/src/components", + "@pages": "/src/pages", + "@data": "/src/data", + "@hooks": "/src/hooks", + "@routes": "/src/routes", + "@sections": "/src/sections", + "@styles": "/src/styles", + "@utils": "/src/utils", + "@store": "/src/store", + "@services": "/src/services", + "@context": "/src/context", + '@api': "/src/api", + '@models': "/src/models" + + }, + }, })