add Currency setting

This commit is contained in:
Alexander Medvedev 2026-07-30 11:54:29 +02:00
parent 93511af260
commit 407f07c045
16 changed files with 426 additions and 104 deletions

View File

@ -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
View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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;

View 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>;
};

View File

@ -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);

View File

@ -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) && (

View File

@ -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>

View File

@ -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>
)}

View File

@ -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>

View 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;
};

View File

@ -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

View File

@ -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 (

View File

@ -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);

View File

@ -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);
}