smaller changes

This commit is contained in:
Alexander Medvedev 2026-07-31 17:35:33 +02:00
parent 9233b48758
commit a18a57114d
12 changed files with 902 additions and 254 deletions

320
package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.5.4", "@marsidev/react-turnstile": "^1.5.4",
"@vnedyalk0v/react19-simple-maps": "^2.0.9", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.19.0", "axios": "^1.19.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
@ -32,16 +32,16 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/country-list": "^2.1.4", "@types/country-list": "^2.1.4",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.8.0", "globals": "^17.8.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.65.0",
"vite": "^8.1.5" "vite": "^8.2.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -294,21 +294,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2", "@emnapi/wasi-threads": "2.0.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.1", "version": "2.0.0-alpha.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -317,9 +317,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -582,28 +582,31 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.3" "@tybys/wasm-util": "^0.10.3"
}, },
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"peerDependencies": { "peerDependencies": {
"@emnapi/core": "^1.7.1", "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
"@emnapi/runtime": "^1.7.1" "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.139.0", "version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -637,9 +640,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -654,9 +657,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -671,9 +674,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -688,9 +691,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -705,9 +708,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -722,9 +725,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -739,9 +742,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -756,9 +759,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -773,9 +776,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -790,9 +793,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -807,9 +810,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -824,9 +827,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -841,28 +844,25 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
"cpu": [
"wasm32"
],
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "1.11.1", "@emnapi/core": "2.0.0-alpha.3",
"@emnapi/runtime": "1.11.1", "@emnapi/runtime": "2.0.0-alpha.3",
"@napi-rs/wasm-runtime": "^1.1.6" "@napi-rs/wasm-runtime": "^1.2.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -877,9 +877,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1066,9 +1066,9 @@
} }
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1076,9 +1076,9 @@
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "19.2.3", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@ -1687,9 +1687,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.4", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1713,9 +1713,9 @@
} }
}, },
"node_modules/@vnedyalk0v/react19-simple-maps": { "node_modules/@vnedyalk0v/react19-simple-maps": {
"version": "2.0.9", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.9.tgz", "resolved": "https://registry.npmjs.org/@vnedyalk0v/react19-simple-maps/-/react19-simple-maps-2.0.10.tgz",
"integrity": "sha512-QMV6QkRcAVulhgA4vEVE68tvXk7xVn5MAi5gCTSfWhWFd4YRwnGaRLA/m4UEmhTRuXe4dabGVIVKFSMfHBy77Q==", "integrity": "sha512-tw8PYah/5c1UiwqhtB0k40p+jlYRW9QjdZZQ37CKONXErIZFHl0Sn8Ho03HNP/mXQs0YrogrBIeolln5mfbIsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"d3-color": "^3.1.0", "d3-color": "^3.1.0",
@ -3252,9 +3252,9 @@
} }
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true, "dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
@ -3268,23 +3268,23 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
}, },
"optionalDependencies": { "optionalDependencies": {
"lightningcss-android-arm64": "1.32.0", "lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.32.0", "lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.32.0", "lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.32.0", "lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.32.0" "lightningcss-win32-x64-msvc": "1.33.0"
} }
}, },
"node_modules/lightningcss-android-arm64": { "node_modules/lightningcss-android-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -3303,9 +3303,9 @@
} }
}, },
"node_modules/lightningcss-darwin-arm64": { "node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -3324,9 +3324,9 @@
} }
}, },
"node_modules/lightningcss-darwin-x64": { "node_modules/lightningcss-darwin-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -3345,9 +3345,9 @@
} }
}, },
"node_modules/lightningcss-freebsd-x64": { "node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -3366,9 +3366,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm-gnueabihf": { "node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -3387,9 +3387,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-gnu": { "node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -3408,9 +3408,9 @@
} }
}, },
"node_modules/lightningcss-linux-arm64-musl": { "node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -3429,9 +3429,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-gnu": { "node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -3450,9 +3450,9 @@
} }
}, },
"node_modules/lightningcss-linux-x64-musl": { "node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -3471,9 +3471,9 @@
} }
}, },
"node_modules/lightningcss-win32-arm64-msvc": { "node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -3492,9 +3492,9 @@
} }
}, },
"node_modules/lightningcss-win32-x64-msvc": { "node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4960,13 +4960,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.139.0", "@oxc-project/types": "=0.142.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@ -4976,21 +4976,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-android-arm64": "1.2.1",
"@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.2.1",
"@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-darwin-x64": "1.2.1",
"@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.2.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
"@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.2.1",
"@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.2.1",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
"@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.2.1",
"@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.2.1",
"@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.2.1",
"@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.2.1",
"@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.2.1",
"@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.2.1",
"@rolldown/binding-win32-x64-msvc": "1.1.5" "@rolldown/binding-win32-x64-msvc": "1.2.1"
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
@ -5435,16 +5435,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.5", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
"postcss": "^8.5.17", "postcss": "^8.5.23",
"rolldown": "~1.1.5", "rolldown": "~1.2.0",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@ -5461,7 +5461,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0", "@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",

View File

@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.5.4", "@marsidev/react-turnstile": "^1.5.4",
"@vnedyalk0v/react19-simple-maps": "^2.0.9", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.19.0", "axios": "^1.19.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
@ -35,18 +35,18 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/country-list": "^2.1.4", "@types/country-list": "^2.1.4",
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.8.0", "globals": "^17.8.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.65.0",
"vite": "^8.1.5" "vite": "^8.2.0"
}, },
"overrides": { "overrides": {
"vite": "^8.1.5" "vite": "^8.2.0"
} }
} }

View File

@ -64,12 +64,10 @@ h3 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 800; font-weight: 800;
margin-bottom: 2rem; margin-bottom: 2rem;
text-transform: uppercase;
} }
.section-title span { .section-title span {
color: var(--primary); color: inherit;
border-bottom: 2px solid var(--primary);
padding-bottom: 4px; padding-bottom: 4px;
} }
@ -194,35 +192,48 @@ h3 {
text-decoration: none !important; text-decoration: none !important;
} }
.nav-search { .nav-search-center {
flex-grow: 1; flex: 1;
margin: 0 2rem; display: flex;
max-width: 400px; justify-content: center;
margin: 0 1.5rem;
} }
.nav-search input { .nav-search-input-wrap {
position: relative;
width: 100%; width: 100%;
padding: 10px 15px; max-width: 440px;
border-radius: 6px; display: flex;
border: 1px solid var(--border); align-items: center;
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
} }
.nav-search input::placeholder { .nav-search-icon {
position: absolute;
left: 14px;
color: var(--text-muted);
pointer-events: none;
}
.nav-search-input-wrap input {
width: 100%;
padding: 8px 16px 8px 40px;
border-radius: 20px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
color: var(--text);
font-size: 0.875rem;
outline: none;
transition: all 0.2s ease;
}
.nav-search-input-wrap input::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
.nav-search input:focus { .nav-search-input-wrap input:focus {
border-color: var(--primary); border-color: var(--primary);
} background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 12px rgba(249, 115, 22, 0.25);
.nav-search-right {
margin-right: 1rem;
width: 250px;
} }
.nav-search-right input { .nav-search-right input {
@ -296,7 +307,7 @@ h3 {
} }
.plugin-sidebar { .plugin-sidebar {
order: -1; order: 1;
width: 100%; width: 100%;
} }

View File

@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import Home from './pages/Home'; import Home from './pages/Home';
import PluginDetail from './pages/PluginDetail'; import PluginDetail from './pages/PluginDetail';
import LoginPage from './pages/auth/LoginPage'; import LoginPage from './pages/auth/LoginPage';
@ -171,9 +172,14 @@ import './pages/dashboard/Dashboard.css';
const DashboardLayout = () => { const DashboardLayout = () => {
return ( return (
<div className="dashboard-container"> <div className="dashboard-container">
<nav className="dashboard-nav"> <header className="dashboard-app-header">
<NavLink to="/dashboard/plugins">My Plugins</NavLink> <div className="dashboard-app-header-inner">
</nav> <Link to="/dashboard/plugins" className="dashboard-app-brand">
<img src="/icon.png" alt="Market Logo" className="dashboard-app-logo" />
<span className="dashboard-app-title">DEVELOPER <span>STUDIO</span></span>
</Link>
</div>
</header>
<div className="dashboard-content"> <div className="dashboard-content">
<Outlet /> <Outlet />
</div> </div>
@ -227,8 +233,10 @@ const Navbar = ({ user }: any) => {
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>} {user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
</div> </div>
{!user && ( {/* ── Center Search Bar ── */}
<div className="nav-search"> <div className="nav-search-center">
<div className="nav-search-input-wrap">
<Search size={16} className="nav-search-icon" />
<input <input
type="text" type="text"
placeholder={t('search')} placeholder={t('search')}
@ -237,22 +245,11 @@ const Navbar = ({ user }: any) => {
onKeyDown={handleSearch} onKeyDown={handleSearch}
/> />
</div> </div>
)} </div>
<div className="nav-actions"> <div className="nav-actions">
{user ? ( {user ? (
<> <NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
<div className="nav-search-right">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
</>
) : ( ) : (
<> <>
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('login')}</Link> <Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('login')}</Link>

View File

@ -31,7 +31,7 @@ const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; ori
}; };
}; };
const PluginCard = ({ plugin }: { plugin: any }) => { const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDescription?: boolean }) => {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
@ -129,7 +129,7 @@ const PluginCard = ({ plugin }: { plugin: any }) => {
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>} {plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
</p> </p>
<p className="pcv2-dev">by {plugin.dev_name}</p> <p className="pcv2-dev">by {plugin.dev_name}</p>
<p className="pcv2-desc">{desc}</p> {!hideDescription && <p className="pcv2-desc">{desc}</p>}
<div className="pcv2-footer"> <div className="pcv2-footer">
<span className="pcv2-stat"> <span className="pcv2-stat">

View File

@ -219,7 +219,7 @@ const Home = () => {
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{popular.length > 0 ? ( {popular.length > 0 ? (
popular.map((plugin) => ( popular.map((plugin) => (
<PluginCard key={`pop-${plugin.id}`} plugin={plugin} /> <PluginCard key={`pop-${plugin.id}`} plugin={plugin} hideDescription />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading popular plugins...' : 'Scroll to load popular plugins'}</div> <div className="section-placeholder">{loading ? 'Loading popular plugins...' : 'Scroll to load popular plugins'}</div>
@ -232,7 +232,7 @@ const Home = () => {
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{newest.length > 0 ? ( {newest.length > 0 ? (
newest.map((plugin) => ( newest.map((plugin) => (
<PluginCard key={`new-${plugin.id}`} plugin={plugin} /> <PluginCard key={`new-${plugin.id}`} plugin={plugin} hideDescription />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading new plugins...' : 'Scroll to load new plugins'}</div> <div className="section-placeholder">{loading ? 'Loading new plugins...' : 'Scroll to load new plugins'}</div>
@ -245,7 +245,7 @@ const Home = () => {
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{allPlugins.length > 0 ? ( {allPlugins.length > 0 ? (
allPlugins.map((plugin) => ( allPlugins.map((plugin) => (
<PluginCard key={`all-${plugin.id}`} plugin={plugin} /> <PluginCard key={`all-${plugin.id}`} plugin={plugin} hideDescription />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div> <div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div>

View File

@ -171,6 +171,51 @@ const ProfilePage = () => {
}); });
}; };
const handleSaveAccountInfo = async (e: React.FormEvent) => {
e.preventDefault();
await withSaving('accountInfo', async () => {
let updatedAny = false;
if (formData.username && formData.username !== user?.username) {
try {
const res = await api.post('/user/change-username', { newUsername: formData.username });
if (res.data.token) login(res.data.token);
updatedAny = true;
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to update username.', 'error');
return;
}
}
if (formData.email && formData.email !== user?.email) {
try {
const res = await api.post('/user/change-email', { newEmail: formData.email });
updatedAny = true;
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to update email.', 'error');
return;
}
}
if (formData.country && formData.country !== user?.country) {
try {
await api.post('/user/change-country', { newCountry: formData.country });
updatedAny = true;
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to update country.', 'error');
return;
}
}
if (updatedAny) {
showToast('Account details updated successfully!');
refreshUser?.();
} else {
showToast('No changes to save.');
}
});
};
const handleLogout = () => { logout(); navigate('/login'); }; const handleLogout = () => { logout(); navigate('/login'); };
const handleConnectStripe = async () => { const handleConnectStripe = async () => {
@ -375,48 +420,34 @@ const ProfilePage = () => {
<h2 className="section-title" style={{ marginBottom: 0 }}> <h2 className="section-title" style={{ marginBottom: 0 }}>
<span>Account</span> Details <span>Account</span> Details
</h2> </h2>
<p className="settings-section-sub">Manage your public identity and contact information.</p>
</div> </div>
<SettingsCard title="Username" description="Your public display name across the marketplace." icon={User}> <SettingsCard title="Account Profile" icon={User}>
<form onSubmit={e => handleUpdate(e, '/user/change-username', { newUsername: formData.username }, 'username')}> <form onSubmit={handleSaveAccountInfo} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<Field label="Username"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1.25rem' }}>
<input <Field label="Username">
className="settings-input" <input
type="text" className="settings-input"
value={formData.username} type="text"
onChange={e => setFormData({ ...formData, username: e.target.value })} value={formData.username}
placeholder="Your username" onChange={e => setFormData({ ...formData, username: e.target.value })}
/> placeholder="Your username"
</Field> required
<SaveBtn id="username" label="Update Username" /> />
</form> </Field>
</SettingsCard>
<SettingsCard title="Email Address" description="We'll send a verification link to your new address." icon={Mail}> <Field label="Email Address">
<form onSubmit={e => handleUpdate(e, '/user/change-email', { newEmail: formData.email }, 'email')}> <input
<Field label="Email"> className="settings-input"
<input type="email"
className="settings-input" value={formData.email}
type="email" onChange={e => setFormData({ ...formData, email: e.target.value })}
value={formData.email} placeholder="your@email.com"
onChange={e => setFormData({ ...formData, email: e.target.value })} required
placeholder="your@email.com" />
/> </Field>
</Field> </div>
<SaveBtn id="email" label="Update Email" />
</form>
</SettingsCard>
<SettingsCard title="Country" description="Used for tax purposes and regional features. Auto-detected from IP on registration." icon={Globe}>
<form onSubmit={e => {
if (!formData.country) {
e.preventDefault();
showToast('Please select a country.', 'error');
return;
}
handleUpdate(e, '/user/change-country', { newCountry: formData.country }, 'country');
}}>
<Field label="Country"> <Field label="Country">
<select <select
className="settings-input settings-select" className="settings-input settings-select"
@ -432,7 +463,10 @@ const ProfilePage = () => {
))} ))}
</select> </select>
</Field> </Field>
<SaveBtn id="country" label="Update Country" />
<div style={{ marginTop: '0.5rem' }}>
<SaveBtn id="accountInfo" label="Save Changes" />
</div>
</form> </form>
</SettingsCard> </SettingsCard>
</div> </div>

View File

@ -13,6 +13,44 @@
--dash-shadow: 0 1px 3px rgba(0,0,0,0.08); /* New: for subtle shadows */ --dash-shadow: 0 1px 3px rgba(0,0,0,0.08); /* New: for subtle shadows */
} }
/* Dashboard Top App Header */
.dashboard-app-header {
background: var(--dash-surface);
border-bottom: 1px solid var(--dash-border);
padding: 0.85rem 2rem;
margin-bottom: 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.dashboard-app-header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.dashboard-app-brand {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none !important;
color: var(--dash-text);
font-weight: 800;
font-size: 1.1rem;
letter-spacing: -0.3px;
}
.dashboard-app-logo {
height: 32px;
width: auto;
object-fit: contain;
}
.dashboard-app-title span {
color: var(--dash-accent, #ff7518);
}
/* Override App.css within the dashboard */ /* Override App.css within the dashboard */
.dashboard-content, .dashboard-nav { .dashboard-content, .dashboard-nav {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";

View File

@ -3,10 +3,11 @@ import { useNavigate } from 'react-router-dom';
import { import {
Globe, ImageIcon, Plus, Search, X, Upload, FileCode, Globe, ImageIcon, Plus, Search, X, Upload, FileCode,
DollarSign, CheckCircle, ChevronRight, ChevronLeft, DollarSign, CheckCircle, ChevronRight, ChevronLeft,
Zap, Lock, AlertTriangle, Tag, Store, User, Zap, Lock, AlertTriangle, Tag, Store, User, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import api from '../../../api'; import api from '../../../api';
import { useAuth } from '../../../App'; import { useAuth } from '../../../App';
import { DeveloperOnboardingModal } from '../../../components/DeveloperOnboardingModal';
import './AddPlugin.css'; import './AddPlugin.css';
// ── Locale helpers ──────────────────────────────────────────────────────────── // ── Locale helpers ────────────────────────────────────────────────────────────
@ -193,6 +194,7 @@ const LangPicker = ({ usedCodes, onAdd, onClose }: { usedCodes: string[]; onAdd:
const AddPlugin = () => { const AddPlugin = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
@ -205,6 +207,45 @@ const AddPlugin = () => {
const [youtubeVideoUrl, setYoutubeVideoUrl] = useState(''); const [youtubeVideoUrl, setYoutubeVideoUrl] = useState('');
const [keywords, setKeywords] = useState(''); const [keywords, setKeywords] = useState('');
if (user && !user.is_developer) {
return (
<div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}>
<div style={{
maxWidth: 520, width: '100%', padding: '2.5rem', background: 'var(--mp-surface, #14171c)',
borderRadius: 16, border: '1px solid var(--mp-border, rgba(255,255,255,0.1))', textAlign: 'center',
boxShadow: '0 8px 32px rgba(0,0,0,0.3)'
}}>
<div style={{
width: 60, height: 60, borderRadius: '50%', background: 'rgba(249, 115, 22, 0.15)',
color: 'var(--mp-accent, #f97316)', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', marginBottom: '1.25rem'
}}>
<Sparkles size={28} />
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem 0', color: 'var(--mp-text, #fff)' }}>
Developer Profile Required
</h2>
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.9rem', lineHeight: 1.5, marginBottom: '1.75rem' }}>
Before publishing plugins on Pumpkin Market, you must complete your developer profile. It takes less than a minute!
</p>
<button
className="mp-btn mp-btn-primary"
style={{ width: '100%', padding: '0.85rem', fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', cursor: 'pointer' }}
onClick={() => setIsDevModalOpen(true)}
>
<Sparkles size={16} /> Set Up Developer Profile
</button>
<DeveloperOnboardingModal
isOpen={isDevModalOpen}
onClose={() => setIsDevModalOpen(false)}
onSuccess={() => refreshUser?.()}
/>
</div>
</div>
);
}
// ── Step 1: Store Listing ── // ── Step 1: Store Listing ──
const [descriptions, setDescriptions] = useState<Record<string, string>>({ [DEFAULT_LOCALE]: '' }); const [descriptions, setDescriptions] = useState<Record<string, string>>({ [DEFAULT_LOCALE]: '' });
const [activeLocale, setActiveLocale] = useState(DEFAULT_LOCALE); const [activeLocale, setActiveLocale] = useState(DEFAULT_LOCALE);

View File

@ -0,0 +1,251 @@
import { useState, useEffect } from 'react';
import { Key, Plus, Trash2, ShieldAlert, ShieldCheck, Copy, Check, UserCheck } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
interface PluginLicense {
id: number;
plugin_id: number;
user_id: number;
username: string;
email: string | null;
license_key: string;
revoked: boolean;
created_at: string;
}
type Props = { plugin: PluginData };
const Licenses = ({ plugin }: Props) => {
const [licenses, setLicenses] = useState<PluginLicense[]>([]);
const [loading, setLoading] = useState(true);
// Form state
const [userIdentifier, setUserIdentifier] = useState('');
const [submitting, setSubmitting] = useState(false);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
useEffect(() => {
fetchLicenses();
}, [plugin.id]);
const fetchLicenses = async () => {
try {
const res = await api.get(`/plugins/${plugin.id}/licenses`);
setLicenses(res.data);
} catch (err) {
console.error('Failed to fetch licenses', err);
} finally {
setLoading(false);
}
};
const handleGrantLicense = async (e: React.FormEvent) => {
e.preventDefault();
if (!userIdentifier.trim()) return;
setSubmitting(true);
try {
const res = await api.post(`/plugins/${plugin.id}/licenses`, {
user_identifier: userIdentifier.trim()
});
setLicenses(prev => [res.data, ...prev.filter(l => l.id !== res.data.id)]);
setUserIdentifier('');
} catch (err: any) {
alert(err.response?.data || 'Failed to grant license to user.');
} finally {
setSubmitting(false);
}
};
const handleToggleRevoke = async (licenseId: number, currentRevoked: boolean) => {
const action = currentRevoked ? 'unrevoke' : 'revoke';
if (!window.confirm(`Are you sure you want to ${action} this license key?`)) return;
try {
await api.put(`/plugins/${plugin.id}/licenses/${licenseId}/revoke`, {
revoked: !currentRevoked
});
setLicenses(prev =>
prev.map(l => l.id === licenseId ? { ...l, revoked: !currentRevoked } : l)
);
} catch (err) {
alert(`Failed to ${action} license.`);
}
};
const handleDelete = async (licenseId: number) => {
if (!window.confirm('Are you sure you want to permanently delete this license key record?')) return;
try {
await api.delete(`/plugins/${plugin.id}/licenses/${licenseId}`);
setLicenses(prev => prev.filter(l => l.id !== licenseId));
} catch (err) {
alert('Failed to delete license record.');
}
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div className="mp-section-header">
<h2 className="mp-title">License Key Management</h2>
<p>View, grant, revoke, and manage license keys for your paid plugin buyers and testers.</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '2rem' }}>
{/* Grant License Card */}
<div className="mp-card" style={{ height: 'fit-content' }}>
<div className="mp-card-title">
<Plus size={14} /> Grant Manual License
</div>
<p style={{ fontSize: '0.85rem', color: 'var(--mp-muted)', marginBottom: '1rem' }}>
Issue a valid license key to a user by entering their username, email address, or account ID.
</p>
<form onSubmit={handleGrantLicense} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="mp-form-group">
<label className="mp-label">User (Username, Email, or User ID)</label>
<input
type="text"
className="mp-input"
placeholder="e.g. alex or alex@example.com"
value={userIdentifier}
onChange={e => setUserIdentifier(e.target.value)}
required
/>
</div>
<button
type="submit"
className="mp-btn mp-btn-primary"
disabled={submitting}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}
>
<Key size={14} />
{submitting ? 'Granting...' : 'Issue License Key'}
</button>
</form>
</div>
{/* Licenses Table / List */}
<div className="mp-card" style={{ gridColumn: 'span 2' }}>
<div className="mp-card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Key size={14} /> Active &amp; Issued Licenses ({licenses.length})
</span>
</div>
{loading ? (
<p style={{ color: 'var(--mp-muted)', fontSize: '0.9rem', padding: '1rem 0' }}>Loading licenses…</p>
) : licenses.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--mp-muted)' }}>
<Key size={32} style={{ marginBottom: '0.5rem', opacity: 0.5 }} />
<p>No license keys issued yet for this plugin.</p>
<p style={{ fontSize: '0.85rem' }}>Licenses will automatically appear here when customers purchase your plugin.</p>
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: '1rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--mp-border)', textAlign: 'left', color: 'var(--mp-muted)' }}>
<th style={{ padding: '0.75rem' }}>User</th>
<th style={{ padding: '0.75rem' }}>License Key</th>
<th style={{ padding: '0.75rem' }}>Issued Date</th>
<th style={{ padding: '0.75rem' }}>Status</th>
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.id} style={{ borderBottom: '1px solid var(--mp-border)', opacity: lic.revoked ? 0.65 : 1 }}>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 500 }}>
<UserCheck size={14} color="var(--mp-accent)" />
{lic.username}
</div>
{lic.email && (
<div style={{ fontSize: '0.75rem', color: 'var(--mp-muted)' }}>{lic.email}</div>
)}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace' }}>
<span>{lic.license_key}</span>
<button
type="button"
onClick={() => handleCopyKey(lic.license_key)}
title="Copy license key"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--mp-muted)' }}
>
{copiedKey === lic.license_key ? <Check size={13} color="var(--mp-success)" /> : <Copy size={13} />}
</button>
</div>
</td>
<td style={{ padding: '0.75rem', color: 'var(--mp-muted)', fontSize: '0.8rem' }}>
{new Date(lic.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '0.75rem' }}>
{lic.revoked ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.2rem 0.5rem', borderRadius: '4px', fontSize: '0.75rem',
background: 'rgba(239, 68, 68, 0.15)', color: '#f87171'
}}>
<ShieldAlert size={12} /> Revoked
</span>
) : (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.2rem 0.5rem', borderRadius: '4px', fontSize: '0.75rem',
background: 'rgba(34, 197, 94, 0.15)', color: '#4ade80'
}}>
<ShieldCheck size={12} /> Active
</span>
)}
</td>
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button
type="button"
onClick={() => handleToggleRevoke(lic.id, lic.revoked)}
className="mp-btn"
style={{
padding: '0.3rem 0.6rem',
fontSize: '0.75rem',
background: lic.revoked ? 'var(--mp-accent)' : 'rgba(239, 68, 68, 0.2)',
color: lic.revoked ? '#fff' : '#f87171',
border: 'none', borderRadius: '4px', cursor: 'pointer'
}}
>
{lic.revoked ? 'Unrevoke' : 'Revoke'}
</button>
<button
type="button"
onClick={() => handleDelete(lic.id)}
title="Delete License"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--mp-muted)', padding: '0.3rem'
}}
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
};
export default Licenses;

View File

@ -3,13 +3,15 @@ import { useNavigate, useParams } from 'react-router-dom';
import api from '../../../api'; import api from '../../../api';
import { import {
LayoutGrid, Tag, Upload, DollarSign, Trash2, LayoutGrid, Tag, Upload, DollarSign, Trash2,
Circle, BarChart3 Circle, BarChart3, Key, Star
} from 'lucide-react'; } from 'lucide-react';
import StoreListing from './StoreListing'; import StoreListing from './StoreListing';
import PublishUpdate from './PublishUpdate'; import PublishUpdate from './PublishUpdate';
import Pricing from './Pricing'; import Pricing from './Pricing';
import Coupons from './Coupons'; import Coupons from './Coupons';
import Licenses from './Licenses';
import PluginReviewsTab from './PluginReviewsTab';
import DangerZone from './DangerZone'; import DangerZone from './DangerZone';
import PluginAnalyticsTab from './PluginAnalyticsTab'; import PluginAnalyticsTab from './PluginAnalyticsTab';
import './ManagePlugin.css'; import './ManagePlugin.css';
@ -37,7 +39,9 @@ const NAV_ITEMS = [
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid }, { key: 'listing', label: 'Store Listing', icon: LayoutGrid },
{ key: 'update', label: 'Publish Update', icon: Upload }, { key: 'update', label: 'Publish Update', icon: Upload },
{ key: 'analytics', label: 'Analytics', icon: BarChart3 }, { key: 'analytics', label: 'Analytics', icon: BarChart3 },
{ key: 'reviews', label: 'Reviews', icon: Star },
{ key: 'pricing', label: 'Pricing', icon: DollarSign }, { key: 'pricing', label: 'Pricing', icon: DollarSign },
{ key: 'licenses', label: 'Licenses', icon: Key },
{ key: 'coupons', label: 'Coupons', icon: Tag }, { key: 'coupons', label: 'Coupons', icon: Tag },
{ key: 'danger', label: 'Danger Zone', icon: Trash2 }, { key: 'danger', label: 'Danger Zone', icon: Trash2 },
]; ];
@ -136,9 +140,15 @@ const ManagePlugin = () => {
{activeTab === 'analytics' && ( {activeTab === 'analytics' && (
<PluginAnalyticsTab pluginId={plugin.id} pluginName={plugin.name} /> <PluginAnalyticsTab pluginId={plugin.id} pluginName={plugin.name} />
)} )}
{activeTab === 'reviews' && (
<PluginReviewsTab plugin={plugin} />
)}
{activeTab === 'pricing' && ( {activeTab === 'pricing' && (
<Pricing plugin={plugin} onSaved={refreshPlugin} /> <Pricing plugin={plugin} onSaved={refreshPlugin} />
)} )}
{activeTab === 'licenses' && (
<Licenses plugin={plugin} />
)}
{activeTab === 'coupons' && ( {activeTab === 'coupons' && (
<Coupons plugin={plugin} /> <Coupons plugin={plugin} />
)} )}

View File

@ -0,0 +1,266 @@
import { useState, useEffect, useMemo } from 'react';
import { Star, MessageSquare, CornerDownRight, Send, CheckCircle2 } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
interface Review {
id: number;
user_id: number;
plugin_id: number;
rating: number;
comment: string;
developer_reply?: string | null;
replied_at?: string | null;
created_at: string;
username: string;
}
type Props = {
plugin: PluginData;
};
const PluginReviewsTab = ({ plugin }: Props) => {
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [replyText, setReplyText] = useState<{ [reviewId: number]: string }>({});
const [submitting, setSubmitting] = useState<{ [reviewId: number]: boolean }>({});
useEffect(() => {
fetchPluginReviews();
}, [plugin.id]);
const fetchPluginReviews = async () => {
setLoading(true);
try {
const res = await api.get(`/plugins/${plugin.id}`);
const fetchedReviews: Review[] = res.data.reviews || [];
setReviews(fetchedReviews);
} catch (err) {
console.error('Failed to fetch plugin reviews', err);
} finally {
setLoading(false);
}
};
// Calculate rating statistics & distribution
const stats = useMemo(() => {
if (reviews.length === 0) {
return {
average: 0,
total: 0,
distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }
};
}
const total = reviews.length;
const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
const average = Number((sum / total).toFixed(1));
const distribution: { [key: number]: number } = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
reviews.forEach(r => {
if (distribution[r.rating] !== undefined) {
distribution[r.rating]++;
}
});
return { average, total, distribution };
}, [reviews]);
const handleSendReply = async (reviewId: number) => {
const text = replyText[reviewId]?.trim();
if (!text) return;
setSubmitting(s => ({ ...s, [reviewId]: true }));
try {
await api.post(`/plugins/${plugin.id}/reviews/${reviewId}/reply`, { reply: text });
setReviews(prev =>
prev.map(r =>
r.id === reviewId
? { ...r, developer_reply: text, replied_at: new Date().toISOString() }
: r
)
);
setReplyText(t => ({ ...t, [reviewId]: '' }));
} catch (err: any) {
alert(err.response?.data?.error || err.response?.data || 'Failed to submit reply.');
} finally {
setSubmitting(s => ({ ...s, [reviewId]: false }));
}
};
const renderStars = (rating: number, size = 14) => (
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
size={size}
fill={star <= rating ? '#f59e0b' : 'transparent'}
color={star <= rating ? '#f59e0b' : 'var(--mp-border)'}
/>
))}
</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Header */}
<div className="mp-section-header">
<h2 className="mp-title">Reviews &amp; Ratings</h2>
<p>Monitor user feedback, rating trends, and reply directly to customer reviews.</p>
</div>
{loading ? (
<div style={{ color: 'var(--mp-muted)', padding: '2rem 0' }}>Loading plugin reviews…</div>
) : (
<>
{/* Rating Overview Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1.5rem' }}>
{/* Summary Box */}
<div className="mp-card" style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: '2rem' }}>
<div style={{ fontSize: '3.2rem', fontWeight: 800, color: 'var(--mp-text)', lineHeight: 1 }}>
{stats.average > 0 ? stats.average : 'N/A'}
</div>
<div style={{ margin: '0.75rem 0' }}>
{renderStars(Math.round(stats.average), 18)}
</div>
<div style={{ fontSize: '0.875rem', color: 'var(--mp-muted)' }}>
Based on {stats.total} {stats.total === 1 ? 'review' : 'reviews'}
</div>
</div>
{/* Breakdown Bars */}
<div className="mp-card" style={{ flex: 1 }}>
<div className="mp-card-title" style={{ marginBottom: '1rem' }}>
Rating Breakdown
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{[5, 4, 3, 2, 1].map(starsCount => {
const count = stats.distribution[starsCount] || 0;
const pct = stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
return (
<div key={starsCount} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', width: '50px', color: 'var(--mp-muted)' }}>
<span>{starsCount}</span>
<Star size={12} fill="#f59e0b" color="#f59e0b" />
</div>
<div style={{ flex: 1, height: '8px', background: 'rgba(255, 255, 255, 0.08)', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '4px', transition: 'width 0.3s' }} />
</div>
<div style={{ width: '40px', textAlign: 'right', color: 'var(--mp-muted)', fontSize: '0.8rem' }}>
{pct}%
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Reviews List */}
<div className="mp-card">
<div className="mp-card-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1.5rem' }}>
<MessageSquare size={16} /> All Reviews ({reviews.length})
</div>
{reviews.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: 'var(--mp-muted)' }}>
<MessageSquare size={36} style={{ marginBottom: '0.75rem', opacity: 0.4 }} />
<p style={{ fontWeight: 600, margin: '0 0 0.25rem 0' }}>No reviews yet</p>
<p style={{ fontSize: '0.85rem' }}>When users review this plugin, their feedback and ratings will appear here.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{reviews.map(rev => (
<div
key={rev.id}
style={{
padding: '1.25rem',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid var(--mp-border)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--mp-text)' }}>
{rev.username}
</div>
<div style={{ marginTop: '0.25rem' }}>
{renderStars(rev.rating)}
</div>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--mp-muted)' }}>
{new Date(rev.created_at).toLocaleDateString()}
</div>
</div>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--mp-text)', lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>
{rev.comment}
</p>
{/* Developer Reply Box or Input */}
{rev.developer_reply ? (
<div style={{
marginTop: '0.5rem',
padding: '1rem',
borderRadius: '6px',
background: 'rgba(34, 197, 94, 0.08)',
borderLeft: '3px solid var(--mp-success)',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', fontWeight: 600, color: 'var(--mp-success)' }}>
<CheckCircle2 size={13} /> Developer Response
{rev.replied_at && (
<span style={{ fontWeight: 400, color: 'var(--mp-muted)', marginLeft: 'auto' }}>
{new Date(rev.replied_at).toLocaleDateString()}
</span>
)}
</div>
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--mp-text)', lineHeight: 1.4 }}>
{rev.developer_reply}
</p>
</div>
) : (
<div style={{ marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: 'var(--mp-muted)' }}>
<CornerDownRight size={13} /> Respond to {rev.username}:
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<textarea
className="mp-input"
rows={2}
placeholder="Write your official response..."
value={replyText[rev.id] || ''}
onChange={e => setReplyText({ ...replyText, [rev.id]: e.target.value })}
style={{ flex: 1, resize: 'vertical', fontSize: '0.85rem' }}
/>
<button
type="button"
className="mp-btn mp-btn-primary"
disabled={submitting[rev.id] || !replyText[rev.id]?.trim()}
onClick={() => handleSendReply(rev.id)}
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', height: 'fit-content', padding: '0.6rem 1rem' }}
>
<Send size={13} />
{submitting[rev.id] ? 'Posting…' : 'Reply'}
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
};
export default PluginReviewsTab;