add Currency setting
This commit is contained in:
parent
93511af260
commit
407f07c045
8
.github/workflows/deploy-backend.yml
vendored
8
.github/workflows/deploy-backend.yml
vendored
@ -8,12 +8,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@ -31,4 +31,4 @@ jobs:
|
|||||||
source: "dist/*"
|
source: "dist/*"
|
||||||
target: "/var/www/market/frontend/"
|
target: "/var/www/market/frontend/"
|
||||||
strip_components: 1
|
strip_components: 1
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|||||||
58
package-lock.json
generated
58
package-lock.json
generated
@ -8,15 +8,15 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marsidev/react-turnstile": "^1.5.3",
|
"@marsidev/react-turnstile": "^1.5.4",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.19.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",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.28.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@ -24,21 +24,21 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-is": "^19.2.8",
|
"react-is": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"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.1.1",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.8.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5"
|
||||||
@ -572,9 +572,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@marsidev/react-turnstile": {
|
"node_modules/@marsidev/react-turnstile": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.4.tgz",
|
||||||
"integrity": "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A==",
|
"integrity": "sha512-2+ulBzQPYcC5jZ4Pghlcc6a6+CQ6L0cgTxtbavZbcHcG5/wSQsTAM+vDudF94L9AUPG4uSNQF1kotmvx0Ns/QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||||
@ -1056,9 +1056,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "26.1.1",
|
"version": "26.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1793,13 +1793,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.18.1",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.6",
|
||||||
"https-proxy-agent": "^5.0.1",
|
"https-proxy-agent": "^5.0.1",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
@ -2853,9 +2853,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "17.7.0",
|
"version": "17.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
|
||||||
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
|
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -3549,9 +3549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.27.0",
|
"version": "1.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||||
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@ -4805,9 +4805,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@ -4827,12 +4827,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
12
package.json
12
package.json
@ -11,15 +11,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marsidev/react-turnstile": "^1.5.3",
|
"@marsidev/react-turnstile": "^1.5.4",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.19.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",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.28.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@ -27,21 +27,21 @@
|
|||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-is": "^19.2.8",
|
"react-is": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"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.1.1",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.8.0",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.1.5"
|
"vite": "^8.1.5"
|
||||||
|
|||||||
@ -2,5 +2,6 @@ User-agent: *
|
|||||||
Allow: /
|
Allow: /
|
||||||
Disallow: /admin
|
Disallow: /admin
|
||||||
Disallow: /dashboard
|
Disallow: /dashboard
|
||||||
|
Disallow: /staff
|
||||||
|
|
||||||
Sitemap: https://pumpkinmc.org/sitemap.xml
|
Sitemap: https://market.pumpkinmc.org/api/sitemap.xml
|
||||||
|
|||||||
45
src/App.tsx
45
src/App.tsx
@ -14,6 +14,7 @@ import ProfilePage from './pages/ProfilePage';
|
|||||||
import AuthorProfilePage from './pages/AuthorProfilePage';
|
import AuthorProfilePage from './pages/AuthorProfilePage';
|
||||||
import SearchResultsPage from './pages/SearchResultsPage';
|
import SearchResultsPage from './pages/SearchResultsPage';
|
||||||
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
||||||
|
import DashboardOverview from './pages/dashboard/DashboardOverview';
|
||||||
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
||||||
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
||||||
import AddPlugin from './pages/dashboard/plugin/AddPlugin';
|
import AddPlugin from './pages/dashboard/plugin/AddPlugin';
|
||||||
@ -24,6 +25,9 @@ import AdminPanel from './pages/admin/AdminPanel';
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
import api from './api';
|
import api from './api';
|
||||||
|
import { AnalyticsContext } from './pages/dashboard/AnalyticsContext';
|
||||||
|
import { useAnalytics } from './pages/dashboard/useAnalytics';
|
||||||
|
import { CurrencyProvider } from './context/CurrencyContext';
|
||||||
|
|
||||||
// --- Scroll To Top Helper ---
|
// --- Scroll To Top Helper ---
|
||||||
const ScrollToTop = () => {
|
const ScrollToTop = () => {
|
||||||
@ -94,7 +98,7 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
|
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
|
||||||
{!loading && children}
|
{!loading && <CurrencyProvider>{children}</CurrencyProvider>}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -140,7 +144,8 @@ const App = () => (
|
|||||||
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
||||||
<Route path="check-email" element={<CheckEmailPage />} />
|
<Route path="check-email" element={<CheckEmailPage />} />
|
||||||
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
<Route index element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
<Route path="overview" element={<DashboardOverview />} />
|
||||||
<Route path="audience" element={<DashboardAudience />} />
|
<Route path="audience" element={<DashboardAudience />} />
|
||||||
<Route path="plugins" element={<DashboardPlugins />} />
|
<Route path="plugins" element={<DashboardPlugins />} />
|
||||||
<Route path="add-plugin" element={<AddPlugin />} />
|
<Route path="add-plugin" element={<AddPlugin />} />
|
||||||
@ -148,6 +153,7 @@ const App = () => (
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="staff" element={<StaffRoute><AdminPanel /></StaffRoute>} />
|
<Route path="staff" element={<StaffRoute><AdminPanel /></StaffRoute>} />
|
||||||
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
@ -168,15 +174,23 @@ const MainLayout = () => {
|
|||||||
import './pages/dashboard/Dashboard.css';
|
import './pages/dashboard/Dashboard.css';
|
||||||
|
|
||||||
const DashboardLayout = () => {
|
const DashboardLayout = () => {
|
||||||
|
// Instantiate analytics once here and share via context — prevents each tab
|
||||||
|
// from making its own redundant API call.
|
||||||
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-container">
|
<AnalyticsContext.Provider value={analytics}>
|
||||||
<nav className="dashboard-nav">
|
<div className="dashboard-container">
|
||||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
<nav className="dashboard-nav">
|
||||||
</nav>
|
<NavLink to="/dashboard/overview">Overview</NavLink>
|
||||||
<div className="dashboard-content">
|
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||||
<Outlet />
|
<NavLink to="/dashboard/audience">Analytics</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="dashboard-content">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnalyticsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -219,7 +233,7 @@ const Navbar = ({ user }: any) => {
|
|||||||
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
|
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
|
||||||
{user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
|
{user && (user.is_developer || user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
|
||||||
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>
|
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>
|
||||||
)}
|
)}
|
||||||
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
||||||
@ -316,4 +330,15 @@ const Footer = () => (
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const NotFoundPage = () => (
|
||||||
|
<div className="container" style={{ textAlign: 'center', padding: '6rem 1rem' }}>
|
||||||
|
<div style={{ fontSize: '6rem', fontWeight: 900, color: 'var(--primary)', lineHeight: 1, marginBottom: '1rem' }}>404</div>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', fontWeight: 700, marginBottom: '0.75rem' }}>Page Not Found</h1>
|
||||||
|
<p style={{ color: 'var(--text-muted)', maxWidth: '400px', margin: '0 auto 2rem' }}>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-primary" style={{ display: 'inline-block', padding: '0.75rem 2rem' }}>Go Home</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
125
src/context/CurrencyContext.tsx
Normal file
125
src/context/CurrencyContext.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* CurrencyContext
|
||||||
|
*
|
||||||
|
* Provides `currency`, `setCurrency`, and `formatPrice(cents)` throughout the app.
|
||||||
|
* The preferred currency is read from the JWT claims (via AuthContext) so it's available
|
||||||
|
* immediately without an extra API call. When the user saves a new currency in settings,
|
||||||
|
* we call `login()` with the refreshed token — the context automatically picks up the change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo } from 'react';
|
||||||
|
import { useAuth } from '../../App';
|
||||||
|
|
||||||
|
// ISO 4217 currency metadata used for the settings select and price formatting.
|
||||||
|
export interface CurrencyMeta {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
symbol: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_CURRENCIES: CurrencyMeta[] = [
|
||||||
|
{ code: 'EUR', label: 'Euro', symbol: '€', locale: 'de-DE' },
|
||||||
|
{ code: 'USD', label: 'US Dollar', symbol: '$', locale: 'en-US' },
|
||||||
|
{ code: 'GBP', label: 'British Pound', symbol: '£', locale: 'en-GB' },
|
||||||
|
{ code: 'JPY', label: 'Japanese Yen', symbol: '¥', locale: 'ja-JP' },
|
||||||
|
{ code: 'CAD', label: 'Canadian Dollar', symbol: 'CA$',locale: 'en-CA' },
|
||||||
|
{ code: 'AUD', label: 'Australian Dollar', symbol: 'A$', locale: 'en-AU' },
|
||||||
|
{ code: 'CHF', label: 'Swiss Franc', symbol: 'Fr', locale: 'de-CH' },
|
||||||
|
{ code: 'CNY', label: 'Chinese Yuan', symbol: '¥', locale: 'zh-CN' },
|
||||||
|
{ code: 'SEK', label: 'Swedish Krona', symbol: 'kr', locale: 'sv-SE' },
|
||||||
|
{ code: 'NOK', label: 'Norwegian Krone', symbol: 'kr', locale: 'nb-NO' },
|
||||||
|
{ code: 'DKK', label: 'Danish Krone', symbol: 'kr', locale: 'da-DK' },
|
||||||
|
{ code: 'NZD', label: 'New Zealand Dollar', symbol: 'NZ$',locale: 'en-NZ' },
|
||||||
|
{ code: 'SGD', label: 'Singapore Dollar', symbol: 'S$', locale: 'en-SG' },
|
||||||
|
{ code: 'HKD', label: 'Hong Kong Dollar', symbol: 'HK$',locale: 'zh-HK' },
|
||||||
|
{ code: 'KRW', label: 'South Korean Won', symbol: '₩', locale: 'ko-KR' },
|
||||||
|
{ code: 'BRL', label: 'Brazilian Real', symbol: 'R$', locale: 'pt-BR' },
|
||||||
|
{ code: 'INR', label: 'Indian Rupee', symbol: '₹', locale: 'en-IN' },
|
||||||
|
{ code: 'MXN', label: 'Mexican Peso', symbol: 'MX$',locale: 'es-MX' },
|
||||||
|
{ code: 'PLN', label: 'Polish Złoty', symbol: 'zł', locale: 'pl-PL' },
|
||||||
|
{ code: 'CZK', label: 'Czech Koruna', symbol: 'Kč', locale: 'cs-CZ' },
|
||||||
|
{ code: 'HUF', label: 'Hungarian Forint', symbol: 'Ft', locale: 'hu-HU' },
|
||||||
|
{ code: 'RON', label: 'Romanian Leu', symbol: 'lei',locale: 'ro-RO' },
|
||||||
|
{ code: 'BGN', label: 'Bulgarian Lev', symbol: 'лв', locale: 'bg-BG' },
|
||||||
|
{ code: 'TRY', label: 'Turkish Lira', symbol: '₺', locale: 'tr-TR' },
|
||||||
|
{ code: 'ZAR', label: 'South African Rand', symbol: 'R', locale: 'en-ZA' },
|
||||||
|
{ code: 'ILS', label: 'Israeli Shekel', symbol: '₪', locale: 'he-IL' },
|
||||||
|
{ code: 'AED', label: 'UAE Dirham', symbol: 'د.إ',locale: 'ar-AE' },
|
||||||
|
{ code: 'SAR', label: 'Saudi Riyal', symbol: '﷼', locale: 'ar-SA' },
|
||||||
|
{ code: 'THB', label: 'Thai Baht', symbol: '฿', locale: 'th-TH' },
|
||||||
|
{ code: 'MYR', label: 'Malaysian Ringgit', symbol: 'RM', locale: 'ms-MY' },
|
||||||
|
{ code: 'IDR', label: 'Indonesian Rupiah', symbol: 'Rp', locale: 'id-ID' },
|
||||||
|
{ code: 'PHP', label: 'Philippine Peso', symbol: '₱', locale: 'fil-PH'},
|
||||||
|
{ code: 'VND', label: 'Vietnamese Dong', symbol: '₫', locale: 'vi-VN' },
|
||||||
|
{ code: 'NGN', label: 'Nigerian Naira', symbol: '₦', locale: 'en-NG' },
|
||||||
|
{ code: 'EGP', label: 'Egyptian Pound', symbol: '£', locale: 'ar-EG' },
|
||||||
|
{ code: 'UAH', label: 'Ukrainian Hryvnia', symbol: '₴', locale: 'uk-UA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximate EUR → other currency exchange rates.
|
||||||
|
* These are static approximations for display purposes only — actual charges are
|
||||||
|
* always processed in EUR. A production app would fetch live rates from an API.
|
||||||
|
*/
|
||||||
|
const EUR_RATES: Record<string, number> = {
|
||||||
|
EUR: 1, USD: 1.08, GBP: 0.86, JPY: 163, CAD: 1.47,
|
||||||
|
AUD: 1.65, CHF: 0.96, CNY: 7.82, SEK: 11.5, NOK: 11.4,
|
||||||
|
DKK: 7.46, NZD: 1.79, SGD: 1.45, HKD: 8.44, KRW: 1455,
|
||||||
|
BRL: 5.4, INR: 89.8, MXN: 18.5, PLN: 4.3, CZK: 25.3,
|
||||||
|
HUF: 390, RON: 4.97, BGN: 1.96, TRY: 34.6, ZAR: 20.2,
|
||||||
|
ILS: 4.01, AED: 3.97, SAR: 4.05, THB: 37.7, MYR: 5.05,
|
||||||
|
IDR: 17200, PHP: 62.1, VND: 26900, NGN: 1780, EGP: 52.7,
|
||||||
|
UAH: 44.7,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CurrencyContextValue {
|
||||||
|
currency: string;
|
||||||
|
currencyMeta: CurrencyMeta;
|
||||||
|
/** Format EUR cents into the user's preferred display currency */
|
||||||
|
formatPrice: (eurCents: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrencyContext = createContext<CurrencyContextValue | null>(null);
|
||||||
|
|
||||||
|
/** Hook to access the currency context. Falls back to EUR if used outside the provider. */
|
||||||
|
export const useCurrency = (): CurrencyContextValue => {
|
||||||
|
const ctx = useContext(CurrencyContext);
|
||||||
|
if (ctx) return ctx;
|
||||||
|
// Fallback so hooks in non-provider-wrapped code don't crash during init.
|
||||||
|
return {
|
||||||
|
currency: 'EUR',
|
||||||
|
currencyMeta: SUPPORTED_CURRENCIES[0],
|
||||||
|
formatPrice: (cents) =>
|
||||||
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(cents / 100),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drop this provider at the root of your app (inside AuthContext) */
|
||||||
|
export const CurrencyProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const value = useMemo<CurrencyContextValue>(() => {
|
||||||
|
const code = (user as any)?.preferred_currency || 'EUR';
|
||||||
|
const meta = SUPPORTED_CURRENCIES.find(c => c.code === code) ?? SUPPORTED_CURRENCIES[0];
|
||||||
|
const rate = EUR_RATES[code] ?? 1;
|
||||||
|
|
||||||
|
const formatPrice = (eurCents: number): string => {
|
||||||
|
if (eurCents === 0) return 'Free';
|
||||||
|
const amount = (eurCents / 100) * rate;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(meta.locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: meta.code,
|
||||||
|
maximumFractionDigits: meta.code === 'JPY' || meta.code === 'KRW' || meta.code === 'IDR' || meta.code === 'VND' ? 0 : 2,
|
||||||
|
}).format(amount);
|
||||||
|
} catch {
|
||||||
|
return `${meta.symbol}${amount.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { currency: code, currencyMeta: meta, formatPrice };
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return <CurrencyContext.Provider value={value}>{children}</CurrencyContext.Provider>;
|
||||||
|
};
|
||||||
@ -15,16 +15,15 @@ const AuthorProfilePage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Fetch author public info
|
// Fetch author info and plugins in parallel — saves one full round-trip.
|
||||||
api.get(`/user/public/${username}`)
|
Promise.all([
|
||||||
.then(res => {
|
api.get(`/user/public/${username}`),
|
||||||
setAuthor(res.data);
|
api.get('/plugins', { params: { dev_name: username, sort: 'newest' } }),
|
||||||
// Fetch author plugins
|
])
|
||||||
return api.get('/plugins', { params: { dev_name: username, sort: 'newest' } });
|
.then(([authorRes, pluginsRes]) => {
|
||||||
})
|
setAuthor(authorRes.data);
|
||||||
.then(res => {
|
setPlugins(Array.isArray(pluginsRes.data) ? pluginsRes.data : []);
|
||||||
setPlugins(Array.isArray(res.data) ? res.data : []);
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('Fetch error:', err);
|
console.error('Fetch error:', err);
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import SEO from '../components/SEO';
|
|||||||
import PluginCard from '../components/PluginCard';
|
import PluginCard from '../components/PluginCard';
|
||||||
|
|
||||||
// Global cache to persist data across component unmounts (navigation)
|
// Global cache to persist data across component unmounts (navigation)
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
let homeCache: {
|
let homeCache: {
|
||||||
popular: any[];
|
popular: any[];
|
||||||
newest: any[];
|
newest: any[];
|
||||||
all: any[];
|
all: any[];
|
||||||
|
allHasMore: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
@ -17,13 +20,16 @@ const Home = () => {
|
|||||||
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 || []);
|
||||||
|
const [allHasMore, setAllHasMore] = useState(homeCache?.allHasMore ?? true);
|
||||||
|
const [allOffset, setAllOffset] = useState(homeCache?.all.length || 0);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [heroIndex, setHeroIndex] = useState(0);
|
const [heroIndex, setHeroIndex] = useState(0);
|
||||||
const [heroSubImageIndex, setHeroSubImageIndex] = useState(0);
|
const [heroSubImageIndex, setHeroSubImageIndex] = useState(0);
|
||||||
|
|
||||||
const popularRef = useRef<HTMLElement | null>(null);
|
const popularRef = useRef<HTMLElement | null>(null);
|
||||||
const newestRef = useRef<HTMLElement | null>(null);
|
const newestRef = useRef<HTMLElement | null>(null);
|
||||||
const fetched = useRef({ popular: !!homeCache?.popular, newest: !!homeCache?.newest, all: !!homeCache?.all });
|
const fetched = useRef({ popular: !!homeCache?.popular.length, newest: !!homeCache?.newest.length, all: !!homeCache?.all.length });
|
||||||
|
|
||||||
const saveSection = (key: 'popular' | 'newest' | 'all', data: any[]) => {
|
const saveSection = (key: 'popular' | 'newest' | 'all', data: any[]) => {
|
||||||
if (key === 'popular') setPopular(data);
|
if (key === 'popular') setPopular(data);
|
||||||
@ -46,13 +52,23 @@ const Home = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [popRes, newRes, allRes] = await Promise.all([
|
const [popRes, newRes, allRes] = await Promise.all([
|
||||||
api.get('/plugins', { params: { sort: 'downloads' } }),
|
api.get('/plugins', { params: { sort: 'downloads', limit: 12 } }),
|
||||||
api.get('/plugins', { params: { sort: 'newest' } }),
|
api.get('/plugins', { params: { sort: 'newest', limit: 12 } }),
|
||||||
api.get('/plugins')
|
api.get('/plugins', { params: { limit: PAGE_SIZE, offset: 0 } })
|
||||||
]);
|
]);
|
||||||
saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
|
const popData = Array.isArray(popRes.data) ? popRes.data : [];
|
||||||
saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
|
const newData = Array.isArray(newRes.data) ? newRes.data : [];
|
||||||
saveSection('all', Array.isArray(allRes.data) ? allRes.data : []);
|
const allData = Array.isArray(allRes.data) ? allRes.data : [];
|
||||||
|
const hasMore = allData.length >= PAGE_SIZE;
|
||||||
|
|
||||||
|
setPopular(popData);
|
||||||
|
setNewest(newData);
|
||||||
|
setAllPlugins(allData);
|
||||||
|
setAllHasMore(hasMore);
|
||||||
|
setAllOffset(allData.length);
|
||||||
|
|
||||||
|
homeCache = { popular: popData, newest: newData, all: allData, allHasMore: hasMore };
|
||||||
|
fetched.current = { popular: true, newest: true, all: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch error for home sections:', err);
|
console.error('Fetch error for home sections:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -63,6 +79,25 @@ const Home = () => {
|
|||||||
loadSections();
|
loadSections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMoreAll = async () => {
|
||||||
|
if (loadingMore || !allHasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/plugins', { params: { limit: PAGE_SIZE, offset: allOffset } });
|
||||||
|
const newData = Array.isArray(res.data) ? res.data : [];
|
||||||
|
const merged = [...allPlugins, ...newData];
|
||||||
|
const hasMore = newData.length >= PAGE_SIZE;
|
||||||
|
setAllPlugins(merged);
|
||||||
|
setAllHasMore(hasMore);
|
||||||
|
setAllOffset(merged.length);
|
||||||
|
if (homeCache) homeCache = { ...homeCache, all: merged, allHasMore: hasMore };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load more error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Featured plugins for top Steam-like carousel
|
// Featured plugins for top Steam-like carousel
|
||||||
const featuredPlugins = useMemo(() => popular.slice(0, 5), [popular]);
|
const featuredPlugins = useMemo(() => popular.slice(0, 5), [popular]);
|
||||||
|
|
||||||
@ -239,7 +274,7 @@ const Home = () => {
|
|||||||
</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>All</span> Plugins ({allPlugins.length}{allHasMore ? '+' : ''})</h2>
|
||||||
<div className="home-plugin-grid">
|
<div className="home-plugin-grid">
|
||||||
{allPlugins.length > 0 ? (
|
{allPlugins.length > 0 ? (
|
||||||
allPlugins.map((plugin) => (
|
allPlugins.map((plugin) => (
|
||||||
@ -249,6 +284,18 @@ const Home = () => {
|
|||||||
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div>
|
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{allHasMore && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={loadMoreAll}
|
||||||
|
disabled={loadingMore}
|
||||||
|
style={{ padding: '0.75rem 2.5rem', fontSize: '0.9rem' }}
|
||||||
|
>
|
||||||
|
{loadingMore ? 'Loading...' : 'Load More Plugins'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0 && allPlugins.length === 0) && (
|
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0 && allPlugins.length === 0) && (
|
||||||
|
|||||||
@ -187,7 +187,7 @@ const PluginDetail = () => {
|
|||||||
setCouponMessage(null);
|
setCouponMessage(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ownership
|
// Ownership — resolved from plugin.owned (injected by server) so no extra HTTP call needed
|
||||||
const [ownsPlugin, setOwnsPlugin] = useState(false);
|
const [ownsPlugin, setOwnsPlugin] = useState(false);
|
||||||
const [ownershipChecked, setOwnershipChecked] = useState(false);
|
const [ownershipChecked, setOwnershipChecked] = useState(false);
|
||||||
|
|
||||||
@ -201,6 +201,7 @@ const PluginDetail = () => {
|
|||||||
const [reportSubmitting, setReportSubmitting] = useState(false);
|
const [reportSubmitting, setReportSubmitting] = useState(false);
|
||||||
const [reportDone, setReportDone] = useState(false);
|
const [reportDone, setReportDone] = useState(false);
|
||||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||||
|
const [moreByDev, setMoreByDev] = useState<any[]>([]);
|
||||||
|
|
||||||
// Review Report State
|
// Review Report State
|
||||||
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
|
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
|
||||||
@ -233,19 +234,41 @@ const PluginDetail = () => {
|
|||||||
} else if (res.data.screenshots?.length > 0) {
|
} else if (res.data.screenshots?.length > 0) {
|
||||||
setMainScreenshot(res.data.screenshots[0].path);
|
setMainScreenshot(res.data.screenshots[0].path);
|
||||||
}
|
}
|
||||||
|
// Resolve ownership from the plugin response — server includes it when a JWT is sent.
|
||||||
|
if (res.data.owned !== undefined && res.data.owned !== null) {
|
||||||
|
setOwnsPlugin(res.data.owned);
|
||||||
|
setOwnershipChecked(true);
|
||||||
|
}
|
||||||
|
// Save to recently viewed
|
||||||
|
try {
|
||||||
|
const key = 'recentlyViewed';
|
||||||
|
const existing: any[] = JSON.parse(localStorage.getItem(key) || '[]');
|
||||||
|
const filtered = existing.filter((p: any) => String(p.id) !== String(res.data.id));
|
||||||
|
const entry = { id: res.data.id, name: res.data.name, preview_path: res.data.preview_path, dev_name: res.data.dev_name };
|
||||||
|
localStorage.setItem(key, JSON.stringify([entry, ...filtered].slice(0, 10)));
|
||||||
|
} catch { /* ignore */ }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchPlugin(); }, [id]);
|
useEffect(() => { fetchPlugin(); }, [id]);
|
||||||
|
|
||||||
// ── Check ownership ──────────────────────────────────────────────────────
|
// ── Fetch more plugins by same developer ─────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || !id) { setOwnershipChecked(true); return; }
|
if (!plugin?.dev_name || !plugin?.id) return;
|
||||||
api.get(`/plugins/${id}/owned`)
|
api.get('/plugins', { params: { dev_name: plugin.dev_name, limit: 4 } })
|
||||||
.then(res => setOwnsPlugin(res.data.owned === true))
|
.then(res => {
|
||||||
.catch(() => setOwnsPlugin(false))
|
const data = Array.isArray(res.data) ? res.data : [];
|
||||||
.finally(() => setOwnershipChecked(true));
|
setMoreByDev(data.filter((p: any) => String(p.id) !== String(plugin.id)).slice(0, 3));
|
||||||
}, [user, id]);
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [plugin?.dev_name, plugin?.id]);
|
||||||
|
|
||||||
|
// ── Ownership is now provided by the plugin detail response.
|
||||||
|
// Fallback: if the server didn't include it (unauthenticated request that later
|
||||||
|
// becomes authenticated via token refresh), mark it checked immediately.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) { setOwnershipChecked(true); }
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// ── Translations ─────────────────────────────────────────────────────────
|
// ── Translations ─────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1104,6 +1127,32 @@ const PluginDetail = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MORE BY THIS DEVELOPER */}
|
||||||
|
{moreByDev.length > 0 && (
|
||||||
|
<div className="sidebar-widget" style={{ marginTop: '1rem' }}>
|
||||||
|
<div style={{ fontSize: '0.7rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
|
More by {plugin.dev_name}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{moreByDev.map((p: any) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to={`/plugin/${p.id}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', textDecoration: 'none', padding: '0.5rem', borderRadius: '6px', background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.07)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.03)')}
|
||||||
|
>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 6, backgroundImage: p.preview_path ? `url(${p.preview_path})` : 'none', backgroundSize: 'cover', backgroundPosition: 'center', background: p.preview_path ? undefined : 'var(--surface)', border: '1px solid var(--border)', flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: '0.82rem', fontWeight: 600, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{p.type === 'free' ? 'Free' : `€${((p.price_cents || 0) / 100).toFixed(2)}`}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAuth } from '../App';
|
import { useAuth } from '../App';
|
||||||
import { getCodeList } from 'country-list';
|
import { getCodeList } from 'country-list';
|
||||||
|
import { SUPPORTED_CURRENCIES, useCurrency } from '../context/CurrencyContext';
|
||||||
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
|
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
|
||||||
import {
|
import {
|
||||||
User, Mail, Globe, Lock, Shield, CreditCard,
|
User, Mail, Globe, Lock, Shield, CreditCard,
|
||||||
@ -35,6 +36,7 @@ const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger
|
|||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, login, logout, refreshUser } = useAuth();
|
const { user, login, logout, refreshUser } = useAuth();
|
||||||
|
const { currency: currentCurrency } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -48,7 +50,7 @@ const ProfilePage = () => {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: '', email: '', country: '',
|
username: '', email: '', country: '', currency: '',
|
||||||
password: '', currentPassword: '', disable2faPassword: '',
|
password: '', currentPassword: '', disable2faPassword: '',
|
||||||
newPassword: '', confirmPassword: '',
|
newPassword: '', confirmPassword: '',
|
||||||
});
|
});
|
||||||
@ -79,7 +81,9 @@ const ProfilePage = () => {
|
|||||||
marketingEmail: false,
|
marketingEmail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => { refreshUser?.(); }, []);
|
// Note: We intentionally do NOT call refreshUser() on mount here — the JWT payload already
|
||||||
|
// contains all the data ProfilePage needs. Individual save actions call login() with the
|
||||||
|
// updated token when the server returns a new one.
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -88,6 +92,7 @@ const ProfilePage = () => {
|
|||||||
username: user.username || '',
|
username: user.username || '',
|
||||||
email: user.email || '',
|
email: user.email || '',
|
||||||
country: user.country || '',
|
country: user.country || '',
|
||||||
|
currency: user.preferred_currency || currentCurrency || 'EUR',
|
||||||
}));
|
}));
|
||||||
setIs2faEnabled(user.totp_enabled || false);
|
setIs2faEnabled(user.totp_enabled || false);
|
||||||
}
|
}
|
||||||
@ -101,7 +106,8 @@ const ProfilePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'developer' && user?.is_developer) {
|
// Skip if we already loaded the dev profile or the user isn't a developer.
|
||||||
|
if (activeTab === 'developer' && user?.is_developer && devProfile === null) {
|
||||||
api.get('/user/developer/profile')
|
api.get('/user/developer/profile')
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.data.profile) {
|
if (res.data.profile) {
|
||||||
@ -122,7 +128,7 @@ const ProfilePage = () => {
|
|||||||
})
|
})
|
||||||
.catch(err => console.error('Failed to load dev profile', err));
|
.catch(err => console.error('Failed to load dev profile', err));
|
||||||
}
|
}
|
||||||
}, [activeTab, user?.is_developer]);
|
}, [activeTab, user?.is_developer, devProfile]);
|
||||||
|
|
||||||
const handleUpdateDevProfile = async (e: React.FormEvent) => {
|
const handleUpdateDevProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -138,14 +144,15 @@ const ProfilePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'library') return;
|
// Skip if library is already loaded or currently being fetched.
|
||||||
|
if (activeTab !== 'library' || library.length > 0 || libraryLoading) return;
|
||||||
setLibraryLoading(true);
|
setLibraryLoading(true);
|
||||||
setLibraryError(null);
|
setLibraryError(null);
|
||||||
api.get('/user/library')
|
api.get('/user/library')
|
||||||
.then(res => setLibrary(Array.isArray(res.data) ? res.data : []))
|
.then(res => setLibrary(Array.isArray(res.data) ? res.data : []))
|
||||||
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
|
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
|
||||||
.finally(() => setLibraryLoading(false));
|
.finally(() => setLibraryLoading(false));
|
||||||
}, [activeTab]);
|
}, [activeTab, library.length, libraryLoading]);
|
||||||
|
|
||||||
const showToast = (text: string, type: 'success' | 'error' = 'success') => {
|
const showToast = (text: string, type: 'success' | 'error' = 'success') => {
|
||||||
setToast({ text, type });
|
setToast({ text, type });
|
||||||
@ -434,6 +441,34 @@ const ProfilePage = () => {
|
|||||||
<SaveBtn id="country" label="Update Country" />
|
<SaveBtn id="country" label="Update Country" />
|
||||||
</form>
|
</form>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Currency" description="Choose how prices are displayed across the marketplace. Charges are always processed in EUR." icon={CreditCard}>
|
||||||
|
<form onSubmit={e => {
|
||||||
|
if (!formData.currency) {
|
||||||
|
e.preventDefault();
|
||||||
|
showToast('Please select a currency.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleUpdate(e, '/user/change-currency', { newCurrency: formData.currency }, 'currency');
|
||||||
|
}}>
|
||||||
|
<Field label="Display Currency" hint="Converted prices are approximate and for reference only.">
|
||||||
|
<select
|
||||||
|
id="currency-select"
|
||||||
|
className="settings-input settings-select"
|
||||||
|
value={formData.currency}
|
||||||
|
onChange={e => setFormData({ ...formData, currency: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{SUPPORTED_CURRENCIES.map(c => (
|
||||||
|
<option key={c.code} value={c.code}>
|
||||||
|
{c.symbol} {c.code} — {c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<SaveBtn id="currency" label="Update Currency" />
|
||||||
|
</form>
|
||||||
|
</SettingsCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -24,18 +24,38 @@ const SearchResultsPage = () => {
|
|||||||
const [minPrice, setMinPrice] = useState(initialMinPrice);
|
const [minPrice, setMinPrice] = useState(initialMinPrice);
|
||||||
const [maxPrice, setMaxPrice] = useState(initialMaxPrice);
|
const [maxPrice, setMaxPrice] = useState(initialMaxPrice);
|
||||||
|
|
||||||
|
// Debounce filter changes — wait 350ms after user stops typing before firing a request.
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||||
|
const [debouncedSort, setDebouncedSort] = useState(sort);
|
||||||
|
const [debouncedCategory, setDebouncedCategory] = useState(category);
|
||||||
|
const [debouncedType, setDebouncedType] = useState(type);
|
||||||
|
const [debouncedMinPrice, setDebouncedMinPrice] = useState(minPrice);
|
||||||
|
const [debouncedMaxPrice, setDebouncedMaxPrice] = useState(maxPrice);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(searchQuery);
|
||||||
|
setDebouncedSort(sort);
|
||||||
|
setDebouncedCategory(category);
|
||||||
|
setDebouncedType(type);
|
||||||
|
setDebouncedMinPrice(minPrice);
|
||||||
|
setDebouncedMaxPrice(maxPrice);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, sort, category, type, minPrice, maxPrice]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlugins = async () => {
|
const fetchPlugins = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
q: searchQuery || undefined,
|
q: debouncedQuery || undefined,
|
||||||
sort: sort || undefined,
|
sort: debouncedSort || undefined,
|
||||||
category: category || undefined,
|
category: debouncedCategory || undefined,
|
||||||
type: type || undefined,
|
type: debouncedType || undefined,
|
||||||
min_price: minPrice ? parseInt(minPrice) * 100 : undefined,
|
min_price: debouncedMinPrice ? parseInt(debouncedMinPrice) * 100 : undefined,
|
||||||
max_price: maxPrice ? parseInt(maxPrice) * 100 : undefined,
|
max_price: debouncedMaxPrice ? parseInt(debouncedMaxPrice) * 100 : undefined,
|
||||||
};
|
};
|
||||||
const response = await api.get('/plugins', { params });
|
const response = await api.get('/plugins', { params });
|
||||||
setPlugins(Array.isArray(response.data) ? response.data : []);
|
setPlugins(Array.isArray(response.data) ? response.data : []);
|
||||||
@ -52,15 +72,15 @@ const SearchResultsPage = () => {
|
|||||||
|
|
||||||
// Update URL params
|
// Update URL params
|
||||||
const newParams: any = {};
|
const newParams: any = {};
|
||||||
if (searchQuery) newParams.q = searchQuery;
|
if (debouncedQuery) newParams.q = debouncedQuery;
|
||||||
if (sort) newParams.sort = sort;
|
if (debouncedSort) newParams.sort = debouncedSort;
|
||||||
if (category) newParams.category = category;
|
if (debouncedCategory) newParams.category = debouncedCategory;
|
||||||
if (type) newParams.type = type;
|
if (debouncedType) newParams.type = debouncedType;
|
||||||
if (minPrice) newParams.min_price = minPrice;
|
if (debouncedMinPrice) newParams.min_price = debouncedMinPrice;
|
||||||
if (maxPrice) newParams.max_price = maxPrice;
|
if (debouncedMaxPrice) newParams.max_price = debouncedMaxPrice;
|
||||||
setSearchParams(newParams, { replace: true });
|
setSearchParams(newParams, { replace: true });
|
||||||
|
|
||||||
}, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
|
}, [debouncedQuery, debouncedSort, debouncedCategory, debouncedType, debouncedMinPrice, debouncedMaxPrice, setSearchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -100,7 +120,7 @@ const SearchResultsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label>Price Range ($)</label>
|
<label>Price Range (€)</label>
|
||||||
<div className="price-range-inputs">
|
<div className="price-range-inputs">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -134,6 +154,7 @@ const SearchResultsPage = () => {
|
|||||||
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
|
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||||
<option value="">Newest</option>
|
<option value="">Newest</option>
|
||||||
<option value="downloads">Most Popular</option>
|
<option value="downloads">Most Popular</option>
|
||||||
|
<option value="top">Top Rated</option>
|
||||||
<option value="name-asc">Name (A-Z)</option>
|
<option value="name-asc">Name (A-Z)</option>
|
||||||
<option value="name-desc">Name (Z-A)</option>
|
<option value="name-desc">Name (Z-A)</option>
|
||||||
<option value="price-asc">Price (Low to High)</option>
|
<option value="price-asc">Price (Low to High)</option>
|
||||||
|
|||||||
19
src/pages/dashboard/AnalyticsContext.tsx
Normal file
19
src/pages/dashboard/AnalyticsContext.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalyticsContext — shared across all dashboard sub-pages so the analytics
|
||||||
|
* data is fetched exactly once per timeframe change, not once per mounted tab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AnalyticsContextValue = ReturnType<typeof import('./useAnalytics').useAnalytics>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const AnalyticsContext = createContext<any>(null);
|
||||||
|
|
||||||
|
export const useAnalyticsContext = () => {
|
||||||
|
const ctx = useContext(AnalyticsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAnalyticsContext must be used inside <AnalyticsProvider>');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
||||||
import { scaleLinear } from "d3-scale";
|
import { scaleLinear } from "d3-scale";
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalyticsContext } from './AnalyticsContext';
|
||||||
|
|
||||||
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||||
|
|
||||||
const DashboardAudience = () => {
|
const DashboardAudience = () => {
|
||||||
const { mapData, loading } = useAnalytics();
|
const { mapData, loading } = useAnalyticsContext();
|
||||||
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
|
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
|
||||||
|
|
||||||
// Memoize scales to prevent recalculation on every render
|
// Memoize scales to prevent recalculation on every render
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
TrendingUp, TrendingDown, Minus, DollarSign,
|
TrendingUp, TrendingDown, Minus, DollarSign,
|
||||||
Download, MousePointer2, Clock, User, Star
|
Download, MousePointer2, Clock, User, Star
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalyticsContext } from './AnalyticsContext';
|
||||||
|
|
||||||
// Helper for formatting large numbers
|
// Helper for formatting large numbers
|
||||||
const formatValue = (val: number) => {
|
const formatValue = (val: number) => {
|
||||||
@ -83,7 +83,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DashboardOverview = () => {
|
const DashboardOverview = () => {
|
||||||
const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
|
const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalyticsContext();
|
||||||
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
|
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalyticsContext } from './AnalyticsContext';
|
||||||
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';
|
||||||
import { useAuth } from '../../App';
|
import { useAuth } from '../../App';
|
||||||
@ -8,7 +8,7 @@ import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal'
|
|||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
const DashboardPlugins = () => {
|
const DashboardPlugins = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { timeSeries } = useAnalytics();
|
const { timeSeries } = useAnalyticsContext();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -47,8 +47,9 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
setVersion('');
|
setVersion('');
|
||||||
setReleaseNotes('');
|
setReleaseNotes('');
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
alert('Publish failed.');
|
const msg = err?.response?.data || err?.message || 'Publish failed.';
|
||||||
|
alert(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user