add Currency setting
This commit is contained in:
parent
93511af260
commit
407f07c045
6
.github/workflows/deploy-backend.yml
vendored
6
.github/workflows/deploy-backend.yml
vendored
@ -8,12 +8,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 26
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
|
||||
58
package-lock.json
generated
58
package-lock.json
generated
@ -8,15 +8,15 @@
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.5.3",
|
||||
"@marsidev/react-turnstile": "^1.5.4",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"country-list": "^2.4.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"or": "^0.2.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.8",
|
||||
@ -24,21 +24,21 @@
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-is": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/country-list": "^2.1.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"globals": "^17.8.0",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.5"
|
||||
@ -572,9 +572,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@marsidev/react-turnstile": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.3.tgz",
|
||||
"integrity": "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A==",
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.5.4.tgz",
|
||||
"integrity": "sha512-2+ulBzQPYcC5jZ4Pghlcc6a6+CQ6L0cgTxtbavZbcHcG5/wSQsTAM+vDudF94L9AUPG4uSNQF1kotmvx0Ns/QA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||
@ -1056,9 +1056,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -1793,13 +1793,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@ -2853,9 +2853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.7.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
|
||||
"integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
|
||||
"version": "17.8.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz",
|
||||
"integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -3549,9 +3549,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@ -4805,9 +4805,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@ -4827,12 +4827,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.1"
|
||||
"react-router": "7.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
12
package.json
12
package.json
@ -11,15 +11,15 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.5.3",
|
||||
"@marsidev/react-turnstile": "^1.5.4",
|
||||
"@vnedyalk0v/react19-simple-maps": "^2.0.9",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"country-list": "^2.4.1",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"or": "^0.2.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.8",
|
||||
@ -27,21 +27,21 @@
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-is": "^19.2.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/country-list": "^2.1.4",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"globals": "^17.8.0",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.5"
|
||||
|
||||
@ -2,5 +2,6 @@ User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
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 SearchResultsPage from './pages/SearchResultsPage';
|
||||
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
||||
import DashboardOverview from './pages/dashboard/DashboardOverview';
|
||||
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
||||
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
||||
import AddPlugin from './pages/dashboard/plugin/AddPlugin';
|
||||
@ -24,6 +25,9 @@ import AdminPanel from './pages/admin/AdminPanel';
|
||||
import './App.css';
|
||||
|
||||
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 ---
|
||||
const ScrollToTop = () => {
|
||||
@ -94,7 +98,7 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
|
||||
{!loading && children}
|
||||
{!loading && <CurrencyProvider>{children}</CurrencyProvider>}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -140,7 +144,8 @@ const App = () => (
|
||||
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
||||
<Route path="check-email" element={<CheckEmailPage />} />
|
||||
<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="plugins" element={<DashboardPlugins />} />
|
||||
<Route path="add-plugin" element={<AddPlugin />} />
|
||||
@ -148,6 +153,7 @@ const App = () => (
|
||||
</Route>
|
||||
<Route path="staff" element={<StaffRoute><AdminPanel /></StaffRoute>} />
|
||||
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
@ -168,15 +174,23 @@ const MainLayout = () => {
|
||||
import './pages/dashboard/Dashboard.css';
|
||||
|
||||
const DashboardLayout = () => {
|
||||
// Instantiate analytics once here and share via context — prevents each tab
|
||||
// from making its own redundant API call.
|
||||
const analytics = useAnalytics();
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<nav className="dashboard-nav">
|
||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||
</nav>
|
||||
<div className="dashboard-content">
|
||||
<Outlet />
|
||||
<AnalyticsContext.Provider value={analytics}>
|
||||
<div className="dashboard-container">
|
||||
<nav className="dashboard-nav">
|
||||
<NavLink to="/dashboard/overview">Overview</NavLink>
|
||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||
<NavLink to="/dashboard/audience">Analytics</NavLink>
|
||||
</nav>
|
||||
<div className="dashboard-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -219,7 +233,7 @@ const Navbar = ({ user }: any) => {
|
||||
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="nav-links">
|
||||
<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>
|
||||
)}
|
||||
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
||||
@ -316,4 +330,15 @@ const 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;
|
||||
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>;
|
||||
};
|
||||
@ -16,15 +16,14 @@ const AuthorProfilePage = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch author public info
|
||||
api.get(`/user/public/${username}`)
|
||||
.then(res => {
|
||||
setAuthor(res.data);
|
||||
// Fetch author plugins
|
||||
return api.get('/plugins', { params: { dev_name: username, sort: 'newest' } });
|
||||
})
|
||||
.then(res => {
|
||||
setPlugins(Array.isArray(res.data) ? res.data : []);
|
||||
// Fetch author info and plugins in parallel — saves one full round-trip.
|
||||
Promise.all([
|
||||
api.get(`/user/public/${username}`),
|
||||
api.get('/plugins', { params: { dev_name: username, sort: 'newest' } }),
|
||||
])
|
||||
.then(([authorRes, pluginsRes]) => {
|
||||
setAuthor(authorRes.data);
|
||||
setPlugins(Array.isArray(pluginsRes.data) ? pluginsRes.data : []);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Fetch error:', err);
|
||||
|
||||
@ -6,10 +6,13 @@ import SEO from '../components/SEO';
|
||||
import PluginCard from '../components/PluginCard';
|
||||
|
||||
// Global cache to persist data across component unmounts (navigation)
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
let homeCache: {
|
||||
popular: any[];
|
||||
newest: any[];
|
||||
all: any[];
|
||||
allHasMore: boolean;
|
||||
} | null = null;
|
||||
|
||||
const Home = () => {
|
||||
@ -17,13 +20,16 @@ const Home = () => {
|
||||
const [popular, setPopular] = useState<any[]>(homeCache?.popular || []);
|
||||
const [newest, setNewest] = useState<any[]>(homeCache?.newest || []);
|
||||
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 [heroIndex, setHeroIndex] = useState(0);
|
||||
const [heroSubImageIndex, setHeroSubImageIndex] = useState(0);
|
||||
|
||||
const popularRef = 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[]) => {
|
||||
if (key === 'popular') setPopular(data);
|
||||
@ -46,13 +52,23 @@ const Home = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [popRes, newRes, allRes] = await Promise.all([
|
||||
api.get('/plugins', { params: { sort: 'downloads' } }),
|
||||
api.get('/plugins', { params: { sort: 'newest' } }),
|
||||
api.get('/plugins')
|
||||
api.get('/plugins', { params: { sort: 'downloads', limit: 12 } }),
|
||||
api.get('/plugins', { params: { sort: 'newest', limit: 12 } }),
|
||||
api.get('/plugins', { params: { limit: PAGE_SIZE, offset: 0 } })
|
||||
]);
|
||||
saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
|
||||
saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
|
||||
saveSection('all', Array.isArray(allRes.data) ? allRes.data : []);
|
||||
const popData = Array.isArray(popRes.data) ? popRes.data : [];
|
||||
const newData = Array.isArray(newRes.data) ? newRes.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) {
|
||||
console.error('Fetch error for home sections:', err);
|
||||
} finally {
|
||||
@ -63,6 +79,25 @@ const Home = () => {
|
||||
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
|
||||
const featuredPlugins = useMemo(() => popular.slice(0, 5), [popular]);
|
||||
|
||||
@ -239,7 +274,7 @@ const 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">
|
||||
{allPlugins.length > 0 ? (
|
||||
allPlugins.map((plugin) => (
|
||||
@ -249,6 +284,18 @@ const Home = () => {
|
||||
<div className="section-placeholder">{loading ? 'Loading all plugins...' : 'No plugins available'}</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>
|
||||
|
||||
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0 && allPlugins.length === 0) && (
|
||||
|
||||
@ -187,7 +187,7 @@ const PluginDetail = () => {
|
||||
setCouponMessage(null);
|
||||
};
|
||||
|
||||
// Ownership
|
||||
// Ownership — resolved from plugin.owned (injected by server) so no extra HTTP call needed
|
||||
const [ownsPlugin, setOwnsPlugin] = useState(false);
|
||||
const [ownershipChecked, setOwnershipChecked] = useState(false);
|
||||
|
||||
@ -201,6 +201,7 @@ const PluginDetail = () => {
|
||||
const [reportSubmitting, setReportSubmitting] = useState(false);
|
||||
const [reportDone, setReportDone] = useState(false);
|
||||
const [changelogOpen, setChangelogOpen] = useState(false);
|
||||
const [moreByDev, setMoreByDev] = useState<any[]>([]);
|
||||
|
||||
// Review Report State
|
||||
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
|
||||
@ -233,19 +234,41 @@ const PluginDetail = () => {
|
||||
} else if (res.data.screenshots?.length > 0) {
|
||||
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]);
|
||||
|
||||
// ── Check ownership ──────────────────────────────────────────────────────
|
||||
// ── Fetch more plugins by same developer ─────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!user || !id) { setOwnershipChecked(true); return; }
|
||||
api.get(`/plugins/${id}/owned`)
|
||||
.then(res => setOwnsPlugin(res.data.owned === true))
|
||||
.catch(() => setOwnsPlugin(false))
|
||||
.finally(() => setOwnershipChecked(true));
|
||||
}, [user, id]);
|
||||
if (!plugin?.dev_name || !plugin?.id) return;
|
||||
api.get('/plugins', { params: { dev_name: plugin.dev_name, limit: 4 } })
|
||||
.then(res => {
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setMoreByDev(data.filter((p: any) => String(p.id) !== String(plugin.id)).slice(0, 3));
|
||||
})
|
||||
.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 ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@ -1104,6 +1127,32 @@ const PluginDetail = () => {
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||
import api from '../api';
|
||||
import { useAuth } from '../App';
|
||||
import { getCodeList } from 'country-list';
|
||||
import { SUPPORTED_CURRENCIES, useCurrency } from '../context/CurrencyContext';
|
||||
import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal';
|
||||
import {
|
||||
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 { user, login, logout, refreshUser } = useAuth();
|
||||
const { currency: currentCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@ -48,7 +50,7 @@ const ProfilePage = () => {
|
||||
}, [location]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '', email: '', country: '',
|
||||
username: '', email: '', country: '', currency: '',
|
||||
password: '', currentPassword: '', disable2faPassword: '',
|
||||
newPassword: '', confirmPassword: '',
|
||||
});
|
||||
@ -79,7 +81,9 @@ const ProfilePage = () => {
|
||||
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(() => {
|
||||
if (user) {
|
||||
@ -88,6 +92,7 @@ const ProfilePage = () => {
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
country: user.country || '',
|
||||
currency: user.preferred_currency || currentCurrency || 'EUR',
|
||||
}));
|
||||
setIs2faEnabled(user.totp_enabled || false);
|
||||
}
|
||||
@ -101,7 +106,8 @@ const ProfilePage = () => {
|
||||
});
|
||||
|
||||
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')
|
||||
.then(res => {
|
||||
if (res.data.profile) {
|
||||
@ -122,7 +128,7 @@ const ProfilePage = () => {
|
||||
})
|
||||
.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) => {
|
||||
e.preventDefault();
|
||||
@ -138,14 +144,15 @@ const ProfilePage = () => {
|
||||
};
|
||||
|
||||
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);
|
||||
setLibraryError(null);
|
||||
api.get('/user/library')
|
||||
.then(res => setLibrary(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
|
||||
.finally(() => setLibraryLoading(false));
|
||||
}, [activeTab]);
|
||||
}, [activeTab, library.length, libraryLoading]);
|
||||
|
||||
const showToast = (text: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ text, type });
|
||||
@ -434,6 +441,34 @@ const ProfilePage = () => {
|
||||
<SaveBtn id="country" label="Update Country" />
|
||||
</form>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@ -24,18 +24,38 @@ const SearchResultsPage = () => {
|
||||
const [minPrice, setMinPrice] = useState(initialMinPrice);
|
||||
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(() => {
|
||||
const fetchPlugins = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: any = {
|
||||
q: searchQuery || undefined,
|
||||
sort: sort || undefined,
|
||||
category: category || undefined,
|
||||
type: type || undefined,
|
||||
min_price: minPrice ? parseInt(minPrice) * 100 : undefined,
|
||||
max_price: maxPrice ? parseInt(maxPrice) * 100 : undefined,
|
||||
q: debouncedQuery || undefined,
|
||||
sort: debouncedSort || undefined,
|
||||
category: debouncedCategory || undefined,
|
||||
type: debouncedType || undefined,
|
||||
min_price: debouncedMinPrice ? parseInt(debouncedMinPrice) * 100 : undefined,
|
||||
max_price: debouncedMaxPrice ? parseInt(debouncedMaxPrice) * 100 : undefined,
|
||||
};
|
||||
const response = await api.get('/plugins', { params });
|
||||
setPlugins(Array.isArray(response.data) ? response.data : []);
|
||||
@ -52,15 +72,15 @@ const SearchResultsPage = () => {
|
||||
|
||||
// Update URL params
|
||||
const newParams: any = {};
|
||||
if (searchQuery) newParams.q = searchQuery;
|
||||
if (sort) newParams.sort = sort;
|
||||
if (category) newParams.category = category;
|
||||
if (type) newParams.type = type;
|
||||
if (minPrice) newParams.min_price = minPrice;
|
||||
if (maxPrice) newParams.max_price = maxPrice;
|
||||
if (debouncedQuery) newParams.q = debouncedQuery;
|
||||
if (debouncedSort) newParams.sort = debouncedSort;
|
||||
if (debouncedCategory) newParams.category = debouncedCategory;
|
||||
if (debouncedType) newParams.type = debouncedType;
|
||||
if (debouncedMinPrice) newParams.min_price = debouncedMinPrice;
|
||||
if (debouncedMaxPrice) newParams.max_price = debouncedMaxPrice;
|
||||
setSearchParams(newParams, { replace: true });
|
||||
|
||||
}, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
|
||||
}, [debouncedQuery, debouncedSort, debouncedCategory, debouncedType, debouncedMinPrice, debouncedMaxPrice, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
@ -100,7 +120,7 @@ const SearchResultsPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Price Range ($)</label>
|
||||
<label>Price Range (€)</label>
|
||||
<div className="price-range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
@ -134,6 +154,7 @@ const SearchResultsPage = () => {
|
||||
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||
<option value="">Newest</option>
|
||||
<option value="downloads">Most Popular</option>
|
||||
<option value="top">Top Rated</option>
|
||||
<option value="name-asc">Name (A-Z)</option>
|
||||
<option value="name-desc">Name (Z-A)</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 { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
||||
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 DashboardAudience = () => {
|
||||
const { mapData, loading } = useAnalytics();
|
||||
const { mapData, loading } = useAnalyticsContext();
|
||||
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
|
||||
|
||||
// Memoize scales to prevent recalculation on every render
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
TrendingUp, TrendingDown, Minus, DollarSign,
|
||||
Download, MousePointer2, Clock, User, Star
|
||||
} from 'lucide-react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { useAnalyticsContext } from './AnalyticsContext';
|
||||
|
||||
// Helper for formatting large numbers
|
||||
const formatValue = (val: number) => {
|
||||
@ -83,7 +83,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
|
||||
};
|
||||
|
||||
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'];
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { useAnalyticsContext } from './AnalyticsContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from '../../App';
|
||||
@ -8,7 +8,7 @@ import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal'
|
||||
// --- Main Component ---
|
||||
const DashboardPlugins = () => {
|
||||
const { user } = useAuth();
|
||||
const { timeSeries } = useAnalytics();
|
||||
const { timeSeries } = useAnalyticsContext();
|
||||
const navigate = useNavigate();
|
||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||
|
||||
|
||||
@ -47,8 +47,9 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
setVersion('');
|
||||
setReleaseNotes('');
|
||||
}, 3000);
|
||||
} catch {
|
||||
alert('Publish failed.');
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data || err?.message || 'Publish failed.';
|
||||
alert(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user