feat: add translations

This commit is contained in:
Alexander Medvedev 2026-08-27 18:02:10 +02:00
parent fe14649ff6
commit 669b33baef
46 changed files with 6189 additions and 798 deletions

View File

@ -4,25 +4,29 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="description" content="Discover, download, and publish high-performance WebAssembly plugins for PumpkinMC Minecraft servers." />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <meta name="theme-color" content="#ff6b00" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800;900&family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet"> <meta property="og:title" content="Pumpkin Market" />
<meta property="og:description" content="Discover, download, and publish high-performance WebAssembly plugins for PumpkinMC Minecraft servers." />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Pumpkin Market" />
<link rel="search" type="application/opensearchdescription+xml" title="Pumpkin Market" href="/opensearch.xml" /> <link rel="search" type="application/opensearchdescription+xml" title="Pumpkin Market" href="/opensearch.xml" />
<title>Pumpkin Market</title> <title>Pumpkin Market</title>
</head> <!-- Privacy-friendly Google Consent Mode v2 Default -->
<body>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-QK7NXQQ2ZP"
></script>
<script> <script>
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag() { function gtag() {
dataLayer.push(arguments); dataLayer.push(arguments);
} }
gtag("js", new Date()); gtag("consent", "default", {
gtag("config", "G-QK7NXQQ2ZP"); analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
});
</script> </script>
</head>
<body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

204
package-lock.json generated
View File

@ -8,9 +8,13 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.6.0", "@fontsource/dm-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/nunito": "^5.3.0",
"@marsidev/react-turnstile": "^1.6.1",
"@vnedyalk0v/react19-simple-maps": "^2.0.10", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.19.0", "axios": "^1.20.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
@ -31,16 +35,16 @@
"devDependencies": { "devDependencies": {
"@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.2.0", "@types/node": "^26.4.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"eslint": "^10.9.0", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4", "eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0", "globals": "^17.11.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.68.0",
"vite": "^8.2.2" "vite": "^8.2.2"
} }
}, },
@ -421,6 +425,42 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fontsource/dm-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz",
"integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/nunito": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/nunito/-/nunito-5.3.0.tgz",
"integrity": "sha512-vw9TaTQJ/zpEpKrsODuPmOvaVjXgdda6B+xXA4YqjrdeJ62MLgVoDerdRXFQHyBnSCt2yQ2nHKCHPP/Pv7Xq4Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@ -538,9 +578,9 @@
} }
}, },
"node_modules/@marsidev/react-turnstile": { "node_modules/@marsidev/react-turnstile": {
"version": "1.6.0", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
"integrity": "sha512-T2Um71ZdBgQBiyS01xgRMvDWk+mEsrLtXLFVhOC917OVEO584wbPOWj1sw35RaM17+q9QoADgLVcH60xr9NRqQ==", "integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0", "react": "^17.0.2 || ^18.0.0 || ^19.0",
@ -990,9 +1030,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.2.0", "version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1032,17 +1072,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
"integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/type-utils": "8.67.0", "@typescript-eslint/type-utils": "8.68.0",
"@typescript-eslint/utils": "8.67.0", "@typescript-eslint/utils": "8.68.0",
"@typescript-eslint/visitor-keys": "8.67.0", "@typescript-eslint/visitor-keys": "8.68.0",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@ -1055,7 +1095,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.67.0", "@typescript-eslint/parser": "^8.68.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
@ -1071,16 +1111,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/visitor-keys": "8.67.0", "@typescript-eslint/visitor-keys": "8.68.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1096,14 +1136,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
"integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.67.0", "@typescript-eslint/tsconfig-utils": "^8.68.0",
"@typescript-eslint/types": "^8.67.0", "@typescript-eslint/types": "^8.68.0",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@ -1118,14 +1158,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
"integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.67.0" "@typescript-eslint/visitor-keys": "8.68.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1136,9 +1176,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
"integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1153,15 +1193,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
"integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.67.0", "@typescript-eslint/utils": "8.68.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@ -1178,9 +1218,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
"integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1192,16 +1232,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
"integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.67.0", "@typescript-eslint/project-service": "8.68.0",
"@typescript-eslint/tsconfig-utils": "8.67.0", "@typescript-eslint/tsconfig-utils": "8.68.0",
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"@typescript-eslint/visitor-keys": "8.67.0", "@typescript-eslint/visitor-keys": "8.68.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@ -1233,16 +1273,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
"integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.67.0", "@typescript-eslint/scope-manager": "8.68.0",
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"@typescript-eslint/typescript-estree": "8.67.0" "@typescript-eslint/typescript-estree": "8.68.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1257,13 +1297,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
"integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.67.0", "@typescript-eslint/types": "8.68.0",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@ -1731,9 +1771,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.19.0", "version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
@ -2374,9 +2414,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.9.0", "version": "10.9.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==", "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -2453,9 +2493,9 @@
} }
}, },
"node_modules/eslint-plugin-react-refresh": { "node_modules/eslint-plugin-react-refresh": {
"version": "0.5.4", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz",
"integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@ -5147,16 +5187,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.67.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
"integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.67.0", "@typescript-eslint/eslint-plugin": "8.68.0",
"@typescript-eslint/parser": "8.67.0", "@typescript-eslint/parser": "8.68.0",
"@typescript-eslint/typescript-estree": "8.67.0", "@typescript-eslint/typescript-estree": "8.68.0",
"@typescript-eslint/utils": "8.67.0" "@typescript-eslint/utils": "8.68.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -11,9 +11,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.6.0", "@fontsource/dm-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/nunito": "^5.3.0",
"@marsidev/react-turnstile": "^1.6.1",
"@vnedyalk0v/react19-simple-maps": "^2.0.10", "@vnedyalk0v/react19-simple-maps": "^2.0.10",
"axios": "^1.19.0", "axios": "^1.20.0",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
@ -34,16 +38,16 @@
"devDependencies": { "devDependencies": {
"@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.2.0", "@types/node": "^26.4.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5", "@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"eslint": "^10.9.0", "eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4", "eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0", "globals": "^17.11.0",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.68.0",
"vite": "^8.2.2" "vite": "^8.2.2"
}, },
"overrides": { "overrides": {

View File

@ -21,7 +21,11 @@ import AddPlugin from './pages/dashboard/plugin/AddPlugin';
import TermsOfServicePage from './pages/legal/TermsOfServicePage'; import TermsOfServicePage from './pages/legal/TermsOfServicePage';
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage'; import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
import GuidelinesPage from './pages/legal/GuidelinesPage'; import GuidelinesPage from './pages/legal/GuidelinesPage';
import DeveloperTermsPage from './pages/legal/DeveloperTermsPage';
import LegalNoticePage from './pages/legal/LegalNoticePage';
import AdminPanel from './pages/admin/AdminPanel'; import AdminPanel from './pages/admin/AdminPanel';
import { CookieBanner } from './components/CookieBanner';
import { openCookiePreferencesModal } from './utils/consent';
import './App.css'; import './App.css';
import api from './api'; import api from './api';
@ -123,6 +127,7 @@ const App = () => (
<Router> <Router>
<AuthProvider> <AuthProvider>
<ScrollToTop /> <ScrollToTop />
<CookieBanner />
<Routes> <Routes>
{/* All marketplace routes share MainLayout (original navbar + footer) */} {/* All marketplace routes share MainLayout (original navbar + footer) */}
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
@ -134,6 +139,10 @@ const App = () => (
<Route path="terms" element={<TermsOfServicePage />} /> <Route path="terms" element={<TermsOfServicePage />} />
<Route path="privacy" element={<PrivacyPolicyPage />} /> <Route path="privacy" element={<PrivacyPolicyPage />} />
<Route path="guidelines" element={<GuidelinesPage />} /> <Route path="guidelines" element={<GuidelinesPage />} />
<Route path="developer-terms" element={<DeveloperTermsPage />} />
<Route path="legal/developer-terms" element={<DeveloperTermsPage />} />
<Route path="legal-notice" element={<LegalNoticePage />} />
<Route path="impressum" element={<LegalNoticePage />} />
<Route path="login" element={<LoginPage />} /> <Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} /> <Route path="register" element={<RegisterPage />} />
<Route path="forgot-password" element={<ForgotPasswordPage />} /> <Route path="forgot-password" element={<ForgotPasswordPage />} />
@ -383,6 +392,44 @@ const Footer = () => (
> >
Guidelines Guidelines
</Link> </Link>
<Link
to="/developer-terms"
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
>
Developer Terms
</Link>
<Link
to="/legal-notice"
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
>
Legal Notice
</Link>
<button
type="button"
onClick={openCookiePreferencesModal}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: '12px',
color: 'inherit',
opacity: 0.4,
textDecoration: 'none'
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
>
Cookie Preferences
</button>
</div> </div>
</footer> </footer>
); );

View File

@ -0,0 +1,348 @@
.cookie-banner-overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
padding: 1rem 1.5rem;
background: rgba(15, 17, 23, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 107, 0, 0.25);
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.cookie-banner-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.cookie-banner-content {
flex: 1 1 500px;
display: flex;
align-items: flex-start;
gap: 1rem;
}
.cookie-banner-icon {
color: #ff6b00;
flex-shrink: 0;
margin-top: 0.2rem;
}
.cookie-banner-text {
font-size: 0.88rem;
color: #c8cdd8;
line-height: 1.5;
margin: 0;
}
.cookie-banner-title {
font-size: 0.95rem;
font-weight: 700;
color: #ffffff;
margin: 0 0 0.25rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cookie-banner-link {
color: #ff8c00;
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
}
.cookie-banner-link:hover {
color: #ffa534;
}
.cookie-banner-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cookie-btn {
padding: 0.55rem 1.1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.cookie-btn-primary {
background: #ff6b00;
color: #ffffff;
border-color: #ff6b00;
}
.cookie-btn-primary:hover {
background: #e85d00;
border-color: #e85d00;
transform: translateY(-1px);
}
.cookie-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: #e0e5ec;
border-color: rgba(255, 255, 255, 0.15);
}
.cookie-btn-secondary:hover {
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.25);
}
.cookie-btn-outline {
background: transparent;
color: #9aa5b8;
border-color: rgba(255, 255, 255, 0.1);
}
.cookie-btn-outline:hover {
background: rgba(255, 107, 0, 0.08);
color: #ff8c00;
border-color: rgba(255, 107, 0, 0.3);
}
/* Modal styles */
.cookie-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.cookie-modal {
background: #161b26;
border: 1px solid #232b3d;
border-radius: 14px;
width: 100%;
max-width: 620px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
animation: modalZoom 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalZoom {
from {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
to {
transform: scale(1) translateY(0);
opacity: 1;
}
}
.cookie-modal-header {
padding: 1.5rem 1.75rem;
border-bottom: 1px solid #232b3d;
display: flex;
align-items: center;
justify-content: space-between;
}
.cookie-modal-title {
font-size: 1.2rem;
font-weight: 700;
color: #ffffff;
margin: 0;
display: flex;
align-items: center;
gap: 0.6rem;
}
.cookie-modal-close {
background: none;
border: none;
color: #718096;
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.cookie-modal-close:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
.cookie-modal-body {
padding: 1.5rem 1.75rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.cookie-modal-intro {
font-size: 0.88rem;
color: #9aa5b8;
line-height: 1.6;
margin: 0;
}
.cookie-category-card {
background: #11141c;
border: 1px solid #1e2535;
border-radius: 10px;
padding: 1.2rem;
transition: border-color 0.15s;
}
.cookie-category-card:hover {
border-color: #2a3449;
}
.cookie-category-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cookie-category-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cookie-category-name {
font-size: 0.95rem;
font-weight: 700;
color: #e2e8f0;
margin: 0;
}
.cookie-badge-required {
font-size: 0.72rem;
font-weight: 700;
background: rgba(255, 107, 0, 0.15);
color: #ff8c00;
padding: 2px 8px;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cookie-category-desc {
font-size: 0.84rem;
color: #718096;
line-height: 1.5;
margin: 0 0 0.75rem 0;
}
.cookie-category-meta {
font-size: 0.78rem;
color: #55627a;
background: #0d0f15;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid #1a202c;
}
/* Switch toggle */
.cookie-switch {
position: relative;
width: 44px;
height: 24px;
display: inline-block;
}
.cookie-switch input {
opacity: 0;
width: 0;
height: 0;
}
.cookie-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #2d3748;
transition: 0.2s;
border-radius: 24px;
}
.cookie-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
.cookie-switch input:checked + .cookie-slider {
background-color: #ff6b00;
}
.cookie-switch input:checked + .cookie-slider:before {
transform: translateX(20px);
}
.cookie-switch input:disabled + .cookie-slider {
opacity: 0.5;
cursor: not-allowed;
}
.cookie-modal-footer {
padding: 1.25rem 1.75rem;
border-top: 1px solid #232b3d;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
background: #131720;
flex-wrap: wrap;
}

View File

@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Cookie, Shield, Check, X, Sliders, Lock } from 'lucide-react';
import {
getStoredConsent,
saveConsent,
applyConsent,
type CookieConsent,
} from '../utils/consent';
import './CookieBanner.css';
export const CookieBanner: React.FC = () => {
const { t } = useTranslation();
const [showBanner, setShowBanner] = useState(false);
const [showModal, setShowModal] = useState(false);
const [analyticsAllowed, setAnalyticsAllowed] = useState(false);
useEffect(() => {
const consent = getStoredConsent();
if (!consent) {
setShowBanner(true);
applyConsent(null);
} else {
setAnalyticsAllowed(consent.analytics);
applyConsent(consent);
}
const handleOpenModal = () => {
const current = getStoredConsent();
if (current) {
setAnalyticsAllowed(current.analytics);
}
setShowModal(true);
};
const handleConsentUpdated = (e: CustomEvent<CookieConsent>) => {
setAnalyticsAllowed(e.detail.analytics);
};
window.addEventListener('open-cookie-preferences', handleOpenModal);
window.addEventListener('cookie-consent-updated', handleConsentUpdated as EventListener);
return () => {
window.removeEventListener('open-cookie-preferences', handleOpenModal);
window.removeEventListener('cookie-consent-updated', handleConsentUpdated as EventListener);
};
}, []);
const handleAcceptAll = () => {
saveConsent(true);
setShowBanner(false);
setShowModal(false);
};
const handleRejectNonEssential = () => {
saveConsent(false);
setShowBanner(false);
setShowModal(false);
};
const handleSaveCustom = () => {
saveConsent(analyticsAllowed);
setShowBanner(false);
setShowModal(false);
};
return (
<>
{/* ── Cookie Consent Banner ── */}
{showBanner && (
<div className="cookie-banner-overlay" role="region" aria-label="Cookie consent banner">
<div className="cookie-banner-container">
<div className="cookie-banner-content">
<Cookie className="cookie-banner-icon" size={24} />
<div>
<p className="cookie-banner-title">{t('cookie.banner_title')}</p>
<p className="cookie-banner-text">
{t('cookie.banner_desc')}{' '}
<Link to="/privacy" className="cookie-banner-link">
Privacy Policy
</Link>
</p>
</div>
</div>
<div className="cookie-banner-actions">
<button
type="button"
className="cookie-btn cookie-btn-outline"
onClick={() => setShowModal(true)}
>
<Sliders size={14} />
{t('cookie.customize')}
</button>
<button
type="button"
className="cookie-btn cookie-btn-secondary"
onClick={handleRejectNonEssential}
>
{t('cookie.reject_non_essential')}
</button>
<button
type="button"
className="cookie-btn cookie-btn-primary"
onClick={handleAcceptAll}
>
<Check size={14} />
{t('cookie.accept_all')}
</button>
</div>
</div>
</div>
)}
{/* ── Cookie Preferences Modal ── */}
{showModal && (
<div
className="cookie-modal-backdrop"
onClick={() => setShowModal(false)}
role="dialog"
aria-modal="true"
aria-labelledby="cookie-modal-title"
>
<div className="cookie-modal" onClick={e => e.stopPropagation()}>
<div className="cookie-modal-header">
<h2 id="cookie-modal-title" className="cookie-modal-title">
<Shield size={20} color="#ff6b00" />
Privacy & Cookie Preferences
</h2>
<button
type="button"
className="cookie-modal-close"
onClick={() => setShowModal(false)}
aria-label="Close preferences"
>
<X size={18} />
</button>
</div>
<div className="cookie-modal-body">
<p className="cookie-modal-intro">
Customize your cookie settings below. Strictly necessary cookies are required for authentication,
security, and basic site functionality and cannot be disabled.
</p>
{/* Category 1: Strictly Necessary */}
<div className="cookie-category-card">
<div className="cookie-category-header">
<div className="cookie-category-title-group">
<Lock size={16} color="#ff8c00" />
<p className="cookie-category-name">Strictly Necessary Cookies & Storage</p>
</div>
<span className="cookie-badge-required">Always Active</span>
</div>
<p className="cookie-category-desc">
Essential for security, user authentication sessions, bot protection (Cloudflare Turnstile), and
remembering your preferences (such as selected language and currency).
</p>
<div className="cookie-category-meta">
<strong>Providers:</strong> PumpkinMarket, Cloudflare · <strong>Storage:</strong> LocalStorage & Session Tokens
</div>
</div>
{/* Category 2: Analytics & Performance */}
<div className="cookie-category-card">
<div className="cookie-category-header">
<div className="cookie-category-title-group">
<Cookie size={16} color="#60a5fa" />
<p className="cookie-category-name">Analytics & Performance Cookies</p>
</div>
<label className="cookie-switch">
<input
type="checkbox"
checked={analyticsAllowed}
onChange={e => setAnalyticsAllowed(e.target.checked)}
aria-label="Toggle analytics cookies"
/>
<span className="cookie-slider" />
</label>
</div>
<p className="cookie-category-desc">
Collects anonymous aggregated statistical data about site usage (pages viewed, session lengths) to help
us optimize Marketplace features and discover popular plugins. IP addresses are masked and anonymized.
</p>
<div className="cookie-category-meta">
<strong>Provider:</strong> Google Analytics 4 (IP Anonymized) · <strong>Retention:</strong> Up to 14 months
</div>
</div>
</div>
<div className="cookie-modal-footer">
<button
type="button"
className="cookie-btn cookie-btn-secondary"
onClick={handleRejectNonEssential}
>
Reject Non-Essential
</button>
<button
type="button"
className="cookie-btn cookie-btn-primary"
onClick={handleSaveCustom}
>
Save Preferences
</button>
</div>
</div>
</div>
)}
</>
);
};

View File

@ -1,6 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getCodeList } from 'country-list'; import { getCodeList } from 'country-list';
import { Building2, User, Globe, Mail, CheckCircle2, ChevronRight, AlertCircle, CreditCard, Sparkles, Code2, ShieldCheck, MapPin, FileText } from 'lucide-react'; import {
Building2, User, Globe, Mail, CheckCircle2, ChevronRight,
AlertCircle, CreditCard, Sparkles, Code2, ShieldCheck, MapPin,
Gift, DollarSign,
} from 'lucide-react';
import api from '../api'; import api from '../api';
import { useAuth } from '../App'; import { useAuth } from '../App';
import './DeveloperOnboardingModal.css'; import './DeveloperOnboardingModal.css';
@ -11,15 +16,20 @@ interface DeveloperOnboardingModalProps {
onSuccess?: () => void; onSuccess?: () => void;
} }
export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> = ({ isOpen, onClose, onSuccess }) => { export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const { user, login } = useAuth(); const { user, login } = useAuth();
const [step, setStep] = useState<1 | 2 | 3 | 4>(1); const [step, setStep] = useState<number>(1);
const [entityType, setEntityType] = useState<'individual' | 'organization'>('individual'); const [entityType, setEntityType] = useState<'individual' | 'organization'>('individual');
const [displayName, setDisplayName] = useState(user?.username || ''); const [displayName, setDisplayName] = useState(user?.username || '');
const [isSellingPaid, setIsSellingPaid] = useState<boolean>(false);
// Legal / Address (for paid sellers)
const [legalName, setLegalName] = useState(''); const [legalName, setLegalName] = useState('');
const [publishingIntent, setPublishingIntent] = useState('');
// Legal / Address
const [streetAddress, setStreetAddress] = useState(''); const [streetAddress, setStreetAddress] = useState('');
const [city, setCity] = useState(''); const [city, setCity] = useState('');
const [postalCode, setPostalCode] = useState(''); const [postalCode, setPostalCode] = useState('');
@ -30,29 +40,38 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
const [supportEmail, setSupportEmail] = useState(user?.email || ''); const [supportEmail, setSupportEmail] = useState(user?.email || '');
const [websiteUrl, setWebsiteUrl] = useState(''); const [websiteUrl, setWebsiteUrl] = useState('');
const [githubUrl, setGithubUrl] = useState(''); const [githubUrl, setGithubUrl] = useState('');
const [acceptedTerms, setAcceptedTerms] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
if (!isOpen) return null; if (!isOpen) return null;
// Max steps: 3 for Free, 4 for Paid
const totalSteps = isSellingPaid ? 4 : 3;
const handleCompleteOnboarding = async (connectStripe: boolean) => { const handleCompleteOnboarding = async (connectStripe: boolean) => {
if (!acceptedTerms) {
setError('You must accept the Developer Distribution Agreement to continue.');
return;
}
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await api.post('/user/developer/onboard', { const res = await api.post('/user/developer/onboard', {
entityType, entityType,
displayName, displayName,
legalName: legalName || null, legalName: isSellingPaid ? (legalName.trim() || null) : null,
streetAddress: streetAddress || null, streetAddress: isSellingPaid ? (streetAddress.trim() || null) : null,
city: city || null, city: isSellingPaid ? (city.trim() || null) : null,
postalCode: postalCode || null, postalCode: isSellingPaid ? (postalCode.trim() || null) : null,
vatId: vatId || null, vatId: isSellingPaid ? (vatId.trim() || null) : null,
publishingIntent: publishingIntent || null, publishingIntent: isSellingPaid ? 'paid' : 'free',
supportEmail: supportEmail || null, supportEmail: supportEmail.trim() || null,
websiteUrl: websiteUrl || null, websiteUrl: websiteUrl.trim() || null,
githubUrl: githubUrl || null, githubUrl: githubUrl.trim() || null,
country: country || null, country: country || user?.country || null,
acceptedTerms,
}); });
if (res.data.token) { if (res.data.token) {
@ -76,28 +95,48 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
} }
}; };
const nextStep = () => {
setStep(s => s + 1);
};
const prevStep = () => {
setStep(s => Math.max(1, s - 1));
};
return ( return (
<div className="dev-modal-overlay"> <div className="dev-modal-overlay">
<div className="dev-modal-container"> <div className="dev-modal-container">
<button className="dev-modal-close" onClick={onClose}>✕</button> <button className="dev-modal-close" onClick={onClose} aria-label="Close modal">&times;</button>
<div className="dev-modal-header"> <div className="dev-modal-header">
<div className="dev-modal-badge"> <div className="dev-modal-badge">
<Sparkles size={14} /> Developer Portal <Sparkles size={14} /> {t('developer.onboarding.badge')}
</div> </div>
<h2>Become a Verified Developer</h2> <h2>{t('developer.onboarding.title')}</h2>
<p>Set up your creator profile and legal seller identity to publish plugins on PumpkinMarket.</p> <p>
{isSellingPaid
? t('developer.onboarding.subtitle_paid')
: t('developer.onboarding.subtitle_free')}
</p>
</div> </div>
{/* Step Indicator */} {/* Step Indicator */}
<div className="dev-steps-nav"> <div className="dev-steps-nav">
<div className={`dev-step-pill ${step >= 1 ? 'active' : ''}`}>1. Entity</div> <div className={`dev-step-pill ${step >= 1 ? 'active' : ''}`}>{t('developer.onboarding.step_profile')}</div>
<div className="dev-step-line" /> <div className="dev-step-line" />
<div className={`dev-step-pill ${step >= 2 ? 'active' : ''}`}>2. Legal</div> {isSellingPaid && (
<>
<div className={`dev-step-pill ${step >= 2 ? 'active' : ''}`}>{t('developer.onboarding.step_seller')}</div>
<div className="dev-step-line" />
</>
)}
<div className={`dev-step-pill ${step >= (isSellingPaid ? 3 : 2) ? 'active' : ''}`}>
{isSellingPaid ? `3. ${t('developer.onboarding.step_links')}` : `2. ${t('developer.onboarding.step_links')}`}
</div>
<div className="dev-step-line" /> <div className="dev-step-line" />
<div className={`dev-step-pill ${step >= 3 ? 'active' : ''}`}>3. Support</div> <div className={`dev-step-pill ${step >= totalSteps ? 'active' : ''}`}>
<div className="dev-step-line" /> {totalSteps}. {t('developer.onboarding.step_finish')}
<div className={`dev-step-pill ${step >= 4 ? 'active' : ''}`}>4. Finish</div> </div>
</div> </div>
{error && ( {error && (
@ -107,20 +146,20 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div> </div>
)} )}
{/* STEP 1: Entity Type & Public Name */} {/* ── STEP 1: Entity Type, Public Name & Plan ── */}
{step === 1 && ( {step === 1 && (
<div className="dev-step-content"> <div className="dev-step-content">
<label className="dev-field-label">How will you be publishing plugins?</label> <label className="dev-field-label">{t('developer.onboarding.entity_question')}</label>
<div className="dev-entity-grid"> <div className="dev-entity-grid">
<button <button
type="button" type="button"
className={`dev-entity-card ${entityType === 'individual' ? 'selected' : ''}`} className={`dev-entity-card ${entityType === 'individual' ? 'selected' : ''}`}
onClick={() => setEntityType('individual')} onClick={() => setEntityType('individual')}
> >
<div className="dev-entity-icon"><User size={24} /></div> <div className="dev-entity-icon"><User size={22} /></div>
<div> <div>
<h4>Individual / Solo Developer</h4> <h4>{t('developer.onboarding.individual_title')}</h4>
<p>Publishing as an independent creator or hobbyist.</p> <p>{t('developer.onboarding.individual_desc')}</p>
</div> </div>
</button> </button>
@ -129,57 +168,74 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
className={`dev-entity-card ${entityType === 'organization' ? 'selected' : ''}`} className={`dev-entity-card ${entityType === 'organization' ? 'selected' : ''}`}
onClick={() => setEntityType('organization')} onClick={() => setEntityType('organization')}
> >
<div className="dev-entity-icon"><Building2 size={24} /></div> <div className="dev-entity-icon"><Building2 size={22} /></div>
<div> <div>
<h4>Organization / Studio</h4> <h4>{t('developer.onboarding.org_title')}</h4>
<p>Publishing on behalf of a company, team, or studio.</p> <p>{t('developer.onboarding.org_desc')}</p>
</div> </div>
</button> </button>
</div> </div>
<div className="dev-form-group" style={{ marginTop: '1.25rem' }}> <div className="dev-form-group">
<label className="dev-field-label"> <label>{t('developer.onboarding.display_name_label')}</label>
{entityType === 'individual' ? 'Public Creator Name' : 'Studio / Organization Name'} *
</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
placeholder={t('developer.onboarding.display_name_placeholder')}
value={displayName} value={displayName}
onChange={e => setDisplayName(e.target.value)} onChange={e => setDisplayName(e.target.value)}
placeholder={entityType === 'individual' ? 'e.g. John Doe or AlexDev' : 'e.g. Acme Studio'} maxLength={60}
required autoFocus
/> />
<span className="dev-hint">This name will be publicly shown on all your plugin pages.</span> <span className="dev-hint">{t('developer.onboarding.display_name_hint')}</span>
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><FileText size={14} /> What type of plugins do you plan to publish?</label> <label>{t('developer.onboarding.intent_label')}</label>
<input <div className="dev-entity-grid">
type="text" <button
className="dev-input" type="button"
value={publishingIntent} className={`dev-entity-card ${!isSellingPaid ? 'active' : ''}`}
onChange={e => setPublishingIntent(e.target.value)} onClick={() => setIsSellingPaid(false)}
placeholder="e.g. Economy utilities, Chat moderation tools, Mini-games" >
/> <div className="dev-entity-icon"><Gift size={22} color="#10b981" /></div>
<div>
<h4>{t('developer.onboarding.intent_free')}</h4>
<p>{t('developer.onboarding.intent_free_desc')}</p>
</div>
</button>
<button
type="button"
className={`dev-entity-card ${isSellingPaid ? 'active' : ''}`}
onClick={() => setIsSellingPaid(true)}
>
<div className="dev-entity-icon"><DollarSign size={22} color="#f97316" /></div>
<div>
<h4>{t('developer.onboarding.intent_paid')}</h4>
<p>{t('developer.onboarding.intent_paid_desc')}</p>
</div>
</button>
</div>
</div> </div>
<button <button
className="dev-btn dev-btn-primary" className="dev-btn dev-btn-primary"
style={{ marginTop: '1.25rem' }} style={{ marginTop: '1.25rem' }}
disabled={!displayName.trim()} disabled={!displayName.trim()}
onClick={() => setStep(2)} onClick={nextStep}
> >
Continue <ChevronRight size={16} /> {t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
</button> </button>
</div> </div>
)} )}
{/* STEP 2: Legal Identity & Physical Address */} {/* ── STEP 2 (Paid Only): Legal Seller Identity & Address ── */}
{step === 2 && ( {isSellingPaid && step === 2 && (
<div className="dev-step-content"> <div className="dev-step-content">
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"> <label className="dev-field-label">
<ShieldCheck size={14} /> {entityType === 'individual' ? 'Full Legal Name' : 'Registered Business Name'} * <ShieldCheck size={14} /> {t('developer.onboarding.legal_name_label')}
</label> </label>
<input <input
type="text" type="text"
@ -189,11 +245,10 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'} placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
required required
/> />
<span className="dev-hint">Used for verification, legal compliance, and tax records.</span>
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><MapPin size={14} /> Street Address *</label> <label className="dev-field-label"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
@ -206,7 +261,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label">City *</label> <label className="dev-field-label">{t('developer.onboarding.city_label')}</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
@ -217,7 +272,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
/> />
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label">Postal / ZIP Code *</label> <label className="dev-field-label">{t('developer.onboarding.postal_label')}</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
@ -231,7 +286,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> Country / Region *</label> <label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
<select <select
className="dev-input dev-select" className="dev-input dev-select"
value={country} value={country}
@ -246,7 +301,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label">VAT / Tax ID <span className="dev-hint">(optional)</span></label> <label className="dev-field-label">{t('developer.onboarding.vat_label')}</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
@ -258,23 +313,23 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div> </div>
<div className="dev-btn-row" style={{ marginTop: '1.25rem' }}> <div className="dev-btn-row" style={{ marginTop: '1.25rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(1)}>Back</button> <button className="dev-btn dev-btn-secondary" onClick={prevStep}>{t('developer.onboarding.btn_back')}</button>
<button <button
className="dev-btn dev-btn-primary" className="dev-btn dev-btn-primary"
disabled={!legalName.trim() || !streetAddress.trim() || !city.trim() || !postalCode.trim() || !country} disabled={!legalName.trim() || !streetAddress.trim() || !city.trim() || !postalCode.trim() || !country}
onClick={() => setStep(3)} onClick={nextStep}
> >
Continue <ChevronRight size={16} /> {t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* STEP 3: Support Contact & Portfolio Links */} {/* ── STEP (Free: Step 2, Paid: Step 3): Support Contact & Portfolio Links ── */}
{step === 3 && ( {((!isSellingPaid && step === 2) || (isSellingPaid && step === 3)) && (
<div className="dev-step-content"> <div className="dev-step-content">
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><Mail size={14} /> Support / Public Contact Email *</label> <label className="dev-field-label"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
<input <input
type="email" type="email"
className="dev-input" className="dev-input"
@ -283,11 +338,10 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
placeholder="support@yourdomain.com" placeholder="support@yourdomain.com"
required required
/> />
<span className="dev-hint">Where users can reach you for plugin help and questions.</span>
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> Website / Portfolio URL</label> <label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.website_label')}</label>
<input <input
type="url" type="url"
className="dev-input" className="dev-input"
@ -298,7 +352,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div> </div>
<div className="dev-form-group"> <div className="dev-form-group">
<label className="dev-field-label"><Code2 size={14} /> GitHub Profile / Organization</label> <label className="dev-field-label"><Code2 size={14} /> {t('developer.onboarding.github_label')}</label>
<input <input
type="text" type="text"
className="dev-input" className="dev-input"
@ -309,55 +363,128 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div> </div>
<div className="dev-btn-row" style={{ marginTop: '1.5rem' }}> <div className="dev-btn-row" style={{ marginTop: '1.5rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(2)}>Back</button> <button className="dev-btn dev-btn-secondary" onClick={prevStep}>{t('developer.onboarding.btn_back')}</button>
<button <button
className="dev-btn dev-btn-primary" className="dev-btn dev-btn-primary"
disabled={!supportEmail.trim()} disabled={!supportEmail.trim()}
onClick={() => setStep(4)} onClick={nextStep}
> >
Continue <ChevronRight size={16} /> {t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* STEP 4: Monetization & Confirmation */} {/* ── STEP (Free: Step 3, Paid: Step 4): Finish & Confirmation ── */}
{step === 4 && ( {step === totalSteps && (
<div className="dev-step-content"> <div className="dev-step-content">
<div className="dev-monetization-box"> {!isSellingPaid ? (
<div className="dev-monetization-header"> <>
<CreditCard size={20} /> <div className="dev-monetization-box">
<div> <div className="dev-monetization-header">
<h4>Publishing & Payouts</h4> <Gift size={22} color="#10b981" />
<p>You can publish <strong>Free plugins immediately</strong> without linking a bank account.</p> <div>
<h4>Ready to Publish Free Plugins!</h4>
<p>Your developer profile is configured for free &amp; open-source uploads.</p>
</div>
</div>
<ul className="dev-check-list">
<li><CheckCircle2 size={16} color="#10b981" /> Publish unlimited free plugins ($0 fee)</li>
<li><CheckCircle2 size={16} color="#10b981" /> Full analytics and review dashboard</li>
<li><CheckCircle2 size={16} color="#10b981" /> Option to add address &amp; sell paid plugins anytime later</li>
</ul>
</div> </div>
</div>
<ul className="dev-check-list">
<li><CheckCircle2 size={16} /> Publish unlimited free plugins ($0 upload fee)</li>
<li><CheckCircle2 size={16} /> Option to sell paid plugins via Stripe Connect</li>
<li><CheckCircle2 size={16} /> Access full analytics and reviews dashboard</li>
</ul>
</div>
<div className="dev-btn-row-stacked" style={{ marginTop: '1.5rem' }}> {/* ── Mandatory Legal Consent ── */}
<button <div style={{
className="dev-btn dev-btn-primary" marginTop: '1.25rem',
disabled={loading} padding: '0.85rem 1rem',
onClick={() => handleCompleteOnboarding(false)} background: 'rgba(255, 183, 77, 0.06)',
> border: '1px solid rgba(255, 183, 77, 0.25)',
{loading ? 'Completing setup...' : 'Complete Registration & Start Free Uploads'} borderRadius: 8,
</button> }}>
<button <label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.65rem', cursor: 'pointer', fontSize: '0.78rem', color: '#cbd5e1', lineHeight: 1.5 }}>
className="dev-btn dev-btn-stripe" <input
disabled={loading} type="checkbox"
onClick={() => handleCompleteOnboarding(true)} checked={acceptedTerms}
> onChange={e => setAcceptedTerms(e.target.checked)}
<CreditCard size={16} /> Connect Stripe for Paid Plugins & Complete Setup style={{ marginTop: '0.15rem', accentColor: '#f97316', width: 16, height: 16, flexShrink: 0, cursor: 'pointer' }}
</button> />
</div> <span>
I agree to the <a href="/developer-terms" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline', fontWeight: 600 }}>Developer Distribution Agreement</a>, <a href="/terms" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline' }}>Terms of Service</a>, and <a href="/guidelines" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline' }}>Review Guidelines</a>. I grant Pumpkin Marketplace the worldwide license to distribute my plugins, acknowledge platform moderation and takedown authority, and confirm my plugins comply with Mojang's Minecraft EULA.
</span>
</label>
</div>
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
<button
className="dev-btn dev-btn-primary"
disabled={loading || !acceptedTerms}
onClick={() => handleCompleteOnboarding(false)}
>
{loading ? 'Completing registration...' : t('developer.onboarding.btn_complete')}
</button>
</div>
</>
) : (
<>
<div className="dev-monetization-box">
<div className="dev-monetization-header">
<CreditCard size={22} color="#f97316" />
<div>
<h4>{t('developer.onboarding.intent_paid')}</h4>
<p>{t('developer.onboarding.intent_paid_desc')}</p>
</div>
</div>
<ul className="dev-check-list">
<li><CheckCircle2 size={16} color="#10b981" /> Automated payouts directly to your bank account</li>
<li><CheckCircle2 size={16} color="#10b981" /> Set custom pricing, discounts, and coupons</li>
<li><CheckCircle2 size={16} color="#10b981" /> Automated license key generation for buyers</li>
</ul>
</div>
{/* ── Mandatory Legal Consent ── */}
<div style={{
marginTop: '1.25rem',
padding: '0.85rem 1rem',
background: 'rgba(255, 183, 77, 0.06)',
border: '1px solid rgba(255, 183, 77, 0.25)',
borderRadius: 8,
}}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.65rem', cursor: 'pointer', fontSize: '0.78rem', color: '#cbd5e1', lineHeight: 1.5 }}>
<input
type="checkbox"
checked={acceptedTerms}
onChange={e => setAcceptedTerms(e.target.checked)}
style={{ marginTop: '0.15rem', accentColor: '#f97316', width: 16, height: 16, flexShrink: 0, cursor: 'pointer' }}
/>
<span>
I agree to the <a href="/developer-terms" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline', fontWeight: 600 }}>Developer Distribution Agreement</a>, <a href="/terms" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline' }}>Terms of Service</a>, and <a href="/guidelines" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline' }}>Review Guidelines</a>. I grant Pumpkin Marketplace the worldwide license to distribute my plugins, acknowledge platform moderation and takedown authority, and confirm my plugins comply with Mojang's Minecraft EULA.
</span>
</label>
</div>
<div className="dev-btn-row-stacked" style={{ marginTop: '1.25rem' }}>
<button
className="dev-btn dev-btn-stripe"
disabled={loading || !acceptedTerms}
onClick={() => handleCompleteOnboarding(true)}
>
<CreditCard size={16} /> {loading ? 'Connecting Stripe...' : t('developer.onboarding.btn_connect_stripe')}
</button>
<button
className="dev-btn dev-btn-secondary"
disabled={loading || !acceptedTerms}
onClick={() => handleCompleteOnboarding(false)}
>
{t('developer.onboarding.btn_complete')}
</button>
</div>
</>
)}
<div className="dev-btn-row" style={{ marginTop: '1rem' }}> <div className="dev-btn-row" style={{ marginTop: '1rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(3)}>Back</button> <button className="dev-btn dev-btn-secondary" onClick={prevStep}>{t('developer.onboarding.btn_back')}</button>
</div> </div>
</div> </div>
)} )}

View File

@ -112,6 +112,10 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
{plugin.type === 'free' ? ( {plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span> <span className="pcv2-price-badge free">Free</span>
) : plugin.is_preorder ? (
<span className="pcv2-price-badge paid" style={{ background: '#f97316', color: '#fff' }}>
Pre-Order • {priceInfo.display}
</span>
) : isSale ? ( ) : isSale ? (
<span className="pcv2-price-badge sale"> <span className="pcv2-price-badge sale">
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span> <span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
@ -126,6 +130,11 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
<div className="pcv2-info"> <div className="pcv2-info">
<p className="pcv2-name"> <p className="pcv2-name">
{plugin.name} {plugin.name}
{plugin.is_preorder && (
<span style={{ marginLeft: 6, fontSize: '0.62rem', fontWeight: 700, padding: '2px 6px', borderRadius: 4, background: 'rgba(249,115,22,0.15)', color: '#f97316', border: '1px solid rgba(249,115,22,0.3)', verticalAlign: 'middle' }}>
Pre-Order
</span>
)}
{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>

View File

@ -5,15 +5,33 @@ import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files directly to avoid extra HTTP requests // Import translation files directly to avoid extra HTTP requests
// and ensure we don't have issues with Vite's dev server for static assets // and ensure we don't have issues with Vite's dev server for static assets
import enTranslation from './locales/en.json'; import enTranslation from './locales/en.json';
import deTranslation from './locales/de.json';
import frTranslation from './locales/fr.json';
import esTranslation from './locales/es.json'; import esTranslation from './locales/es.json';
import ruTranslation from './locales/ru.json';
import zhTranslation from './locales/zh.json';
import ptTranslation from './locales/pt.json';
import plTranslation from './locales/pl.json';
import trTranslation from './locales/tr.json';
import jaTranslation from './locales/ja.json';
import koTranslation from './locales/ko.json';
import itTranslation from './locales/it.json';
import nlTranslation from './locales/nl.json';
const resources = { const resources = {
en: { en: { translation: enTranslation },
translation: enTranslation, de: { translation: deTranslation },
}, fr: { translation: frTranslation },
es: { es: { translation: esTranslation },
translation: esTranslation, ru: { translation: ruTranslation },
}, zh: { translation: zhTranslation },
pt: { translation: ptTranslation },
pl: { translation: plTranslation },
tr: { translation: trTranslation },
ja: { translation: jaTranslation },
ko: { translation: koTranslation },
it: { translation: itTranslation },
nl: { translation: nlTranslation },
}; };
i18n i18n
@ -22,12 +40,15 @@ i18n
.init({ .init({
resources, resources,
fallbackLng: 'en', fallbackLng: 'en',
supportedLngs: ['en', 'de', 'fr', 'es', 'ru', 'zh', 'pt', 'pl', 'tr', 'ja', 'ko', 'it', 'nl'],
nonExplicitSupportedLngs: true,
load: 'languageOnly',
interpolation: { interpolation: {
escapeValue: false, // React already escapes values escapeValue: false, // React already escapes values
}, },
detection: { detection: {
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'], order: ['navigator', 'htmlTag'],
caches: ['localStorage', 'cookie'], caches: [], // Always auto-detect based on the user's browser/system region
}, },
}); });

236
src/locales/de.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Plugins, Autoren, Tags suchen...",
"browse": "Entdecken",
"dashboard": "Dashboard",
"library": "Meine Bibliothek",
"profile": "Profil",
"login": "Anmelden",
"register": "Registrieren",
"logout": "Abmelden"
},
"home": {
"hero_title": "Next-Gen Minecraft Server-Plugins",
"hero_subtitle": "Entdecke, kaufe und nutze blitzschnelle WebAssembly-Plugins für PumpkinMC.",
"search_cta": "Finde dein nächstes Plugin...",
"trending": "Beliebte Plugins",
"latest": "Neueste Veröffentlichungen",
"all_plugins": "Alle Plugins",
"filter_all": "Alle Kategorien",
"sort_popular": "Beliebtheit",
"sort_newest": "Neueste",
"sort_rating": "Beste Bewertung",
"sort_price_low": "Preis: Aufsteigend",
"sort_price_high": "Preis: Absteigend",
"no_plugins": "Keine passenden Plugins gefunden."
},
"plugin": {
"buy_now": "Jetzt kaufen",
"pre_order": "Vorbestellen",
"in_library": "In deiner Bibliothek",
"download_wasm": ".wasm herunterladen",
"early_access": "Vorbestellung / Vorabzugang",
"expected_release": "Voraussichtliche Veröffentlichung:",
"free": "Kostenlos",
"version": "Version",
"author": "Entwickler",
"category": "Kategorie",
"downloads": "Downloads",
"views": "Aufrufe",
"rating": "Bewertung",
"license_key": "Lizenzschlüssel",
"copy_license": "Schlüssel kopieren",
"copied": "Kopiert!",
"tabs": {
"overview": "Übersicht",
"versions": "Versionen",
"reviews": "Bewertungen",
"installation": "Installationsanleitung"
},
"reviews": {
"title": "Bewertungen & Rezensionen",
"write_review": "Bewertung schreiben",
"edit_review": "Bewertung bearbeiten",
"delete_review": "Löschen",
"rating_label": "Deine Bewertung:",
"placeholder": "Teile deine Erfahrungen mit diesem Plugin...",
"submit": "Bewertung absenden",
"update": "Bewertung aktualisieren",
"verified_purchase": "Verifizierter Kauf",
"no_reviews": "Noch keine Bewertungen. Sei der Erste!",
"dev_reply": "Antwort des Entwicklers",
"reply_placeholder": "Deine Antwort schreiben...",
"reply_submit": "Antwort senden",
"reply_btn": "Antworten"
},
"report": {
"report_plugin": "Plugin melden",
"report_review": "Bewertung melden",
"modal_title": "Problem melden",
"reason_malware": "Schadcode oder Sicherheitsrisiko",
"reason_broken": "Plugin funktioniert überhaupt nicht",
"reason_misleading": "Irreführende Beschreibung oder falsche Features",
"reason_other": "Sonstiger Richtlinienverstoß",
"details_placeholder": "Bitte gib weitere Details an...",
"submit": "Meldung absenden",
"success": "Meldung eingereicht. Unser Team wird sie prüfen."
}
},
"profile": {
"title": "Konto & Einstellungen",
"tab_profile": "Profil",
"tab_library": "Meine Bibliothek",
"tab_security": "Sicherheit & 2FA",
"tab_developer": "Entwicklerportal",
"library": {
"title": "Gekaufte Plugins",
"empty": "Du hast noch keine Plugins gekauft.",
"browse_btn": "Plugins durchsuchen",
"purchased_on": "Gekauft am:",
"preorder_badge": "Vorbestellt",
"download_latest": "Neueste .wasm herunterladen"
},
"security": {
"title": "Kontosicherheit",
"tfa_title": "Zwei-Faktor-Authentifizierung (2FA)",
"tfa_enabled": "2FA ist derzeit auf deinem Konto AKTIVIERT.",
"tfa_disabled": "2FA ist derzeit DEAKTIVIERT. Aktiviere 2FA für besseren Schutz.",
"enable_tfa": "2FA aktivieren",
"disable_tfa": "2FA deaktivieren",
"change_password": "Passwort ändern",
"change_email": "E-Mail-Adresse ändern",
"current_password": "Aktuelles Passwort",
"new_password": "Neues Passwort",
"confirm_password": "Neues Passwort bestätigen",
"new_email": "Neue E-Mail-Adresse",
"save_security": "Zugangsdaten aktualisieren"
}
},
"developer": {
"onboarding": {
"title": "Entwicklerprofil erstellen",
"badge": "Entwicklerportal",
"subtitle_paid": "Richte dein Creator-Profil und deine Verkäufer-Identität ein, um Plugins zu verkaufen.",
"subtitle_free": "Richte dein Creator-Profil ein, um kostenlose Plugins für die Community zu veröffentlichen.",
"step_profile": "1. Profil",
"step_seller": "2. Verkäuferdaten",
"step_links": "Links",
"step_finish": "Fertigstellen",
"entity_question": "Veröffentlichst du als Einzelperson oder Organisation?",
"individual_title": "Einzelentwickler",
"individual_desc": "Unabhängiger Entwickler oder Hobbyist.",
"org_title": "Organisation / Studio",
"org_desc": "Unternehmen, Team oder Studio.",
"display_name_label": "Öffentlicher Entwicklername *",
"display_name_placeholder": "z. B. PixelCraft Studios, MaxDev",
"display_name_hint": "Dieser Name wird öffentlich auf allen deinen Plugins angezeigt.",
"intent_label": "Veröffentlichungsabsicht",
"intent_free": "Nur kostenlose Plugins",
"intent_free_desc": "Open-Source- und Community-Plugins teilen.",
"intent_paid": "Kostenpflichtige Plugins verkaufen",
"intent_paid_desc": "Einnahmen über Stripe Connect Auszahlungen erzielen.",
"seller_info_title": "Rechtliche Angaben & Händlerinformationen",
"legal_name_label": "Vollständiger Name / Firmenname *",
"street_label": "Straße und Hausnummer *",
"city_label": "Stadt *",
"postal_label": "Postleitzahl *",
"country_label": "Land *",
"vat_label": "USt-IdNr. (Optional)",
"links_title": "Support & Weblinks",
"support_email_label": "Support-E-Mail-Adresse",
"website_label": "Webseite",
"github_label": "GitHub Profil / Organisations-URL",
"agreement_label": "Ich akzeptiere die Entwickler-Vertriebsvereinbarung und bestätige die Richtigkeit aller Angaben.",
"btn_continue": "Weiter ->",
"btn_back": "Zurück",
"btn_complete": "Onboarding abschließen",
"btn_connect_stripe": "Speichern & Stripe verbinden ->"
},
"dashboard": {
"overview": "Übersicht",
"plugins": "Meine Plugins",
"add_plugin": "Plugin veröffentlichen",
"manage_plugin": "Plugin verwalten",
"analytics": "Analysen",
"reviews": "Bewertungen",
"licenses": "Lizenzen",
"coupons": "Gutscheine",
"danger_zone": "Gefahrenzone",
"total_revenue": "Gesamteinnahmen",
"total_downloads": "Gesamte Downloads",
"active_licenses": "Aktive Lizenzen",
"avg_rating": "Durchschnittliche Bewertung",
"payouts": "Stripe-Auszahlungen",
"payouts_status": "Stripe Connect Status",
"connected": "Verbunden",
"setup_stripe": "Stripe-Auszahlungen einrichten"
},
"upload": {
"title": "Neues Plugin veröffentlichen",
"subtitle": "Lade deine kompilierte WebAssembly-Datei (.wasm) hoch und teile sie mit Serverbesitzern.",
"step_details": "1. Grunddaten",
"step_media": "2. Medien & Screenshots",
"step_description": "3. Beschreibung & Doku",
"step_pricing": "4. Preis & Datei",
"plugin_name": "Plugin-Name *",
"category": "Kategorie *",
"version": "Version *",
"keywords": "Tags & Suchbegriffe (kommagetrennt)",
"source_code": "Quellcode-Repository (Optional)",
"preview_image": "Icon / Logo *",
"screenshots": "Screenshots (bis zu 8)",
"youtube_url": "YouTube Video- / Trailer-URL",
"pricing_type": "Preismodell",
"price_free": "Kostenlos",
"price_paid": "Kostenpflichtig",
"price_cents": "Preis (EUR €)",
"wasm_file": "WebAssembly-Datei (.wasm) *",
"preorder_mode": "Vorbestellung / Vorabzugang",
"preorder_desc": "Nutzern den Kauf vor Veröffentlichung der Datei ermöglichen",
"release_date": "Geplantes Veröffentlichungsdatum",
"publish_btn": "Plugin veröffentlichen ->",
"publishing": "Wird hochgeladen und validiert..."
}
},
"auth": {
"login_title": "Willkommen zurück",
"login_subtitle": "Melde dich bei deinem Pumpkin Marketplace Konto an",
"register_title": "Konto erstellen",
"register_subtitle": "Werde Teil der nächsten Generation von Minecraft-Entwicklern",
"email": "E-Mail-Adresse",
"username": "Benutzername",
"password": "Passwort",
"confirm_password": "Passwort bestätigen",
"forgot_password": "Passwort vergessen?",
"sign_in": "Anmelden",
"create_account": "Konto erstellen",
"no_account": "Noch kein Konto?",
"have_account": "Bereits ein Konto?",
"two_factor_title": "Zwei-Faktor-Authentifizierung",
"two_factor_prompt": "Gib den 6-stelligen Code deiner Authenticator-App ein:",
"verify_code": "Code bestätigen",
"reset_password_title": "Passwort zurücksetzen",
"reset_password_instructions": "Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen.",
"send_reset_link": "Link senden"
},
"cookie": {
"banner_title": "Cookie-Einstellungen",
"banner_desc": "Wir nutzen notwendige Cookies zur Anmeldung und optionale Cookies zur Analyse.",
"accept_all": "Alle akzeptieren",
"reject_non_essential": "Nur notwendige",
"customize": "Einstellungen anpassen",
"save": "Einstellungen speichern"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"back": "Zurück",
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten.",
"success": "Erfolgreich gespeichert.",
"close": "Schließen",
"search": "Suchen"
}
}

View File

@ -1,35 +1,236 @@
{ {
"welcome": "Welcome to PumpkinMarket", "nav": {
"search": "Search for plugins...", "search_placeholder": "Search plugins, authors, tags...",
"login": "Login", "browse": "Browse",
"register": "Register", "dashboard": "Dashboard",
"home": "Home", "library": "My Library",
"dashboard": "Dashboard", "profile": "Profile",
"profile": "Profile", "login": "Login",
"logout": "Logout", "register": "Register",
"loading": "Loading...", "logout": "Logout"
"error": "Something went wrong. Please try again later.", },
"plugins": { "home": {
"title": "Browse Plugins", "hero_title": "Next-Gen Minecraft Server Plugins",
"upload": "Upload Plugin", "hero_subtitle": "Discover, buy, and instantly deploy ultra-fast WebAssembly plugins built for PumpkinMC.",
"manage": "Manage Plugin", "search_cta": "Find your next plugin...",
"price": "Price", "trending": "Trending Plugins",
"latest": "Latest Releases",
"all_plugins": "All Plugins",
"filter_all": "All Categories",
"sort_popular": "Most Popular",
"sort_newest": "Newest",
"sort_rating": "Top Rated",
"sort_price_low": "Price: Low to High",
"sort_price_high": "Price: High to Low",
"no_plugins": "No plugins found matching your criteria."
},
"plugin": {
"buy_now": "Buy Now",
"pre_order": "Pre-Order",
"in_library": "In Your Library",
"download_wasm": "Download .wasm",
"early_access": "Early Access / Pre-Order",
"expected_release": "Expected Release:",
"free": "Free",
"version": "Version",
"author": "Developer",
"category": "Category",
"downloads": "Downloads", "downloads": "Downloads",
"views": "Views" "views": "Views",
"rating": "Rating",
"license_key": "License Key",
"copy_license": "Copy Key",
"copied": "Copied!",
"tabs": {
"overview": "Overview",
"versions": "Versions",
"reviews": "Reviews",
"installation": "Installation Guide"
},
"reviews": {
"title": "Ratings & Reviews",
"write_review": "Write a Review",
"edit_review": "Edit Review",
"delete_review": "Delete",
"rating_label": "Your Rating:",
"placeholder": "Share your experience with this plugin...",
"submit": "Submit Review",
"update": "Update Review",
"verified_purchase": "Verified Purchase",
"no_reviews": "No reviews yet. Be the first to review this plugin!",
"dev_reply": "Developer Reply",
"reply_placeholder": "Write your reply to this review...",
"reply_submit": "Post Reply",
"reply_btn": "Reply"
},
"report": {
"report_plugin": "Report Plugin",
"report_review": "Report Review",
"modal_title": "Report an Issue",
"reason_malware": "Malicious code or security vulnerability",
"reason_broken": "Plugin is completely non-functional",
"reason_misleading": "Misleading description or fake features",
"reason_other": "Other policy violation",
"details_placeholder": "Please provide additional details...",
"submit": "Submit Report",
"success": "Report submitted. Our moderation team will investigate."
}
},
"profile": {
"title": "Account & Settings",
"tab_profile": "Profile",
"tab_library": "My Library",
"tab_security": "Security & 2FA",
"tab_developer": "Developer Portal",
"library": {
"title": "Purchased Plugins",
"empty": "You have not purchased any plugins yet.",
"browse_btn": "Explore Plugins",
"purchased_on": "Purchased on:",
"preorder_badge": "Pre-Ordered",
"download_latest": "Download Latest .wasm"
},
"security": {
"title": "Account Security",
"tfa_title": "Two-Factor Authentication (2FA)",
"tfa_enabled": "2FA is currently ENABLED on your account.",
"tfa_disabled": "2FA is currently DISABLED. Enable it to protect your account.",
"enable_tfa": "Enable Two-Factor Auth",
"disable_tfa": "Disable Two-Factor Auth",
"change_password": "Change Password",
"change_email": "Change Email Address",
"current_password": "Current Password",
"new_password": "New Password",
"confirm_password": "Confirm New Password",
"new_email": "New Email Address",
"save_security": "Update Credentials"
}
},
"developer": {
"onboarding": {
"title": "Create Your Developer Profile",
"badge": "Developer Portal",
"subtitle_paid": "Set up your creator profile and seller identity to publish and sell plugins.",
"subtitle_free": "Set up your creator profile to publish free plugins for the Pumpkin community.",
"step_profile": "1. Profile",
"step_seller": "2. Seller Info",
"step_links": "Links",
"step_finish": "Finish",
"entity_question": "Are you publishing as an individual or organization?",
"individual_title": "Individual Creator",
"individual_desc": "Independent developer or hobbyist.",
"org_title": "Organization / Studio",
"org_desc": "Company, team, or studio.",
"display_name_label": "Developer / Studio Display Name *",
"display_name_placeholder": "e.g. PixelCraft Studios, JohnDoeDev",
"display_name_hint": "This name will be publicly visible on all your published plugins.",
"intent_label": "Publishing Intent",
"intent_free": "Free Plugins Only",
"intent_free_desc": "Share open source and community plugins.",
"intent_paid": "Sell Paid Plugins",
"intent_paid_desc": "Earn revenue via Stripe Connect payouts.",
"seller_info_title": "Legal & Merchant Information",
"legal_name_label": "Full Legal Name / Business Name *",
"street_label": "Street Address *",
"city_label": "City *",
"postal_label": "Postal / ZIP Code *",
"country_label": "Country *",
"vat_label": "VAT / Tax ID (Optional)",
"links_title": "Support & Social Links",
"support_email_label": "Support Email Address",
"website_label": "Website URL",
"github_label": "GitHub Profile / Organization URL",
"agreement_label": "I accept the Developer Distribution Agreement and confirm all details are accurate.",
"btn_continue": "Continue ->",
"btn_back": "Back",
"btn_complete": "Complete Onboarding",
"btn_connect_stripe": "Save & Connect Stripe ->"
},
"dashboard": {
"overview": "Overview",
"plugins": "My Plugins",
"add_plugin": "Publish Plugin",
"manage_plugin": "Manage Plugin",
"analytics": "Analytics",
"reviews": "Reviews",
"licenses": "Licenses",
"coupons": "Coupons",
"danger_zone": "Danger Zone",
"total_revenue": "Gross Revenue",
"total_downloads": "Total Downloads",
"active_licenses": "Active Licenses",
"avg_rating": "Average Rating",
"payouts": "Stripe Payouts",
"payouts_status": "Stripe Connect Status",
"connected": "Connected",
"setup_stripe": "Set Up Stripe Payouts"
},
"upload": {
"title": "Publish a New Plugin",
"subtitle": "Upload your compiled WebAssembly binary (.wasm) and share it with server owners.",
"step_details": "1. Basic Details",
"step_media": "2. Media & Visuals",
"step_description": "3. Description & Docs",
"step_pricing": "4. Pricing & File",
"plugin_name": "Plugin Name *",
"category": "Category *",
"version": "Initial Version *",
"keywords": "Tags & Keywords (comma separated)",
"source_code": "Source Code Repository (Optional)",
"preview_image": "Preview Icon / Logo *",
"screenshots": "Screenshots (Up to 8)",
"youtube_url": "YouTube Video / Trailer URL",
"pricing_type": "Pricing Type",
"price_free": "Free",
"price_paid": "Paid",
"price_cents": "Price (EUR €)",
"wasm_file": "WebAssembly Binary (.wasm) *",
"preorder_mode": "Pre-Order / Early Access",
"preorder_desc": "Allow users to purchase before binary release",
"release_date": "Target Release Date",
"publish_btn": "Publish Plugin ->",
"publishing": "Uploading & Validating..."
}
}, },
"auth": { "auth": {
"login_title": "Welcome Back",
"login_subtitle": "Sign in to your Pumpkin Marketplace account",
"register_title": "Create an Account",
"register_subtitle": "Join the next generation Minecraft developer marketplace",
"email": "Email Address", "email": "Email Address",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"confirm_password": "Confirm Password", "confirm_password": "Confirm Password",
"forgot_password": "Forgot password?",
"sign_in": "Sign In",
"create_account": "Create Account", "create_account": "Create Account",
"sign_in": "Sign In" "no_account": "Don't have an account?",
"have_account": "Already have an account?",
"two_factor_title": "Two-Factor Authentication",
"two_factor_prompt": "Enter the 6-digit code from your authenticator app:",
"verify_code": "Verify Code",
"reset_password_title": "Reset Password",
"reset_password_instructions": "Enter your email address and we'll send you a password reset link.",
"send_reset_link": "Send Reset Link"
},
"cookie": {
"banner_title": "Cookie Preferences",
"banner_desc": "We use essential cookies to keep you signed in and optional cookies to analyze site traffic and improve performance.",
"accept_all": "Accept All",
"reject_non_essential": "Essential Only",
"customize": "Customize Preferences",
"save": "Save Preferences"
}, },
"common": { "common": {
"cancel": "Cancel",
"save": "Save", "save": "Save",
"cancel": "Cancel",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"back": "Back" "back": "Back",
"loading": "Loading...",
"error": "An error occurred. Please try again.",
"success": "Changes saved successfully.",
"close": "Close",
"search": "Search"
} }
} }

View File

@ -1,35 +1,236 @@
{ {
"welcome": "Bienvenido a PumpkinMarket", "nav": {
"search": "Buscar plugins...", "search_placeholder": "Buscar plugins, autores, etiquetas...",
"login": "Iniciar sesión", "browse": "Explorar",
"register": "Registrarse", "dashboard": "Panel de control",
"home": "Inicio", "library": "Mi biblioteca",
"dashboard": "Panel de control", "profile": "Perfil",
"profile": "Perfil", "login": "Iniciar sesión",
"logout": "Cerrar sesión", "register": "Registrarse",
"loading": "Cargando...", "logout": "Cerrar sesión"
"error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde.", },
"plugins": { "home": {
"title": "Explorar Plugins", "hero_title": "Plugins de Servidor Minecraft de Nueva Generación",
"upload": "Subir Plugin", "hero_subtitle": "Descubre, compra e instala plugins WebAssembly ultrarrápidos para PumpkinMC.",
"manage": "Gestionar Plugin", "search_cta": "Encuentra tu próximo plugin...",
"price": "Precio", "trending": "Plugins Populares",
"latest": "Últimos Lanzamientos",
"all_plugins": "Todos los Plugins",
"filter_all": "Todas las Categorías",
"sort_popular": "Más populares",
"sort_newest": "Más recientes",
"sort_rating": "Mejor valorados",
"sort_price_low": "Precio: menor a mayor",
"sort_price_high": "Precio: mayor a menor",
"no_plugins": "No se encontraron plugins con esos criterios."
},
"plugin": {
"buy_now": "Comprar ahora",
"pre_order": "Reservar",
"in_library": "En tu biblioteca",
"download_wasm": "Descargar .wasm",
"early_access": "Reserva / Acceso anticipado",
"expected_release": "Lanzamiento previsto:",
"free": "Gratis",
"version": "Versión",
"author": "Desarrollador",
"category": "Categoría",
"downloads": "Descargas", "downloads": "Descargas",
"views": "Vistas" "views": "Vistas",
"rating": "Valoración",
"license_key": "Clave de Licencia",
"copy_license": "Copiar clave",
"copied": "¡Copiado!",
"tabs": {
"overview": "Descripción",
"versions": "Versiones",
"reviews": "Reseñas",
"installation": "Guía de instalación"
},
"reviews": {
"title": "Valoraciones y Reseñas",
"write_review": "Escribir una reseña",
"edit_review": "Editar reseña",
"delete_review": "Eliminar",
"rating_label": "Tu valoración:",
"placeholder": "Comparte tu experiencia con este plugin...",
"submit": "Enviar reseña",
"update": "Actualizar reseña",
"verified_purchase": "Compra verificada",
"no_reviews": "Aún no hay reseñas. ¡Sé el primero!",
"dev_reply": "Respuesta del desarrollador",
"reply_placeholder": "Escribe tu respuesta a esta reseña...",
"reply_submit": "Publicar respuesta",
"reply_btn": "Responder"
},
"report": {
"report_plugin": "Reportar plugin",
"report_review": "Reportar reseña",
"modal_title": "Reportar un problema",
"reason_malware": "Código malicioso o vulnerabilidad",
"reason_broken": "El plugin no funciona en absoluto",
"reason_misleading": "Descripción engañosa o funciones falsas",
"reason_other": "Otra infracción de normas",
"details_placeholder": "Proporciona más detalles...",
"submit": "Enviar reporte",
"success": "Reporte enviado. Nuestro equipo lo revisará."
}
},
"profile": {
"title": "Cuenta y Ajustes",
"tab_profile": "Perfil",
"tab_library": "Mi biblioteca",
"tab_security": "Seguridad y 2FA",
"tab_developer": "Portal de Desarrollador",
"library": {
"title": "Plugins Adquiridos",
"empty": "Aún no has comprado ningún plugin.",
"browse_btn": "Explorar plugins",
"purchased_on": "Comprado el:",
"preorder_badge": "Reservado",
"download_latest": "Descargar último .wasm"
},
"security": {
"title": "Seguridad de la Cuenta",
"tfa_title": "Autenticación en Dos Pasos (2FA)",
"tfa_enabled": "La autenticación 2FA está ACTIVADA en tu cuenta.",
"tfa_disabled": "La autenticación 2FA está DESACTIVADA. Actívala para mayor seguridad.",
"enable_tfa": "Activar 2FA",
"disable_tfa": "Desactivar 2FA",
"change_password": "Cambiar contraseña",
"change_email": "Cambiar correo electrónico",
"current_password": "Contraseña actual",
"new_password": "Nueva contraseña",
"confirm_password": "Confirmar nueva contraseña",
"new_email": "Nuevo correo electrónico",
"save_security": "Actualizar credenciales"
}
},
"developer": {
"onboarding": {
"title": "Crear perfil de desarrollador",
"badge": "Portal de Desarrollador",
"subtitle_paid": "Configura tu perfil de creador e identidad fiscal para vender plugins.",
"subtitle_free": "Configura tu perfil de creador para publicar plugins gratuitos para la comunidad.",
"step_profile": "1. Perfil",
"step_seller": "2. Datos de Vendedor",
"step_links": "Enlaces",
"step_finish": "Finalizar",
"entity_question": "¿Publicas como particular u organización?",
"individual_title": "Creador Individual",
"individual_desc": "Desarrollador independiente o aficionado.",
"org_title": "Organización / Estudio",
"org_desc": "Empresa, equipo o estudio.",
"display_name_label": "Nombre público de Desarrollador / Estudio *",
"display_name_placeholder": "ej. PixelCraft Studios, JuanDev",
"display_name_hint": "Este nombre será visible públicamente en todos tus plugins.",
"intent_label": "Tipo de publicación",
"intent_free": "Solo plugins gratuitos",
"intent_free_desc": "Compartir plugins libres y de código abierto.",
"intent_paid": "Vender plugins de pago",
"intent_paid_desc": "Obtener ingresos mediante transferencias de Stripe Connect.",
"seller_info_title": "Información Legal y Fiscal",
"legal_name_label": "Nombre legal completo / Razón social *",
"street_label": "Dirección (calle y número) *",
"city_label": "Ciudad *",
"postal_label": "Código Postal *",
"country_label": "País *",
"vat_label": "NIF / CIF / VAT ID (Opcional)",
"links_title": "Soporte y Enlaces",
"support_email_label": "Correo de soporte técnico",
"website_label": "Sitio Web",
"github_label": "Enlace de GitHub / Organización",
"agreement_label": "Acepto el Contrato de Distribución de Desarrolladores y confirmo que los datos son verídicos.",
"btn_continue": "Continuar ->",
"btn_back": "Atrás",
"btn_complete": "Completar Registro",
"btn_connect_stripe": "Guardar y Conectar Stripe ->"
},
"dashboard": {
"overview": "Resumen",
"plugins": "Mis Plugins",
"add_plugin": "Publicar Plugin",
"manage_plugin": "Gestionar Plugin",
"analytics": "Estadísticas",
"reviews": "Reseñas",
"licenses": "Licencias",
"coupons": "Cupones",
"danger_zone": "Zona de Peligro",
"total_revenue": "Ingresos Brutos",
"total_downloads": "Descargas Totales",
"active_licenses": "Licencias Activas",
"avg_rating": "Valoración Media",
"payouts": "Pagos con Stripe",
"payouts_status": "Estado de Stripe Connect",
"connected": "Conectado",
"setup_stripe": "Configurar Pagos Stripe"
},
"upload": {
"title": "Publicar un Nuevo Plugin",
"subtitle": "Sube tu binario WebAssembly (.wasm) y compártelo con administradores de servidores.",
"step_details": "1. Datos Básicos",
"step_media": "2. Imágenes y Vídeos",
"step_description": "3. Descripción y Documentación",
"step_pricing": "4. Precio y Archivo",
"plugin_name": "Nombre del Plugin *",
"category": "Categoría *",
"version": "Versión Inicial *",
"keywords": "Etiquetas y palabras clave (separadas por comas)",
"source_code": "Repositorio de Código Fuente (Opcional)",
"preview_image": "Icono / Logotipo *",
"screenshots": "Capturas de Pantalla (Hasta 8)",
"youtube_url": "Enlace de Vídeo / Trailer de YouTube",
"pricing_type": "Modelo de Precio",
"price_free": "Gratis",
"price_paid": "De Pago",
"price_cents": "Precio (EUR €)",
"wasm_file": "Binario WebAssembly (.wasm) *",
"preorder_mode": "Reserva / Acceso Anticipado",
"preorder_desc": "Permitir compras antes del lanzamiento oficial del archivo",
"release_date": "Fecha Estimada de Lanzamiento",
"publish_btn": "Publicar Plugin ->",
"publishing": "Subiendo y validando..."
}
}, },
"auth": { "auth": {
"login_title": "¡Hola de nuevo!",
"login_subtitle": "Inicia sesión en tu cuenta de Pumpkin Marketplace",
"register_title": "Crear una Cuenta",
"register_subtitle": "Únete a la plataforma para creadores de Minecraft",
"email": "Correo electrónico", "email": "Correo electrónico",
"username": "Nombre de usuario", "username": "Nombre de usuario",
"password": "Contraseña", "password": "Contraseña",
"confirm_password": "Confirmar contraseña", "confirm_password": "Confirmar contraseña",
"forgot_password": "¿Olvidaste tu contraseña?",
"sign_in": "Iniciar sesión",
"create_account": "Crear cuenta", "create_account": "Crear cuenta",
"sign_in": "Iniciar sesión" "no_account": "¿No tienes cuenta?",
"have_account": "¿Ya tienes una cuenta?",
"two_factor_title": "Autenticación en Dos Pasos",
"two_factor_prompt": "Ingresa el código de 6 dígitos de tu app autenticadora:",
"verify_code": "Verificar código",
"reset_password_title": "Restablecer Contraseña",
"reset_password_instructions": "Introduce tu correo para enviarte el enlace de restablecimiento.",
"send_reset_link": "Enviar enlace"
},
"cookie": {
"banner_title": "Preferencias de Cookies",
"banner_desc": "Usamos cookies esenciales para tu sesión y cookies opcionales para análisis de rendimiento.",
"accept_all": "Aceptar todas",
"reject_non_essential": "Solo esenciales",
"customize": "Personalizar",
"save": "Guardar preferencias"
}, },
"common": { "common": {
"cancel": "Cancelar",
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar", "delete": "Eliminar",
"edit": "Editar", "edit": "Editar",
"back": "Volver" "back": "Volver",
"loading": "Cargando...",
"error": "Ha ocurrido un error.",
"success": "Guardado correctamente.",
"close": "Cerrar",
"search": "Buscar"
} }
} }

236
src/locales/fr.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Rechercher des plugins, auteurs, tags...",
"browse": "Explorer",
"dashboard": "Tableau de bord",
"library": "Ma bibliothèque",
"profile": "Profil",
"login": "Connexion",
"register": "Inscription",
"logout": "Déconnexion"
},
"home": {
"hero_title": "Plugins Minecraft Nouvelle Génération",
"hero_subtitle": "Découvrez, achetez et déployez des plugins WebAssembly ultra-rapides pour PumpkinMC.",
"search_cta": "Trouvez votre prochain plugin...",
"trending": "Plugins populaires",
"latest": "Dernières sorties",
"all_plugins": "Tous les plugins",
"filter_all": "Toutes les catégories",
"sort_popular": "Popularité",
"sort_newest": "Nouveautés",
"sort_rating": "Mieux notés",
"sort_price_low": "Prix croissant",
"sort_price_high": "Prix décroissant",
"no_plugins": "Aucun plugin trouvé selon vos critères."
},
"plugin": {
"buy_now": "Acheter maintenant",
"pre_order": "Précommander",
"in_library": "Dans votre bibliothèque",
"download_wasm": "Télécharger .wasm",
"early_access": "Précommande / Accès anticipé",
"expected_release": "Sortie prévue :",
"free": "Gratuit",
"version": "Version",
"author": "Développeur",
"category": "Catégorie",
"downloads": "Téléchargements",
"views": "Vues",
"rating": "Note",
"license_key": "Clé de licence",
"copy_license": "Copier la clé",
"copied": "Copié !",
"tabs": {
"overview": "Aperçu",
"versions": "Versions",
"reviews": "Avis",
"installation": "Guide d'installation"
},
"reviews": {
"title": "Notes & Avis",
"write_review": "Laisser un avis",
"edit_review": "Modifier l'avis",
"delete_review": "Supprimer",
"rating_label": "Votre note :",
"placeholder": "Partagez votre expérience avec ce plugin...",
"submit": "Publier l'avis",
"update": "Mettre à jour l'avis",
"verified_purchase": "Achat vérifié",
"no_reviews": "Aucun avis pour le moment. Soyez le premier !",
"dev_reply": "Réponse du développeur",
"reply_placeholder": "Rédigez votre réponse...",
"reply_submit": "Publier la réponse",
"reply_btn": "Répondre"
},
"report": {
"report_plugin": "Signaler le plugin",
"report_review": "Signaler l'avis",
"modal_title": "Signaler un problème",
"reason_malware": "Code malveillant ou faille de sécurité",
"reason_broken": "Le plugin ne fonctionne pas du tout",
"reason_misleading": "Description trompeuse ou fausses fonctionnalités",
"reason_other": "Autre violation des règles",
"details_placeholder": "Veuillez préciser le problème...",
"submit": "Envoyer le signalement",
"success": "Signalement envoyé. Notre équipe va l'examiner."
}
},
"profile": {
"title": "Compte & Paramètres",
"tab_profile": "Profil",
"tab_library": "Ma bibliothèque",
"tab_security": "Sécurité & 2FA",
"tab_developer": "Portail Développeur",
"library": {
"title": "Plugins achetés",
"empty": "Vous n'avez acheté aucun plugin pour le moment.",
"browse_btn": "Explorer les plugins",
"purchased_on": "Acheté le :",
"preorder_badge": "Précommandé",
"download_latest": "Télécharger le dernier .wasm"
},
"security": {
"title": "Sécurité du compte",
"tfa_title": "Authentification à deux facteurs (2FA)",
"tfa_enabled": "La 2FA est actuellement ACTIVÉE sur votre compte.",
"tfa_disabled": "La 2FA est DÉSACTIVÉE. Activez-la pour renforcer la sécurité.",
"enable_tfa": "Activer la 2FA",
"disable_tfa": "Désactiver la 2FA",
"change_password": "Changer de mot de passe",
"change_email": "Changer d'adresse e-mail",
"current_password": "Mot de passe actuel",
"new_password": "Nouveau mot de passe",
"confirm_password": "Confirmer le nouveau mot de passe",
"new_email": "Nouvelle adresse e-mail",
"save_security": "Mettre à jour mes identifiants"
}
},
"developer": {
"onboarding": {
"title": "Créer votre profil de développeur",
"badge": "Portail Développeur",
"subtitle_paid": "Configurez votre profil de créateur et vos coordonnées de vendeur pour distribuer et vendre des plugins.",
"subtitle_free": "Configurez votre profil de créateur pour publier des plugins gratuits pour la communauté Pumpkin.",
"step_profile": "1. Profil",
"step_seller": "2. Vendeur",
"step_links": "Liens",
"step_finish": "Terminer",
"entity_question": "Publiez-vous en tant que particulier ou organisation ?",
"individual_title": "Créateur individuel",
"individual_desc": "Développeur indépendant ou passionné.",
"org_title": "Organisation / Studio",
"org_desc": "Entreprise, équipe ou studio.",
"display_name_label": "Nom public du développeur / studio *",
"display_name_placeholder": "ex : PixelCraft Studios, AlexDev",
"display_name_hint": "Ce nom apparaîtra sur tous vos plugins publiés.",
"intent_label": "Type de publication",
"intent_free": "Plugins gratuits uniquement",
"intent_free_desc": "Partager des plugins open-source avec la communauté.",
"intent_paid": "Vendre des plugins payants",
"intent_paid_desc": "Générer des revenus via des virements Stripe Connect.",
"seller_info_title": "Informations légales & vendeur",
"legal_name_label": "Nom légal complet / Raison sociale *",
"street_label": "Adresse (rue et numéro) *",
"city_label": "Ville *",
"postal_label": "Code postal *",
"country_label": "Pays *",
"vat_label": "Numéro de TVA (Optionnel)",
"links_title": "Support & Réseaux",
"support_email_label": "Adresse e-mail de support",
"website_label": "Site Web",
"github_label": "Lien GitHub / Organisation",
"agreement_label": "J'accepte le contrat de distribution développeur et certifie l'exactitude des informations.",
"btn_continue": "Continuer ->",
"btn_back": "Retour",
"btn_complete": "Terminer l'inscription",
"btn_connect_stripe": "Enregistrer & Connecter Stripe ->"
},
"dashboard": {
"overview": "Aperçu",
"plugins": "Mes plugins",
"add_plugin": "Publier un plugin",
"manage_plugin": "Gérer le plugin",
"analytics": "Statistiques",
"reviews": "Avis",
"licenses": "Licences",
"coupons": "Coupons",
"danger_zone": "Zone de danger",
"total_revenue": "Revenus bruts",
"total_downloads": "Téléchargements totaux",
"active_licenses": "Licences actives",
"avg_rating": "Note moyenne",
"payouts": "Virements Stripe",
"payouts_status": "Statut Stripe Connect",
"connected": "Connecté",
"setup_stripe": "Configurer les virements Stripe"
},
"upload": {
"title": "Publier un nouveau plugin",
"subtitle": "Téléversez votre binaire WebAssembly (.wasm) et partagez-le avec les administrateurs de serveurs.",
"step_details": "1. Informations de base",
"step_media": "2. Médias & Visuels",
"step_description": "3. Description & Documentation",
"step_pricing": "4. Prix & Fichier",
"plugin_name": "Nom du plugin *",
"category": "Catégorie *",
"version": "Version initiale *",
"keywords": "Tags et mots-clés (séparés par des virgules)",
"source_code": "Dépôt de code source (Optionnel)",
"preview_image": "Icône / Logo *",
"screenshots": "Captures d'écran (Jusqu'à 8)",
"youtube_url": "Lien vidéo / Bande-annonce YouTube",
"pricing_type": "Modèle de prix",
"price_free": "Gratuit",
"price_paid": "Payant",
"price_cents": "Prix (EUR €)",
"wasm_file": "Binaire WebAssembly (.wasm) *",
"preorder_mode": "Précommande / Accès anticipé",
"preorder_desc": "Permettre aux utilisateurs d'acheter avant la publication du binaire",
"release_date": "Date de sortie prévue",
"publish_btn": "Publier le plugin ->",
"publishing": "Téléversement et validation en cours..."
}
},
"auth": {
"login_title": "Bon retour parmi nous",
"login_subtitle": "Connectez-vous à votre compte Pumpkin Marketplace",
"register_title": "Créer un compte",
"register_subtitle": "Rejoignez la nouvelle plateforme de créateurs Minecraft",
"email": "Adresse e-mail",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"confirm_password": "Confirmer le mot de passe",
"forgot_password": "Mot de passe oublié ?",
"sign_in": "Se connecter",
"create_account": "Créer un compte",
"no_account": "Pas encore de compte ?",
"have_account": "Déjà un compte ?",
"two_factor_title": "Authentification à deux facteurs",
"two_factor_prompt": "Entrez le code à 6 chiffres de votre application d'authentification :",
"verify_code": "Vérifier le code",
"reset_password_title": "Réinitialiser le mot de passe",
"reset_password_instructions": "Entrez votre e-mail pour recevoir un lien de réinitialisation.",
"send_reset_link": "Envoyer le lien"
},
"cookie": {
"banner_title": "Préférences de cookies",
"banner_desc": "Nous utilisons des cookies essentiels pour votre session et des cookies optionnels pour améliorer les performances.",
"accept_all": "Tout accepter",
"reject_non_essential": "Essentiels uniquement",
"customize": "Personnaliser",
"save": "Enregistrer les préférences"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"back": "Retour",
"loading": "Chargement...",
"error": "Une erreur est survenue.",
"success": "Modifications enregistrées.",
"close": "Fermer",
"search": "Rechercher"
}
}

236
src/locales/it.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Cerca plugin, sviluppatori, tag...",
"browse": "Esplora",
"dashboard": "Pannello",
"library": "La Mia Libreria",
"profile": "Profilo",
"login": "Accedi",
"register": "Registrati",
"logout": "Esci"
},
"home": {
"hero_title": "Plugin Minecraft di Nuova Generazione",
"hero_subtitle": "Scopri, acquista e installa plugin WebAssembly ad altissime prestazioni creati per PumpkinMC.",
"search_cta": "Trova il tuo prossimo plugin...",
"trending": "Plugin in Tendenza",
"latest": "Ultime Novità",
"all_plugins": "Tutti i Plugin",
"filter_all": "Tutte le Categorie",
"sort_popular": "Più Popolari",
"sort_newest": "Più Recenti",
"sort_rating": "Più Votati",
"sort_price_low": "Prezzo: dal più basso",
"sort_price_high": "Prezzo: dal più alto",
"no_plugins": "Nessun plugin trovato con questi criteri."
},
"plugin": {
"buy_now": "Acquista Ora",
"pre_order": "Pre-Ordina",
"in_library": "Nella tua libreria",
"download_wasm": "Scarica .wasm",
"early_access": "Pre-Ordine / Accesso Anticipato",
"expected_release": "Rilascio previsto:",
"free": "Gratis",
"version": "Versione",
"author": "Sviluppatore",
"category": "Categoria",
"downloads": "Download",
"views": "Visualizzazioni",
"rating": "Valutazione",
"license_key": "Chiave di Licenza",
"copy_license": "Copia Chiave",
"copied": "Copiato!",
"tabs": {
"overview": "Panoramica",
"versions": "Versioni",
"reviews": "Recensioni",
"installation": "Guida all'Installazione"
},
"reviews": {
"title": "Valutazioni e Recensioni",
"write_review": "Scrivi una Recensione",
"edit_review": "Modifica Recensione",
"delete_review": "Elimina",
"rating_label": "La tua Valutazione:",
"placeholder": "Condividi la tua esperienza con questo plugin...",
"submit": "Invia Recensione",
"update": "Aggiorna Recensione",
"verified_purchase": "Acquisto Verificato",
"no_reviews": "Nessuna recensione presente. Scrivi la prima recensione!",
"dev_reply": "Risposta dello Sviluppatore",
"reply_placeholder": "Scrivi una risposta...",
"reply_submit": "Pubblica Risposta",
"reply_btn": "Rispondi"
},
"report": {
"report_plugin": "Segnala Plugin",
"report_review": "Segnala Recensione",
"modal_title": "Segnala un Problema",
"reason_malware": "Codice malevolo o vulnerabilità di sicurezza",
"reason_broken": "Il plugin non funziona del tutto",
"reason_misleading": "Descrizione ingannevole o funzionalità false",
"reason_other": "Altra violazione del regolamento",
"details_placeholder": "Fornisci ulteriori dettagli...",
"submit": "Invia Segnalazione",
"success": "Segnalazione inviata. Il team di moderazione verificherà."
}
},
"profile": {
"title": "Account e Impostazioni",
"tab_profile": "Profilo",
"tab_library": "La Mia Libreria",
"tab_security": "Sicurezza e 2FA",
"tab_developer": "Portale Sviluppatore",
"library": {
"title": "Plugin Acquistati",
"empty": "Non hai ancora acquistato alcun plugin.",
"browse_btn": "Esplora i Plugin",
"purchased_on": "Acquistato il:",
"preorder_badge": "Pre-Ordinato",
"download_latest": "Scarica l'ultimo .wasm"
},
"security": {
"title": "Sicurezza Account",
"tfa_title": "Autenticazione a Due Fattori (2FA)",
"tfa_enabled": "La 2FA è attualmente ATTIVA sul tuo account.",
"tfa_disabled": "La 2FA è DISATTIVATA. Attivala per proteggere il tuo account.",
"enable_tfa": "Attiva 2FA",
"disable_tfa": "Disattiva 2FA",
"change_password": "Cambia Password",
"change_email": "Cambia Indirizzo Email",
"current_password": "Password Attuale",
"new_password": "Nuova Password",
"confirm_password": "Conferma Nuova Password",
"new_email": "Nuovo Indirizzo Email",
"save_security": "Aggiorna Credenziali"
}
},
"developer": {
"onboarding": {
"title": "Crea Profilo Sviluppatore",
"badge": "Portale Sviluppatore",
"subtitle_paid": "Configura il tuo profilo creator e i tuoi dati fiscali per pubblicare e vendere plugin.",
"subtitle_free": "Configura il tuo profilo creator per pubblicare plugin gratuiti per la community.",
"step_profile": "1. Profilo",
"step_seller": "2. Dati Venditore",
"step_links": "Link",
"step_finish": "Completa",
"entity_question": "Pubblichi come persona fisica o organizzazione?",
"individual_title": "Creator Singolo",
"individual_desc": "Sviluppatore indipendente o hobbista.",
"org_title": "Organizzazione / Studio",
"org_desc": "Azienda, team o studio di sviluppo.",
"display_name_label": "Nome Pubblico Sviluppatore / Studio *",
"display_name_placeholder": "es. PixelCraft Studios, MarcoDev",
"display_name_hint": "Questo nome sarà visibile pubblicamente su tutti i tuoi plugin.",
"intent_label": "Tipo di Pubblicazione",
"intent_free": "Solo Plugin Gratuiti",
"intent_free_desc": "Condividi plugin open-source e gratuiti con la community.",
"intent_paid": "Vendi Plugin a Pagamento",
"intent_paid_desc": "Guadagna tramite accrediti automatici con Stripe Connect.",
"seller_info_title": "Dati Legali e Fiscali",
"legal_name_label": "Nome Legale Completo / Ragione Sociale *",
"street_label": "Indirizzo (Via e Numero Civico) *",
"city_label": "Città *",
"postal_label": "Codice Postale / CAP *",
"country_label": "Paese *",
"vat_label": "Partita IVA / Codice Fiscale (Opzionale)",
"links_title": "Supporto e Canali Social",
"support_email_label": "Email di Supporto",
"website_label": "Sito Web",
"github_label": "Profilo GitHub / Organizzazione",
"agreement_label": "Accetto il Contratto di Distribuzione Sviluppatori e confermo la veridicità dei dati.",
"btn_continue": "Continua ->",
"btn_back": "Indietro",
"btn_complete": "Completa Registrazione",
"btn_connect_stripe": "Salva e Collega Stripe ->"
},
"dashboard": {
"overview": "Panoramica",
"plugins": "I Miei Plugin",
"add_plugin": "Pubblica Plugin",
"manage_plugin": "Gestisci Plugin",
"analytics": "Statistiche",
"reviews": "Recensioni",
"licenses": "Licenze",
"coupons": "Coupon",
"danger_zone": "Zona di Pericolo",
"total_revenue": "Incasso Totale",
"total_downloads": "Download Totali",
"active_licenses": "Licenze Attive",
"avg_rating": "Valutazione Media",
"payouts": "Pagamenti Stripe",
"payouts_status": "Stato Stripe Connect",
"connected": "Collegato",
"setup_stripe": "Configura Pagamenti Stripe"
},
"upload": {
"title": "Pubblica un Nuovo Plugin",
"subtitle": "Carica il tuo binario WebAssembly (.wasm) e condividilo con i proprietari di server.",
"step_details": "1. Informazioni di Base",
"step_media": "2. Immagini e Media",
"step_description": "3. Descrizione e Documentazione",
"step_pricing": "4. Prezzo e File",
"plugin_name": "Nome del Plugin *",
"category": "Categoria *",
"version": "Versione Iniziale *",
"keywords": "Tag e Parole Chiave (separate da virgola)",
"source_code": "Repository del Codice Sorgente (Opzionale)",
"preview_image": "Icona / Logo *",
"screenshots": "Screenshot (Fino a 8)",
"youtube_url": "Link Video / Trailer YouTube",
"pricing_type": "Modello di Prezzo",
"price_free": "Gratuito",
"price_paid": "A Pagamento",
"price_cents": "Prezzo (EUR €)",
"wasm_file": "File WebAssembly (.wasm) *",
"preorder_mode": "Pre-Ordine / Accesso Anticipato",
"preorder_desc": "Consenti agli utenti di acquistare prima dell'uscita del file",
"release_date": "Data di Rilascio Prevista",
"publish_btn": "Pubblica Plugin ->",
"publishing": "Caricamento e verifica file in corso..."
}
},
"auth": {
"login_title": "Bentornato",
"login_subtitle": "Accedi al tuo account Pumpkin Marketplace",
"register_title": "Crea un Account",
"register_subtitle": "Unisciti alla community di creator Minecraft",
"email": "Indirizzo Email",
"username": "Nome Utente",
"password": "Password",
"confirm_password": "Conferma Password",
"forgot_password": "Password dimenticata?",
"sign_in": "Accedi",
"create_account": "Crea Account",
"no_account": "Non hai un account?",
"have_account": "Hai già un account?",
"two_factor_title": "Autenticazione a Due Fattori",
"two_factor_prompt": "Inserisci il codice a 6 cifre dall'app di autenticazione:",
"verify_code": "Verifica Codice",
"reset_password_title": "Reimposta Password",
"reset_password_instructions": "Inserisci la tua email per ricevere il link di reimpostazione.",
"send_reset_link": "Invia Link"
},
"cookie": {
"banner_title": "Preferenze Cookie",
"banner_desc": "Utilizziamo cookie essenziali per la sessione e cookie opzionali per analizzare il traffico.",
"accept_all": "Accetta Tutti",
"reject_non_essential": "Solo Essenziali",
"customize": "Personalizza",
"save": "Salva Preferenze"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"back": "Indietro",
"loading": "Caricamento...",
"error": "Si è verificato un errore.",
"success": "Modifiche salvate con successo.",
"close": "Chiudi",
"search": "Cerca"
}
}

236
src/locales/ja.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "プラグイン、作者、タグを検索...",
"browse": "見つける",
"dashboard": "ダッシュボード",
"library": "マイライブラリ",
"profile": "プロフィール",
"login": "ログイン",
"register": "新規登録",
"logout": "ログアウト"
},
"home": {
"hero_title": "次世代 Minecraft サーバープラグイン",
"hero_subtitle": "PumpkinMC 向けに最適化された超高速 WebAssembly プラグインを探して、購入・即時導入しましょう。",
"search_cta": "プラグインを探す...",
"trending": "人気のプラグイン",
"latest": "最新リリース",
"all_plugins": "すべてのプラグイン",
"filter_all": "すべてのカテゴリー",
"sort_popular": "人気順",
"sort_newest": "新着順",
"sort_rating": "高評価順",
"sort_price_low": "価格が安い順",
"sort_price_high": "価格が高い順",
"no_plugins": "条件に一致するプラグインが見つかりませんでした。"
},
"plugin": {
"buy_now": "今すぐ購入",
"pre_order": "予約注文",
"in_library": "ライブラリに入っています",
"download_wasm": ".wasm をダウンロード",
"early_access": "予約注文 / 早期アクセス",
"expected_release": "リリース予定日:",
"free": "無料",
"version": "バージョン",
"author": "開発者",
"category": "カテゴリー",
"downloads": "ダウンロード数",
"views": "閲覧数",
"rating": "評価",
"license_key": "ライセンスキー",
"copy_license": "キーをコピー",
"copied": "コピー完了!",
"tabs": {
"overview": "概要",
"versions": "バージョン一覧",
"reviews": "レビュー",
"installation": "導入手順"
},
"reviews": {
"title": "評価とレビュー",
"write_review": "レビューを書く",
"edit_review": "レビューを編集",
"delete_review": "削除",
"rating_label": "あなたの評価:",
"placeholder": "このプラグインの使用感を共有してください...",
"submit": "レビューを送信",
"update": "レビューを更新",
"verified_purchase": "購入済みユーザー",
"no_reviews": "まだレビューはありません。最初のレビューを書きましょう!",
"dev_reply": "開発者からの返信",
"reply_placeholder": "レビューへの返信を入力...",
"reply_submit": "返信を投稿",
"reply_btn": "返信"
},
"report": {
"report_plugin": "プラグインを通報",
"report_review": "レビューを通報",
"modal_title": "問題を報告",
"reason_malware": "不正コードまたは脆弱性",
"reason_broken": "全く動作しない",
"reason_misleading": "虚偽の説明または存在しない機能",
"reason_other": "その他の規約違反",
"details_placeholder": "詳細を入力してください...",
"submit": "報告を送信",
"success": "報告を受け付けました。運営チームが確認いたします。"
}
},
"profile": {
"title": "アカウント設定",
"tab_profile": "プロフィール",
"tab_library": "マイライブラリ",
"tab_security": "セキュリティと2段階認証",
"tab_developer": "開発者ポータル",
"library": {
"title": "購入したプラグイン",
"empty": "購入済みのプラグインはまだありません。",
"browse_btn": "プラグインを探す",
"purchased_on": "購入日:",
"preorder_badge": "予約済み",
"download_latest": "最新の .wasm をダウンロード"
},
"security": {
"title": "アカウントセキュリティ",
"tfa_title": "2段階認証 (2FA)",
"tfa_enabled": "2段階認証は現在「有効」です。",
"tfa_disabled": "2段階認証は「無効」です。安全のため有効化をおすすめします。",
"enable_tfa": "2段階認証を有効にする",
"disable_tfa": "2段階認証を無効にする",
"change_password": "パスワード変更",
"change_email": "メールアドレス変更",
"current_password": "現在のパスワード",
"new_password": "新しいパスワード",
"confirm_password": "新しいパスワード(確認)",
"new_email": "新しいメールアドレス",
"save_security": "認証情報を更新"
}
},
"developer": {
"onboarding": {
"title": "開発者プロフィールの作成",
"badge": "開発者ポータル",
"subtitle_paid": "プラグインを販売・配信するためのクリエイタープロフィールと事業者情報を設定します。",
"subtitle_free": "Pumpkin コミュニティ向けに無料プラグインを公開するためのクリエイタープロフィールを設定します。",
"step_profile": "1. プロフィール",
"step_seller": "2. 販売者情報",
"step_links": "リンク",
"step_finish": "完了",
"entity_question": "個人として公開しますか、それとも組織・スタジオとして公開しますか?",
"individual_title": "個人クリエイター",
"individual_desc": "個人開発者またはホビー制作者。",
"org_title": "組織 / スタジオ",
"org_desc": "企業、チーム、またはスタジオ。",
"display_name_label": "公開開発者 / スタジオ名 *",
"display_name_placeholder": "例: PixelCraft Studios, TanakaDev",
"display_name_hint": "この名前は公開するすべてのプラグインに表示されます。",
"intent_label": "公開目的",
"intent_free": "無料プラグインのみ",
"intent_free_desc": "オープンソースおよびコミュニティプラグインを共有。",
"intent_paid": "有料プラグインの販売",
"intent_paid_desc": "Stripe Connect による自動送金で収益化。",
"seller_info_title": "事業者および納税情報",
"legal_name_label": "正式氏名 / 法人名 *",
"street_label": "住所(番地・建物名) *",
"city_label": "市区町村 *",
"postal_label": "郵便番号 *",
"country_label": "国 / 地域 *",
"vat_label": "適格請求書発行事業者番号 / VAT ID(任意)",
"links_title": "サポートおよびリンク",
"support_email_label": "サポート用メールアドレス",
"website_label": "ウェブサイト",
"github_label": "GitHub プロフィール / 組織リンク",
"agreement_label": "開発者配信規約に同意し、入力した情報が正確であることを確認します。",
"btn_continue": "次へ進む ->",
"btn_back": "戻る",
"btn_complete": "登録を完了する",
"btn_connect_stripe": "保存して Stripe と連携 ->"
},
"dashboard": {
"overview": "概要",
"plugins": "自分のプラグイン",
"add_plugin": "プラグインを公開",
"manage_plugin": "プラグインを管理",
"analytics": "アナリティクス",
"reviews": "レビュー管理",
"licenses": "ライセンス管理",
"coupons": "クーポン",
"danger_zone": "危険ゾーン",
"total_revenue": "総売上",
"total_downloads": "総ダウンロード数",
"active_licenses": "有効ライセンス数",
"avg_rating": "平均評価",
"payouts": "Stripe 送金",
"payouts_status": "Stripe Connect ステータス",
"connected": "連携済み",
"setup_stripe": "Stripe 送金を設定"
},
"upload": {
"title": "新しいプラグインの公開",
"subtitle": "コンパイル済みの WebAssembly バイナリ(.wasm)をアップロードしてサーバー管理者に届けましょう。",
"step_details": "1. 基本情報",
"step_media": "2. 画像と動画",
"step_description": "3. 説明とドキュメント",
"step_pricing": "4. 価格とファイル",
"plugin_name": "プラグイン名 *",
"category": "カテゴリー *",
"version": "初期バージョン *",
"keywords": "タグ・キーワード(カンマ区切り)",
"source_code": "ソースコードリポジトリ(任意)",
"preview_image": "アイコン / ロゴ *",
"screenshots": "スクリーンショット(最大8枚)",
"youtube_url": "YouTube 紹介動画 / トレイラーURL",
"pricing_type": "価格モデル",
"price_free": "無料",
"price_paid": "有料",
"price_cents": "価格 (EUR €)",
"wasm_file": "WebAssembly ファイル (.wasm) *",
"preorder_mode": "予約注文 / 早期アクセス",
"preorder_desc": "ファイル公開前に先行予約を許可する",
"release_date": "正式リリース予定日",
"publish_btn": "プラグインを公開 ->",
"publishing": "ファイルをアップロード中..."
}
},
"auth": {
"login_title": "お帰りなさい",
"login_subtitle": "Pumpkin Marketplace アカウントにログイン",
"register_title": "新規アカウント作成",
"register_subtitle": "Minecraft プラグインクリエイターコミュニティに参加",
"email": "メールアドレス",
"username": "ユーザー名",
"password": "パスワード",
"confirm_password": "パスワード(確認)",
"forgot_password": "パスワードをお忘れですか?",
"sign_in": "ログイン",
"create_account": "アカウントを作成",
"no_account": "アカウントをお持ちでないですか?",
"have_account": "すでにアカウントをお持ちですか?",
"two_factor_title": "2段階認証",
"two_factor_prompt": "認証アプリの6桁のコードを入力してください:",
"verify_code": "認証する",
"reset_password_title": "パスワード再設定",
"reset_password_instructions": "登録メールアドレス宛に再設定リンクをお送りします。",
"send_reset_link": "送信する"
},
"cookie": {
"banner_title": "Cookie の設定",
"banner_desc": "ログイン維持のための必須 Cookie と、サイト改善のための分析 Cookie を使用しています。",
"accept_all": "すべて同意",
"reject_non_essential": "必須のみ",
"customize": "設定を変更",
"save": "設定を保存"
},
"common": {
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"back": "戻る",
"loading": "読み込み中...",
"error": "エラーが発生しました。",
"success": "正常に保存されました。",
"close": "閉じる",
"search": "検索"
}
}

236
src/locales/ko.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "플러그인, 개발자, 태그 검색...",
"browse": "둘러보기",
"dashboard": "대시보드",
"library": "내 라이브러리",
"profile": "프로필",
"login": "로그인",
"register": "회원가입",
"logout": "로그아웃"
},
"home": {
"hero_title": "차세대 마인크래프트 서버 플러그인",
"hero_subtitle": "PumpkinMC를 위해 최적화된 초고속 WebAssembly 플러그인을 탐색하고 즉시 도입해 보세요.",
"search_cta": "새로운 플러그인 찾기...",
"trending": "인기 플러그인",
"latest": "최신 출시작",
"all_plugins": "전체 플러그인",
"filter_all": "모든 카테고리",
"sort_popular": "인기순",
"sort_newest": "최신순",
"sort_rating": "평점 높은순",
"sort_price_low": "가격 낮은순",
"sort_price_high": "가격 높은순",
"no_plugins": "검색 조건에 맞는 플러그인이 없습니다."
},
"plugin": {
"buy_now": "지금 구매",
"pre_order": "사전 예약",
"in_library": "보유 중인 플러그인",
"download_wasm": ".wasm 다운로드",
"early_access": "사전 예약 / 얼리 액세스",
"expected_release": "출시 예정일:",
"free": "무료",
"version": "버전",
"author": "개발자",
"category": "카테고리",
"downloads": "다운로드",
"views": "조회수",
"rating": "평점",
"license_key": "라이선스 키",
"copy_license": "키 복사",
"copied": "복사 완료!",
"tabs": {
"overview": "개요",
"versions": "버전 목록",
"reviews": "리뷰",
"installation": "설치 안내"
},
"reviews": {
"title": "평점 및 리뷰",
"write_review": "리뷰 작성하기",
"edit_review": "리뷰 수정",
"delete_review": "삭제",
"rating_label": "내 평점:",
"placeholder": "이 플러그인에 대한 경험을 공유해 주세요...",
"submit": "리뷰 등록",
"update": "리뷰 수정 완료",
"verified_purchase": "구매 인증됨",
"no_reviews": "아직 리뷰가 없습니다. 첫 리뷰를 작성해 보세요!",
"dev_reply": "개발자 답변",
"reply_placeholder": "리뷰에 대한 답변을 작성해 주세요...",
"reply_submit": "답변 등록",
"reply_btn": "답변하기"
},
"report": {
"report_plugin": "플러그인 신고",
"report_review": "리뷰 신고",
"modal_title": "문제 신고하기",
"reason_malware": "악성 코드 또는 보안 취약점",
"reason_broken": "플러그인이 정상 작동하지 않음",
"reason_misleading": "허위 설명 또는 기능 누락",
"reason_other": "기타 정책 위반",
"details_placeholder": "상세 내용을 입력해 주세요...",
"submit": "신고 접수",
"success": "신고가 접수되었습니다. 검토 후 처리하겠습니다."
}
},
"profile": {
"title": "계정 및 설정",
"tab_profile": "프로필",
"tab_library": "내 라이브러리",
"tab_security": "보안 및 2FA",
"tab_developer": "개발자 포털",
"library": {
"title": "구매한 플러그인",
"empty": "아직 구매한 플러그인이 없습니다.",
"browse_btn": "플러그인 탐색하기",
"purchased_on": "구매 일자:",
"preorder_badge": "사전 예약됨",
"download_latest": "최신 .wasm 다운로드"
},
"security": {
"title": "계정 보안",
"tfa_title": "2단계 인증 (2FA)",
"tfa_enabled": "현재 2단계 인증이 활성화되어 있습니다.",
"tfa_disabled": "2단계 인증이 비활성화되어 있습니다. 계정 보호를 위해 활성화하세요.",
"enable_tfa": "2단계 인증 켜기",
"disable_tfa": "2단계 인증 끄기",
"change_password": "비밀번호 변경",
"change_email": "이메일 주소 변경",
"current_password": "현재 비밀번호",
"new_password": "새 비밀번호",
"confirm_password": "새 비밀번호 확인",
"new_email": "새 이메일 주소",
"save_security": "보안 정보 갱신"
}
},
"developer": {
"onboarding": {
"title": "개발자 프로필 생성",
"badge": "개발자 포털",
"subtitle_paid": "플러그인을 출시하고 판매하기 위해 크리에이터 프로필과 판매자 정보를 설정하세요.",
"subtitle_free": "Pumpkin 커뮤니티에 무료 플러그인을 공개하기 위해 크리에이터 프로필을 설정하세요.",
"step_profile": "1. 프로필",
"step_seller": "2. 판매자 정보",
"step_links": "소셜 링크",
"step_finish": "완료",
"entity_question": "개인 자격으로 활동하십니까, 아니면 스튜디오/법인입니까?",
"individual_title": "개인 크리에이터",
"individual_desc": "독립 개발자 또는 개인 개발자.",
"org_title": "기업 / 스튜디오",
"org_desc": "회사, 팀 또는 스튜디오.",
"display_name_label": "개발자 / 스튜디오 공개 표시 이름 *",
"display_name_placeholder": "예: PixelCraft Studios, KimDev",
"display_name_hint": "이 이름은 출시하는 모든 플러그인에 공개적으로 표시됩니다.",
"intent_label": "출시 목적",
"intent_free": "무료 플러그인만 배포",
"intent_free_desc": "오픈소스 및 커뮤니티 플러그인 공유.",
"intent_paid": "유료 플러그인 판매",
"intent_paid_desc": "Stripe Connect 자동 정산으로 수익 창출.",
"seller_info_title": "사업자 및 세무 정보",
"legal_name_label": "법적 성명 / 법인명 *",
"street_label": "상세 주소 (도로명 및 번지) *",
"city_label": "도시 *",
"postal_label": "우편번호 *",
"country_label": "국가 / 지역 *",
"vat_label": "사업자등록번호 / VAT ID (선택사항)",
"links_title": "고객지원 및 소셜 링크",
"support_email_label": "고객지원 이메일 주소",
"website_label": "웹사이트",
"github_label": "GitHub 프로필 / 조직 링크",
"agreement_label": "개발자 유통 계약 약관에 동의하며 입력한 정보가 정확함을 확인합니다.",
"btn_continue": "계속 ->",
"btn_back": "이전",
"btn_complete": "온보딩 완료",
"btn_connect_stripe": "저장 및 Stripe 연결 ->"
},
"dashboard": {
"overview": "개요",
"plugins": "내 플러그인",
"add_plugin": "플러그인 등록",
"manage_plugin": "플러그인 관리",
"analytics": "통계 분석",
"reviews": "리뷰 관리",
"licenses": "라이선스 관리",
"coupons": "쿠폰 관리",
"danger_zone": "위험 구역",
"total_revenue": "총 매출",
"total_downloads": "총 다운로드",
"active_licenses": "활성 라이선스",
"avg_rating": "평균 평점",
"payouts": "Stripe 정산",
"payouts_status": "Stripe Connect 상태",
"connected": "연결됨",
"setup_stripe": "Stripe 정산 설정"
},
"upload": {
"title": "새 플러그인 출시",
"subtitle": "컴파일된 WebAssembly 파일(.wasm)을 업로드하여 마인크래프트 서버 관리자에게 배포하세요.",
"step_details": "1. 기본 정보",
"step_media": "2. 미디어 및 스크린샷",
"step_description": "3. 설명 및 문서",
"step_pricing": "4. 가격 및 바이너리",
"plugin_name": "플러그인 이름 *",
"category": "카테고리 *",
"version": "초기 버전 *",
"keywords": "태그 및 키워드 (쉼표로 구분)",
"source_code": "소스코드 저장소 링크 (선택사항)",
"preview_image": "아이콘 / 로고 이미지 *",
"screenshots": "스크린샷 (최대 8장)",
"youtube_url": "YouTube 소개 영상 / 트레일러 URL",
"pricing_type": "가격 모델",
"price_free": "무료",
"price_paid": "유료",
"price_cents": "가격 (EUR €)",
"wasm_file": "WebAssembly 파일 (.wasm) *",
"preorder_mode": "사전 예약 / 얼리 액세스",
"preorder_desc": "파일 정식 출시 전 사전 구매 허용",
"release_date": "정식 출시 예정일",
"publish_btn": "플러그인 출시하기 ->",
"publishing": "파일 업로드 및 검증 중..."
}
},
"auth": {
"login_title": "다시 오신 것을 환영합니다",
"login_subtitle": "Pumpkin Marketplace 계정에 로그인하세요",
"register_title": "새 계정 만들기",
"register_subtitle": "마인크래프트 개발자 생태계에 참여하세요",
"email": "이메일 주소",
"username": "사용자 이름",
"password": "비밀번호",
"confirm_password": "비밀번호 확인",
"forgot_password": "비밀번호를 잊으셨나요?",
"sign_in": "로그인",
"create_account": "계정 생성",
"no_account": "계정이 없으신가요?",
"have_account": "이미 계정이 있으신가요?",
"two_factor_title": "2단계 인증",
"two_factor_prompt": "인증 앱의 6자리 인증 코드를 입력하세요:",
"verify_code": "코드 확인",
"reset_password_title": "비밀번호 재설정",
"reset_password_instructions": "가입한 이메일을 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
"send_reset_link": "링크 전송"
},
"cookie": {
"banner_title": "쿠키 설정",
"banner_desc": "로그인 유지를 위한 필수 쿠키와 사용성 개선을 위한 분석 쿠키를 사용합니다.",
"accept_all": "모두 동의",
"reject_non_essential": "필수만 동의",
"customize": "설정 변경",
"save": "설정 저장"
},
"common": {
"save": "저장",
"cancel": "취소",
"delete": "삭제",
"edit": "수정",
"back": "뒤로",
"loading": "로딩 중...",
"error": "오류가 발생했습니다.",
"success": "성공적으로 저장되었습니다.",
"close": "닫기",
"search": "검색"
}
}

236
src/locales/nl.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Zoek plugins, ontwikkelaars, tags...",
"browse": "Ontdekken",
"dashboard": "Dashboard",
"library": "Mijn Bibliotheek",
"profile": "Profiel",
"login": "Inloggen",
"register": "Registreren",
"logout": "Uitloggen"
},
"home": {
"hero_title": "Volgende Generatie Minecraft Server Plugins",
"hero_subtitle": "Ontdek, koop en installeer razendsnelle WebAssembly-plugins gebouwd voor PumpkinMC.",
"search_cta": "Vind je volgende plugin...",
"trending": "Populaire Plugins",
"latest": "Nieuwste Releases",
"all_plugins": "Alle Plugins",
"filter_all": "Alle Categorieën",
"sort_popular": "Populairst",
"sort_newest": "Nieuwste",
"sort_rating": "Hoogste Beoordeling",
"sort_price_low": "Prijs: Laag naar Hoog",
"sort_price_high": "Prijs: Hoog naar Laag",
"no_plugins": "Geen plugins gevonden die voldoen aan je zoekopdracht."
},
"plugin": {
"buy_now": "Nu Kopen",
"pre_order": "Pre-Orderen",
"in_library": "In je bibliotheek",
"download_wasm": "Download .wasm",
"early_access": "Pre-Order / Vroege Toegang",
"expected_release": "Verwachte Release:",
"free": "Gratis",
"version": "Versie",
"author": "Ontwikkelaar",
"category": "Categorie",
"downloads": "Downloads",
"views": "Weergaven",
"rating": "Beoordeling",
"license_key": "Licentiesleutel",
"copy_license": "Kopieer Sleutel",
"copied": "Gekopieerd!",
"tabs": {
"overview": "Overzicht",
"versions": "Versies",
"reviews": "Beoordelingen",
"installation": "Installatiehandleiding"
},
"reviews": {
"title": "Beoordelingen & Reviews",
"write_review": "Schrijf een Beoordeling",
"edit_review": "Beoordeling Bewerken",
"delete_review": "Verwijderen",
"rating_label": "Jouw Beoordeling:",
"placeholder": "Deel je ervaringen met deze plugin...",
"submit": "Beoordeling Plaatsen",
"update": "Beoordeling Bijwerken",
"verified_purchase": "Geverifieerde Aankoop",
"no_reviews": "Nog geen beoordelingen. Wees de eerste die deze plugin beoordeelt!",
"dev_reply": "Reactie van Ontwikkelaar",
"reply_placeholder": "Schrijf je reactie...",
"reply_submit": "Reactie Plaatsen",
"reply_btn": "Reageren"
},
"report": {
"report_plugin": "Plugin Rapporteren",
"report_review": "Beoordeling Rapporteren",
"modal_title": "Probleem Melden",
"reason_malware": "Schadelijke code of beveiligingslek",
"reason_broken": "Plugin functioneert helemaal niet",
"reason_misleading": "Misleidende beschrijving of neppe functies",
"reason_other": "Andere beleidsschending",
"details_placeholder": "Geef aanvullende details...",
"submit": "Melding Versturen",
"success": "Melding verzonden. Ons moderatieteam zal dit onderzoeken."
}
},
"profile": {
"title": "Account & Instellingen",
"tab_profile": "Profiel",
"tab_library": "Mijn Bibliotheek",
"tab_security": "Beveiliging & 2FA",
"tab_developer": "Ontwikkelaarsportaal",
"library": {
"title": "Gekochte Plugins",
"empty": "Je hebt nog geen plugins aangeschaft.",
"browse_btn": "Ontdek Plugins",
"purchased_on": "Aangeschaft op:",
"preorder_badge": "Gepre-orderd",
"download_latest": "Download Nieuwste .wasm"
},
"security": {
"title": "Accountbeveiliging",
"tfa_title": "Tweestapsverificatie (2FA)",
"tfa_enabled": "2FA is momenteel INGESCHAKELD op je account.",
"tfa_disabled": "2FA is momenteel UITGESCHAKELD. Schakel dit in voor betere beveiliging.",
"enable_tfa": "2FA Inschakelen",
"disable_tfa": "2FA Uitschakelen",
"change_password": "Wachtwoord Wijzigen",
"change_email": "E-mailadres Wijzigen",
"current_password": "Huidig Wachtwoord",
"new_password": "Nieuw Wachtwoord",
"confirm_password": "Bevestig Nieuw Wachtwoord",
"new_email": "Nieuw E-mailadres",
"save_security": "Gegevens Bijwerken"
}
},
"developer": {
"onboarding": {
"title": "Ontwikkelaarsprofiel Aanmaken",
"badge": "Ontwikkelaarsportaal",
"subtitle_paid": "Stel je creatorprofiel en verkopersgegevens in om plugins te publiceren en verkopen.",
"subtitle_free": "Stel je creatorprofiel in om gratis plugins te delen met de Pumpkin-community.",
"step_profile": "1. Profiel",
"step_seller": "2. Verkopersgegevens",
"step_links": "Links",
"step_finish": "Afronden",
"entity_question": "Publiceer je als individu of als organisatie?",
"individual_title": "Individuele Maker",
"individual_desc": "Zelfstandige ontwikkelaar of hobbyist.",
"org_title": "Organisatie / Studio",
"org_desc": "Bedrijf, team of ontwikkelstudio.",
"display_name_label": "Openbare Ontwikkelaars- / Studionaam *",
"display_name_placeholder": "bijv. PixelCraft Studios, JanDev",
"display_name_hint": "Deze naam wordt openbaar getoond op al je gepubliceerde plugins.",
"intent_label": "Type Publicaties",
"intent_free": "Alleen Gratis Plugins",
"intent_free_desc": "Deel open-source en gratis community-plugins.",
"intent_paid": "Betaalde Plugins Verkopen",
"intent_paid_desc": "Genereer inkomsten via automatische Stripe Connect uitbetalingen.",
"seller_info_title": "Juridische en Fiscale Gegevens",
"legal_name_label": "Volledige Juridische Naam / Bedrijfsnaam *",
"street_label": "Adres (Straat en Huisnummer) *",
"city_label": "Stad / Woonplaats *",
"postal_label": "Postcode *",
"country_label": "Land *",
"vat_label": "Btw-identificatienummer / KvK (Optioneel)",
"links_title": "Ondersteuning en Sociale Links",
"support_email_label": "Support E-mailadres",
"website_label": "Website URL",
"github_label": "GitHub Profiel- / Organisatie-URL",
"agreement_label": "Ik ga akkoord met de Ontwikkelaarsdistributieovereenkomst en verklaar dat alle gegevens juist zijn.",
"btn_continue": "Doorgaan ->",
"btn_back": "Terug",
"btn_complete": "Registratie Voltooien",
"btn_connect_stripe": "Opslaan en Stripe Koppelen ->"
},
"dashboard": {
"overview": "Overzicht",
"plugins": "Mijn Plugins",
"add_plugin": "Plugin Publiceren",
"manage_plugin": "Plugin Beheren",
"analytics": "Statistieken",
"reviews": "Beoordelingen",
"licenses": "Licenties",
"coupons": "Kortingsbonnen",
"danger_zone": "Gevarenzone",
"total_revenue": "Totale Omzet",
"total_downloads": "Totale Downloads",
"active_licenses": "Actieve Licenties",
"avg_rating": "Gemiddelde Beoordeling",
"payouts": "Stripe Uitbetalingen",
"payouts_status": "Stripe Connect Status",
"connected": "Verbonden",
"setup_stripe": "Stripe Uitbetalingen Instellen"
},
"upload": {
"title": "Nieuwe Plugin Publiceren",
"subtitle": "Upload je gecompileerde WebAssembly-bestand (.wasm) en deel het met serverbeheerders.",
"step_details": "1. Basisgegevens",
"step_media": "2. Media & Schermafbeeldingen",
"step_description": "3. Beschrijving & Documentatie",
"step_pricing": "4. Prijs & Bestand",
"plugin_name": "Plugin Naam *",
"category": "Categorie *",
"version": "Initiële Versie *",
"keywords": "Tags & Trefwoorden (door komma's gescheiden)",
"source_code": "Broncode Repository (Optioneel)",
"preview_image": "Icoon / Logo *",
"screenshots": "Schermafbeeldingen (Maximaal 8)",
"youtube_url": "YouTube Video- / Trailer-URL",
"pricing_type": "Prijsmodel",
"price_free": "Gratis",
"price_paid": "Betaald",
"price_cents": "Prijs (EUR €)",
"wasm_file": "WebAssembly Bestand (.wasm) *",
"preorder_mode": "Pre-Order / Vroege Toegang",
"preorder_desc": "Laat gebruikers vooraf kopen vóór de officiële release",
"release_date": "Verwachte Releasedatum",
"publish_btn": "Plugin Publiceren ->",
"publishing": "Bestand uploaden en verifiëren..."
}
},
"auth": {
"login_title": "Welkom Terug",
"login_subtitle": "Log in op je Pumpkin Marketplace account",
"register_title": "Account Aanmaken",
"register_subtitle": "Sluit je aan bij de Minecraft ontwikkelaarscommunity",
"email": "E-mailadres",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"confirm_password": "Bevestig Wachtwoord",
"forgot_password": "Wachtwoord vergeten?",
"sign_in": "Inloggen",
"create_account": "Account Aanmaken",
"no_account": "Nog geen account?",
"have_account": "Heb je al een account?",
"two_factor_title": "Tweestapsverificatie",
"two_factor_prompt": "Voer de 6-cijferige code uit je authenticator-app in:",
"verify_code": "Code Verifiëren",
"reset_password_title": "Wachtwoord Herstellen",
"reset_password_instructions": "Voer je e-mailadres in om een herstellink te ontvangen.",
"send_reset_link": "Link Versturen"
},
"cookie": {
"banner_title": "Cookievoorkeuren",
"banner_desc": "We gebruiken noodzakelijke cookies voor je sessie en optionele cookies om de website te verbeteren.",
"accept_all": "Alles Accepteren",
"reject_non_essential": "Alleen Noodzakelijk",
"customize": "Aanpassen",
"save": "Voorkeuren Opslaan"
},
"common": {
"save": "Opslaan",
"cancel": "Annuleren",
"delete": "Verwijderen",
"edit": "Bewerken",
"back": "Terug",
"loading": "Laden...",
"error": "Er is een fout opgetreden.",
"success": "Succesvol opgeslagen.",
"close": "Sluiten",
"search": "Zoeken"
}
}

236
src/locales/pl.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Szukaj pluginów, autorów, tagów...",
"browse": "Przeglądaj",
"dashboard": "Panel",
"library": "Moja Biblioteka",
"profile": "Profil",
"login": "Zaloguj się",
"register": "Zarejestruj się",
"logout": "Wyloguj się"
},
"home": {
"hero_title": "Pluginy Nowej Generacji do Serwerów Minecraft",
"hero_subtitle": "Odkrywaj, kupuj i wdrażaj błyskawiczne pluginy WebAssembly stworzone dla PumpkinMC.",
"search_cta": "Znajdź swój następny plugin...",
"trending": "Popularne Pluginy",
"latest": "Najnowsze Wydania",
"all_plugins": "Wszystkie Pluginy",
"filter_all": "Wszystkie Kategorie",
"sort_popular": "Najpopularniejsze",
"sort_newest": "Najnowsze",
"sort_rating": "Najwyżej Oceniane",
"sort_price_low": "Cena: od najniższej",
"sort_price_high": "Cena: od najwyższej",
"no_plugins": "Nie znaleziono pluginów spełniających kryteria."
},
"plugin": {
"buy_now": "Kup Teraz",
"pre_order": "Przedsprzedaż",
"in_library": "W Twojej Bibliotece",
"download_wasm": "Pobierz .wasm",
"early_access": "Przedsprzedaż / Wczesny Dostęp",
"expected_release": "Przewidywana premiera:",
"free": "Darmowy",
"version": "Wersja",
"author": "Deweloper",
"category": "Kategoria",
"downloads": "Pobrań",
"views": "Wyświetleń",
"rating": "Ocena",
"license_key": "Klucz Licencyjny",
"copy_license": "Kopiuj Klucz",
"copied": "Skopiowano!",
"tabs": {
"overview": "Przegląd",
"versions": "Wersje",
"reviews": "Opinie",
"installation": "Instrukcja Instalacji"
},
"reviews": {
"title": "Oceny i Opinie",
"write_review": "Napisz Opinię",
"edit_review": "Edytuj Opinię",
"delete_review": "Usuń",
"rating_label": "Twoja Ocena:",
"placeholder": "Podziel się wrażeniami z korzystania z tego pluginu...",
"submit": "Opublikuj Opinię",
"update": "Zaktualizuj Opinię",
"verified_purchase": "Zweryfikowany Zakup",
"no_reviews": "Brak opinii. Bądź pierwszym, który oceni ten plugin!",
"dev_reply": "Odpowiedź Dewelopera",
"reply_placeholder": "Napisz swoją odpowiedź na tę opinię...",
"reply_submit": "Opublikuj Odpowiedź",
"reply_btn": "Odpowiedz"
},
"report": {
"report_plugin": "Zgłoś Plugin",
"report_review": "Zgłoś Opinię",
"modal_title": "Zgłoś Problem",
"reason_malware": "Złośliwy kod lub luka w zabezpieczeniach",
"reason_broken": "Plugin całkowicie nie działa",
"reason_misleading": "Wprowadzający w błąd opis lub fałszywe funkcje",
"reason_other": "Inne naruszenie regulaminu",
"details_placeholder": "Prosimy o podanie dodatkowych szczegółów...",
"submit": "Wyślij Zgłoszenie",
"success": "Zgłoszenie wysłane. Zespół moderacji zajmie się sprawą."
}
},
"profile": {
"title": "Konto i Ustawienia",
"tab_profile": "Profil",
"tab_library": "Moja Biblioteka",
"tab_security": "Bezpieczeństwo i 2FA",
"tab_developer": "Panel Dewelopera",
"library": {
"title": "Kupione Pluginy",
"empty": "Nie kupiłeś jeszcze żadnych pluginów.",
"browse_btn": "Przeglądaj Pluginy",
"purchased_on": "Kupiono:",
"preorder_badge": "Przedsprzedaż",
"download_latest": "Pobierz najnowszy .wasm"
},
"security": {
"title": "Bezpieczeństwo Konta",
"tfa_title": "Weryfikacja Dwuetapowa (2FA)",
"tfa_enabled": "Weryfikacja 2FA jest WŁĄCZONA na Twoim koncie.",
"tfa_disabled": "Weryfikacja 2FA jest WYŁĄCZONA. Włącz ją, aby zabezpieczyć konto.",
"enable_tfa": "Włącz 2FA",
"disable_tfa": "Wyłącz 2FA",
"change_password": "Zmień Hasło",
"change_email": "Zmień Adres E-mail",
"current_password": "Obecne Hasło",
"new_password": "Nowe Hasło",
"confirm_password": "Potwierdź Nowe Hasło",
"new_email": "Nowy Adres E-mail",
"save_security": "Zaktualizuj Dane"
}
},
"developer": {
"onboarding": {
"title": "Utwórz Profil Dewelopera",
"badge": "Panel Dewelopera",
"subtitle_paid": "Skonfiguruj profil twórcy i dane sprzedawcy, aby publikować i sprzedawać pluginy.",
"subtitle_free": "Skonfiguruj profil twórcy, aby publikować darmowe pluginy dla społeczności Pumpkin.",
"step_profile": "1. Profil",
"step_seller": "2. Dane Sprzedawcy",
"step_links": "Linki",
"step_finish": "Koniec",
"entity_question": "Publikujesz jako osoba prywatna czy organizacja?",
"individual_title": "Niezależny Twórca",
"individual_desc": "Niezależny deweloper lub hobbysta.",
"org_title": "Organizacja / Studio",
"org_desc": "Firma, zespół lub studio.",
"display_name_label": "Publiczna Nazwa Dewelopera / Studia *",
"display_name_placeholder": "np. PixelCraft Studios, JanDev",
"display_name_hint": "Ta nazwa będzie widoczna publicznie przy wszystkich Twoich pluginach.",
"intent_label": "Model Publikacji",
"intent_free": "Tylko Darmowe Pluginy",
"intent_free_desc": "Udostępniaj darmowe pluginy open-source społeczności.",
"intent_paid": "Sprzedaż Płatnych Pluginów",
"intent_paid_desc": "Zarabiaj dzięki automatycznym wypłatom przez Stripe Connect.",
"seller_info_title": "Dane Prawne i Informacje o Sprzedawcy",
"legal_name_label": "Imię i Nazwisko / Nazwa Firmy *",
"street_label": "Ulica i Numer Domu *",
"city_label": "Miejscowość *",
"postal_label": "Kod Pocztowy *",
"country_label": "Kraj *",
"vat_label": "NIP / VAT ID (Opcjonalnie)",
"links_title": "Wsparcie i Media Społecznościowe",
"support_email_label": "Adres E-mail Wsparcia",
"website_label": "Strona Internetowa",
"github_label": "Profil GitHub / Link Organizacji",
"agreement_label": "Akceptuję Umowę Dystrybucyjną Dewelopera i potwierdzam poprawność danych.",
"btn_continue": "Dalej ->",
"btn_back": "Wstecz",
"btn_complete": "Zakończ Rejestrację",
"btn_connect_stripe": "Zapisz i Połącz Stripe ->"
},
"dashboard": {
"overview": "Przegląd",
"plugins": "Moje Pluginy",
"add_plugin": "Opublikuj Plugin",
"manage_plugin": "Zarządzaj Pluginem",
"analytics": "Analityka",
"reviews": "Opinie",
"licenses": "Licencje",
"coupons": "Kupony",
"danger_zone": "Strefa Zagrożenia",
"total_revenue": "Przychód Całkowity",
"total_downloads": "Łączna Liczba Pobrań",
"active_licenses": "Aktywne Licencje",
"avg_rating": "Średnia Ocena",
"payouts": "Wypłaty Stripe",
"payouts_status": "Status Stripe Connect",
"connected": "Połączono",
"setup_stripe": "Skonfiguruj Wypłaty Stripe"
},
"upload": {
"title": "Opublikuj Nowy Plugin",
"subtitle": "Prześlij skompilowany plik WebAssembly (.wasm) i udostępnij go właścicielom serwerów.",
"step_details": "1. Podstawowe Dane",
"step_media": "2. Grafika i Media",
"step_description": "3. Opis i Dokumentacja",
"step_pricing": "4. Cena i Plik",
"plugin_name": "Nazwa Pluginu *",
"category": "Kategoria *",
"version": "Wersja Początkowa *",
"keywords": "Tagi i Słowa Kluczowe (oddzielone przecinkami)",
"source_code": "Repozytorium Kodu Źródłowego (Opcjonalnie)",
"preview_image": "Ikona / Logo Pluginu *",
"screenshots": "Zrzuty Ekranu (Do 8 zdjęć)",
"youtube_url": "Link do Filmu / Zwiastuna na YouTube",
"pricing_type": "Typ Ceny",
"price_free": "Darmowy",
"price_paid": "Płatny",
"price_cents": "Cena (EUR €)",
"wasm_file": "Plik WebAssembly (.wasm) *",
"preorder_mode": "Przedsprzedaż / Wczesny Dostęp",
"preorder_desc": "Zezwalaj użytkownikom na zakup przed oficjalnym wydaniem pliku",
"release_date": "Planowana Data Premiery",
"publish_btn": "Opublikuj Plugin ->",
"publishing": "Przesyłanie i weryfikacja pliku..."
}
},
"auth": {
"login_title": "Witaj Ponownie",
"login_subtitle": "Zaloguj się do swojego konta Pumpkin Marketplace",
"register_title": "Utwórz Konto",
"register_subtitle": "Dołącz do nowej platformy dla twórców Minecraft",
"email": "Adres E-mail",
"username": "Nazwa Użytkownika",
"password": "Hasło",
"confirm_password": "Potwierdź Hasło",
"forgot_password": "Nie pamiętasz hasła?",
"sign_in": "Zaloguj się",
"create_account": "Zarejestruj się",
"no_account": "Nie masz jeszcze konta?",
"have_account": "Masz już konto?",
"two_factor_title": "Weryfikacja Dwuetapowa",
"two_factor_prompt": "Wpisz 6-cyfrowy kod z aplikacji uwierzytelniającej:",
"verify_code": "Zweryfikuj Kod",
"reset_password_title": "Resetowanie Hasła",
"reset_password_instructions": "Wpisz swój adres e-mail, a wyślemy link do resetowania hasła.",
"send_reset_link": "Wyślij Link"
},
"cookie": {
"banner_title": "Ustawienia Prywatności",
"banner_desc": "Używamy niezbędnych plików cookie do logowania oraz opcjonalnych plików cookie do analizy wydajności.",
"accept_all": "Zaakceptuj Wszystkie",
"reject_non_essential": "Tylko Niezbędne",
"customize": "Dostosuj",
"save": "Zapisz Ustawienia"
},
"common": {
"save": "Zapisz",
"cancel": "Anuluj",
"delete": "Usuń",
"edit": "Edytuj",
"back": "Wstecz",
"loading": "Ładowanie...",
"error": "Wystąpił błąd.",
"success": "Zapisano pomyślnie.",
"close": "Zamknij",
"search": "Szukaj"
}
}

236
src/locales/pt.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Pesquisar plugins, autores, tags...",
"browse": "Explorar",
"dashboard": "Painel",
"library": "Minha Biblioteca",
"profile": "Perfil",
"login": "Entrar",
"register": "Registrar",
"logout": "Sair"
},
"home": {
"hero_title": "Plugins de Servidor Minecraft de Nova Geração",
"hero_subtitle": "Descubra, compre e implante plugins WebAssembly ultrarrápidos para PumpkinMC.",
"search_cta": "Encontre seu próximo plugin...",
"trending": "Plugins em Destaque",
"latest": "Últimos Lançamentos",
"all_plugins": "Todos os Plugins",
"filter_all": "Todas as Categorias",
"sort_popular": "Mais Populares",
"sort_newest": "Mais Recentes",
"sort_rating": "Melhor Avaliados",
"sort_price_low": "Menor Preço",
"sort_price_high": "Maior Preço",
"no_plugins": "Nenhum plugin encontrado com esses critérios."
},
"plugin": {
"buy_now": "Comprar Agora",
"pre_order": "Pré-Venda",
"in_library": "Na sua biblioteca",
"download_wasm": "Baixar .wasm",
"early_access": "Pré-Venda / Acesso Antecipado",
"expected_release": "Lançamento previsto:",
"free": "Grátis",
"version": "Versão",
"author": "Desenvolvedor",
"category": "Categoria",
"downloads": "Downloads",
"views": "Visualizações",
"rating": "Avaliação",
"license_key": "Chave de Licença",
"copy_license": "Copiar Chave",
"copied": "Copiado!",
"tabs": {
"overview": "Visão Geral",
"versions": "Versões",
"reviews": "Avaliações",
"installation": "Guia de Instalação"
},
"reviews": {
"title": "Notas e Avaliações",
"write_review": "Escrever Avaliação",
"edit_review": "Editar Avaliação",
"delete_review": "Excluir",
"rating_label": "Sua Nota:",
"placeholder": "Compartilhe sua experiência com este plugin...",
"submit": "Enviar Avaliação",
"update": "Atualizar Avaliação",
"verified_purchase": "Compra Verificada",
"no_reviews": "Ainda não há avaliações. Seja o primeiro!",
"dev_reply": "Resposta do Desenvolvedor",
"reply_placeholder": "Escreva sua resposta para esta avaliação...",
"reply_submit": "Publicar Resposta",
"reply_btn": "Responder"
},
"report": {
"report_plugin": "Denunciar Plugin",
"report_review": "Denunciar Avaliação",
"modal_title": "Denunciar um Problema",
"reason_malware": "Código malicioso ou vulnerabilidade de segurança",
"reason_broken": "Plugin não funciona de forma alguma",
"reason_misleading": "Descrição enganosa ou recursos falsos",
"reason_other": "Outra violação das regras",
"details_placeholder": "Por favor, forneça mais detalhes...",
"submit": "Enviar Denúncia",
"success": "Denúncia enviada com sucesso. Nossa equipe irá analisar."
}
},
"profile": {
"title": "Conta e Configurações",
"tab_profile": "Perfil",
"tab_library": "Minha Biblioteca",
"tab_security": "Segurança e 2FA",
"tab_developer": "Portal do Desenvolvedor",
"library": {
"title": "Plugins Comprados",
"empty": "Você ainda não comprou nenhum plugin.",
"browse_btn": "Explorar Plugins",
"purchased_on": "Comprado em:",
"preorder_badge": "Em Pré-Venda",
"download_latest": "Baixar versão mais recente .wasm"
},
"security": {
"title": "Segurança da Conta",
"tfa_title": "Autenticação em Duas Etapas (2FA)",
"tfa_enabled": "O 2FA está atualmente ATIVADO na sua conta.",
"tfa_disabled": "O 2FA está DESATIVADO. Ative para proteger sua conta.",
"enable_tfa": "Ativar 2FA",
"disable_tfa": "Desativar 2FA",
"change_password": "Alterar Senha",
"change_email": "Alterar E-mail",
"current_password": "Senha Atual",
"new_password": "Nova Senha",
"confirm_password": "Confirmar Nova Senha",
"new_email": "Novo Endereço de E-mail",
"save_security": "Atualizar Credenciais"
}
},
"developer": {
"onboarding": {
"title": "Criar Perfil de Desenvolvedor",
"badge": "Portal do Desenvolvedor",
"subtitle_paid": "Configure seu perfil de criador e dados fiscais para publicar e vender plugins.",
"subtitle_free": "Configure seu perfil de criador para publicar plugins gratuitos para a comunidade Pumpkin.",
"step_profile": "1. Perfil",
"step_seller": "2. Dados de Vendedor",
"step_links": "Links",
"step_finish": "Finalizar",
"entity_question": "Você publica como pessoa física ou organização?",
"individual_title": "Criador Individual",
"individual_desc": "Desenvolvedor independente ou entusiasta.",
"org_title": "Organização / Estúdio",
"org_desc": "Empresa, equipe ou estúdio.",
"display_name_label": "Nome Público de Desenvolvedor / Estúdio *",
"display_name_placeholder": "ex: PixelCraft Studios, DevAlex",
"display_name_hint": "Este nome será visível publicamente em todos os seus plugins.",
"intent_label": "Tipo de Publicação",
"intent_free": "Apenas Plugins Gratuitos",
"intent_free_desc": "Compartilhe plugins de código aberto com a comunidade.",
"intent_paid": "Vender Plugins Pagos",
"intent_paid_desc": "Ganhe receita por meio de repasses automáticos do Stripe Connect.",
"seller_info_title": "Informações Legais e Fiscais",
"legal_name_label": "Nome Legal Completo / Razão Social *",
"street_label": "Endereço (Rua e Número) *",
"city_label": "Cidade *",
"postal_label": "CEP / Código Postal *",
"country_label": "País *",
"vat_label": "CNPJ / NIF / VAT ID (Opcional)",
"links_title": "Suporte e Redes",
"support_email_label": "E-mail de Suporte ao Cliente",
"website_label": "Website",
"github_label": "Perfil do GitHub / Organização",
"agreement_label": "Aceito o Contrato de Distribuição do Desenvolvedor e confirmo que os dados são verdadeiros.",
"btn_continue": "Continuar ->",
"btn_back": "Voltar",
"btn_complete": "Concluir Cadastro",
"btn_connect_stripe": "Salvar e Conectar Stripe ->"
},
"dashboard": {
"overview": "Visão Geral",
"plugins": "Meus Plugins",
"add_plugin": "Publicar Plugin",
"manage_plugin": "Gerenciar Plugin",
"analytics": "Estatísticas",
"reviews": "Avaliações",
"licenses": "Licenças",
"coupons": "Cupons",
"danger_zone": "Zona de Perigo",
"total_revenue": "Receita Bruta",
"total_downloads": "Downloads Totais",
"active_licenses": "Licenças Ativas",
"avg_rating": "Avaliação Média",
"payouts": "Repasses Stripe",
"payouts_status": "Status do Stripe Connect",
"connected": "Conectado",
"setup_stripe": "Configurar Repasses Stripe"
},
"upload": {
"title": "Publicar Novo Plugin",
"subtitle": "Envie seu binário WebAssembly (.wasm) e compartilhe com administradores de servidores.",
"step_details": "1. Informações Básicas",
"step_media": "2. Mídia e Visuais",
"step_description": "3. Descrição e Documentação",
"step_pricing": "4. Preço e Arquivo",
"plugin_name": "Nome do Plugin *",
"category": "Categoria *",
"version": "Versão Inicial *",
"keywords": "Tags e Palavras-chave (separadas por vírgula)",
"source_code": "Repositório do Código Fonte (Opcional)",
"preview_image": "Ícone / Logotipo *",
"screenshots": "Capturas de Tela (Até 8)",
"youtube_url": "Link do Vídeo / Trailer no YouTube",
"pricing_type": "Modelo de Preço",
"price_free": "Gratuito",
"price_paid": "Pago",
"price_cents": "Preço (EUR €)",
"wasm_file": "Binário WebAssembly (.wasm) *",
"preorder_mode": "Pré-Venda / Acesso Antecipado",
"preorder_desc": "Permitir compras antes do lançamento do arquivo oficial",
"release_date": "Data Prevista de Lançamento",
"publish_btn": "Publicar Plugin ->",
"publishing": "Enviando e validando arquivo..."
}
},
"auth": {
"login_title": "Bem-vindo de volta",
"login_subtitle": "Entre na sua conta Pumpkin Marketplace",
"register_title": "Criar uma Conta",
"register_subtitle": "Junte-se à comunidade de criadores do Minecraft",
"email": "Endereço de E-mail",
"username": "Nome de Usuário",
"password": "Senha",
"confirm_password": "Confirmar Senha",
"forgot_password": "Esqueceu a senha?",
"sign_in": "Entrar",
"create_account": "Criar Conta",
"no_account": "Não tem uma conta?",
"have_account": "Já tem uma conta?",
"two_factor_title": "Autenticação em Duas Etapas",
"two_factor_prompt": "Digite o código de 6 dígitos do seu app autenticador:",
"verify_code": "Verificar Código",
"reset_password_title": "Redefinir Senha",
"reset_password_instructions": "Digite seu e-mail para receber o link de redefinição de senha.",
"send_reset_link": "Enviar Link"
},
"cookie": {
"banner_title": "Preferências de Cookies",
"banner_desc": "Usamos cookies essenciais para manter sua sessão e opcionais para análise de tráfego e melhorias.",
"accept_all": "Aceitar Todos",
"reject_non_essential": "Apenas Essenciais",
"customize": "Personalizar",
"save": "Salvar Preferências"
},
"common": {
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Excluir",
"edit": "Editar",
"back": "Voltar",
"loading": "Carregando...",
"error": "Ocorreu um erro.",
"success": "Alterações salvas com sucesso.",
"close": "Fechar",
"search": "Buscar"
}
}

236
src/locales/ru.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Поиск плагинов, авторов, тегов...",
"browse": "Каталог",
"dashboard": "Панель управления",
"library": "Моя библиотека",
"profile": "Профиль",
"login": "Войти",
"register": "Регистрация",
"logout": "Выйти"
},
"home": {
"hero_title": "Плагины нового поколения для серверов Minecraft",
"hero_subtitle": "Находите, покупайте и мгновенно запускайте сверхбыстрые WebAssembly-плагины для PumpkinMC.",
"search_cta": "Найти нужный плагин...",
"trending": "Популярные плагины",
"latest": "Новинки",
"all_plugins": "Все плагины",
"filter_all": "Все категории",
"sort_popular": "По популярности",
"sort_newest": "Сначала новые",
"sort_rating": "По рейтингу",
"sort_price_low": "Сначала недорогие",
"sort_price_high": "Сначала дорогие",
"no_plugins": "По вашему запросу плагины не найдены."
},
"plugin": {
"buy_now": "Купить сейчас",
"pre_order": "Предзаказ",
"in_library": "В вашей библиотеке",
"download_wasm": "Скачать .wasm",
"early_access": "Предзаказ / Ранний доступ",
"expected_release": "Ожидаемый релиз:",
"free": "Бесплатно",
"version": "Версия",
"author": "Разработчик",
"category": "Категория",
"downloads": "Скачиваний",
"views": "Просмотров",
"rating": "Рейтинг",
"license_key": "Ключ лицензии",
"copy_license": "Скопировать ключ",
"copied": "Скопировано!",
"tabs": {
"overview": "Обзор",
"versions": "Версии",
"reviews": "Отзывы",
"installation": "Инструкция по установке"
},
"reviews": {
"title": "Оценки и отзывы",
"write_review": "Написать отзыв",
"edit_review": "Изменить отзыв",
"delete_review": "Удалить",
"rating_label": "Ваша оценка:",
"placeholder": "Поделитесь впечатлениями о работе плагина...",
"submit": "Отправить отзыв",
"update": "Обновить отзыв",
"verified_purchase": "Подтвержденная покупка",
"no_reviews": "Отзывов пока нет. Оставьте отзыв первым!",
"dev_reply": "Ответ разработчика",
"reply_placeholder": "Напишите ответ на этот отзыв...",
"reply_submit": "Опубликовать ответ",
"reply_btn": "Ответить"
},
"report": {
"report_plugin": "Пожаловаться на плагин",
"report_review": "Пожаловаться на отзыв",
"modal_title": "Сообщить о нарушении",
"reason_malware": "Вредоносный код или уязвимость",
"reason_broken": "Плагин полностью не работает",
"reason_misleading": "Ложное описание или отсутствующий функционал",
"reason_other": "Другое нарушение правил",
"details_placeholder": "Опишите подробности проблемы...",
"submit": "Отправить жалобу",
"success": "Жалоба отправлена на рассмотрение модераторам."
}
},
"profile": {
"title": "Аккаунт и настройки",
"tab_profile": "Профиль",
"tab_library": "Моя библиотека",
"tab_security": "Безопасность и 2FA",
"tab_developer": "Кабинет разработчика",
"library": {
"title": "Купленные плагины",
"empty": "У вас пока нет купленных плагинов.",
"browse_btn": "Перейти в каталог",
"purchased_on": "Дата покупки:",
"preorder_badge": "Предзаказ",
"download_latest": "Скачать последнюю версию .wasm"
},
"security": {
"title": "Безопасность аккаунта",
"tfa_title": "Двухфакторная аутентификация (2FA)",
"tfa_enabled": "2FA сейчас ВКЛЮЧЕНА на вашем аккаунте.",
"tfa_disabled": "2FA сейчас ВЫКЛЮЧЕНА. Включите ее для надежной защиты.",
"enable_tfa": "Включить 2FA",
"disable_tfa": "Отключить 2FA",
"change_password": "Сменить пароль",
"change_email": "Сменить адрес электронной почты",
"current_password": "Текущий пароль",
"new_password": "Новый пароль",
"confirm_password": "Подтвердите новый пароль",
"new_email": "Новый адрес почты",
"save_security": "Обновить данные"
}
},
"developer": {
"onboarding": {
"title": "Создание профиля разработчика",
"badge": "Портал разработчика",
"subtitle_paid": "Настройте профиль автора и данные продавца для публикации и продажи плагинов.",
"subtitle_free": "Настройте профиль автора для публикации бесплатных плагинов для сообщества Pumpkin.",
"step_profile": "1. Профиль",
"step_seller": "2. Данные продавца",
"step_links": "Ссылки",
"step_finish": "Завершение",
"entity_question": "Вы публикуете плагины как физлицо или организация?",
"individual_title": "Индивидуальный автор",
"individual_desc": "Независимый разработчик или энтузиаст.",
"org_title": "Организация / Студия",
"org_desc": "Компания, команда или студия.",
"display_name_label": "Публичное имя разработчика / студии *",
"display_name_placeholder": "напр. PixelCraft Studios, AlexDev",
"display_name_hint": "Это имя будет отображаться на всех ваших опубликованных плагинах.",
"intent_label": "Тип публикации",
"intent_free": "Только бесплатные плагины",
"intent_free_desc": "Публикация бесплатных решений с открытым исходным кодом.",
"intent_paid": "Продажа платных плагинов",
"intent_paid_desc": "Получение дохода через выплаты Stripe Connect.",
"seller_info_title": "Юридические данные продавца",
"legal_name_label": "Полное имя / Название юридического лица *",
"street_label": "Улица, дом *",
"city_label": "Город *",
"postal_label": "Почтовый индекс *",
"country_label": "Страна *",
"vat_label": "ИНН / VAT ID (Необязательно)",
"links_title": "Контакты и поддержка",
"support_email_label": "Электронная почта поддержки",
"website_label": "Веб-сайт",
"github_label": "GitHub / Профиль организации",
"agreement_label": "Я принимаю Соглашение о дистрибуции для разработчиков и подтверждаю достоверность данных.",
"btn_continue": "Продолжить ->",
"btn_back": "Назад",
"btn_complete": "Завершить регистрацию",
"btn_connect_stripe": "Сохранить и подключить Stripe ->"
},
"dashboard": {
"overview": "Обзор",
"plugins": "Мои плагины",
"add_plugin": "Опубликовать плагин",
"manage_plugin": "Управление плагином",
"analytics": "Аналитика",
"reviews": "Отзывы",
"licenses": "Лицензии",
"coupons": "Купоны",
"danger_zone": "Опасная зона",
"total_revenue": "Общий доход",
"total_downloads": "Всего загрузок",
"active_licenses": "Активные лицензии",
"avg_rating": "Средний рейтинг",
"payouts": "Выплаты Stripe",
"payouts_status": "Статус Stripe Connect",
"connected": "Подключено",
"setup_stripe": "Настроить выплаты Stripe"
},
"upload": {
"title": "Публикация нового плагина",
"subtitle": "Загрузите скомпилированный WebAssembly-файл (.wasm) и поделитесь им с владельцами серверов.",
"step_details": "1. Основная информация",
"step_media": "2. Медиа и скриншоты",
"step_description": "3. Описание и документация",
"step_pricing": "4. Цена и файл",
"plugin_name": "Название плагина *",
"category": "Категория *",
"version": "Версия *",
"keywords": "Теги и ключевые слова (через запятую)",
"source_code": "Репозиторий исходного кода (Необязательно)",
"preview_image": "Иконка / Логотип *",
"screenshots": "Скриншоты (до 8 шт.)",
"youtube_url": "Ссылка на YouTube видео / трейлер",
"pricing_type": "Тип распространения",
"price_free": "Бесплатно",
"price_paid": "Платно",
"price_cents": "Цена (EUR €)",
"wasm_file": "Файл WebAssembly (.wasm) *",
"preorder_mode": "Предзаказ / Ранний доступ",
"preorder_desc": "Разрешить покупку до официального релиза файла",
"release_date": "Планируемая дата релиза",
"publish_btn": "Опубликовать плагин ->",
"publishing": "Загрузка и проверка файла..."
}
},
"auth": {
"login_title": "С возвращением!",
"login_subtitle": "Войдите в аккаунт Pumpkin Marketplace",
"register_title": "Создание аккаунта",
"register_subtitle": "Присоединяйтесь к платформе авторов плагинов для Minecraft",
"email": "Электронная почта",
"username": "Имя пользователя",
"password": "Пароль",
"confirm_password": "Подтверждение пароля",
"forgot_password": "Забыли пароль?",
"sign_in": "Войти",
"create_account": "Зарегистрироваться",
"no_account": "Еще нет аккаунта?",
"have_account": "Уже есть аккаунт?",
"two_factor_title": "Двухфакторная аутентификация",
"two_factor_prompt": "Введите 6-значный код из приложения аутентификатора:",
"verify_code": "Подтвердить код",
"reset_password_title": "Сброс пароля",
"reset_password_instructions": "Введите адрес почты, и мы вышлем ссылку для сброса пароля.",
"send_reset_link": "Отправить ссылку"
},
"cookie": {
"banner_title": "Настройки cookie",
"banner_desc": "Мы используем обязательные cookie для авторизации и аналитические cookie для улучшения работы сайта.",
"accept_all": "Принять все",
"reject_non_essential": "Только обязательные",
"customize": "Настроить",
"save": "Сохранить настройки"
},
"common": {
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"back": "Назад",
"loading": "Загрузка...",
"error": "Произошла ошибка.",
"success": "Успешно сохранено.",
"close": "Закрыть",
"search": "Поиск"
}
}

236
src/locales/tr.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "Eklenti, yazar veya etiket ara...",
"browse": "Keşfet",
"dashboard": "Panel",
"library": "Kütüphanem",
"profile": "Profil",
"login": "Giriş Yap",
"register": "Kayıt Ol",
"logout": "Çıkış Yap"
},
"home": {
"hero_title": "Yeni Nesil Minecraft Sunucu Eklentileri",
"hero_subtitle": "PumpkinMC için geliştirilmiş yüksek performanslı WebAssembly eklentilerini keşfedin ve anında kurun.",
"search_cta": "Yeni eklentinizi bulun...",
"trending": "Popüler Eklentiler",
"latest": "Son Eklenenler",
"all_plugins": "Tüm Eklentiler",
"filter_all": "Tüm Kategoriler",
"sort_popular": "En Çok İndirilen",
"sort_newest": "En Yeniler",
"sort_rating": "En Yüksek Puanlı",
"sort_price_low": "Fiyat: Düşükten Yükseğe",
"sort_price_high": "Fiyat: Yüksekten Düşüğe",
"no_plugins": "Kriterlerinize uygun eklenti bulunamadı."
},
"plugin": {
"buy_now": "Hemen Satın Al",
"pre_order": "Ön Sipariş Ver",
"in_library": "Kütüphanenizde",
"download_wasm": ".wasm İndir",
"early_access": "Ön Sipariş / Erken Erişim",
"expected_release": "Beklenen Çıkış:",
"free": "Ücretsiz",
"version": "Sürüm",
"author": "Geliştirici",
"category": "Kategori",
"downloads": "İndirme",
"views": "Görüntülenme",
"rating": "Puan",
"license_key": "Lisans Anahtarı",
"copy_license": "Anahtarı Kopyala",
"copied": "Kopyalandı!",
"tabs": {
"overview": "Genel Bakış",
"versions": "Sürümler",
"reviews": "Değerlendirmeler",
"installation": "Kurulum Rehberi"
},
"reviews": {
"title": "Puanlar ve Değerlendirmeler",
"write_review": "Değerlendirme Yaz",
"edit_review": "Değerlendirmeyi Düzenle",
"delete_review": "Sil",
"rating_label": "Puanınız:",
"placeholder": "Bu eklenti hakkındaki deneyimlerinizi paylaşın...",
"submit": "Değerlendirmeyi Gönder",
"update": "Güncelle",
"verified_purchase": "Onaylı Satın Alma",
"no_reviews": "Henüz değerlendirme yok. İlk değerlendiren siz olun!",
"dev_reply": "Geliştirici Yanıtı",
"reply_placeholder": "Bu değerlendirmeye yanıtınızı yazın...",
"reply_submit": "Yanıtı Yayınla",
"reply_btn": "Yanıtla"
},
"report": {
"report_plugin": "Eklentiyi Bildir",
"report_review": "Yorumu Bildir",
"modal_title": "Sorun Bildir",
"reason_malware": "Zararlı kod veya güvenlik açığı",
"reason_broken": "Eklenti tamamen çalışmıyor",
"reason_misleading": "Yanıltıcı açıklama veya sahte özellikler",
"reason_other": "Diğer kural ihlali",
"details_placeholder": "Lütfen detayları belirtin...",
"submit": "Bildirimi Gönder",
"success": "Bildirim iletildi. Moderasyon ekibimiz inceleyecektir."
}
},
"profile": {
"title": "Hesap ve Ayarlar",
"tab_profile": "Profil",
"tab_library": "Kütüphanem",
"tab_security": "Güvenlik ve 2FA",
"tab_developer": "Geliştirici Portalı",
"library": {
"title": "Satın Alınan Eklentiler",
"empty": "Henüz satın aldığınız bir eklenti bulunmuyor.",
"browse_btn": "Eklentileri Keşfet",
"purchased_on": "Satın alma tarihi:",
"preorder_badge": "Ön Sipariş",
"download_latest": "En Son .wasm Dosyasını İndir"
},
"security": {
"title": "Hesap Güvenliği",
"tfa_title": "İki Aşamalı Doğrulama (2FA)",
"tfa_enabled": "2FA hesabınızda şu anda ETKİN.",
"tfa_disabled": "2FA şu anda DEVRE DIŞI. Hesabınızı korumak için etkinleştirin.",
"enable_tfa": "2FA'yı Etkinleştir",
"disable_tfa": "2FA'yı Devre Dışı Bırak",
"change_password": "Şifre Değiştir",
"change_email": "E-Posta Değiştir",
"current_password": "Mevcut Şifre",
"new_password": "Yeni Şifre",
"confirm_password": "Yeni Şifreyi Onayla",
"new_email": "Yeni E-Posta Adresi",
"save_security": "Bilgileri Güncelle"
}
},
"developer": {
"onboarding": {
"title": "Geliştirici Profili Oluştur",
"badge": "Geliştirici Portalı",
"subtitle_paid": "Eklenti yayınlamak ve satmak için üretici profilinizi ve satıcı bilgilerinizi ayarlayın.",
"subtitle_free": "Pumpkin topluluğu için ücretsiz eklentiler yayınlamak amacıyla profilinizi oluşturun.",
"step_profile": "1. Profil",
"step_seller": "2. Satıcı Bilgileri",
"step_links": "Bağlantılar",
"step_finish": "Tamamla",
"entity_question": "Bireysel mi yoksa kuruluş olarak mı yayınlıyorsunuz?",
"individual_title": "Bireysel Geliştirici",
"individual_desc": "Bağımsız geliştirici veya hobi üreticisi.",
"org_title": "Kuruluş / Stüdyo",
"org_desc": "Şirket, ekip veya stüdyo.",
"display_name_label": "Geliştirici / Stüdyo Görünen Adı *",
"display_name_placeholder": "örn. PixelCraft Studios, CanDev",
"display_name_hint": "Bu isim yayınladığınız tüm eklentilerde herkese açık olarak görünecektir.",
"intent_label": "Yayınlama Türü",
"intent_free": "Yalnızca Ücretsiz Eklentiler",
"intent_free_desc": "Açık kaynaklı ve ücretsiz eklentileri toplulukla paylaşın.",
"intent_paid": "Ücretli Eklentiler Sat",
"intent_paid_desc": "Stripe Connect otomatik ödemeleriyle gelir elde edin.",
"seller_info_title": "Yasal ve Fatura Bilgileri",
"legal_name_label": "Tam Yasal İsim / Ticari Unvan *",
"street_label": "Adres (Sokak ve No) *",
"city_label": "Şehir *",
"postal_label": "Posta Kodu *",
"country_label": "Ülke *",
"vat_label": "Vergi Kimlik Numarası / VAT ID (İsteğe bağlı)",
"links_title": "Destek ve Sosyal Medya",
"support_email_label": "Müşteri Destek E-Postası",
"website_label": "Web Sitesi",
"github_label": "GitHub Profili / Kuruluş Bağlantısı",
"agreement_label": "Geliştirici Dağıtım Sözleşmesi'ni kabul ediyorum ve bilgilerin doğruluğunu onaylıyorum.",
"btn_continue": "Devam Et ->",
"btn_back": "Geri",
"btn_complete": "Kaydı Tamamla",
"btn_connect_stripe": "Kaydet ve Stripe'a Bağla ->"
},
"dashboard": {
"overview": "Genel Bakış",
"plugins": "Eklentilerim",
"add_plugin": "Eklenti Yayınla",
"manage_plugin": "Eklentiyi Yönet",
"analytics": "Analizler",
"reviews": "Değerlendirmeler",
"licenses": "Lisanslar",
"coupons": "Kuponlar",
"danger_zone": "Tehlike Bölgesi",
"total_revenue": "Toplam Gelir",
"total_downloads": "Toplam İndirme",
"active_licenses": "Aktif Lisanslar",
"avg_rating": "Ortalama Puan",
"payouts": "Stripe Ödemeleri",
"payouts_status": "Stripe Connect Durumu",
"connected": "Bağlandı",
"setup_stripe": "Stripe Ödemelerini Ayarla"
},
"upload": {
"title": "Yeni Eklenti Yayınla",
"subtitle": "Derlenmiş WebAssembly dosyanızı (.wasm) yükleyin ve sunucu sahipleriyle paylaşın.",
"step_details": "1. Temel Bilgiler",
"step_media": "2. Medya ve Görseller",
"step_description": "3. Açıklama ve Belgeler",
"step_pricing": "4. Fiyat ve Dosya",
"plugin_name": "Eklenti Adı *",
"category": "Kategori *",
"version": "Başlangıç Sürümü *",
"keywords": "Etiketler ve Anahtar Kelimeler (virgülle ayırın)",
"source_code": "Kaynak Kod Deposu (İsteğe bağlı)",
"preview_image": "Simge / Logo *",
"screenshots": "Ekran Görüntüleri (En fazla 8 adet)",
"youtube_url": "YouTube Tanıtım Videosu Bağlantısı",
"pricing_type": "Fiyatlandırma Modeli",
"price_free": "Ücretsiz",
"price_paid": "Ücretli",
"price_cents": "Fiyat (EUR €)",
"wasm_file": "WebAssembly Dosyası (.wasm) *",
"preorder_mode": "Ön Sipariş / Erken Erişim",
"preorder_desc": "Kullanıcıların dosya çıkışından önce satın almasına izin verin",
"release_date": "Hedeflenen Çıkış Tarihi",
"publish_btn": "Eklentiyi Yayınla ->",
"publishing": "Dosya yükleniyor ve doğrulanıyor..."
}
},
"auth": {
"login_title": "Tekrar Hoş Geldiniz",
"login_subtitle": "Pumpkin Marketplace hesabınıza giriş yapın",
"register_title": "Hesap Oluştur",
"register_subtitle": "Minecraft eklenti geliştirici topluluğuna katılın",
"email": "E-Posta Adresi",
"username": "Kullanıcı Adı",
"password": "Şifre",
"confirm_password": "Şifreyi Onayla",
"forgot_password": "Şifrenizi mi unuttunuz?",
"sign_in": "Giriş Yap",
"create_account": "Hesap Oluştur",
"no_account": "Hesabınız yok mu?",
"have_account": "Zaten bir hesabınız var mı?",
"two_factor_title": "İki Aşamalı Doğrulama",
"two_factor_prompt": "Doğrulama uygulamanızdaki 6 haneli kodu girin:",
"verify_code": "Kodu Doğrula",
"reset_password_title": "Şifre Sıfırlama",
"reset_password_instructions": "E-posta adresinizi girin, sıfırlama bağlantısını gönderelim.",
"send_reset_link": "Bağlantı Gönder"
},
"cookie": {
"banner_title": "Çerez Tercihleri",
"banner_desc": "Oturumunuz için zorunlu çerezleri ve deneyimi iyileştirmek için analitik çerezleri kullanıyoruz.",
"accept_all": "Tümünü Kabul Et",
"reject_non_essential": "Yalnızca Zorunlu",
"customize": "Tercihleri Özelleştir",
"save": "Tercihleri Kaydet"
},
"common": {
"save": "Kaydet",
"cancel": "İptal",
"delete": "Sil",
"edit": "Düzenle",
"back": "Geri",
"loading": "Yükleniyor...",
"error": "Bir hata oluştu.",
"success": "Başarıyla kaydedildi.",
"close": "Kapat",
"search": "Ara"
}
}

236
src/locales/zh.json Normal file
View File

@ -0,0 +1,236 @@
{
"nav": {
"search_placeholder": "搜索插件、作者、标签...",
"browse": "探索",
"dashboard": "控制台",
"library": "我的库",
"profile": "个人中心",
"login": "登录",
"register": "注册",
"logout": "退出登录"
},
"home": {
"hero_title": "下一代 Minecraft 服务端插件市场",
"hero_subtitle": "探索、购买并一键部署专为 PumpkinMC 构建的超高速 WebAssembly 插件。",
"search_cta": "寻找您的下一个插件...",
"trending": "热门插件",
"latest": "最新发布",
"all_plugins": "全部插件",
"filter_all": "全部分类",
"sort_popular": "最受欢迎",
"sort_newest": "最新上架",
"sort_rating": "评分最高",
"sort_price_low": "价格由低到高",
"sort_price_high": "价格由高到低",
"no_plugins": "没有找到符合条件的插件。"
},
"plugin": {
"buy_now": "立即购买",
"pre_order": "立即预购",
"in_library": "已在您的库中",
"download_wasm": "下载 .wasm",
"early_access": "预售 / 抢先体验",
"expected_release": "预计发布时间:",
"free": "免费",
"version": "版本",
"author": "开发者",
"category": "分类",
"downloads": "下载量",
"views": "浏览量",
"rating": "评分",
"license_key": "许可证密钥",
"copy_license": "复制密钥",
"copied": "已复制!",
"tabs": {
"overview": "概览",
"versions": "版本列表",
"reviews": "评价反馈",
"installation": "安装指南"
},
"reviews": {
"title": "评分与评价",
"write_review": "撰写评价",
"edit_review": "编辑评价",
"delete_review": "删除",
"rating_label": "您的评分:",
"placeholder": "分享您使用此插件的体验...",
"submit": "提交评价",
"update": "更新评价",
"verified_purchase": "已购买用户",
"no_reviews": "暂无评价。成为第一个评价的人吧!",
"dev_reply": "开发者回复",
"reply_placeholder": "写下您对该评价的回复...",
"reply_submit": "发布回复",
"reply_btn": "回复"
},
"report": {
"report_plugin": "举报插件",
"report_review": "举报评价",
"modal_title": "举报问题",
"reason_malware": "包含恶意代码或严重安全隐患",
"reason_broken": "插件完全无法运行",
"reason_misleading": "虚假功能宣传或误导描述",
"reason_other": "其他违规行为",
"details_placeholder": "请提供更多详细信息...",
"submit": "提交举报",
"success": "举报已提交,审核团队将尽快处理。"
}
},
"profile": {
"title": "账户与设置",
"tab_profile": "个人资料",
"tab_library": "我的库",
"tab_security": "安全与 2FA",
"tab_developer": "开发者中心",
"library": {
"title": "已购插件",
"empty": "您尚未购买任何插件。",
"browse_btn": "浏览插件市场",
"purchased_on": "购买日期:",
"preorder_badge": "预购商品",
"download_latest": "下载最新 .wasm 文件"
},
"security": {
"title": "账户安全",
"tfa_title": "双重认证 (2FA)",
"tfa_enabled": "您的账户已启用双重认证 (2FA)。",
"tfa_disabled": "双重认证 (2FA) 未开启。建议开启以保护账户安全。",
"enable_tfa": "启用双重认证",
"disable_tfa": "禁用双重认证",
"change_password": "修改密码",
"change_email": "更换电子邮箱",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_password": "确认新密码",
"new_email": "新电子邮箱地址",
"save_security": "保存凭据变更"
}
},
"developer": {
"onboarding": {
"title": "创建您的开发者档案",
"badge": "开发者门户",
"subtitle_paid": "设置您的创作者档案和商家信息以发布并销售插件。",
"subtitle_free": "设置您的创作者档案以为 Pumpkin 社区发布免费开源插件。",
"step_profile": "1. 基本档案",
"step_seller": "2. 商家信息",
"step_links": "社交链接",
"step_finish": "完成",
"entity_question": "您是以个人还是组织/工作室名义发布?",
"individual_title": "独立创作者",
"individual_desc": "独立开发者或个人爱好者。",
"org_title": "组织 / 工作室",
"org_desc": "公司、团队或工作室。",
"display_name_label": "开发者 / 工作室公开显示名称 *",
"display_name_placeholder": "例如:PixelCraft Studios, AlexDev",
"display_name_hint": "此名称将公开显示在您发布的所有插件页面上。",
"intent_label": "发布意图",
"intent_free": "仅发布免费插件",
"intent_free_desc": "与社区分享开源及免费插件。",
"intent_paid": "销售付费插件",
"intent_paid_desc": "通过 Stripe Connect 自动结算获取收益。",
"seller_info_title": "法定主体与税务信息",
"legal_name_label": "法定全名 / 企业名称 *",
"street_label": "详细地址(街道与门牌号) *",
"city_label": "城市 *",
"postal_label": "邮政编码 *",
"country_label": "国家 / 地区 *",
"vat_label": "纳税人识别号 / VAT ID(可选)",
"links_title": "技术支持与社交主页",
"support_email_label": "客户支持电子邮箱",
"website_label": "官方网站",
"github_label": "GitHub 主页 / 组织链接",
"agreement_label": "我同意《开发者分发协议》并确认所填信息真实有效。",
"btn_continue": "继续 ->",
"btn_back": "返回",
"btn_complete": "完成注册",
"btn_connect_stripe": "保存并绑定 Stripe ->"
},
"dashboard": {
"overview": "数据概览",
"plugins": "我的插件",
"add_plugin": "发布插件",
"manage_plugin": "管理插件",
"analytics": "数据分析",
"reviews": "评价管理",
"licenses": "授权管理",
"coupons": "优惠券",
"danger_zone": "危险操作区",
"total_revenue": "总收入",
"total_downloads": "总下载量",
"active_licenses": "活跃授权数",
"avg_rating": "平均评分",
"payouts": "Stripe 结算",
"payouts_status": "Stripe Connect 状态",
"connected": "已连接",
"setup_stripe": "设置 Stripe 结算账户"
},
"upload": {
"title": "发布新插件",
"subtitle": "上传已编译的 WebAssembly 二进制文件(.wasm)并与服主分享。",
"step_details": "1. 基本信息",
"step_media": "2. 媒体与截图",
"step_description": "3. 详细介绍与文档",
"step_pricing": "4. 价格与文件",
"plugin_name": "插件名称 *",
"category": "插件分类 *",
"version": "初始版本号 *",
"keywords": "标签与关键词(逗号分隔)",
"source_code": "开源代码仓库链接(可选)",
"preview_image": "插件图标 / Logo *",
"screenshots": "宣传截图(最多 8 张)",
"youtube_url": "YouTube 演示视频 / 宣传片链接",
"pricing_type": "定价模式",
"price_free": "免费",
"price_paid": "付费",
"price_cents": "价格 (EUR €)",
"wasm_file": "WebAssembly 文件 (.wasm) *",
"preorder_mode": "开启预售 / 抢先体验",
"preorder_desc": "允许买家在正式发布文件前先行预购",
"release_date": "预计正式发售日期",
"publish_btn": "确认发布插件 ->",
"publishing": "文件上传与安全校验中..."
}
},
"auth": {
"login_title": "欢迎回来",
"login_subtitle": "登录您的 Pumpkin Marketplace 账号",
"register_title": "创建新账号",
"register_subtitle": "加入下一代 Minecraft 插件创作者社区",
"email": "电子邮箱",
"username": "用户名",
"password": "密码",
"confirm_password": "确认密码",
"forgot_password": "忘记密码?",
"sign_in": "登录",
"create_account": "创建账户",
"no_account": "还没有账号?",
"have_account": "已有账号?",
"two_factor_title": "双重认证 (2FA)",
"two_factor_prompt": "请输入身份验证器中的 6 位动态验证码:",
"verify_code": "验证并登录",
"reset_password_title": "重置密码",
"reset_password_instructions": "输入您的注册邮箱,我们将向您发送重置密码链接。",
"send_reset_link": "发送重置邮件"
},
"cookie": {
"banner_title": "Cookie 设置",
"banner_desc": "我们使用必要 Cookie 保持您的登录状态,并使用分析 Cookie 提升访问体验。",
"accept_all": "全部同意",
"reject_non_essential": "仅必要",
"customize": "自定义设置",
"save": "保存偏好"
},
"common": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"edit": "编辑",
"back": "返回",
"loading": "加载中...",
"error": "发生错误,请重试。",
"success": "保存成功。",
"close": "关闭",
"search": "搜索"
}
}

View File

@ -1,5 +1,20 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/inter/800.css'
import '@fontsource/inter/900.css'
import '@fontsource/jetbrains-mono/700.css'
import '@fontsource/nunito/400.css'
import '@fontsource/nunito/500.css'
import '@fontsource/nunito/600.css'
import '@fontsource/nunito/700.css'
import '@fontsource/nunito/800.css'
import '@fontsource/dm-sans/400.css'
import '@fontsource/dm-sans/500.css'
import '@fontsource/dm-sans/700.css'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import './i18n' import './i18n'

View File

@ -15,7 +15,7 @@ let homeCache: {
} | null = null; } | null = null;
const Home = () => { const Home = () => {
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [popular, setPopular] = useState<any[]>(homeCache?.popular || []); const [popular, setPopular] = useState<any[]>(homeCache?.popular || []);
const [newest, setNewest] = useState<any[]>(homeCache?.newest || []); const [newest, setNewest] = useState<any[]>(homeCache?.newest || []);
const [allPlugins, setAllPlugins] = useState<any[]>(homeCache?.all || []); const [allPlugins, setAllPlugins] = useState<any[]>(homeCache?.all || []);
@ -215,40 +215,40 @@ const Home = () => {
<div className="container" id="browse"> <div className="container" id="browse">
<section className="home-section" ref={popularRef}> <section className="home-section" ref={popularRef}>
<h2 className="section-title"><span>Most</span> Popular</h2> <h2 className="section-title"><span>{t('home.trending')}</span></h2>
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{popular.length > 0 ? ( {popular.length > 0 ? (
popular.map((plugin) => ( popular.slice(0, 10).map((plugin: any) => (
<PluginCard key={`pop-${plugin.id}`} plugin={plugin} hideDescription /> <PluginCard key={plugin.id} plugin={plugin} />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading popular plugins...' : 'Scroll to load popular plugins'}</div> <p className="no-plugins">{t('home.no_plugins')}</p>
)} )}
</div> </div>
</section> </section>
<section className="home-section" ref={newestRef}> <section className="home-section" ref={newestRef}>
<h2 className="section-title"><span>New</span> & Trending</h2> <h2 className="section-title"><span>{t('home.latest')}</span></h2>
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{newest.length > 0 ? ( {newest.length > 0 ? (
newest.map((plugin) => ( newest.slice(0, 10).map((plugin: any) => (
<PluginCard key={`new-${plugin.id}`} plugin={plugin} hideDescription /> <PluginCard key={plugin.id} plugin={plugin} />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading new plugins...' : 'Scroll to load new plugins'}</div> <p className="no-plugins">{t('home.no_plugins')}</p>
)} )}
</div> </div>
</section> </section>
<section className="home-section"> <section className="home-section">
<h2 className="section-title"><span>All</span> Plugins ({allPlugins.length})</h2> <h2 className="section-title"><span>{t('home.all_plugins')}</span></h2>
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{allPlugins.length > 0 ? ( {allPlugins.length > 0 ? (
allPlugins.map((plugin) => ( allPlugins.map((plugin: any) => (
<PluginCard key={`all-${plugin.id}`} plugin={plugin} hideDescription /> <PluginCard key={plugin.id} plugin={plugin} />
)) ))
) : ( ) : (
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div> <p className="no-plugins">{t('home.no_plugins')}</p>
)} )}
</div> </div>
</section> </section>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useLocation, Link } from 'react-router-dom'; import { useParams, useLocation, Link } from 'react-router-dom';
import api from '../api'; import api from '../api';
import { useAuth } from '../App'; import { useAuth } from '../App';
@ -6,7 +7,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import { Sparkles, History, ChevronRight } from 'lucide-react'; import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react';
const getYoutubeVideoId = (url: string) => { const getYoutubeVideoId = (url: string) => {
if (!url) return null; if (!url) return null;
@ -52,14 +53,15 @@ const REVIEW_REPORT_REASONS = [
const MAX_REVIEW_LENGTH = 2000; const MAX_REVIEW_LENGTH = 2000;
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => ( const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
<div className="star-rating-container"> <div className="star-rating-container" style={{ display: 'flex', gap: 2 }}>
{[...Array(max)].map((_, i) => ( {[...Array(max)].map((_, i) => (
<span <span
key={i} key={i}
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`} className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
onClick={() => onRate && onRate(i + 1)} onClick={() => onRate && onRate(i + 1)}
style={{ display: 'inline-flex', alignItems: 'center' }}
> >
{i < rating ? '★' : '☆'} <Star size={16} fill={i < rating ? 'currentColor' : 'none'} />
</span> </span>
))} ))}
</div> </div>
@ -136,6 +138,7 @@ const formatDate = (dateStr: string) => {
}; };
const PluginDetail = () => { const PluginDetail = () => {
const { i18n } = useTranslation();
const { id: rawId, slug } = useParams(); const { id: rawId, slug } = useParams();
const id = rawId ? rawId.split('-')[0] : rawId; const id = rawId ? rawId.split('-')[0] : rawId;
const { user } = useAuth(); const { user } = useAuth();
@ -148,7 +151,6 @@ const PluginDetail = () => {
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null); const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
const [lightboxImage, setLightboxImage] = useState<string | null>(null); const [lightboxImage, setLightboxImage] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number>(0); const [lightboxIndex, setLightboxIndex] = useState<number>(0);
const [selectedLanguage] = useState('en');
const [currentDescription, setCurrentDescription] = useState(''); const [currentDescription, setCurrentDescription] = useState('');
// Coupon state // Coupon state
@ -263,19 +265,20 @@ const PluginDetail = () => {
if (plugin?.translated_descriptions) { if (plugin?.translated_descriptions) {
try { try {
const translations = JSON.parse(plugin.translated_descriptions); const translations = JSON.parse(plugin.translated_descriptions);
const activeLang = (i18n.language || 'en').slice(0, 2);
setCurrentDescription( setCurrentDescription(
translations[selectedLanguage] || translations[activeLang] ||
translations['en'] || translations['en'] ||
Object.values(translations)[0] as string || (Object.values(translations)[0] as string) ||
'' ''
); );
} catch { } catch {
setCurrentDescription('Description not available.'); setCurrentDescription('Description not available.');
} }
} else { } else if (plugin?.description) {
setCurrentDescription('Description not available.'); setCurrentDescription(plugin.description);
} }
}, [plugin, selectedLanguage]); }, [plugin, i18n.language]);
// ── Lightbox helpers ───────────────────────────────────────────────────── // ── Lightbox helpers ─────────────────────────────────────────────────────
const screenshots = plugin?.screenshots ?? []; const screenshots = plugin?.screenshots ?? [];
@ -348,6 +351,10 @@ const PluginDetail = () => {
if (plugin.type === 'paid') { if (plugin.type === 'paid') {
// Already owns it — download directly, no checkout // Already owns it — download directly, no checkout
if (ownsPlugin) { if (ownsPlugin) {
if (plugin.is_preorder) {
alert('You have successfully pre-ordered this plugin! You will receive an automated email notification the moment the developer releases the downloadable version.');
return;
}
try { try {
await triggerDownload(`/plugins/${id}/download/premium`); await triggerDownload(`/plugins/${id}/download/premium`);
} catch { } catch {
@ -365,8 +372,12 @@ const PluginDetail = () => {
if (err?.response?.status === 409) { if (err?.response?.status === 409) {
// Webhook already recorded purchase before redirect resolved // Webhook already recorded purchase before redirect resolved
setOwnsPlugin(true); setOwnsPlugin(true);
alert('You already own this plugin — downloading now.'); if (plugin.is_preorder) {
await triggerDownload(`/plugins/${id}/download/premium`); alert('You have pre-ordered this plugin! You will receive an email as soon as it releases.');
} else {
alert('You already own this plugin — downloading now.');
await triggerDownload(`/plugins/${id}/download/premium`);
}
} else { } else {
alert('Checkout failed. Please try again.'); alert('Checkout failed. Please try again.');
} }
@ -406,9 +417,8 @@ const PluginDetail = () => {
try { try {
await api.delete(`/plugins/${id}/reviews`); await api.delete(`/plugins/${id}/reviews`);
fetchPlugin(); fetchPlugin();
} catch (err) { } catch {
console.error('Failed to delete review', err); alert('Failed to delete review.');
alert('Failed to delete review');
} }
}; };
@ -421,14 +431,20 @@ const PluginDetail = () => {
setReplyOpen(null); setReplyOpen(null);
setReplyText(''); setReplyText('');
fetchPlugin(); fetchPlugin();
} catch (err) { } catch (err: any) {
console.error('Failed to submit reply', err); alert(err.response?.data?.error || 'Failed to post reply.');
alert('Failed to submit reply');
} finally { } finally {
setReplySubmitting(false); setReplySubmitting(false);
} }
}; };
const resetReport = () => {
setReportOpen(false);
setReportSelected('');
setReportCustom('');
setReportDone(false);
};
const submitReport = async (e: React.FormEvent) => { const submitReport = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected; const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected;
@ -437,8 +453,6 @@ const PluginDetail = () => {
try { try {
await api.post(`/plugins/${id}/report`, { reason }); await api.post(`/plugins/${id}/report`, { reason });
setReportDone(true); setReportDone(true);
setReportSelected('');
setReportCustom('');
} catch { } catch {
alert('Failed to submit report. Please try again.'); alert('Failed to submit report. Please try again.');
} finally { } finally {
@ -446,10 +460,10 @@ const PluginDetail = () => {
} }
}; };
const resetReport = () => { const resetReviewReport = () => {
setReportOpen(false); setReviewReportOpen(null);
setReportSelected(''); setReviewReportSelected('');
setReportCustom(''); setReviewReportCustom('');
}; };
const submitReviewReport = async (e: React.FormEvent, reviewId: number) => { const submitReviewReport = async (e: React.FormEvent, reviewId: number) => {
@ -461,22 +475,15 @@ const PluginDetail = () => {
await api.post(`/plugins/${id}/reviews/${reviewId}/report`, { reason }); await api.post(`/plugins/${id}/reviews/${reviewId}/report`, { reason });
setReportedReviews(prev => new Set(prev).add(reviewId)); setReportedReviews(prev => new Set(prev).add(reviewId));
resetReviewReport(); resetReviewReport();
alert('Thank you. The report has been submitted to moderators.');
} catch { } catch {
alert('Failed to submit report. Please try again.'); alert('Failed to report review. Please try again.');
} finally { } finally {
setReviewReportSubmitting(false); setReviewReportSubmitting(false);
} }
}; };
const resetReviewReport = () => { const formatSize = (bytes?: number) => {
setReviewReportOpen(null);
setReviewReportSelected('');
setReviewReportCustom('');
};
if (!plugin) return <div className="container"><h2>LOADING...</h2></div>;
const formatSize = (bytes: number) => {
if (!bytes) return ''; if (!bytes) return '';
if (bytes >= 1048576) return `(${(bytes / 1048576).toFixed(2)} MB)`; if (bytes >= 1048576) return `(${(bytes / 1048576).toFixed(2)} MB)`;
if (bytes >= 1024) return `(${(bytes / 1024).toFixed(1)} KB)`; if (bytes >= 1024) return `(${(bytes / 1024).toFixed(1)} KB)`;
@ -486,14 +493,23 @@ const PluginDetail = () => {
const downloadLabel = (() => { const downloadLabel = (() => {
const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : ''; const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : '';
if (plugin.type === 'paid') { if (plugin.type === 'paid') {
if (!ownershipChecked) return 'Loading…'; if (!ownershipChecked) return 'Loading…';
return ownsPlugin ? `Download ${sizeStr}` : 'Purchase Plugin'; if (ownsPlugin) {
} if (plugin.is_preorder) {
return 'Pre-Ordered (Awaiting Release)';
}
return `Download ${sizeStr}`;
}
if (plugin.is_preorder) {
return 'Pre-Order Now';
}
return 'Purchase Plugin';
}
if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download'; if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download';
return `Download ${sizeStr}`; return `Download ${sizeStr}`;
})(); })();
const cleanDesc = currentDescription.replace(/[#*`_\[\]]/g, '').slice(0, 160).trim(); const cleanDesc = currentDescription.replace(/[#*`_\[\]]/g, '').slice(0, 160).trim();
@ -568,10 +584,61 @@ const PluginDetail = () => {
{JSON.stringify(productSchema)} {JSON.stringify(productSchema)}
</script> </script>
{/* ── Pre-Order Banner ── */}
{plugin.is_preorder && (
<div style={{
marginBottom: '1.5rem',
padding: '1.25rem 1.5rem',
background: 'linear-gradient(135deg, rgba(249,115,22,0.12) 0%, rgba(249,115,22,0.04) 100%)',
border: '1px solid rgba(249,115,22,0.35)',
borderRadius: 12,
display: 'flex',
alignItems: 'flex-start',
gap: '1rem',
boxShadow: '0 4px 20px rgba(249,115,22,0.08)'
}}>
<div style={{
width: 38,
height: 38,
borderRadius: 8,
background: 'rgba(249,115,22,0.2)',
color: '#f97316',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.2rem',
flexShrink: 0
}}>
⏳
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#f97316', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Available for Pre-Order
{plugin.preorder_release_date && (
<span style={{ fontSize: '0.75rem', fontWeight: 600, background: 'rgba(249,115,22,0.2)', color: '#f97316', padding: '2px 8px', borderRadius: 4 }}>
Expected: {formatDate(plugin.preorder_release_date)}
</span>
)}
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted, #94a3b8)', lineHeight: 1.5 }}>
{ownsPlugin ? (
<span style={{ color: '#4ade80', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={16} color="#4ade80" /> You have successfully pre-ordered this plugin! Your license key is reserved, and you will receive an automatic email notification the moment the download is released.
</span>
) : (
<span>
Pre-order now to secure your license key and early access. You will receive an automatic email notification the moment the developer releases the downloadable version.
</span>
)}
</div>
</div>
</div>
)}
{/* ── Early Access Banner ── */} {/* ── Early Access Banner ── */}
{plugin.is_early_access && ( {plugin.is_early_access && (
<div className="early-access-banner" style={{ marginBottom: '1.5rem' }}> <div className="early-access-banner" style={{ marginBottom: '1.5rem' }}>
<div className="early-access-icon">⏳</div> <div className="early-access-icon"><Clock size={18} /></div>
<div className="early-access-content"> <div className="early-access-content">
<div className="early-access-title">Early Access Plugin</div> <div className="early-access-title">Early Access Plugin</div>
<div className="early-access-text"> <div className="early-access-text">
@ -585,7 +652,7 @@ const PluginDetail = () => {
{/* ── Payment success banner ── */} {/* ── Payment success banner ── */}
{showPaymentSuccess && ( {showPaymentSuccess && (
<div className="payment-success-banner"> <div className="payment-success-banner">
<span className="success-icon">🎉</span> <span className="success-icon"><CheckCircle2 size={24} color="#10b981" /></span>
<div className="banner-content"> <div className="banner-content">
<div className="banner-title"> <div className="banner-title">
Purchase successful! Purchase successful!
@ -753,7 +820,9 @@ const PluginDetail = () => {
<div className="review-big-avg">{stats.avg}</div> <div className="review-big-avg">{stats.avg}</div>
<div className="review-stars-row"> <div className="review-stars-row">
{[1, 2, 3, 4, 5].map(n => ( {[1, 2, 3, 4, 5].map(n => (
<span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>★</span> <span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>
<Star size={14} fill={n <= Math.round(Number(stats.avg)) ? 'currentColor' : 'none'} />
</span>
))} ))}
</div> </div>
<div className="review-total-label">{stats.total.toLocaleString()} Ratings</div> <div className="review-total-label">{stats.total.toLocaleString()} Ratings</div>
@ -793,7 +862,9 @@ const PluginDetail = () => {
</div> </div>
<div className="review-item-stars"> <div className="review-item-stars">
{[1, 2, 3, 4, 5].map(n => ( {[1, 2, 3, 4, 5].map(n => (
<span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>★</span> <span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>
<Star size={12} fill={n <= r.rating ? 'currentColor' : 'none'} />
</span>
))} ))}
</div> </div>
<ExpandableText className="review-item-body" text={r.comment} limit={400} /> <ExpandableText className="review-item-body" text={r.comment} limit={400} />
@ -824,7 +895,9 @@ const PluginDetail = () => {
{/* Review Report & Reply Buttons */} {/* Review Report & Reply Buttons */}
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'center' }}> <div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
{reportedReviews.has(r.id) ? ( {reportedReviews.has(r.id) ? (
<p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0 }}>✓ Report submitted</p> <p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0, display: 'flex', alignItems: 'center', gap: 4 }}>
<Check size={13} /> Report submitted
</p>
) : reviewReportOpen === r.id ? ( ) : reviewReportOpen === r.id ? (
<form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}> <form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
<label className="report-form-label">Reason for reporting this review</label> <label className="report-form-label">Reason for reporting this review</label>
@ -902,12 +975,12 @@ const PluginDetail = () => {
) : ( ) : (
<> <>
{user && user.username === r.username ? ( {user && user.username === r.username ? (
<button className="btn-report" onClick={deleteReview} style={{ fontSize: '0.7rem', color: '#ff4d4f' }}> <button className="btn-report" onClick={deleteReview} style={{ fontSize: '0.7rem', color: '#ff4d4f', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
🗑 Delete my review <Trash2 size={12} /> Delete my review
</button> </button>
) : user && ( ) : user && (
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}> <button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
🚩 Report review <Flag size={12} /> Report review
</button> </button>
)} )}
@ -915,9 +988,9 @@ const PluginDetail = () => {
<button <button
className="btn-report" className="btn-report"
onClick={() => setReplyOpen(r.id)} onClick={() => setReplyOpen(r.id)}
style={{ fontSize: '0.7rem', color: 'var(--primary)' }} style={{ fontSize: '0.7rem', color: 'var(--primary)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
> >
💬 Reply as developer <MessageSquare size={12} /> Reply as developer
</button> </button>
)} )}
</> </>
@ -930,7 +1003,7 @@ const PluginDetail = () => {
{user ? ( {user ? (
hasReviewed ? ( hasReviewed ? (
<div className="review-already-posted"> <div className="review-already-posted">
<span className="review-already-icon">✓</span> <span className="review-already-icon"><Check size={14} /></span>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="review-already-title">Review posted</div> <div className="review-already-title">Review posted</div>
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div> <div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
@ -986,14 +1059,19 @@ const PluginDetail = () => {
{/* Price / owned indicator */} {/* Price / owned indicator */}
<div className="price-hero-section"> <div className="price-hero-section">
{plugin.type === 'paid' && ownsPlugin ? ( {plugin.type === 'paid' && ownsPlugin ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: plugin.is_preorder ? '#f97316' : 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
Purchased {plugin.is_preorder ? 'Pre-Ordered (Awaiting Release)' : 'Purchased'}
</div> </div>
) : plugin.type === 'paid' ? ( ) : plugin.type === 'paid' ? (
<div> <div>
{plugin.is_preorder && (
<span style={{ display: 'inline-block', fontSize: '0.68rem', fontWeight: 800, background: 'rgba(249,115,22,0.15)', color: '#f97316', border: '1px solid rgba(249,115,22,0.3)', borderRadius: 4, padding: '1px 6px', marginBottom: '0.35rem' }}>
PRE-ORDER
</span>
)}
{(() => { {(() => {
const base = plugin.price_cents; const base = plugin.price_cents;
const salePrice = plugin.sale_active && plugin.sale_discount_percent > 0 const salePrice = plugin.sale_active && plugin.sale_discount_percent > 0
@ -1156,8 +1234,8 @@ const PluginDetail = () => {
{user && !reportDone && ( {user && !reportDone && (
<> <>
{!reportOpen ? ( {!reportOpen ? (
<button className="btn-report" onClick={() => setReportOpen(true)}> <button className="btn-report" onClick={() => setReportOpen(true)} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
🚩 Report plugin <Flag size={12} /> Report plugin
</button> </button>
) : ( ) : (
<form onSubmit={submitReport} className="report-form"> <form onSubmit={submitReport} className="report-form">
@ -1205,7 +1283,9 @@ const PluginDetail = () => {
</> </>
)} )}
{reportDone && ( {reportDone && (
<p className="report-done">✓ Report submitted. Thanks for keeping the marketplace safe.</p> <p className="report-done" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Check size={14} /> Report submitted. Thanks for keeping the marketplace safe.
</p>
)} )}
</div> </div>
</div> </div>

View File

@ -6,11 +6,13 @@ import { useAuth } from '../App';
import { getCodeList } from 'country-list'; import { getCodeList } from 'country-list';
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal'; import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
import { getPluginUrl } from '../utils/url'; import { getPluginUrl } from '../utils/url';
import { openCookiePreferencesModal } from '../utils/consent';
import { import {
User, Mail, Lock, Shield, CreditCard, User, Mail, Lock, Shield, CreditCard,
BookOpen, AlertTriangle, LogOut, CheckCircle, BookOpen, AlertTriangle, LogOut, CheckCircle,
AlertCircle, Eye, EyeOff, ChevronRight, Bell, AlertCircle, Eye, EyeOff, ChevronRight, Bell,
Smartphone, Key, Trash2, Code, Sparkles, Building2 Smartphone, Key, Trash2, Code, Sparkles, Building2,
ShieldCheck, Download, Sliders, Package
} from 'lucide-react'; } from 'lucide-react';
interface LibraryEntry { interface LibraryEntry {
@ -21,17 +23,19 @@ interface LibraryEntry {
dev_name: string; dev_name: string;
amount_cents: number; amount_cents: number;
purchased_at: string; purchased_at: string;
is_preorder?: boolean;
} }
type Tab = 'account' | 'security' | 'developer' | 'notifications' | 'library' | 'danger'; type Tab = 'account' | 'security' | 'developer' | 'notifications' | 'library' | 'privacy' | 'danger';
const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [ const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [
{ key: 'account', label: 'Account', icon: User }, { key: 'account', label: 'Account', icon: User },
{ key: 'security', label: 'Security', icon: Shield }, { key: 'security', label: 'Security', icon: Shield },
{ key: 'developer', label: 'Developer Profile', icon: Code }, { key: 'developer', label: 'Developer Profile', icon: Code },
{ key: 'notifications', label: 'Notifications', icon: Bell }, { key: 'notifications', label: 'Notifications', icon: Bell },
{ key: 'library', label: 'Library', icon: BookOpen }, { key: 'library', label: 'Library', icon: BookOpen },
{ key: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true }, { key: 'privacy', label: 'Privacy & Data', icon: ShieldCheck },
{ key: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
]; ];
const ProfilePage = () => { const ProfilePage = () => {
@ -240,8 +244,29 @@ const ProfilePage = () => {
} }
}; };
const [isExporting, setIsExporting] = useState(false);
const handleExportData = async () => {
setIsExporting(true);
try {
const res = await api.get('/user/export-data');
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(res.data, null, 2));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute('href', dataStr);
downloadAnchor.setAttribute('download', `pumpkinmarket-data-export-${user?.username || 'user'}.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
showToast('Personal data export downloaded successfully!');
} catch (err: any) {
showToast(err.response?.data?.error || 'Failed to export your data.', 'error');
} finally {
setIsExporting(false);
}
};
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
if (!window.confirm('Are you sure you want to delete your account? This action is irreversible.')) return; if (!window.confirm('Are you sure you want to permanently delete your account? Under GDPR Article 17 (Right to erasure), all your account data, developer profile, uploaded plugins, and reviews will be permanently wiped.')) return;
try { try {
await api.delete('/user'); await api.delete('/user');
logout(); logout();
@ -867,7 +892,7 @@ const ProfilePage = () => {
{libraryError && ( {libraryError && (
<div className="library-error"> <div className="library-error">
<span style={{ fontSize: '1.5rem' }}>⚠️</span> <AlertTriangle size={20} color="#f59e0b" />
<span>{libraryError}</span> <span>{libraryError}</span>
</div> </div>
)} )}
@ -875,7 +900,7 @@ const ProfilePage = () => {
{!libraryLoading && !libraryError && library.length === 0 && ( {!libraryLoading && !libraryError && library.length === 0 && (
<div className="library-empty"> <div className="library-empty">
<div className="library-empty-bg" /> <div className="library-empty-bg" />
<div className="library-empty-icon">📦</div> <div className="library-empty-icon"><Package size={36} color="var(--mp-text-3, #64748b)" /></div>
<p className="library-empty-title">Your library is empty</p> <p className="library-empty-title">Your library is empty</p>
<p className="library-empty-sub">Purchased plugins will appear here, ready to download anytime.</p> <p className="library-empty-sub">Purchased plugins will appear here, ready to download anytime.</p>
<Link to="/" className="btn" style={{ marginTop: '1.25rem', fontSize: '0.8rem', padding: '0.6rem 1.4rem' }}> <Link to="/" className="btn" style={{ marginTop: '1.25rem', fontSize: '0.8rem', padding: '0.6rem 1.4rem' }}>
@ -901,11 +926,11 @@ const ProfilePage = () => {
{entry.name.charAt(0).toUpperCase()} {entry.name.charAt(0).toUpperCase()}
</div> </div>
)} )}
<div className="library-owned-badge"> <div className="library-owned-badge" style={entry.is_preorder ? { background: '#f97316' } : undefined}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/> <polyline points="20 6 9 17 4 12"/>
</svg> </svg>
Owned {entry.is_preorder ? 'Pre-Ordered' : 'Owned'}
</div> </div>
{entry.category && ( {entry.category && (
<div className="library-card-category-tag">{entry.category}</div> <div className="library-card-category-tag">{entry.category}</div>
@ -933,6 +958,87 @@ const ProfilePage = () => {
</div> </div>
)} )}
{/* ══ PRIVACY & DATA ═════════════════════════════════════════ */}
{activeTab === 'privacy' && (
<div className="profile-section">
<div className="settings-section-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>
<span>Privacy</span> & Data
</h2>
<p className="settings-section-sub">
Manage your personal data, download an export of your account, and configure cookie settings.
</p>
</div>
{/* Data Portability */}
<SettingsCard
title="Export Account Data"
icon={Download}
description="Download a copy of your account data including your purchases, licenses, reviews, and developer details in JSON format."
>
<p style={{ fontSize: '0.88rem', color: 'var(--text-muted)', lineHeight: 1.6, marginBottom: '1.25rem' }}>
Generate a structured machine-readable export of all data associated with your PumpkinMarket account.
</p>
<button
type="button"
onClick={handleExportData}
className="settings-btn settings-btn-primary"
disabled={isExporting}
>
<Download size={15} />
{isExporting ? <><span className="spinner-sm" />Generating Export…</> : 'Download Data Export (.json)'}
</button>
</SettingsCard>
{/* Cookie & Tracking Consent */}
<SettingsCard
title="Cookie Preferences"
icon={Sliders}
description="Choose what cookies and tracking technologies are enabled while you browse PumpkinMarket."
>
<p style={{ fontSize: '0.88rem', color: 'var(--text-muted)', lineHeight: 1.6, marginBottom: '1.25rem' }}>
You can update your preferences or turn off analytics cookies at any time without affecting your marketplace experience.
</p>
<button
type="button"
onClick={openCookiePreferencesModal}
className="settings-btn settings-btn-secondary"
>
<Sliders size={15} />
Manage Cookie Preferences
</button>
</SettingsCard>
{/* Privacy Controls Summary */}
<SettingsCard
title="Data & Privacy Controls"
icon={ShieldCheck}
description="How you can access, update, or remove your data on PumpkinMarket."
>
<div className="settings-faq">
<div className="settings-faq-item">
<p className="settings-faq-q">Access & Portability</p>
<p className="settings-faq-a">You can request and download a full copy of your account records at any time using the export tool above.</p>
</div>
<div className="settings-faq-item">
<p className="settings-faq-q">Updating Your Information</p>
<p className="settings-faq-a">Keep your email, username, country, and developer profile up to date anytime directly from your settings tabs.</p>
</div>
<div className="settings-faq-item">
<p className="settings-faq-q">Deleting Your Account</p>
<p className="settings-faq-a">Permanently delete your account and all associated data anytime in the Danger Zone tab.</p>
</div>
<div className="settings-faq-item">
<p className="settings-faq-q">Contact & Support</p>
<p className="settings-faq-a">
Have questions about your data or privacy? Contact our team anytime at <a href="mailto:privacy@pumpkinmc.org" style={{ color: 'var(--primary)' }}>privacy@pumpkinmc.org</a>.
</p>
</div>
</div>
</SettingsCard>
</div>
)}
{/* ══ DANGER ZONE ═══════════════════════════════════════════════ */} {/* ══ DANGER ZONE ═══════════════════════════════════════════════ */}
{activeTab === 'danger' && ( {activeTab === 'danger' && (
<div className="profile-section"> <div className="profile-section">

View File

@ -89,8 +89,6 @@ const ForgotPasswordPage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
.auth-page { .auth-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -120,8 +120,6 @@ const LoginPage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;

View File

@ -1,5 +1,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import api from '../../api'; import api from '../../api';
@ -27,6 +28,7 @@ function getStrength(pw: string): { score: number; label: string; color: string
} }
const RegisterPage = () => { const RegisterPage = () => {
const { t } = useTranslation();
const [form, setForm] = useState({ username: '', email: '', password: '', website: '' }); const [form, setForm] = useState({ username: '', email: '', password: '', website: '' });
const [captchaToken, setCaptchaToken] = useState<string | null>(null); const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const turnstileRef = useRef<any>(null); const turnstileRef = useRef<any>(null);
@ -112,7 +114,6 @@ const RegisterPage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-font-smoothing: antialiased; } body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-font-smoothing: antialiased; }
@ -269,7 +270,7 @@ const RegisterPage = () => {
<div className="register-page"> <div className="register-page">
<div className="register-card"> <div className="register-card">
<div className="card-header"> <div className="card-header">
<h1 className="card-title">Create your account</h1> <h1 className="card-title">{t('auth.register_title')}</h1>
</div> </div>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
@ -288,7 +289,7 @@ const RegisterPage = () => {
{/* Username */} {/* Username */}
<div className="form-field"> <div className="form-field">
<label className="field-label" htmlFor="username">Username</label> <label className="field-label" htmlFor="username">{t('auth.username')}</label>
<div className="field-input-wrapper"> <div className="field-input-wrapper">
<input <input
id="username" className="field-input" type="text" placeholder="johndoe" id="username" className="field-input" type="text" placeholder="johndoe"
@ -301,7 +302,7 @@ const RegisterPage = () => {
{/* Email */} {/* Email */}
<div className="form-field"> <div className="form-field">
<label className="field-label" htmlFor="email">Email address</label> <label className="field-label" htmlFor="email">{t('auth.email')}</label>
<div className="field-input-wrapper"> <div className="field-input-wrapper">
<input <input
id="email" id="email"
@ -346,7 +347,7 @@ const RegisterPage = () => {
{/* Password */} {/* Password */}
<div className="form-field"> <div className="form-field">
<label className="field-label" htmlFor="password">Password</label> <label className="field-label" htmlFor="password">{t('auth.password')}</label>
<div className="field-input-wrapper"> <div className="field-input-wrapper">
<input <input
id="password" id="password"
@ -359,32 +360,28 @@ const RegisterPage = () => {
/> />
<button <button
type="button" type="button"
className="password-toggle" className="pw-toggle-btn"
onClick={() => setShowPassword(s => !s)} onClick={() => setShowPassword(p => !p)}
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'} aria-label={showPassword ? 'Hide password' : 'Show password'}
tabIndex={-1}
> >
{showPassword ? ( {showPassword ? (
/* eye-off icon */ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 00-2.79.588l.77.771A5.944 5.944 0 018 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0114.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/> <path d="M11.297 9.176a3.5 3.5 0 00-4.474-4.474l.823.823a2.5 2.5 0 012.829 2.829l.822.822zm-2.943 1.299l.822.822a3.5 3.5 0 01-4.474-4.474l.823.823a2.5 2.5 0 002.829 2.829z"/>
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/> <path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 001.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 018 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.709.709zm10.296 8.884l-12-12 .708-.708 12 12-.708.708z"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg> </svg>
) : ( ) : (
/* eye icon */ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 011.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0114.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 011.172 8z"/>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <path d="M8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5zM4.5 8a3.5 3.5 0 117 0 3.5 3.5 0 01-7 0z"/>
<circle cx="12" cy="12" r="3"/>
</svg> </svg>
)} )}
</button> </button>
</div> </div>
{/* Strength meter — shown as soon as the user starts typing */}
{showStrengthMeter && ( {showStrengthMeter && (
<div className="strength-meter"> <div className="strength-meter">
{/* 5 segment bars */}
<div className="strength-bars"> <div className="strength-bars">
{[1,2,3,4,5].map(i => ( {[1,2,3,4,5].map(i => (
<div className="strength-bar" key={i}> <div className="strength-bar" key={i}>
@ -406,7 +403,6 @@ const RegisterPage = () => {
<span>{PASSWORD_REQUIREMENTS.filter(r => r.test(form.password)).length}/{PASSWORD_REQUIREMENTS.length} requirements met</span> <span>{PASSWORD_REQUIREMENTS.filter(r => r.test(form.password)).length}/{PASSWORD_REQUIREMENTS.length} requirements met</span>
</div> </div>
{/* Per-requirement checklist */}
<ul className="requirements-list"> <ul className="requirements-list">
{PASSWORD_REQUIREMENTS.map((req, idx) => { {PASSWORD_REQUIREMENTS.map((req, idx) => {
const met = req.test(form.password); const met = req.test(form.password);
@ -418,10 +414,12 @@ const RegisterPage = () => {
return ( return (
<li key={idx} className={cls}> <li key={idx} className={cls}>
<span className="req-icon"> <span className="req-icon">
{met && ( {met ? (
<svg width="8" height="8" viewBox="0 0 8 8" fill="none"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1.5 4l2 2 3-3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M2.5 6l2.5 2.5 4.5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
) : (
<span className="req-bullet" />
)} )}
</span> </span>
{req.label} {req.label}
@ -433,7 +431,8 @@ const RegisterPage = () => {
)} )}
</div> </div>
<div className="captcha-container"> {/* Turnstile Captcha */}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'center' }}>
<Turnstile <Turnstile
ref={turnstileRef} ref={turnstileRef}
siteKey="0x4AAAAAAClcSibyhKfR0H6o" siteKey="0x4AAAAAAClcSibyhKfR0H6o"
@ -448,7 +447,7 @@ const RegisterPage = () => {
{isSubmitting ? ( {isSubmitting ? (
<><span className="spinner" />Creating account…</> <><span className="spinner" />Creating account…</>
) : ( ) : (
'Create account' t('auth.create_account')
)} )}
</button> </button>
</form> </form>
@ -461,7 +460,7 @@ const RegisterPage = () => {
<div className="divider" /> <div className="divider" />
<div className="card-footer"> <div className="card-footer">
Already have an account? <Link to="/login">Sign in</Link> {t('auth.have_account')} <Link to="/login">{t('auth.sign_in')}</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -62,8 +62,6 @@ const ResetPasswordPage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
.auth-page { .auth-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -612,8 +612,8 @@ input:checked + .slider:before {
box-shadow: none; box-shadow: none;
} }
.icon-link::before { .icon-link::before {
content: "🔗"; /* Unicode link icon */ content: "";
padding: 0 12px; padding: 0;
color: var(--dash-text-muted); color: var(--dash-text-muted);
font-size: 1.2rem; font-size: 1.2rem;
} }

View File

@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
LineChart, Line LineChart, Line
@ -83,6 +84,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
}; };
const DashboardOverview = () => { const DashboardOverview = () => {
const { t } = useTranslation();
const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics(); const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
const timeframes = ['Today', '7 Days', '1 Month', '1 Year']; const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
@ -90,7 +92,7 @@ const DashboardOverview = () => {
<div className="dashboard-wrapper"> <div className="dashboard-wrapper">
<header className="dashboard-top-nav"> <header className="dashboard-top-nav">
<div> <div>
<h1>Overview</h1> <h1>{t('developer.dashboard.overview')}</h1>
<p className="subtitle">Real-time performance for the <strong>{timeframe}</strong> period</p> <p className="subtitle">Real-time performance for the <strong>{timeframe}</strong> period</p>
</div> </div>
<div className="segment-control"> <div className="segment-control">
@ -110,7 +112,7 @@ const DashboardOverview = () => {
<main className="main-stats"> <main className="main-stats">
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}> <div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<KpiCard <KpiCard
title="Revenue" title={t('developer.dashboard.total_revenue')}
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`} value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
comparison={trends?.earnings} comparison={trends?.earnings}
icon={DollarSign} icon={DollarSign}
@ -118,7 +120,7 @@ const DashboardOverview = () => {
dataKey="earnings" dataKey="earnings"
/> />
<KpiCard <KpiCard
title="Downloads" title={t('developer.dashboard.total_downloads')}
value={totals.downloads.toLocaleString()} value={totals.downloads.toLocaleString()}
comparison={trends?.downloads} comparison={trends?.downloads}
icon={Download} icon={Download}
@ -134,8 +136,8 @@ const DashboardOverview = () => {
dataKey="conversion" dataKey="conversion"
/> />
<KpiCard <KpiCard
title="Avg Rating" title={t('developer.dashboard.avg_rating')}
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'} value={ratingSummary?.averageRating ? `${ratingSummary.averageRating.toFixed(1)} / 5.0` : '--'}
comparison={0} comparison={0}
icon={Star} icon={Star}
chartData={processedData} chartData={processedData}
@ -143,84 +145,142 @@ const DashboardOverview = () => {
/> />
</div> </div>
<div className="chart-container-v2" style={{ marginBottom: '1.5rem' }}> {/* Charts Row */}
<div className="chart-header"> <div className="charts-grid-v2">
<h3>Revenue Growth</h3> <div className="chart-container-v2">
<div className="chart-legend"> <div className="chart-header">
<span className="legend-item earnings">Earnings ($)</span> <h3>Views vs Downloads</h3>
<div className="chart-legend">
<span className="legend-item" style={{ color: '#3b82f6' }}>Views</span>
<span className="legend-item" style={{ color: '#10b981' }}>Downloads</span>
</div>
</div> </div>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="viewsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="dlsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
tickFormatter={formatValue}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Area
type="monotone"
dataKey="views"
name="Views"
stroke="#3b82f6"
strokeWidth={2}
fillOpacity={1}
fill="url(#viewsGrad)"
/>
<Area
type="monotone"
dataKey="downloads"
name="Downloads"
stroke="#10b981"
strokeWidth={2}
fillOpacity={1}
fill="url(#dlsGrad)"
/>
</AreaChart>
</ResponsiveContainer>
</div> </div>
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.15}/>
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
tickFormatter={formatValue}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Area
type="monotone"
dataKey="earnings"
name="Earnings ($)"
stroke="#6366f1"
strokeWidth={3}
fillOpacity={1}
fill="url(#chartGradient)"
animationDuration={1500}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* ── Rating Over Time Chart ── */} <div className="chart-container-v2">
<div className="chart-container-v2"> <div className="chart-header">
<div className="chart-header"> <h3>Revenue Growth (€)</h3>
<h3>Rating Over Time</h3> <div className="chart-legend">
<div className="chart-legend"> <span className="legend-item" style={{ color: '#f97316' }}>Gross Sales</span>
<span className="legend-item" style={{ color: '#f59e0b' }}>★ Daily Rating (1-5)</span> </div>
</div> </div>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#f97316" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
tickFormatter={formatValue}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Area
type="monotone"
dataKey="revenue"
name="Revenue"
stroke="#f97316"
strokeWidth={2}
fillOpacity={1}
fill="url(#revGrad)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* ── Rating Over Time Chart ── */}
<div className="chart-container-v2">
<div className="chart-header">
<h3>Rating Over Time</h3>
<div className="chart-legend">
<span className="legend-item" style={{ color: '#f59e0b' }}>Daily Rating (1-5)</span>
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
domain={[1, 5]}
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Line
type="monotone"
dataKey="avgRating"
name="Rating"
stroke="#f59e0b"
strokeWidth={3}
dot={{ r: 4, fill: '#f59e0b' }}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
</div> </div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
domain={[1, 5]}
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Line
type="monotone"
dataKey="avgRating"
name="Rating"
stroke="#f59e0b"
strokeWidth={3}
dot={{ r: 4, fill: '#f59e0b' }}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
</div> </div>
</main> </main>
@ -245,7 +305,7 @@ const DashboardOverview = () => {
const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0; const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0;
return ( return (
<div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.78rem' }}> <div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.78rem' }}>
<span style={{ width: '32px', color: 'var(--text-muted)' }}>{b.stars} ★</span> <span style={{ width: '48px', color: 'var(--text-muted)' }}>{b.stars} Stars</span>
<div style={{ flex: 1, height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' }}> <div style={{ flex: 1, height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '3px' }} /> <div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '3px' }} />
</div> </div>

View File

@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAnalytics } from './useAnalytics'; import { useAnalytics } from './useAnalytics';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react'; import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
@ -8,6 +9,7 @@ import { getPluginUrl } from '../../utils/url';
// --- Main Component --- // --- Main Component ---
const DashboardPlugins = () => { const DashboardPlugins = () => {
const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const { timeSeries } = useAnalytics(); const { timeSeries } = useAnalytics();
const navigate = useNavigate(); const navigate = useNavigate();
@ -81,19 +83,19 @@ const DashboardPlugins = () => {
<div className="dashboard-grid-layout"> <div className="dashboard-grid-layout">
<div className="data-card"> <div className="data-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3>My Active Plugins</h3> <h3>{t('developer.dashboard.plugins')}</h3>
<button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}> <button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}>
<Plus size={18} style={{ marginRight: '8px' }} /> <Plus size={18} style={{ marginRight: '8px' }} />
Add New Plugin {t('developer.dashboard.add_plugin')}
</button> </button>
</div> </div>
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>{t('plugin.version')}</th>
<th>Downloads</th> <th>{t('developer.dashboard.total_downloads')}</th>
<th>Earnings</th> <th>{t('developer.dashboard.total_revenue')}</th>
<th>Dates</th> <th>Dates</th>
<th style={{ textAlign: 'right' }}>Actions</th> <th style={{ textAlign: 'right' }}>Actions</th>
</tr> </tr>
@ -135,7 +137,7 @@ const DashboardPlugins = () => {
style={{ display: 'flex', alignItems: 'center', gap: '4px' }} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
> >
<Settings size={16} /> <Settings size={16} />
Manage {t('developer.dashboard.manage_plugin')}
</button> </button>
</div> </div>
</td> </td>

View File

@ -4,10 +4,12 @@ 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, Sparkles, Zap, Lock, AlertTriangle, Tag, Store, User, Sparkles,
ShieldCheck,
} 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 { DeveloperOnboardingModal } from '../../../components/DeveloperOnboardingModal';
import { validateWasmFile, validateAndSanitizeImage } from '../../../utils/fileValidation';
import './AddPlugin.css'; import './AddPlugin.css';
// ── Locale helpers ──────────────────────────────────────────────────────────── // ── Locale helpers ────────────────────────────────────────────────────────────
@ -59,35 +61,6 @@ const PREVIEW_MAX_WIDTH = 800;
const SCREENSHOT_MAX_WIDTH = 1920; const SCREENSHOT_MAX_WIDTH = 1920;
const IMAGE_QUALITY = 0.80; const IMAGE_QUALITY = 0.80;
// ── Image compression helper ──────────────────────────────────────────────────
const compressImage = (file: File, maxWidth: number, quality: number): Promise<File> =>
new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const ratio = Math.min(1, maxWidth / img.width);
const w = Math.round(img.width * ratio);
const h = Math.round(img.height * ratio);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(
blob => {
if (!blob) { reject(new Error('Canvas toBlob failed')); return; }
const name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
resolve(new File([blob], name, { type: 'image/jpeg' }));
},
'image/jpeg',
quality,
);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
img.src = url;
});
// ── Formatting helpers ──────────────────────────────────────────────────────── // ── Formatting helpers ────────────────────────────────────────────────────────
const fmtBytes = (b: number): string => { const fmtBytes = (b: number): string => {
if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`; if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
@ -246,6 +219,39 @@ const AddPlugin = () => {
); );
} }
if (user && user.is_developer && !user.totp_enabled) {
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'
}}>
<Lock size={28} />
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem 0', color: 'var(--mp-text, #fff)' }}>
Two-Factor Authentication Required
</h2>
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.9rem', lineHeight: 1.5, marginBottom: '1.75rem' }}>
To protect Minecraft servers and prevent unauthorized plugin uploads from compromised accounts, all developers must enable <strong>Two-Factor Authentication (2FA)</strong> before publishing plugins.
</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={() => navigate('/settings?tab=security')}
>
<ShieldCheck size={16} /> Enable 2FA in Security Settings →
</button>
</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);
@ -267,6 +273,8 @@ const AddPlugin = () => {
const [licenseType, setLicenseType] = useState<LicenseType>('free'); const [licenseType, setLicenseType] = useState<LicenseType>('free');
const [price, setPrice] = useState(4.99); const [price, setPrice] = useState(4.99);
const [isEarlyAccess, setIsEarlyAccess] = useState(false); const [isEarlyAccess, setIsEarlyAccess] = useState(false);
const [isPreorder, setIsPreorder] = useState(false);
const [preorderReleaseDate, setPreorderReleaseDate] = useState('');
const stripeConnected = user.stripe_ready; const stripeConnected = user.stripe_ready;
// ── Step 2: Binary ── // ── Step 2: Binary ──
@ -305,42 +313,79 @@ const AddPlugin = () => {
const hasFieldErrors = Object.keys(fieldErrors).length > 0; const hasFieldErrors = Object.keys(fieldErrors).length > 0;
// ── Instant validation states ──
const [wasmValidationErr, setWasmValidationErr] = useState<string | null>(null);
const [wasmFormatInfo, setWasmFormatInfo] = useState<string | null>(null);
const [validatingWasm, setValidatingWasm] = useState(false);
const [previewValidationErr, setPreviewValidationErr] = useState<string | null>(null);
const [screenshotValidationErr, setScreenshotValidationErr] = useState<string | null>(null);
// ── Image helpers ── // ── Image helpers ──
const setPreviewFile = async (file: File) => { const setPreviewFile = async (file: File) => {
if (file.size > 20 * 1024 * 1024) { alert('Image is too large to process (max 20 MB original). Please resize it first.'); return; } setPreviewValidationErr(null);
setCompressingPreview(true); setCompressingPreview(true);
try { try {
const compressed = await compressImage(file, PREVIEW_MAX_WIDTH, IMAGE_QUALITY); const res = await validateAndSanitizeImage(file, {
setPreviewImage(compressed); maxWidth: PREVIEW_MAX_WIDTH,
const r = new FileReader(); maxHeight: PREVIEW_MAX_WIDTH,
r.onload = e => setPreviewImageUrl(e.target?.result as string); quality: IMAGE_QUALITY,
r.readAsDataURL(compressed); minWidth: 32,
} catch { alert('Could not process image. Please try a different file.'); } minHeight: 32,
finally { setCompressingPreview(false); } });
if (!res.valid || !res.file) {
setPreviewValidationErr(res.error || 'Failed to validate preview image.');
setPreviewImage(null);
setPreviewImageUrl(null);
return;
}
setPreviewImage(res.file);
setPreviewImageUrl(res.previewUrl ?? null);
} catch (err: any) {
setPreviewValidationErr(err.message || 'Could not process image.');
setPreviewImage(null);
setPreviewImageUrl(null);
} finally {
setCompressingPreview(false);
}
}; };
const removePreviewImage = () => { setPreviewImage(null); setPreviewImageUrl(null); }; const removePreviewImage = () => {
setPreviewImage(null);
setPreviewImageUrl(null);
setPreviewValidationErr(null);
};
const addScreenshots = async (files: FileList | File[]) => { const addScreenshots = async (files: FileList | File[]) => {
if (screenshots.length >= MAX_SCREENSHOTS) { if (screenshots.length >= MAX_SCREENSHOTS) {
alert(`Maximum ${MAX_SCREENSHOTS} screenshots allowed.`); alert(`Maximum ${MAX_SCREENSHOTS} screenshots allowed.`);
return; return;
} }
setScreenshotValidationErr(null);
setCompressingScreenshot(true); setCompressingScreenshot(true);
try { try {
for (const f of Array.from(files)) { for (const f of Array.from(files)) {
if (screenshots.length >= MAX_SCREENSHOTS) break; if (screenshots.length >= MAX_SCREENSHOTS) break;
if (f.size > 20 * 1024 * 1024) { alert(`"${f.name}" is too large (max 20 MB original). Skipping.`); continue; } const res = await validateAndSanitizeImage(f, {
const compressed = await compressImage(f, SCREENSHOT_MAX_WIDTH, IMAGE_QUALITY); maxWidth: SCREENSHOT_MAX_WIDTH,
setScreenshots(p => [...p, compressed]); maxHeight: SCREENSHOT_MAX_WIDTH,
const r = new FileReader(); quality: IMAGE_QUALITY,
await new Promise<void>(res => { minWidth: 100,
r.onload = e => { setScreenshotPreviews(p => [...p, e.target?.result as string]); res(); }; minHeight: 100,
r.readAsDataURL(compressed);
}); });
if (!res.valid || !res.file) {
setScreenshotValidationErr(res.error || `Invalid screenshot "${f.name}".`);
continue;
}
setScreenshots(p => [...p, res.file!]);
if (res.previewUrl) {
setScreenshotPreviews(p => [...p, res.previewUrl!]);
}
} }
} catch { alert('One or more images could not be processed.'); } } catch (err: any) {
finally { setCompressingScreenshot(false); } setScreenshotValidationErr(err.message || 'One or more images could not be processed.');
} finally {
setCompressingScreenshot(false);
}
}; };
const removeScreenshot = (i: number) => { const removeScreenshot = (i: number) => {
@ -362,15 +407,33 @@ const AddPlugin = () => {
if (activeLocale === code) setActiveLocale(DEFAULT_LOCALE); if (activeLocale === code) setActiveLocale(DEFAULT_LOCALE);
}; };
// ── WASM setter ─────────────────────────────────────────────────────────── // ── WASM setter with instant bytecode verification ───────────────────────────
const handleWasmFile = (file: File | null) => { const handleWasmFile = async (file: File | null) => {
if (!file) { setWasmFile(null); return; } if (!file) {
if (!file.name.endsWith('.wasm')) { alert('Please upload a .wasm file.'); return; } setWasmFile(null);
if (file.size > MAX_WASM_BYTES) { setWasmValidationErr(null);
alert(`Plugin binary exceeds the ${fmtBytes(MAX_WASM_BYTES)} limit (your file: ${fmtBytes(file.size)}). Please optimise or split your plugin.`);
return; return;
} }
setWasmFile(file); setValidatingWasm(true);
setWasmValidationErr(null);
try {
const res = await validateWasmFile(file);
if (!res.valid) {
setWasmValidationErr(res.error || 'Invalid WebAssembly binary.');
setWasmFile(null);
setWasmFormatInfo(null);
return;
}
setWasmFile(file);
setWasmFormatInfo(res.details?.format || 'WebAssembly Binary');
setWasmValidationErr(null);
} catch (err: any) {
setWasmValidationErr(err.message || 'Failed to inspect WebAssembly binary.');
setWasmFile(null);
setWasmFormatInfo(null);
} finally {
setValidatingWasm(false);
}
}; };
// ── Proceed guard ───────────────────────────────────────────────────────── // ── Proceed guard ─────────────────────────────────────────────────────────
@ -385,7 +448,7 @@ const AddPlugin = () => {
!priceTooLow && !priceTooLow &&
!hasFieldErrors !hasFieldErrors
); );
if (step === 2) return wasmFile !== null && !wasmTooLarge; if (step === 2) return isPreorder || (wasmFile !== null && !wasmTooLarge);
return true; return true;
}; };
@ -393,7 +456,7 @@ const AddPlugin = () => {
const handleSubmit = async () => { const handleSubmit = async () => {
if (hasFieldErrors) { alert('Please fix validation errors before publishing.'); return; } if (hasFieldErrors) { alert('Please fix validation errors before publishing.'); return; }
if (imageBudgetExceeded) { alert('Total image size exceeds 5 MB. Please remove some screenshots.'); return; } if (imageBudgetExceeded) { alert('Total image size exceeds 5 MB. Please remove some screenshots.'); return; }
if (wasmTooLarge) { alert('Plugin binary exceeds 5 MB.'); return; } if (!isPreorder && wasmTooLarge) { alert('Plugin binary exceeds 5 MB.'); return; }
setSubmitting(true); setSubmitting(true);
try { try {
const fd = new FormData(); const fd = new FormData();
@ -406,6 +469,10 @@ const AddPlugin = () => {
fd.append('type', licenseType); fd.append('type', licenseType);
fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0'); fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0');
fd.append('is_early_access', String(isEarlyAccess)); fd.append('is_early_access', String(isEarlyAccess));
fd.append('is_preorder', String(isPreorder));
if (isPreorder && preorderReleaseDate) {
fd.append('preorder_release_date', new Date(preorderReleaseDate).toISOString());
}
if (previewImage) fd.append('preview_image', previewImage); if (previewImage) fd.append('preview_image', previewImage);
if (wasmFile) fd.append('wasm', wasmFile); if (wasmFile) fd.append('wasm', wasmFile);
screenshots.forEach(f => fd.append('screenshots', f)); screenshots.forEach(f => fd.append('screenshots', f));
@ -766,7 +833,7 @@ const AddPlugin = () => {
<p style={{ fontSize: '0.72rem', color: 'var(--mp-text-3)', marginBottom: '0.65rem' }}> <p style={{ fontSize: '0.72rem', color: 'var(--mp-text-3)', marginBottom: '0.65rem' }}>
{previewImage ? fmtBytes(previewImage.size) : ''} {previewImage ? fmtBytes(previewImage.size) : ''}
{previewImage && ( {previewImage && (
<span style={{ marginLeft: '0.4rem', fontSize: '0.68rem', color: 'var(--mp-accent)', background: 'rgba(79,126,255,0.1)', border: '1px solid rgba(79,126,255,0.2)', borderRadius: 3, padding: '0.05rem 0.3rem' }}>compressed</span> <span style={{ marginLeft: '0.4rem', fontSize: '0.68rem', color: '#10b981', background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.25)', borderRadius: 3, padding: '0.05rem 0.3rem' }}>verified image</span>
)} )}
</p> </p>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
@ -782,11 +849,18 @@ const AddPlugin = () => {
</> </>
) : ( ) : (
<p style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', lineHeight: 1.55 }}> <p style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', lineHeight: 1.55 }}>
Shown as the listing card thumbnail. Images are automatically compressed to JPEG. Drag & drop or click the box to upload. Shown as the listing card thumbnail. Images are verified and safely sanitized. Drag & drop or click the box to upload.
</p> </p>
)} )}
</div> </div>
</div> </div>
{previewValidationErr && (
<div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{previewValidationErr}</span>
</div>
)}
</div> </div>
{/* Screenshots */} {/* Screenshots */}
@ -822,13 +896,20 @@ const AddPlugin = () => {
onDrop={e => { e.preventDefault(); setImgDragOver(false); if (!compressingScreenshot) addScreenshots(e.dataTransfer.files); }} onDrop={e => { e.preventDefault(); setImgDragOver(false); if (!compressingScreenshot) addScreenshots(e.dataTransfer.files); }}
onClick={() => !compressingScreenshot && document.getElementById('ap-screenshots')?.click()}> onClick={() => !compressingScreenshot && document.getElementById('ap-screenshots')?.click()}>
<div className="mp-dropzone-icon"><ImageIcon size={20} /></div> <div className="mp-dropzone-icon"><ImageIcon size={20} /></div>
<p>{compressingScreenshot ? <strong>Compressing…</strong> : <><strong>Drag & drop screenshots</strong> or click to browse</>}</p> <p>{compressingScreenshot ? <strong>Validating & compressing…</strong> : <><strong>Drag & drop screenshots</strong> or click to browse</>}</p>
<small>Multiple files · auto-compressed to JPEG · first screenshot = cover</small> <small>Multiple files · auto-sanitized & verified · first screenshot = cover</small>
<input id="ap-screenshots" type="file" accept="image/*" multiple <input id="ap-screenshots" type="file" accept="image/*" multiple
onChange={e => e.target.files && addScreenshots(e.target.files)} /> onChange={e => e.target.files && addScreenshots(e.target.files)} />
</div> </div>
)} )}
{screenshotValidationErr && (
<div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{screenshotValidationErr}</span>
</div>
)}
{(previewImage || screenshots.length > 0) && ( {(previewImage || screenshots.length > 0) && (
<SizeBudget usedBytes={totalImageBytes} maxBytes={MAX_TOTAL_IMAGE_BYTES} label="Total image budget (preview + screenshots)" /> <SizeBudget usedBytes={totalImageBytes} maxBytes={MAX_TOTAL_IMAGE_BYTES} label="Total image budget (preview + screenshots)" />
)} )}
@ -958,6 +1039,40 @@ const AddPlugin = () => {
<p style={{ fontSize: '0.7rem', color: 'var(--mp-text-3)', marginTop: '0.5rem' }}> <p style={{ fontSize: '0.7rem', color: 'var(--mp-text-3)', marginTop: '0.5rem' }}>
Stripe fees are estimates. Payouts occur according to your Stripe schedule. Stripe fees are estimates. Payouts occur according to your Stripe schedule.
</p> </p>
{/* Pre-Order Option */}
<div style={{ marginTop: '1.25rem', paddingTop: '1.25rem', borderTop: '1px solid var(--mp-border)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--mp-text-1)', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Tag size={14} color="var(--mp-accent)" /> Available for Pre-Order
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--mp-muted, #94a3b8)', marginTop: '0.15rem' }}>
Allow buyers to pre-order. When released, all pre-order buyers receive an automatic notification email.
</div>
</div>
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
<span className="mp-toggle-slider" />
</label>
</div>
{isPreorder && (
<div style={{ marginTop: '0.85rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
<label className="mp-label" style={{ fontSize: '0.75rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<input
type="date"
className="mp-input"
value={preorderReleaseDate}
onChange={e => setPreorderReleaseDate(e.target.value)}
style={{ maxWidth: 220 }}
/>
<p style={{ fontSize: '0.7rem', color: 'var(--mp-text-3)', marginTop: '0.4rem' }}>
Uploading a binary in Step 3 is optional while pre-order is enabled.
</p>
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>
@ -987,26 +1102,43 @@ const AddPlugin = () => {
<div <div
className={`mp-dropzone ${wasmDragOver ? 'drag-over' : ''}`} className={`mp-dropzone ${wasmDragOver ? 'drag-over' : ''}`}
style={{ opacity: validatingWasm ? 0.6 : 1, cursor: validatingWasm ? 'wait' : 'pointer' }}
onDragOver={e => { e.preventDefault(); setWasmDragOver(true); }} onDragOver={e => { e.preventDefault(); setWasmDragOver(true); }}
onDragLeave={() => setWasmDragOver(false)} onDragLeave={() => setWasmDragOver(false)}
onDrop={e => { e.preventDefault(); setWasmDragOver(false); handleWasmFile(e.dataTransfer.files[0] ?? null); }} onDrop={e => { e.preventDefault(); setWasmDragOver(false); if (!validatingWasm) handleWasmFile(e.dataTransfer.files[0] ?? null); }}
onClick={() => document.getElementById('ap-wasm')?.click()}> onClick={() => !validatingWasm && document.getElementById('ap-wasm')?.click()}>
<div className="mp-dropzone-icon"><Upload size={20} /></div> <div className="mp-dropzone-icon"><Upload size={20} /></div>
<p><strong>Drag & drop your .wasm file</strong> or click to browse</p> <p>
<small>.wasm only · max 5 MB</small> {validatingWasm ? (
<strong>Inspecting & validating WebAssembly binary…</strong>
) : (
<><strong>Drag & drop your .wasm file</strong> or click to browse</>
)}
</p>
<small>.wasm only · max 5 MB · validated instantly on drop</small>
<input id="ap-wasm" type="file" accept=".wasm" <input id="ap-wasm" type="file" accept=".wasm"
onChange={e => handleWasmFile(e.target.files?.[0] ?? null)} /> onChange={e => handleWasmFile(e.target.files?.[0] ?? null)} />
</div> </div>
{wasmFile && ( {wasmValidationErr && (
<div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{wasmValidationErr}</span>
</div>
)}
{wasmFile && !wasmValidationErr && (
<> <>
<div className="mp-file-badge" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div className="mp-file-badge" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', borderColor: 'rgba(16,185,129,0.4)', background: 'rgba(16,185,129,0.06)' }}>
<FileCode size={14} /> <CheckCircle size={15} color="#10b981" />
{wasmFile.name} <span style={{ fontWeight: 600, color: 'var(--mp-text-1)' }}>{wasmFile.name}</span>
<span style={{ fontSize: '0.68rem', color: '#10b981', background: 'rgba(16,185,129,0.15)', border: '1px solid rgba(16,185,129,0.3)', padding: '2px 6px', borderRadius: 4 }}>
Verified {wasmFormatInfo || '.wasm'}
</span>
<span style={{ marginLeft: 'auto', color: wasmTooLarge ? 'var(--mp-error)' : 'var(--mp-text-3)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}> <span style={{ marginLeft: 'auto', color: wasmTooLarge ? 'var(--mp-error)' : 'var(--mp-text-3)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
{fmtBytes(wasmFile.size)} {fmtBytes(wasmFile.size)}
</span> </span>
<button type="button" onClick={() => setWasmFile(null)} <button type="button" onClick={() => { setWasmFile(null); setWasmValidationErr(null); setWasmFormatInfo(null); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--mp-text-3)', display: 'flex', padding: 0 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--mp-text-3)', display: 'flex', padding: 0 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--mp-error)')} onMouseEnter={e => (e.currentTarget.style.color = 'var(--mp-error)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--mp-text-3)')}> onMouseLeave={e => (e.currentTarget.style.color = 'var(--mp-text-3)')}>
@ -1113,10 +1245,32 @@ const AddPlugin = () => {
</div> </div>
<SizeBudget usedBytes={wasmFile.size} maxBytes={MAX_WASM_BYTES} label="Plugin binary size" /> <SizeBudget usedBytes={wasmFile.size} maxBytes={MAX_WASM_BYTES} label="Plugin binary size" />
</> </>
) : isPreorder ? (
<div className="ap-review-field">
<span>Pre-Order Listing</span>
<strong style={{ color: 'var(--mp-accent)' }}>Binary deferred until release</strong>
</div>
) : ( ) : (
<p style={{ fontSize: '0.82rem', color: 'var(--mp-error)' }}>No .wasm file — required</p> <p style={{ fontSize: '0.82rem', color: 'var(--mp-error)' }}>No .wasm file — required</p>
)} )}
</div> </div>
{/* Developer Certification Notice */}
<div style={{
gridColumn: '1 / -1',
padding: '0.85rem 1rem',
background: 'rgba(255, 183, 77, 0.05)',
border: '1px solid rgba(255, 183, 77, 0.25)',
borderRadius: 8,
fontSize: '0.75rem',
color: '#cbd5e1',
lineHeight: 1.55,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: '#ffb74d', fontWeight: 600, marginBottom: '0.25rem' }}>
<ShieldCheck size={14} /> Developer Certification
</div>
By publishing this plugin, you certify that this software contains no malicious code, adheres to the <a href="/developer-terms" target="_blank" rel="noopener noreferrer" style={{ color: '#ffb74d', textDecoration: 'underline' }}>Developer Distribution Agreement</a>, complies with Mojang's Minecraft EULA, and grants Pumpkin Marketplace full moderation and distribution authority.
</div>
</div> </div>
</div> </div>
@ -1182,7 +1336,7 @@ const AddPlugin = () => {
) : ( ) : (
<button className="mp-btn mp-btn-success" <button className="mp-btn mp-btn-success"
onClick={handleSubmit} onClick={handleSubmit}
disabled={submitting || !wasmFile || imageBudgetExceeded || wasmTooLarge || hasFieldErrors}> disabled={submitting || (!isPreorder && !wasmFile) || imageBudgetExceeded || (!isPreorder && wasmTooLarge) || hasFieldErrors}>
{submitting ? 'Publishing…' : 'Publish Plugin'} {submitting ? 'Publishing…' : 'Publish Plugin'}
{!submitting && <CheckCircle size={16} />} {!submitting && <CheckCircle size={16} />}
</button> </button>

View File

@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── Variables ──────────────────────────────────────────────── */ /* ── Variables ──────────────────────────────────────────────── */
:root { :root {
--mp-bg: #0d0f12; --mp-bg: #0d0f12;

View File

@ -32,6 +32,8 @@ export type PluginData = {
sale_active?: boolean; sale_active?: boolean;
sale_discount_percent?: number; sale_discount_percent?: number;
is_early_access?: boolean; is_early_access?: boolean;
is_preorder?: boolean;
preorder_release_date?: string;
youtube_video_url?: string; youtube_video_url?: string;
}; };

View File

@ -5,7 +5,7 @@ import {
} from 'recharts'; } from 'recharts';
import { import {
TrendingUp, TrendingDown, Minus, DollarSign, TrendingUp, TrendingDown, Minus, DollarSign,
Download, MousePointer2, Star, BarChart3, Globe Download, MousePointer2, Star, Globe
} from 'lucide-react'; } from 'lucide-react';
import { useAnalytics } from '../useAnalytics'; import { useAnalytics } from '../useAnalytics';
@ -155,7 +155,7 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
/> />
<KpiCard <KpiCard
title="Avg Rating" title="Avg Rating"
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'} value={ratingSummary?.averageRating ? `${ratingSummary.averageRating.toFixed(1)} / 5.0` : '--'}
comparison={0} comparison={0}
icon={Star} icon={Star}
chartData={processedData} chartData={processedData}
@ -167,56 +167,60 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
{/* Grid: Charts & Rating Breakdown */} {/* Grid: Charts & Rating Breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* Revenue & Download Growth */} {/* Performance Trends */}
<div className="mp-card"> <div className="mp-card">
<div className="mp-card-title"> <div className="mp-card-title">
<BarChart3 size={16} /> <TrendingUp size={16} />
Downloads & Revenue Over Time Performance Trends
</div> </div>
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={260}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}> <AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs> <defs>
<linearGradient id="dlGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="dlGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3ecf8e" stopOpacity={0.2}/> <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#3ecf8e" stopOpacity={0}/> <stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#f97316" stopOpacity={0}/>
</linearGradient> </linearGradient>
</defs> </defs>
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} /> <XAxis dataKey="date" stroke="#64748b" fontSize={12} tickLine={false} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} tickFormatter={formatValue} /> <YAxis stroke="#64748b" fontSize={12} tickLine={false} tickFormatter={formatValue} />
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} /> <Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="downloads" name="Downloads" stroke="#3ecf8e" strokeWidth={3} fillOpacity={1} fill="url(#dlGradient)" animationDuration={1200} /> <Area type="monotone" dataKey="downloads" name="Downloads" stroke="#3b82f6" strokeWidth={2} fill="url(#dlGrad)" />
<Area type="monotone" dataKey="revenue" name="Revenue (€)" stroke="#f97316" strokeWidth={2} fill="url(#revGrad)" />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Rating History */} {/* Rating Trend Line Chart */}
<div className="mp-card"> <div className="mp-card">
<div className="mp-card-title"> <div className="mp-card-title">
<Star size={16} color="#f59e0b" /> <Star size={16} />
Rating Trend Over Time Rating Trend (1-5)
</div> </div>
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={200}>
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}> <LineChart data={processedData} margin={{ top: 10, right: 20, left: -20, bottom: 0 }}>
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} /> <XAxis dataKey="date" stroke="#64748b" fontSize={12} tickLine={false} />
<YAxis domain={[1, 5]} axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} /> <YAxis domain={[1, 5]} stroke="#64748b" fontSize={12} tickLine={false} />
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} /> <Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="avgRating" name="Rating" stroke="#f59e0b" strokeWidth={3} dot={{ r: 4, fill: '#f59e0b' }} animationDuration={1200} /> <Line type="monotone" dataKey="avgRating" name="Avg Rating" stroke="#f59e0b" strokeWidth={2} dot={{ r: 3, fill: '#f59e0b' }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
{/* Right Sidebar: Rating Histogram & Traffic Sources */} <div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> {/* Rating Breakdown */}
{ratingSummary && ( {ratingSummary && (
<div className="mp-card"> <div className="mp-card" style={{ marginBottom: '1.25rem' }}>
<div className="mp-card-title"> <div className="mp-card-title">
<Star size={16} fill="#f59e0b" color="#f59e0b" /> <Star size={16} />
Rating Breakdown Rating Breakdown
</div> </div>
<div style={{ textAlign: 'center', margin: '1.25rem 0' }}>
<div style={{ textAlign: 'center', margin: '1rem 0' }}> <div style={{ fontSize: '2.5rem', fontWeight: 800, color: 'var(--mp-text-1)', lineHeight: 1 }}>
<div style={{ fontSize: '2.5rem', fontWeight: 800, color: 'var(--mp-text)' }}>
{ratingSummary.averageRating.toFixed(1)} {ratingSummary.averageRating.toFixed(1)}
</div> </div>
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-2)' }}> <div style={{ fontSize: '0.8rem', color: 'var(--mp-text-2)' }}>
@ -229,7 +233,7 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0; const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0;
return ( return (
<div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem' }}> <div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem' }}>
<span style={{ width: '32px', color: 'var(--mp-text-2)' }}>{b.stars} ★</span> <span style={{ width: '48px', color: 'var(--mp-text-2)' }}>{b.stars} Stars</span>
<div style={{ flex: 1, height: '7px', background: 'rgba(255,255,255,0.06)', borderRadius: '4px', overflow: 'hidden' }}> <div style={{ flex: 1, height: '7px', background: 'rgba(255,255,255,0.06)', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '4px' }} /> <div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '4px' }} />
</div> </div>

View File

@ -32,6 +32,10 @@ const Pricing = ({ plugin, onSaved }: Props) => {
const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99); const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99);
const [isSaleActive, setIsSaleActive] = useState(plugin.sale_active ?? false); const [isSaleActive, setIsSaleActive] = useState(plugin.sale_active ?? false);
const [discountPercent, setDiscountPercent] = useState(plugin.sale_discount_percent || 20); const [discountPercent, setDiscountPercent] = useState(plugin.sale_discount_percent || 20);
const [isPreorder, setIsPreorder] = useState(plugin.is_preorder ?? false);
const [preorderReleaseDate, setPreorderReleaseDate] = useState(
plugin.preorder_release_date ? plugin.preorder_release_date.split('T')[0] : ''
);
const [priceTooLow, setPriceTooLow] = useState(false); const [priceTooLow, setPriceTooLow] = useState(false);
const stripeConnected = user.stripe_ready; const stripeConnected = user.stripe_ready;
@ -71,6 +75,12 @@ const Pricing = ({ plugin, onSaved }: Props) => {
if (licenseType === 'paid') { if (licenseType === 'paid') {
fd.append('sale_active', isSaleActive.toString()); fd.append('sale_active', isSaleActive.toString());
fd.append('sale_discount_percent', discountPercent.toString()); fd.append('sale_discount_percent', discountPercent.toString());
fd.append('is_preorder', isPreorder.toString());
if (isPreorder && preorderReleaseDate) {
fd.append('preorder_release_date', new Date(preorderReleaseDate).toISOString());
} else {
fd.append('preorder_release_date', '');
}
} }
try { try {
@ -258,6 +268,43 @@ const Pricing = ({ plugin, onSaved }: Props) => {
</div> </div>
)} )}
</div> </div>
{/* ── Pre-Order Settings ── */}
<div className="mp-card" style={{ marginTop: '1.5rem' }}>
<div className="mp-card-title">
<Tag size={14} />Pre-Order Mode
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--mp-text-1)' }}>
Accept Pre-Orders
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)', marginTop: '0.2rem' }}>
Mark this plugin as available for pre-order. When you publish the first release or disable pre-order, all pre-order buyers will be emailed automatically.
</div>
</div>
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
<span className="mp-toggle-slider" />
</label>
</div>
{isPreorder && (
<div style={{ marginTop: '1rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
<label className="mp-label" style={{ fontSize: '0.78rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<input
type="date"
className="mp-input"
value={preorderReleaseDate}
onChange={e => setPreorderReleaseDate(e.target.value)}
style={{ maxWidth: 220 }}
/>
<p style={{ fontSize: '0.72rem', color: 'var(--mp-text-3)', marginTop: '0.4rem' }}>
Displayed on the store page so buyers know when to expect the release.
</p>
</div>
)}
</div>
</> </>
)} )}

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react'; import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react';
import api from '../../../api'; import api from '../../../api';
import type { PluginData } from './ManagePlugin'; import type { PluginData } from './ManagePlugin';
import { validateWasmFile } from '../../../utils/fileValidation';
type Props = { plugin: PluginData; onSaved: () => void }; type Props = { plugin: PluginData; onSaved: () => void };
@ -13,6 +14,9 @@ const TRACKS = [
const PublishUpdate = ({ plugin, onSaved }: Props) => { const PublishUpdate = ({ plugin, onSaved }: Props) => {
const [wasmFile, setWasmFile] = useState<File | null>(null); const [wasmFile, setWasmFile] = useState<File | null>(null);
const [wasmValidationErr, setWasmValidationErr] = useState<string | null>(null);
const [wasmFormatInfo, setWasmFormatInfo] = useState<string | null>(null);
const [validatingWasm, setValidatingWasm] = useState(false);
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [track, setTrack] = useState<'stable' | 'beta' | 'alpha'>('stable'); const [track, setTrack] = useState<'stable' | 'beta' | 'alpha'>('stable');
const [releaseNotes, setReleaseNotes] = useState(''); const [releaseNotes, setReleaseNotes] = useState('');
@ -20,12 +24,40 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [published, setPublished] = useState(false); const [published, setPublished] = useState(false);
const handleFileValidation = async (file: File | null) => {
if (!file) {
setWasmFile(null);
setWasmValidationErr(null);
setWasmFormatInfo(null);
return;
}
setValidatingWasm(true);
setWasmValidationErr(null);
try {
const res = await validateWasmFile(file);
if (!res.valid) {
setWasmValidationErr(res.error || 'Invalid WebAssembly binary.');
setWasmFile(null);
setWasmFormatInfo(null);
return;
}
setWasmFile(file);
setWasmFormatInfo(res.details?.format || 'WebAssembly Binary');
setWasmValidationErr(null);
} catch (err: any) {
setWasmValidationErr(err.message || 'Failed to inspect file.');
setWasmFile(null);
setWasmFormatInfo(null);
} finally {
setValidatingWasm(false);
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
setDragOver(false); setDragOver(false);
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.wasm')) setWasmFile(file); if (file) handleFileValidation(file);
else alert('Please drop a valid .wasm file.');
}; };
const handlePublish = async () => { const handlePublish = async () => {
@ -168,31 +200,46 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
<div <div
className={`mp-dropzone ${dragOver ? 'drag-over' : ''}`} className={`mp-dropzone ${dragOver ? 'drag-over' : ''}`}
style={{ opacity: validatingWasm ? 0.6 : 1, cursor: validatingWasm ? 'wait' : 'pointer' }}
onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)} onDragLeave={() => setDragOver(false)}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => document.getElementById('wasm-upload-input')?.click()} onClick={() => !validatingWasm && document.getElementById('wasm-upload-input')?.click()}
> >
<div className="mp-dropzone-icon"> <div className="mp-dropzone-icon">
<Upload size={20} /> <Upload size={20} />
</div> </div>
<p> <p>
<strong>Drag & drop your .wasm file</strong> or click to browse {validatingWasm ? (
<strong>Validating WebAssembly binary structure…</strong>
) : (
<><strong>Drag & drop your .wasm file</strong> or click to browse</>
)}
</p> </p>
<small>.wasm only</small> <small>.wasm only · max 5 MB · validated on drop</small>
<input <input
id="wasm-upload-input" id="wasm-upload-input"
type="file" type="file"
accept=".wasm" accept=".wasm"
onChange={e => setWasmFile(e.target.files?.[0] ?? null)} onChange={e => handleFileValidation(e.target.files?.[0] ?? null)}
/> />
</div> </div>
{wasmFile && ( {wasmValidationErr && (
<div className="mp-file-badge"> <div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FileCode size={14} /> <AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
{wasmFile.name} <span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{wasmValidationErr}</span>
<span style={{marginLeft:'auto', color:'var(--mp-text-3)'}}> </div>
)}
{wasmFile && !wasmValidationErr && (
<div className="mp-file-badge" style={{ borderColor: 'rgba(16,185,129,0.4)', background: 'rgba(16,185,129,0.06)' }}>
<CheckCircle size={15} color="#10b981" />
<span style={{ fontWeight: 600, color: 'var(--mp-text-1)' }}>{wasmFile.name}</span>
<span style={{ fontSize: '0.68rem', color: '#10b981', background: 'rgba(16,185,129,0.15)', border: '1px solid rgba(16,185,129,0.3)', padding: '2px 6px', borderRadius: 4, marginLeft: '0.5rem' }}>
Verified {wasmFormatInfo || '.wasm'}
</span>
<span style={{ marginLeft: 'auto', color: 'var(--mp-text-3)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
{(wasmFile.size / 1024).toFixed(1)} KB {(wasmFile.size / 1024).toFixed(1)} KB
</span> </span>
</div> </div>
@ -203,7 +250,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
<button <button
className="mp-btn mp-btn-primary" className="mp-btn mp-btn-primary"
onClick={handlePublish} onClick={handlePublish}
disabled={!wasmFile || uploading} disabled={!wasmFile || uploading || validatingWasm}
> >
<Send size={15} /> <Send size={15} />
{uploading ? 'Publishing…' : 'Publish Update'} {uploading ? 'Publishing…' : 'Publish Update'}

View File

@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Globe, ImageIcon, Plus, Save, Search, X } from 'lucide-react'; import { Globe, ImageIcon, Plus, Save, Search, X, AlertTriangle } from 'lucide-react';
import api from '../../../api'; import api from '../../../api';
import type { PluginData } from './ManagePlugin'; import type { PluginData } from './ManagePlugin';
import { validateAndSanitizeImage } from '../../../utils/fileValidation';
const PLUGIN_CATEGORIES = [ const PLUGIN_CATEGORIES = [
"Admin Tools","Economy","Fun","World Management","Utilities","Chat","Other" "Admin Tools","Economy","Fun","World Management","Utilities","Chat","Other"
@ -176,12 +177,34 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const [iconFile, setIconFile] = useState<File | null>(null); const [iconFile, setIconFile] = useState<File | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(plugin.preview_path || null); const [iconPreview, setIconPreview] = useState<string | null>(plugin.preview_path || null);
const [iconValidationErr, setIconValidationErr] = useState<string | null>(null);
const [screenshotValidationErr, setScreenshotValidationErr] = useState<string | null>(null);
const [processingImage, setProcessingImage] = useState(false);
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleIconChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setIconValidationErr(null);
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]; const file = e.target.files[0];
setIconFile(file); setProcessingImage(true);
setIconPreview(URL.createObjectURL(file)); try {
const res = await validateAndSanitizeImage(file, {
maxWidth: 512,
maxHeight: 512,
quality: 0.85,
minWidth: 32,
minHeight: 32,
});
if (!res.valid || !res.file) {
setIconValidationErr(res.error || 'Invalid icon file.');
return;
}
setIconFile(res.file);
setIconPreview(res.previewUrl || URL.createObjectURL(res.file));
} catch (err: any) {
setIconValidationErr(err.message || 'Could not process icon.');
} finally {
setProcessingImage(false);
}
} }
}; };
@ -222,13 +245,36 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const fd = new FormData(); setScreenshotValidationErr(null);
Array.from(files).forEach(f => fd.append('screenshots', f)); setProcessingImage(true);
const validFiles: File[] = [];
try { try {
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd); for (const f of Array.from(files)) {
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]); const res = await validateAndSanitizeImage(f, {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.82,
minWidth: 100,
minHeight: 100,
});
if (!res.valid || !res.file) {
setScreenshotValidationErr(res.error || `Invalid screenshot "${f.name}".`);
continue;
}
validFiles.push(res.file);
}
if (validFiles.length > 0) {
const fd = new FormData();
validFiles.forEach(f => fd.append('screenshots', f));
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd);
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
}
} catch { } catch {
alert('Screenshot upload failed.'); setScreenshotValidationErr('Screenshot upload failed.');
} finally {
setProcessingImage(false);
e.target.value = '';
} }
}; };
@ -264,9 +310,9 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
{!iconPreview && name.charAt(0).toUpperCase()} {!iconPreview && name.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<label htmlFor="icon-upload-input" className="mp-btn mp-btn-secondary" style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem', fontSize: '0.82rem' }}> <label htmlFor="icon-upload-input" className="mp-btn mp-btn-secondary" style={{ cursor: processingImage ? 'wait' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem', fontSize: '0.82rem', opacity: processingImage ? 0.7 : 1 }}>
<ImageIcon size={14} /> <ImageIcon size={14} />
Change Icon {processingImage ? 'Validating & Sanitizing…' : 'Change Icon'}
</label> </label>
<input <input
id="icon-upload-input" id="icon-upload-input"
@ -274,12 +320,19 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
accept="image/png,image/jpeg,image/webp" accept="image/png,image/jpeg,image/webp"
onChange={handleIconChange} onChange={handleIconChange}
style={{ display: 'none' }} style={{ display: 'none' }}
disabled={processingImage}
/> />
<p style={{ margin: '0.35rem 0 0 0', fontSize: '0.74rem', color: 'var(--mp-text-3)' }}> <p style={{ margin: '0.35rem 0 0 0', fontSize: '0.74rem', color: 'var(--mp-text-3)' }}>
Square image recommended (PNG, JPEG, WebP). Max 2MB. Square image recommended (PNG, JPEG, WebP). Max 2MB. Validated on selection.
</p> </p>
</div> </div>
</div> </div>
{iconValidationErr && (
<div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{iconValidationErr}</span>
</div>
)}
</div> </div>
<div className="mp-form-group"> <div className="mp-form-group">
@ -469,8 +522,14 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
<input id="screenshot-upload" type="file" accept="image/*" multiple <input id="screenshot-upload" type="file" accept="image/*" multiple
style={{display:'none'}} onChange={handleScreenshotUpload} /> style={{display:'none'}} onChange={handleScreenshotUpload} />
</div> </div>
{screenshotValidationErr && (
<div className="mp-banner error" style={{ marginTop: '0.85rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<AlertTriangle size={15} color="var(--mp-error)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: '0.8rem', color: 'var(--mp-error)' }}>{screenshotValidationErr}</span>
</div>
)}
<p style={{marginTop:'0.75rem', fontSize:'0.76rem', color:'var(--mp-text-3)'}}> <p style={{marginTop:'0.75rem', fontSize:'0.76rem', color:'var(--mp-text-3)'}}>
Recommended: 16:9 aspect ratio, min 1280×720px. PNG or JPEG. Recommended: 16:9 aspect ratio, min 1280×720px. PNG, JPEG, or WebP. Validated on selection.
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,178 @@
import React from 'react';
import { Link } from 'react-router-dom';
import SEO from '../../components/SEO';
import './LegalPages.css';
const sections = [
{
id: 'definitions',
title: '1. Definitions & Scope of Agreement',
content: `This Developer Distribution Agreement ("Agreement") constitutes a legally binding contract between you (either as an individual developer or an authorized representative of a company/entity, referred to as "Developer" or "You") and Aleksandr Medvedev ("Operator", "Pumpkin Marketplace", "We", or "Us"), governing your distribution, publication, sale, and licensing of Minecraft plugins, WebAssembly modules, and associated digital assets ("Plugins") via the Pumpkin Marketplace platform.
By completing developer onboarding or submitting any Plugin to Pumpkin Marketplace, you explicitly agree to be bound by this Agreement, our general Terms of Service, Privacy Policy, and Marketplace Review Guidelines. If you do not agree to all terms of this Agreement, you must not register as a developer or submit Plugins.`
},
{
id: 'license-grant',
title: '2. Grant of Worldwide Distribution & Processing License',
content: `2.1 Distribution License:
You retain your intellectual property ownership in your original Plugin source code. However, you hereby grant to Pumpkin Marketplace a worldwide, non-exclusive, royalty-free, transferable, and sublicensable license to:
• Host, reproduce, store, cache, format, sign, compile, and distribute the Plugin binary (.wasm) and metadata to end users.
• Parse, inspect, sandbox, benchmark, decompile, and security-scan your Plugin files for malicious behavior, stability, and policy compliance.
• Use your Plugin's name, icon, screenshots, video trailers, and developer display name for marketing, promotional campaigns, showcase reels, and marketplace cataloging.
2.2 End User Sublicense:
You grant Pumpkin Marketplace the authority to issue licenses to end users who acquire your Plugin (whether free, ad-supported, or paid) allowing them to install and run the Plugin on their Minecraft servers in accordance with your specified license terms.`
},
{
id: 'platform-control',
title: '3. Absolute Platform Control, Moderation & Discretionary Takedowns',
content: `3.1 Sole Discretion over Distribution:
Pumpkin Marketplace operates a curated, secure ecosystem. The Operator retains absolute and sole discretion regarding whether to approve, reject, feature, sandbox, delist, disable, or remove any Plugin, update, or developer account from the platform at any time.
3.2 Zero-Liability Takedown Rights:
The Operator may immediately remove, disable, or quarantine any Plugin without prior notice and without liability to you in circumstances including but not limited to:
• Security Vulnerabilities: Discovery of security exploits, backdoors, crash triggers, memory leaks, or unauthorized remote code execution.
• Malicious Functionality: Inclusion of hidden "OP" commands, ransomware, token stealers, botnets, or undisclosed spyware.
• Copyright & IP Disputes: Receipt of a credible DMCA notice, copyright infringement claim, or trademark violation.
• Minecraft / Mojang EULA Breaches: Violations of Mojang Studios' Commercial Usage Guidelines or EULA.
• Deceptive Practices: Misleading store descriptions, false advertising, fake ratings, or review manipulation.
• Developer Abandonment: Extended periods of non-responsiveness to critical bug reports or severe compatibility failures.
3.3 Emergency Remote Kill Switch & Binary Revocation:
In the event of a verified critical security threat or zero-day vulnerability threatening the wider Minecraft server ecosystem, Pumpkin Marketplace reserves the right to disable cryptographic license verification, revoke download links, or instruct client runtimes to block the affected binary immediately.`
},
{
id: 'developer-warranties',
title: '4. Developer Warranties, Security & Prohibited Conduct',
content: `By publishing any Plugin, you represent and warrant that:
• Originality & Ownership: You are the original author of the Plugin or possess all necessary commercial licenses, intellectual property rights, and permissions to distribute all included code, libraries, and assets.
• No Malicious Payloads: The Plugin does not contain malware, trojans, cryptominers, spyware, keyloggers, or hidden administrative backdoors.
• Transparent Telemetry: Any telemetry, remote licensing validation, or analytics performed by your Plugin must be explicitly disclosed in the store listing description and must include an opt-out configuration setting.
• No Circumvention: You will not attempt to bypass platform review processes, evade sandboxing restrictions, or distribute obfuscated code designed to hide non-compliant functionality.
• Truthful Information: All documentation, changelogs, system requirements, and marketing materials provided are accurate and not misleading.`
},
{
id: 'mojang-compliance',
title: '5. Mojang Studios & Minecraft EULA Compliance',
content: `5.1 Strict EULA Compliance:
You acknowledge that all Minecraft-related creations must strictly adhere to Mojang Studios' End User License Agreement (EULA), Brand and Asset Guidelines, and Commercial Usage Guidelines.
5.2 In-Game Monetization Rules:
Plugins distributed on Pumpkin Marketplace must not facilitate "pay-to-win" mechanics or gameplay features that violate Mojang's commercial rules for server monetization. You are solely responsible for ensuring your Plugin's functionality conforms to Mojang's evolving policies.
5.3 No Official Endorsement:
You may not claim, imply, or advertise that your Plugin or studio is officially affiliated with, endorsed by, or sponsored by Mojang Studios, Microsoft, or PumpkinMC.`
},
{
id: 'commercial-terms',
title: '6. Commercial Terms, Revenue Share (70/30) & Payouts',
content: `6.1 Revenue Split:
For all paid Plugin sales and pre-orders processed through the Marketplace, the standard revenue distribution is:
• 70% of Net Revenue to the Developer.
• 30% Platform Fee to Pumpkin Marketplace for hosting, cryptographic licensing infrastructure, bandwidth, catalog distribution, and maintenance.
6.2 Payouts via Stripe Connect:
Sellers must maintain an active, fully verified Stripe Connected Account in an eligible jurisdiction. Payouts are transferred automatically by Stripe in accordance with your regional Stripe payout schedule.
6.3 Payment Processing & Currency:
Transactions are executed in EUR. Standard payment gateway processing fees (e.g., Stripe processing fees) and mandatory government taxes (such as VAT where applicable) are deducted from gross receipts before net distribution.
6.4 Refund & Chargeback Offsets (Clawbacks):
If a refund is issued in accordance with our Digital Withdrawal Waiver & Refund Policy, or if a buyer initiates a fraudulent payment chargeback, the refunded amount and associated payment dispute fees will be deducted from your developer account balance or offset against future earnings.`
},
{
id: 'support-sla',
title: '7. Customer Support & Maintenance Commitments',
content: `7.1 Support Obligation:
As a developer selling or distributing software on Pumpkin Marketplace, you are solely responsible for providing end-user technical support for your Plugins. You must maintain a valid, monitored support email address or support Discord server listed on your profile and plugin listings.
7.2 Response Time SLA:
You agree to make reasonable, good-faith efforts to respond to customer support inquiries and defect reports within 5 business days. Repeated failure to respond to buyer inquiries regarding verified technical defects may result in listing suspension or mandatory refund issuance.`
},
{
id: 'anti-circumvention',
title: '8. Anti-Steering & Circumvention Prohibitions',
content: `Developers may not use Pumpkin Marketplace listings, descriptions, binary messages, or community features to direct users off-platform for the purpose of evading platform fees or purchasing digital goods outside of our secure checkout. Any attempt to circumvent Marketplace payment systems will result in immediate termination of developer privileges and permanent forfeiture of pending payouts.`
},
{
id: 'indemnification',
title: '9. Developer Indemnification & Operator Liability Shield',
content: `To the maximum extent permitted by applicable law, you agree to DEFEND, INDEMNIFY, and HOLD HARMLESS Aleksandr Medvedev (Operator), Pumpkin Marketplace, its affiliates, contributors, and agents from and against any and all claims, demands, liabilities, damages, losses, costs, expenses, and legal fees arising out of or related to:
• Any breach by you of this Agreement, warranties, or representations.
• Any claim that your Plugin infringes or misappropriates any third-party patent, copyright, trademark, trade secret, or privacy right.
• Any harm, server crash, data loss, financial loss, or damage caused to end users or third parties by your Plugin.
• Any violation by your Plugin of the Minecraft / Mojang EULA or applicable regional laws and regulations.`
},
{
id: 'termination',
title: '10. Term, Suspension & Post-Termination Rights',
content: `10.1 Voluntary Termination:
You may terminate your developer account at any time by delisting your Plugins and contacting support@pumpkinmc.org.
10.2 Survival of Buyer Licenses:
In the event of delisting or developer account closure, existing buyers who previously purchased or acquired your Plugin retain their perpetual license to access, download, and use the versions they acquired.
10.3 Survival of Legal Provisions:
Sections 2.1 (for existing installations), 3, 4, 5, 6.4, 9, 10, and 11 shall survive any termination of this Agreement.`
},
{
id: 'governing-law',
title: '11. Governing Law & Operator Contact',
content: `11.1 Governing Law:
This Agreement and any disputes arising out of or related to it shall be governed by and construed in accordance with the substantive laws of the Federal Republic of Germany, without regard to its conflict of law principles.
11.2 Operator Legal Contact:
Aleksandr Medvedev
Kastanienweg 20
40723 Hilden, Germany
Contact: lilalexmed@proton.me / support@pumpkinmc.org`
}
];
export const DeveloperTermsPage: React.FC = () => {
return (
<div className="legal-container">
<SEO
title="Developer Distribution Agreement"
description="Legal terms and distribution agreement for developers publishing plugins on Pumpkin Marketplace."
/>
<div className="legal-content">
<header className="guidelines-header">
<h1>Developer <span>Distribution Agreement</span></h1>
<p className="subtitle">Terms, distribution rights, and platform governance for Pumpkin Marketplace creators.</p>
<div className="last-updated">Effective Date: June 2026 • Version 2026.1</div>
</header>
<div className="callout">
<h3>Summary for Creators</h3>
<ul>
<li><strong>You own your code:</strong> You retain ownership of your original Plugin source code and assets.</li>
<li><strong>Distribution license:</strong> You grant Pumpkin Marketplace a worldwide license to host, cache, security-scan, sign, and distribute your binaries to buyers.</li>
<li><strong>Full platform control:</strong> Pumpkin Marketplace maintains strict quality &amp; safety standards and reserves the right to delist or quarantine non-compliant, unsafe, or abandoned plugins.</li>
<li><strong>Revenue &amp; payouts:</strong> Paid plugins follow a standard 70/30 developer/platform split with automated Stripe Connect payouts.</li>
<li><strong>Security &amp; EULA:</strong> Code must be free of backdoors, spyware, or malicious payloads and comply with Mojang's Minecraft EULA.</li>
</ul>
</div>
{sections.map(sec => (
<section key={sec.id} id={sec.id} className="guideline-section" style={{ marginTop: '2.5rem' }}>
<h2 style={{ fontSize: '1.4rem', color: '#ffb74d', borderBottom: '1px solid #333', paddingBottom: '0.4rem', marginBottom: '1rem' }}>
{sec.title}
</h2>
<div style={{ whiteSpace: 'pre-line', color: '#cbd5e1', fontSize: '0.95rem', lineHeight: '1.7' }}>
{sec.content}
</div>
</section>
))}
<div style={{ marginTop: '3rem', paddingTop: '1.5rem', borderTop: '1px solid #333', display: 'flex', gap: '1.5rem', flexWrap: 'wrap' }}>
<Link to="/terms" style={{ color: '#ffb74d', textDecoration: 'none', fontSize: '0.9rem' }}>→ General Terms of Service</Link>
<Link to="/guidelines" style={{ color: '#ffb74d', textDecoration: 'none', fontSize: '0.9rem' }}>→ Marketplace Review Guidelines</Link>
<Link to="/privacy" style={{ color: '#ffb74d', textDecoration: 'none', fontSize: '0.9rem' }}>→ Privacy Policy</Link>
</div>
</div>
</div>
);
};
export default DeveloperTermsPage;

View File

@ -0,0 +1,395 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Scale } from 'lucide-react';
interface Section {
id: string;
title: string;
content: string;
}
const sections: Section[] = [
{
id: 'operator',
title: '1. Service Provider & Operator Information (§ 5 DDG)',
content: `Information pursuant to § 5 of the German Digital Services Act (Digitale-Dienste-Gesetz - DDG):
Service Operator:
Aleksandr Medvedev
Kastanienweg 20
40723 Hilden
Germany
Contact Details:
• Direct Email: lilalexmed@proton.me
• Platform Support: support@pumpkinmc.org
• Privacy & Data Protection: privacy@pumpkinmc.org
• Official Website: https://pumpkinmc.org
• Discord Community: https://discord.gg/pumpkinmc`
},
{
id: 'editorial',
title: '2. Responsible for Content (§ 18 Abs. 2 MStV)',
content: `Responsible for journalistic and editorial content pursuant to § 18 Section 2 of the German Interstate Media Treaty (Medienstaatsvertrag - MStV):
Aleksandr Medvedev
Kastanienweg 20
40723 Hilden
Germany`
},
{
id: 'dispute-resolution',
title: '3. EU Online Dispute Resolution & Consumer Arbitration',
content: `Online Dispute Resolution (ODR):
The European Commission provides a platform for online dispute resolution (ODR), accessible at:
https://ec.europa.eu/consumers/odr/
Our contact email for consumer dispute matters: lilalexmed@proton.me
Consumer Dispute Resolution:
We are neither obligated nor willing to participate in dispute resolution proceedings before a consumer arbitration board (Verbraucherschlichtungsstelle) pursuant to the German Consumer Dispute Resolution Act (VSBG).`
},
{
id: 'liability-content',
title: '4. Liability for Content (§ 7 Abs. 1 DDG)',
content: `As a service provider, we are responsible for our own content on these pages under general statutory laws pursuant to § 7 Section 1 of the German Digital Services Act (DDG).
According to §§ 8 to 10 DDG, however, we as a service provider are not obligated to permanently monitor transmitted or stored third-party information (such as user-submitted plugins, reviews, or comments) or to investigate circumstances that indicate illegal activity.
Obligations to remove or block the use of information under general statutory laws remain unaffected. Any liability in this respect, however, is only possible from the time of knowledge of a specific infringement. Upon notification of corresponding infringements, we will remove this content immediately.`
},
{
id: 'liability-links',
title: '5. Liability for External Links',
content: `Our Service contains links to external third-party websites (such as plugin repositories, creator documentation, and Discord) over whose content we have no influence. Therefore, we cannot assume any liability for this external content.
The respective provider or operator of the pages is always responsible for the content of the linked pages. The linked pages were checked for possible legal violations at the time of linking. Illegal content was not recognizable at the time of linking.
Permanent monitoring of the content of the linked pages is not reasonable without concrete evidence of an infringement. Upon notification of legal infringements, we will remove such links immediately.`
},
{
id: 'copyright',
title: '6. Copyright & Intellectual Property',
content: `The content and works created by the site operators on these pages are subject to German copyright law. Duplication, processing, distribution, and any kind of exploitation outside the limits of copyright law require the written consent of the respective author or creator.
Downloads and copies of this site are only permitted for private, non-commercial use, unless expressly permitted otherwise by the respective plugin's open-source or commercial license.
Insofar as the content on this site was not created by the operator, the copyrights of third parties (such as plugin creators and contributing developers) are respected. In particular, third-party content is marked as such. Should you nevertheless become aware of a copyright infringement, please notify us at support@pumpkinmc.org. Upon notification of infringements, we will remove such content immediately.`
}
];
const LegalNoticePage = () => {
const [activeSection, setActiveSection] = useState<string | null>(null);
return (
<>
<style>{`
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
background: #0f1117;
}
.ln-page {
min-height: 100vh;
background-color: #0f1117;
background-image: radial-gradient(ellipse 80% 40% at 50% 0%, rgba(255, 107, 0, 0.12) 0%, transparent 65%);
color: #c8cdd8;
}
/* ── Hero ── */
.ln-hero {
text-align: center;
padding: 64px 24px 48px;
border-bottom: 1px solid #1a2030;
}
.ln-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 107, 0, 0.1);
border: 1px solid rgba(255, 107, 0, 0.2);
border-radius: 100px;
padding: 5px 14px;
font-size: 12px;
font-weight: 700;
color: #ff8c00;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 20px;
}
.ln-title {
font-size: clamp(28px, 5vw, 42px);
font-weight: 800;
color: #f0f2f5;
letter-spacing: -0.5px;
line-height: 1.2;
margin-bottom: 14px;
}
.ln-title span {
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.ln-subtitle {
font-size: 15px;
color: #8c9bb4;
font-weight: 600;
max-width: 560px;
margin: 0 auto;
line-height: 1.6;
}
.ln-date {
margin-top: 16px;
font-size: 12.5px;
color: #6b7a94;
font-weight: 600;
}
/* ── Layout ── */
.ln-layout {
display: grid;
grid-template-columns: 260px 1fr;
max-width: 1120px;
margin: 0 auto;
padding: 40px 24px 80px;
gap: 40px;
align-items: start;
}
@media (max-width: 768px) {
.ln-layout { grid-template-columns: 1fr; }
.ln-sidebar { display: none; }
}
/* ── Sidebar ── */
.ln-sidebar {
position: sticky;
top: 100px;
}
.sidebar-label {
font-size: 11px;
font-weight: 700;
color: #6b7a94;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 10px;
padding-left: 10px;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar-link {
font-size: 13px;
font-weight: 600;
color: #8c9bb4;
text-decoration: none;
padding: 7px 10px;
border-radius: 6px;
transition: all 0.15s;
border-left: 2px solid transparent;
line-height: 1.4;
}
.sidebar-link:hover {
color: #ffffff;
background: rgba(255, 107, 0, 0.06);
border-left-color: rgba(255, 107, 0, 0.3);
}
.sidebar-link.active {
color: #ff8c00;
background: rgba(255, 107, 0, 0.08);
border-left-color: #ff6b00;
}
/* ── Content ── */
.ln-content { min-width: 0; }
.ln-section {
background: #161b26;
border: 1px solid #1e2535;
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 16px;
transition: border-color 0.2s;
scroll-margin-top: 100px;
}
.ln-section:hover { border-color: rgba(255, 107, 0, 0.2); }
.ln-section.active { border-color: rgba(255, 107, 0, 0.35); }
.section-title {
font-size: 17px;
font-weight: 800;
color: #e8eaf0;
margin-bottom: 14px;
letter-spacing: -0.2px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: '';
display: inline-block;
width: 3px;
height: 18px;
background: linear-gradient(180deg, #ff8c00, #ff6b00);
border-radius: 2px;
flex-shrink: 0;
}
.section-body {
font-size: 14px;
line-height: 1.8;
color: #9aa5b8;
font-weight: 500;
white-space: pre-line;
}
.section-body strong {
display: block;
color: #e2e8f0;
font-weight: 700;
margin-top: 14px;
margin-bottom: 2px;
}
.section-body strong:first-child { margin-top: 0; }
/* ── Footer ── */
.ln-footer {
border-top: 1px solid #1a2030;
padding: 32px 24px;
text-align: center;
}
.ln-footer-text {
font-size: 13px;
color: #8c9bb4;
font-weight: 600;
line-height: 1.7;
}
.ln-footer-text a {
color: #ff6b00;
text-decoration: none;
font-weight: 700;
}
.ln-footer-text a:hover { text-decoration: underline; text-underline-offset: 2px; }
.ln-footer-links {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.ln-footer-link {
font-size: 13px;
font-weight: 700;
color: #8c9bb4;
text-decoration: none;
transition: color 0.15s;
}
.ln-footer-link:hover { color: #ff6b00; }
.ln-footer-sep {
width: 3px;
height: 3px;
background: #2a3347;
border-radius: 50%;
}
`}</style>
<div className="ln-page">
{/* Hero */}
<div className="ln-hero">
<div className="ln-badge">
<Scale size={13} /> Legal Notice / Impressum
</div>
<h1 className="ln-title">
Legal Notice &amp;<br /><span>Provider Identification</span>
</h1>
<p className="ln-subtitle">
Information and provider identification pursuant to § 5 of the German Digital Services Act (DDG) and § 18 Section 2 MStV.
</p>
<p className="ln-date">Last updated: August 2026 · Effective immediately</p>
</div>
{/* Layout */}
<div className="ln-layout">
{/* Sidebar */}
<aside className="ln-sidebar">
<p className="sidebar-label">On this page</p>
<nav className="sidebar-nav">
{sections.map(s => (
<a
key={s.id}
href={`#${s.id}`}
className={`sidebar-link${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.title}
</a>
))}
</nav>
</aside>
{/* Sections */}
<main className="ln-content">
{sections.map(s => (
<section
key={s.id}
id={s.id}
className={`ln-section${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
<h2 className="section-title">{s.title}</h2>
<div className="section-body">
{s.content.split('\n').map((line, i) => {
const isSubheading = line.length > 0 && !line.startsWith('•') && !line.startsWith(' ') && i > 0 && s.content.split('\n')[i - 1] === '';
return isSubheading
? <strong key={i}>{line}</strong>
: <span key={i}>{line}{'\n'}</span>;
})}
</div>
</section>
))}
</main>
</div>
{/* Footer */}
<footer className="ln-footer">
<p className="ln-footer-text">
Official Provider Identification for the PumpkinMC Plugin Marketplace · Contact:{' '}
<a href="mailto:lilalexmed@proton.me">lilalexmed@proton.me</a>
</p>
<div className="ln-footer-links">
<Link to="/terms" className="ln-footer-link">Terms of Service</Link>
<span className="ln-footer-sep" />
<Link to="/privacy" className="ln-footer-link">Privacy Policy</Link>
<span className="ln-footer-sep" />
<Link to="/" className="ln-footer-link">Marketplace</Link>
</div>
</footer>
</div>
</>
);
};
export default LegalNoticePage;

View File

@ -1,15 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { openCookiePreferencesModal } from '../../utils/consent';
import { Shield, Sliders } from 'lucide-react';
const sections = [ interface Section {
id: string;
title: string;
content: string;
hasCookieButton?: boolean;
}
const sections: Section[] = [
{ {
id: 'overview', id: 'overview',
title: '1. Overview', title: '1. Overview & Data Controller',
content: `PumpkinMC ("we", "us", or "our") operates the PumpkinMC Plugin Marketplace and related services (collectively, the "Service"). This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you visit our website or use our Service. content: `PumpkinMC ("we", "us", or "our") operates the PumpkinMC Plugin Marketplace and related services (the "Service"). This Privacy Policy explains how we collect, use, store, and protect your information when you browse our site, publish plugins, or make purchases.
We are committed to protecting your privacy. We will never sell your personal information to third parties, and we only collect what is necessary to operate and improve the Service. By using the Service, you agree to the collection and use of information in accordance with this policy. We believe in privacy by design. We do not sell your personal data to advertisers or third parties, and we only collect information necessary to operate, improve, and secure our marketplace.
If you have questions or concerns about this policy, please contact us at privacy@pumpkinmc.org.` Data Controller Contact Information:
• Entity: PumpkinMC Data Protection
• Email: privacy@pumpkinmc.org
• Support: support@pumpkinmc.org
• Website: https://pumpkinmc.org`
}, },
{ {
id: 'information-collected', id: 'information-collected',
@ -17,160 +30,130 @@ If you have questions or concerns about this policy, please contact us at privac
content: `We collect the following categories of information: content: `We collect the following categories of information:
Account Information Account Information
When you register, we collect your username, email address, and a hashed version of your password. Sellers may additionally provide payment and payout details required to process transactions. When you create an account, we collect your username, email address, password hash (salted using bcrypt), country of residence, preferred currency, and two-factor authentication (TOTP) status.
Transaction Data Developer & Seller Profile
When you purchase or sell a Plugin, we collect records of those transactions including the Plugin name, price, date, and payment method type (we do not store full card numbers — these are handled by our payment processor). If you register as a creator or publish plugins, we collect your public display name, entity type (individual vs. organization), and payout/tax details where applicable (legal name, address, VAT ID, support email, website, and GitHub link).
Usage Data Transactions & Licenses
We automatically collect certain information when you visit the Service, including your IP address, browser type, operating system, referring URLs, pages visited, and timestamps. This data helps us understand how the Service is used and identify issues. When you purchase or sell plugins, we record transaction details including plugin ID, amount, date, and generated license keys. Payment processing is handled directly by Stripe — we do not store full credit card numbers on our servers.
Communications Technical & Security Data
If you contact our support team or post in community areas, we retain those communications to resolve disputes and improve our service. To defend against automated bots, fraud, and attacks, we process basic connection headers, coarse country location (via Cloudflare edge headers without storing raw IP addresses in application databases), and Cloudflare Turnstile security tokens.
Cookies & Similar Technologies User Content & Reviews
We use cookies and similar tracking technologies to maintain your session, remember your preferences, and analyze usage patterns. See Section 7 for more detail.` Reviews, ratings, replies, and plugin listings you post publicly on the platform.`
}, },
{ {
id: 'how-we-use', id: 'how-we-use',
title: '3. How We Use Your Information', title: '3. How & Why We Process Your Data',
content: `We use the information we collect to: content: `We process your information under the following legal standards and purposes:
• Operate and maintain the Marketplace, including processing purchases and payouts. To Deliver Our Marketplace Services
• Create and manage your account and authenticate your identity. • Creating and managing your account.
• Send transactional emails such as purchase confirmations, account verification, and password resets. • Delivering purchased plugins and verifying license keys.
• Respond to support requests, disputes, and inquiries. • Enabling developer payouts and sales management.
• Detect, investigate, and prevent fraudulent transactions, abuse, and violations of our Terms of Service. • Sending necessary transactional emails (email confirmations, password resets, security alerts).
• Improve the Service through analytics and usage research.
• Send optional product updates and newsletters — you may opt out at any time.
• Comply with legal obligations and enforce our agreements.
We do not use your data for automated decision-making or profiling that produces significant legal effects.` To Ensure Security & Prevent Abuse
}, • Defending against spam, bots, and brute force attacks (via Cloudflare Turnstile).
{ • Preventing fraudulent payments and chargebacks.
id: 'sharing', • Resolving customer support requests and enforcing marketplace rules.
title: '4. How We Share Your Information',
content: `We do not sell, trade, or rent your personal information. We may share your information only in the following limited circumstances:
Service Providers
We work with trusted third-party vendors to help operate the Service, including payment processors, email delivery services, and hosting providers. These parties are contractually obligated to protect your information and may only use it to perform services on our behalf.
Sellers & Buyers
When you purchase a Plugin, your username and order details are shared with the selling developer to facilitate support. Your email address is not shared with sellers without your consent.
Legal Requirements
We may disclose your information if required to do so by law, subpoena, or other legal process, or if we believe in good faith that disclosure is necessary to protect our rights, protect your safety or the safety of others, or investigate fraud.
Business Transfers
If PumpkinMC is acquired by or merged with another company, your information may be transferred as part of that transaction. We will notify you before your information becomes subject to a materially different privacy policy.
With Your Consent With Your Consent
We may share your information in other ways with your explicit consent.` • Anonymous aggregated analytics to understand popular plugins and site traffic (Google Analytics 4 with IP anonymization).
• Optional product announcements. You may change your preferences or opt out at any time.
Compliance with Legal Obligations
• Retaining transaction records and invoices as required by financial, commercial, and tax laws.`
}, },
{ {
id: 'data-retention', id: 'service-providers',
title: '5. Data Retention', title: '4. Third-Party Service Providers',
content: `We retain your personal information for as long as your account is active or as needed to provide the Service. Specifically: content: `We work with trusted third-party providers who process data on our behalf under strict data protection agreements:
• Account data is retained for the lifetime of your account and for up to 90 days after account deletion to allow for dispute resolution. Stripe (Payments & Payouts)
• Transaction records are retained for up to 7 years to comply with financial and tax regulations. Processes payments and creator payout onboarding securely under PCI-DSS Level 1 compliance.
• Support communications are retained for up to 3 years.
• Usage and analytics data is retained in aggregated, anonymized form indefinitely.
You may request deletion of your account and associated personal data at any time by contacting privacy@pumpkinmc.org. Note that we may be unable to delete information that we are legally required to retain.` Cloudflare (Security & CDN)
Provides fast content delivery, DDoS protection, and privacy-preserving bot detection (Turnstile).
Google Analytics (Analytics — Consent Only)
Collects aggregated visit metrics only when you choose to enable analytics cookies. IP anonymization is enforced.
Email Delivery Service
Transmits essential account notices, security updates, and verification emails.
We never sell, rent, or trade your personal data with third-party advertisers or data brokers.`
}, },
{ {
id: 'security', id: 'international-transfers',
title: '6. Data Security', title: '5. International Transfers',
content: `We implement industry-standard security measures to protect your information, including: content: `PumpkinMC operates globally. When data is transferred across international borders, we ensure appropriate safeguards are in place — including Standard Contractual Clauses (SCCs) and verified compliance frameworks — to ensure your data receives strong and consistent protection.`
},
{
id: 'retention',
title: '6. Data Retention',
content: `We keep your personal information only for as long as needed to fulfill the purposes outlined in this policy:
• Passwords are stored using strong one-way hashing (bcrypt). • Active Accounts: Retained for the lifetime of your account.
• All data transmitted between your browser and our servers is encrypted using TLS. • Deleted Accounts: Removed immediately upon account deletion; backup archives clear within 30 days.
• Payment processing is handled by PCI-DSS compliant third-party processors — we do not store raw card numbers on our servers. • Invoices & Financial Records: Retained for up to 7 to 10 years to comply with statutory accounting and tax regulations.
• Access to personal data is restricted to authorized personnel who require it to perform their job functions. • Analytics Logs: Stored in aggregated, anonymized form without personal identifiers.`
• We conduct regular security reviews and promptly address identified vulnerabilities.
Despite these measures, no method of transmission over the internet is 100% secure. We cannot guarantee absolute security, and you use the Service at your own risk. If you believe your account has been compromised, contact support@pumpkinmc.org immediately.`
}, },
{ {
id: 'cookies', id: 'cookies',
title: '7. Cookies & Tracking', title: '7. Cookies & Local Storage',
content: `We use the following types of cookies and tracking technologies: content: `We use cookies and browser storage responsibly:
Essential Cookies Strictly Necessary Storage (Always Active)
Required for the Service to function. These include session cookies that keep you logged in and security tokens that protect against cross-site request forgery. You cannot opt out of these without disabling the Service. Essential for session authentication, security verification, and remembering your language or currency. The marketplace cannot function without these.
Analytics Cookies Analytics Cookies (Optional)
We use privacy-respecting analytics tools to understand how users interact with the Service — pages visited, session duration, and feature usage. This data is aggregated and anonymized where possible. Help us understand how users interact with the site so we can improve performance and feature discovery. These are turned off by default and only activate if you consent.
Preference Cookies You can customize or change your cookie choices at any time using the preferences button below or from the site footer.`,
These remember your settings and preferences, such as your display language or UI preferences, to personalize your experience. hasCookieButton: true,
You can control cookies through your browser settings. Disabling certain cookies may affect the functionality of the Service. We do not use advertising or third-party tracking cookies.`
},
{
id: 'children',
title: '8. Children\'s Privacy',
content: `The Service is not directed at children under the age of 13. We do not knowingly collect personal information from children under 13. If we become aware that we have collected personal data from a child under 13 without verifiable parental consent, we will take steps to delete that information as quickly as possible.
If you are a parent or guardian and believe your child has provided us with personal information without your consent, please contact us at privacy@pumpkinmc.org.`
}, },
{ {
id: 'rights', id: 'rights',
title: '9. Your Rights & Choices', title: '8. Your Rights & Choices',
content: `Depending on your location, you may have the following rights with respect to your personal information: content: `Regardless of your location, you have strong controls over your personal data:
Access & Portability • Access & Portability: You can request and download a full machine-readable JSON export of your account data at any time from your Account Settings.
You may request a copy of the personal information we hold about you and, where technically feasible, receive it in a structured, machine-readable format. • Correction & Updates: You can update your email, username, country, and developer profile directly from your settings.
• Account Deletion: You can permanently delete your account and all associated personal data anytime in the Danger Zone settings tab.
• Manage Consent: You can update your cookie preferences or opt out of analytics tracking at any time with immediate effect.
• Inquiries & Concerns: You have the right to contact us or lodge a complaint with your local data protection supervisory authority if you believe your data has been mishandled.
Correction To exercise any of your rights, use the tools in your Account Settings or contact us at privacy@pumpkinmc.org.`
You may update or correct inaccurate account information at any time through your account settings or by contacting us.
Deletion
You may request deletion of your personal data, subject to our legal retention obligations described in Section 5.
Opt-Out of Marketing
You may unsubscribe from marketing emails at any time using the unsubscribe link in any marketing email or by contacting us. You will continue to receive transactional emails necessary to operate your account.
Restriction & Objection
In certain circumstances, you may request that we restrict or stop processing your personal data.
To exercise any of these rights, contact privacy@pumpkinmc.org. We will respond within 30 days. We may need to verify your identity before processing your request.`
}, },
{ {
id: 'third-party', id: 'security',
title: '10. Third-Party Links & Services', title: '9. Data Security',
content: `The Service may contain links to third-party websites, plugins, or services that are not owned or controlled by PumpkinMC. This Privacy Policy applies only to the PumpkinMC Service. content: `We implement robust security measures to protect your information:
We have no control over and assume no responsibility for the privacy practices of any third-party sites or services. We encourage you to review the privacy policy of every site you visit or service you use. • Strong one-way password hashing using bcrypt.
• End-to-end encrypted web traffic using TLS 1.3 encryption.
Plugin developers are independent third parties. PumpkinMC is not responsible for data collection or privacy practices embedded within third-party Plugins you download. Review each Plugin's documentation before use.` • Two-factor authentication (TOTP) support for all accounts.
• Strict role-based access controls limiting internal data access to authorized systems only.`
}, },
{ {
id: 'international', id: 'children',
title: '11. International Transfers', title: '10. Children\'s Privacy',
content: `PumpkinMC operates globally and your information may be transferred to and processed in countries other than the country in which you reside. These countries may have data protection laws that are different from the laws of your country. content: `The Service is not intended for children under 16 years of age. We do not knowingly collect personal data from minors. If you believe a minor has registered an account without appropriate parental consent, please contact us at privacy@pumpkinmc.org and we will promptly remove the account.`
Where required, we use appropriate safeguards — such as standard contractual clauses — to ensure that your information receives an adequate level of protection wherever it is processed.`
},
{
id: 'changes',
title: '12. Changes to This Policy',
content: `We may update this Privacy Policy from time to time. When we do, we will revise the "Last updated" date at the top of this page. For material changes, we will provide more prominent notice, such as an email notification to registered users or a banner on the Service.
We encourage you to review this Privacy Policy periodically to stay informed about how we are protecting your information. Your continued use of the Service after any changes constitutes your acceptance of the updated policy.`
}, },
{ {
id: 'contact', id: 'contact',
title: '13. Contact Us', title: '11. Contact Us',
content: `If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please reach out to us: content: `If you have any questions or requests regarding this Privacy Policy or your data, please contact our team:
Email: privacy@pumpkinmc.org Email: privacy@pumpkinmc.org
Support: support@pumpkinmc.org Support: support@pumpkinmc.org
Discord: discord.gg/pumpkinmc Discord: https://discord.gg/pumpkinmc
Website: pumpkinmc.org Website: https://pumpkinmc.org
We take privacy seriously and will respond to all inquiries within 5 business days.` We respond to all privacy inquiries within 5 business days.`
} }
]; ];
@ -180,12 +163,10 @@ const PrivacyPolicyPage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
background: #0f1117; background: #0f1117;
} }
@ -197,53 +178,6 @@ const PrivacyPolicyPage = () => {
color: #c8cdd8; color: #c8cdd8;
} }
/* ── Nav ── */
.pp-nav {
position: sticky;
top: 0;
z-index: 50;
background: rgba(15, 17, 23, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 107, 0, 0.1);
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
}
.nav-brand-icon { font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,107,0,0.5)); }
.nav-brand-text {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.3px;
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-back {
font-size: 13px;
font-weight: 600;
color: #4a5568;
text-decoration: none;
display: flex;
align-items: center;
gap: 5px;
transition: color 0.15s;
}
.nav-back:hover { color: #ff6b00; }
/* ── Hero ── */ /* ── Hero ── */
.pp-hero { .pp-hero {
text-align: center; text-align: center;
@ -285,9 +219,9 @@ const PrivacyPolicyPage = () => {
.pp-subtitle { .pp-subtitle {
font-size: 15px; font-size: 15px;
color: #4a5568; color: #8c9bb4;
font-weight: 600; font-weight: 600;
max-width: 520px; max-width: 540px;
margin: 0 auto; margin: 0 auto;
line-height: 1.6; line-height: 1.6;
} }
@ -295,34 +229,15 @@ const PrivacyPolicyPage = () => {
.pp-date { .pp-date {
margin-top: 16px; margin-top: 16px;
font-size: 12.5px; font-size: 12.5px;
color: #3d4a5e; color: #6b7a94;
font-weight: 600; font-weight: 600;
} }
/* ── Highlight banner ── */
.pp-highlight {
max-width: 700px;
margin: 32px auto 0;
background: rgba(255, 107, 0, 0.06);
border: 1px solid rgba(255, 107, 0, 0.18);
border-radius: 10px;
padding: 14px 20px;
font-size: 13.5px;
font-weight: 600;
color: #ff8c00;
display: flex;
align-items: center;
gap: 10px;
line-height: 1.5;
}
.pp-highlight-icon { font-size: 18px; flex-shrink: 0; }
/* ── Layout ── */ /* ── Layout ── */
.pp-layout { .pp-layout {
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: 260px 1fr;
max-width: 1100px; max-width: 1120px;
margin: 0 auto; margin: 0 auto;
padding: 40px 24px 80px; padding: 40px 24px 80px;
gap: 40px; gap: 40px;
@ -343,7 +258,7 @@ const PrivacyPolicyPage = () => {
.sidebar-label { .sidebar-label {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: #3d4a5e; color: #6b7a94;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 10px; margin-bottom: 10px;
@ -355,7 +270,7 @@ const PrivacyPolicyPage = () => {
.sidebar-link { .sidebar-link {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #3d4a5e; color: #8c9bb4;
text-decoration: none; text-decoration: none;
padding: 7px 10px; padding: 7px 10px;
border-radius: 6px; border-radius: 6px;
@ -365,7 +280,7 @@ const PrivacyPolicyPage = () => {
} }
.sidebar-link:hover { .sidebar-link:hover {
color: #c8cdd8; color: #ffffff;
background: rgba(255, 107, 0, 0.06); background: rgba(255, 107, 0, 0.06);
border-left-color: rgba(255, 107, 0, 0.3); border-left-color: rgba(255, 107, 0, 0.3);
} }
@ -387,14 +302,13 @@ const PrivacyPolicyPage = () => {
margin-bottom: 16px; margin-bottom: 16px;
transition: border-color 0.2s; transition: border-color 0.2s;
scroll-margin-top: 100px; scroll-margin-top: 100px;
cursor: default;
} }
.pp-section:hover { border-color: rgba(255, 107, 0, 0.2); } .pp-section:hover { border-color: rgba(255, 107, 0, 0.2); }
.pp-section.active { border-color: rgba(255, 107, 0, 0.35); } .pp-section.active { border-color: rgba(255, 107, 0, 0.35); }
.section-title { .section-title {
font-size: 16px; font-size: 17px;
font-weight: 800; font-weight: 800;
color: #e8eaf0; color: #e8eaf0;
margin-bottom: 14px; margin-bottom: 14px;
@ -417,15 +331,14 @@ const PrivacyPolicyPage = () => {
.section-body { .section-body {
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
color: #6b7a94; color: #9aa5b8;
font-weight: 500; font-weight: 500;
white-space: pre-line; white-space: pre-line;
} }
/* Sub-headings within body */
.section-body strong { .section-body strong {
display: block; display: block;
color: #c8cdd8; color: #e2e8f0;
font-weight: 700; font-weight: 700;
margin-top: 14px; margin-top: 14px;
margin-bottom: 2px; margin-bottom: 2px;
@ -433,6 +346,28 @@ const PrivacyPolicyPage = () => {
.section-body strong:first-child { margin-top: 0; } .section-body strong:first-child { margin-top: 0; }
.cookie-trigger-btn {
margin-top: 16px;
padding: 10px 18px;
background: rgba(255, 107, 0, 0.12);
border: 1px solid rgba(255, 107, 0, 0.3);
color: #ff8c00;
border-radius: 8px;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.15s ease;
}
.cookie-trigger-btn:hover {
background: #ff6b00;
color: #ffffff;
transform: translateY(-1px);
}
/* ── Footer ── */ /* ── Footer ── */
.pp-footer { .pp-footer {
border-top: 1px solid #1a2030; border-top: 1px solid #1a2030;
@ -442,7 +377,7 @@ const PrivacyPolicyPage = () => {
.pp-footer-text { .pp-footer-text {
font-size: 13px; font-size: 13px;
color: #3d4a5e; color: #8c9bb4;
font-weight: 600; font-weight: 600;
line-height: 1.7; line-height: 1.7;
} }
@ -467,7 +402,7 @@ const PrivacyPolicyPage = () => {
.pp-footer-link { .pp-footer-link {
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #3d4a5e; color: #8c9bb4;
text-decoration: none; text-decoration: none;
transition: color 0.15s; transition: color 0.15s;
} }
@ -485,20 +420,20 @@ const PrivacyPolicyPage = () => {
<div className="pp-page"> <div className="pp-page">
{/* Hero */} {/* Hero */}
<div className="pp-hero"> <div className="pp-hero">
<div className="pp-badge">🔒 Privacy</div> <div className="pp-badge">
<Shield size={13} /> Privacy Policy
</div>
<h1 className="pp-title"> <h1 className="pp-title">
Your Privacy,<br /><span>Our Commitment</span> Your Privacy,<br /><span>Our Commitment</span>
</h1> </h1>
<p className="pp-subtitle"> <p className="pp-subtitle">
We believe in being transparent about how we collect and use your data. Learn how we collect, use, and protect your personal information, and the choices you have regarding your data.
Here's everything you need to know.
</p> </p>
<p className="pp-date">Last updated: March 3, 2026 · Effective immediately</p> <p className="pp-date">Last updated: August 2026 · Effective immediately</p>
</div> </div>
{/* Layout */} {/* Layout */}
<div className="pp-layout"> <div className="pp-layout">
{/* Sidebar */} {/* Sidebar */}
<aside className="pp-sidebar"> <aside className="pp-sidebar">
<p className="sidebar-label">On this page</p> <p className="sidebar-label">On this page</p>
@ -528,13 +463,23 @@ const PrivacyPolicyPage = () => {
<h2 className="section-title">{s.title}</h2> <h2 className="section-title">{s.title}</h2>
<div className="section-body"> <div className="section-body">
{s.content.split('\n').map((line, i) => { {s.content.split('\n').map((line, i) => {
// Render sub-headings (lines not starting with bullet and followed by blank line pattern)
const isSubheading = line.length > 0 && !line.startsWith('•') && !line.startsWith(' ') && i > 0 && s.content.split('\n')[i - 1] === ''; const isSubheading = line.length > 0 && !line.startsWith('•') && !line.startsWith(' ') && i > 0 && s.content.split('\n')[i - 1] === '';
return isSubheading return isSubheading
? <strong key={i}>{line}</strong> ? <strong key={i}>{line}</strong>
: <span key={i}>{line}{'\n'}</span>; : <span key={i}>{line}{'\n'}</span>;
})} })}
</div> </div>
{s.hasCookieButton && (
<button
type="button"
className="cookie-trigger-btn"
onClick={openCookiePreferencesModal}
>
<Sliders size={15} />
Manage Cookie Preferences
</button>
)}
</section> </section>
))} ))}
</main> </main>
@ -543,20 +488,37 @@ const PrivacyPolicyPage = () => {
{/* Footer */} {/* Footer */}
<footer className="pp-footer"> <footer className="pp-footer">
<p className="pp-footer-text"> <p className="pp-footer-text">
Questions about your privacy? Contact us at{' '} Questions about your privacy or data? Reach us anytime at{' '}
<a href="mailto:privacy@pumpkinmc.org">privacy@pumpkinmc.org</a> <a href="mailto:privacy@pumpkinmc.org">privacy@pumpkinmc.org</a>
</p> </p>
<div className="pp-footer-links"> <div className="pp-footer-links">
<Link to="/terms" className="pp-footer-link">Terms of Service</Link> <Link to="/terms" className="pp-footer-link">Terms of Service</Link>
<span className="pp-footer-sep" /> <span className="pp-footer-sep" />
<Link to="/marketplace" className="pp-footer-link">Marketplace</Link> <Link to="/" className="pp-footer-link">Marketplace</Link>
<span className="pp-footer-sep" /> <span className="pp-footer-sep" />
<a href="https://discord.gg/pumpkinmc" className="pp-footer-link">Discord</a> <button
type="button"
onClick={openCookiePreferencesModal}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontFamily: 'inherit',
fontSize: '13px',
fontWeight: 700,
color: '#8c9bb4',
transition: 'color 0.15s'
}}
onMouseEnter={e => (e.currentTarget.style.color = '#ff6b00')}
onMouseLeave={e => (e.currentTarget.style.color = '#8c9bb4')}
>
Cookie Preferences
</button>
<span className="pp-footer-sep" /> <span className="pp-footer-sep" />
<Link to="/register" className="pp-footer-link">Create Account</Link> <Link to="/register" className="pp-footer-link">Create Account</Link>
</div> </div>
</footer> </footer>
</div> </div>
</> </>
); );

View File

@ -65,14 +65,29 @@ In the event of a disputed charge, please contact support@pumpkinmc.org before i
}, },
{ {
id: 'refunds', id: 'refunds',
title: '7. Refunds & Disputes', title: '7. Digital Withdrawal Waiver & Refund Policy',
content: `Due to the digital nature of Plugins, all sales are generally non-refundable. Refunds may be issued at PumpkinMC's sole discretion in the following circumstances: content: `Digital Content & Right of Withdrawal Waiver:
Due to the digital nature of software downloads, when you purchase and download a Plugin, you expressly consent to immediate performance of the contract and acknowledge that you lose your statutory 14-day right of withdrawal under EU Consumer Rights Directive (2011/83/EU) once the download or key generation commences.
• The Plugin does not function as described and the seller has not resolved the issue within 72 hours of a support request. When Refunds Are Granted:
• The Plugin contains malicious code or functionality that harms your server or players. We want all buyers to have a safe and fair experience. Refunds may be approved under the following conditions:
• A duplicate purchase was made in error within 24 hours. • Technical Defect: The Plugin has a verifiable critical defect on supported server software, and the developer fails to provide a working fix or update within 5 business days of your support request.
• Material Misrepresentation: The Plugin is missing core advertised features explicitly listed on its store page.
• Accidental Duplicate Purchase: You accidentally purchased the identical Plugin twice within 24 hours.
• Security & Policy Violation: The Plugin is found to contain unauthorized backdoors, malware, or violates our safety policies.
To request a refund, contact support@pumpkinmc.org with your order details and a description of the issue. PumpkinMC will review all refund requests and respond within 5 business days.` Non-Refundable Circumstances:
• Change of Mind ("Buyer's Remorse"): Deciding you no longer need the Plugin or purchased by mistake after downloading.
• Incompatibility with Unsupported Versions: Attempting to run the Plugin on an incompatible server engine or Minecraft version clearly marked unsupported on the store listing.
• Configuration / Administration Issues: Inability or lack of technical knowledge to configure YAML/JSON files or server environments.
Dispute & Refund Workflow:
1. Contact the Developer: Reach out directly to the developer using their support email or Discord listed on the Plugin page.
2. 5-Day Resolution Window: Give the developer 5 business days to assist or resolve the defect.
3. Escalate to PumpkinMarket: If the developer is unresponsive or unable to resolve the verified defect, forward your proof of purchase and communication to support@pumpkinmc.org.
License Revocation:
Upon any approved refund or chargeback, the associated cryptographic license key is immediately revoked, and download access from your Library is disabled.`
}, },
{ {
id: 'sellers', id: 'sellers',
@ -150,8 +165,6 @@ const TermsOfServicePage = () => {
return ( return (
<> <>
<style>{` <style>{`
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
@ -427,7 +440,7 @@ const TermsOfServicePage = () => {
{/* Hero */} {/* Hero */}
<div className="tos-hero"> <div className="tos-hero">
<div className="tos-badge">📜 Legal</div> <div className="tos-badge">Legal</div>
<h1 className="tos-title"> <h1 className="tos-title">
Plugin Marketplace<br /><span>Terms of Service</span> Plugin Marketplace<br /><span>Terms of Service</span>
</h1> </h1>
@ -481,7 +494,7 @@ const TermsOfServicePage = () => {
or join our <a href="https://discord.gg/pumpkinmc">Discord server</a>. or join our <a href="https://discord.gg/pumpkinmc">Discord server</a>.
</p> </p>
<Link to="/register" className="accept-btn"> <Link to="/register" className="accept-btn">
🎃 Create an account Create an account
</Link> </Link>
</footer> </footer>

122
src/utils/consent.ts Normal file
View File

@ -0,0 +1,122 @@
/**
* GDPR & ePrivacy Compliant Cookie and Analytics Consent Manager
*/
export interface CookieConsent {
necessary: true;
analytics: boolean;
timestamp: string;
version: number;
}
const CONSENT_STORAGE_KEY = 'pumpkin_cookie_consent';
const CONSENT_VERSION = 1;
const GA_MEASUREMENT_ID = 'G-QK7NXQQ2ZP';
// Ensure dataLayer & gtag are initialized early
declare global {
interface Window {
dataLayer: any[];
gtag?: (...args: any[]) => void;
}
}
export function getStoredConsent(): CookieConsent | null {
try {
const raw = localStorage.getItem(CONSENT_STORAGE_KEY);
if (!raw) return null;
const parsed: CookieConsent = JSON.parse(raw);
if (parsed.version === CONSENT_VERSION && typeof parsed.analytics === 'boolean') {
return parsed;
}
return null;
} catch {
return null;
}
}
export function hasConsentDecided(): boolean {
return getStoredConsent() !== null;
}
export function saveConsent(analyticsAllowed: boolean): CookieConsent {
const consent: CookieConsent = {
necessary: true,
analytics: analyticsAllowed,
timestamp: new Date().toISOString(),
version: CONSENT_VERSION,
};
try {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(consent));
} catch (e) {
console.error('Failed to store cookie consent', e);
}
applyConsent(consent);
window.dispatchEvent(new CustomEvent('cookie-consent-updated', { detail: consent }));
return consent;
}
export function deleteAnalyticsCookies() {
const cookies = document.cookie.split(';');
const domain = window.location.hostname;
const domainParts = domain.split('.');
const rootDomain = domainParts.length > 1 ? '.' + domainParts.slice(-2).join('.') : domain;
for (const cookie of cookies) {
const cookieName = cookie.split('=')[0].trim();
if (cookieName === '_ga' || cookieName.startsWith('_ga_') || cookieName === '_gid' || cookieName === '_gat') {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain};`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${rootDomain};`;
}
}
}
export function applyConsent(consent: CookieConsent | null) {
window.dataLayer = window.dataLayer || [];
if (!window.gtag) {
window.gtag = function () {
window.dataLayer.push(arguments);
};
}
if (consent && consent.analytics) {
// Grant analytics storage and configure IP anonymization
window.gtag('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
// Ensure GA script tag is loaded
if (!document.getElementById('ga-script')) {
const script = document.createElement('script');
script.id = 'ga-script';
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
document.head.appendChild(script);
window.gtag('js', new Date());
window.gtag('config', GA_MEASUREMENT_ID, {
anonymize_ip: true,
send_page_view: true,
});
}
} else {
// Deny analytics storage and clean up any tracking cookies
window.gtag('consent', 'update', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
deleteAnalyticsCookies();
}
}
export function openCookiePreferencesModal() {
window.dispatchEvent(new CustomEvent('open-cookie-preferences'));
}

343
src/utils/fileValidation.ts Normal file
View File

@ -0,0 +1,343 @@
/**
* Industry-standard file validation and sanitization for WebAssembly plugins and images.
* Provides instant client-side verification on drop/selection with magic byte inspection,
* WebAssembly specification bytecode validation, and canvas-based image sanitization.
*/
export interface WasmValidationResult {
valid: boolean;
error?: string;
sizeBytes: number;
wasmType?: 'module' | 'component';
details?: {
isModuleValid: boolean;
magicValid: boolean;
format: 'Core Module (v1)' | 'Component Model (WIT / Preview 2)';
};
}
export interface ImageValidationResult {
valid: boolean;
error?: string;
file?: File;
previewUrl?: string;
width?: number;
height?: number;
}
export const MAX_WASM_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export const MAX_RAW_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB max raw upload before compression
/**
* Reads an unsigned LEB128 integer from Uint8Array at given offset.
*/
function readLeb128U32(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } | null {
let result = 0;
let shift = 0;
let bytesRead = 0;
while (offset + bytesRead < bytes.length) {
const byte = bytes[offset + bytesRead];
bytesRead++;
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
return { value: result >>> 0, bytesRead };
}
shift += 7;
if (shift > 35) return null; // Overflow protection
}
return null;
}
/**
* Validates the section stream of a WebAssembly Component Model binary.
*/
function validateComponentSections(bytes: Uint8Array): { valid: boolean; error?: string; sectionCount: number } {
let offset = 8;
let count = 0;
while (offset < bytes.length) {
const sectionId = bytes[offset];
offset += 1;
// Standard component section IDs are 0 (custom) through 13
if (sectionId > 13) {
return { valid: false, error: `Invalid component section ID ${sectionId} at byte offset 0x${(offset - 1).toString(16)}.`, sectionCount: count };
}
const lenInfo = readLeb128U32(bytes, offset);
if (!lenInfo) {
return { valid: false, error: `Corrupted LEB128 section length at byte offset 0x${offset.toString(16)}.`, sectionCount: count };
}
offset += lenInfo.bytesRead;
if (offset + lenInfo.value > bytes.length) {
return { valid: false, error: `Section length (${lenInfo.value} bytes) exceeds file size at offset 0x${offset.toString(16)}.`, sectionCount: count };
}
offset += lenInfo.value;
count++;
}
return { valid: true, sectionCount: count };
}
/**
* Validates a WebAssembly (.wasm) file immediately upon selection or drag-and-drop.
* Fully supports both Core WebAssembly Modules (v1) and WebAssembly Component Model
* binaries (WIT / cargo-component / Preview 2).
*/
export async function validateWasmFile(file: File): Promise<WasmValidationResult> {
if (!file) {
return { valid: false, error: 'No file selected.', sizeBytes: 0 };
}
if (!file.name.toLowerCase().endsWith('.wasm')) {
return { valid: false, error: 'File must have a .wasm extension.', sizeBytes: file.size };
}
if (file.size > MAX_WASM_SIZE_BYTES) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(2);
return {
valid: false,
error: `Plugin binary exceeds the 5 MB limit (${sizeMb} MB). Please optimize or split your plugin.`,
sizeBytes: file.size,
};
}
if (file.size < 8) {
return {
valid: false,
error: 'File is too small to be a valid WebAssembly module or component.',
sizeBytes: file.size,
};
}
try {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
// 1. Verify WASM Magic number: \0asm (0x00, 0x61, 0x73, 0x6D)
const isMagicValid =
bytes[0] === 0x00 &&
bytes[1] === 0x61 &&
bytes[2] === 0x73 &&
bytes[3] === 0x6D;
if (!isMagicValid) {
return {
valid: false,
error: 'Invalid file format: missing standard WebAssembly header (\\0asm).',
sizeBytes: file.size,
};
}
// 2. Identify WebAssembly Binary Type:
// Core Module: [0x01, 0x00, 0x00, 0x00]
// Component Model: [0x0d, 0x00, 0x01, 0x00] (v1/preview 2), [0x0a, 0x00, 0x01, 0x00] (preview 1), or layer 0x0001 (bytes[6]=1, bytes[7]=0)
const isCoreModule =
bytes[4] === 0x01 &&
bytes[5] === 0x00 &&
bytes[6] === 0x00 &&
bytes[7] === 0x00;
const isComponentModel =
(bytes[6] === 0x01 && bytes[7] === 0x00) ||
(bytes[4] === 0x0d && bytes[5] === 0x00 && bytes[6] === 0x01 && bytes[7] === 0x00) ||
(bytes[4] === 0x0a && bytes[5] === 0x00 && bytes[6] === 0x01 && bytes[7] === 0x00);
if (!isCoreModule && !isComponentModel) {
return {
valid: false,
error: 'Unsupported WebAssembly version or format. Supported: Core WebAssembly v1 and WebAssembly Component Model (WIT).',
sizeBytes: file.size,
};
}
if (isCoreModule) {
// Bytecode verification for Core Modules via browser's built-in engine
if (typeof WebAssembly !== 'undefined' && typeof WebAssembly.validate === 'function') {
const isValid = WebAssembly.validate(buffer);
if (!isValid) {
return {
valid: false,
error: 'WebAssembly core bytecode validation failed: module structure or types are corrupted.',
sizeBytes: file.size,
};
}
}
return {
valid: true,
sizeBytes: file.size,
wasmType: 'module',
details: {
isModuleValid: true,
magicValid: true,
format: 'Core Module (v1)',
},
};
}
// WebAssembly Component Model verification
const componentCheck = validateComponentSections(bytes);
if (!componentCheck.valid) {
return {
valid: false,
error: componentCheck.error || 'Corrupted WebAssembly Component Model structure.',
sizeBytes: file.size,
};
}
return {
valid: true,
sizeBytes: file.size,
wasmType: 'component',
details: {
isModuleValid: true,
magicValid: true,
format: 'Component Model (WIT / Preview 2)',
},
};
} catch (err: any) {
return {
valid: false,
error: `Failed to inspect WebAssembly binary: ${err.message || 'Unknown error'}`,
sizeBytes: file.size,
};
}
}
/**
* Validates and sanitizes an image file (PNG, JPEG, WebP) upon selection.
* Inspects magic bytes to prevent masquerading payloads, verifies rendering with HTML Image decoder,
* and re-encodes through Canvas to strip all metadata/polyglot payloads.
*/
export async function validateAndSanitizeImage(
file: File,
options: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
minWidth?: number;
minHeight?: number;
} = {}
): Promise<ImageValidationResult> {
const {
maxWidth = 1920,
maxHeight = 1080,
quality = 0.82,
minWidth = 32,
minHeight = 32,
} = options;
if (!file) {
return { valid: false, error: 'No image file provided.' };
}
if (file.size > MAX_RAW_IMAGE_BYTES) {
return {
valid: false,
error: `Image file "${file.name}" is too large (max 20 MB original). Please resize it first.`,
};
}
try {
// 1. Read first 16 bytes to check magic headers
const headerBuffer = await file.slice(0, 16).arrayBuffer();
const header = new Uint8Array(headerBuffer);
const isPng =
header.length >= 8 &&
header[0] === 0x89 &&
header[1] === 0x50 &&
header[2] === 0x4e &&
header[3] === 0x47 &&
header[4] === 0x0d &&
header[5] === 0x0a &&
header[6] === 0x1a &&
header[7] === 0x0a;
const isJpeg =
header.length >= 3 &&
header[0] === 0xff &&
header[1] === 0xd8 &&
header[2] === 0xff;
const isWebp =
header.length >= 12 &&
header[0] === 0x52 &&
header[1] === 0x49 &&
header[2] === 0x46 &&
header[3] === 0x46 && // "RIFF"
header[8] === 0x57 &&
header[9] === 0x45 &&
header[10] === 0x42 &&
header[11] === 0x50; // "WEBP"
if (!isPng && !isJpeg && !isWebp) {
return {
valid: false,
error: `"${file.name}" is not a valid image. Only genuine PNG, JPEG, and WebP files are accepted.`,
};
}
// 2. Decode image in browser memory to verify visual rendering and dimensions
const objectUrl = URL.createObjectURL(file);
const img = new Image();
const loaded = await new Promise<{ width: number; height: number }>((resolve, reject) => {
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Corrupt or malformed image data.'));
img.src = objectUrl;
});
if (loaded.width < minWidth || loaded.height < minHeight) {
URL.revokeObjectURL(objectUrl);
return {
valid: false,
error: `Image dimensions (${loaded.width}x${loaded.height}) are too small. Minimum is ${minWidth}x${minHeight}px.`,
};
}
// 3. Canvas Sanitization & Compression:
// Rendering to an off-screen canvas strips EXIF, executable comments, and unwanted payloads.
const ratio = Math.min(1, maxWidth / loaded.width, maxHeight / loaded.height);
const targetWidth = Math.max(1, Math.round(loaded.width * ratio));
const targetHeight = Math.max(1, Math.round(loaded.height * ratio));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
URL.revokeObjectURL(objectUrl);
return { valid: false, error: 'Could not initialize image processing canvas.' };
}
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
URL.revokeObjectURL(objectUrl);
// Prefer WebP for compression efficiency, fallback to JPEG
const mimeType = isPng && quality === 1.0 ? 'image/png' : 'image/webp';
const blob: Blob | null = await new Promise(res => canvas.toBlob(res, mimeType, quality));
if (!blob) {
return { valid: false, error: 'Image encoding failed.' };
}
const sanitizedFile = new File([blob], file.name.replace(/\.[^.]+$/, mimeType === 'image/webp' ? '.webp' : '.png'), {
type: mimeType,
lastModified: Date.now(),
});
// Generate local preview data URL
const previewUrl = canvas.toDataURL(mimeType, quality);
return {
valid: true,
file: sanitizedFile,
previewUrl,
width: targetWidth,
height: targetHeight,
};
} catch (err: any) {
return {
valid: false,
error: `Could not process "${file.name}": ${err.message || 'Corrupted file'}`,
};
}
}