Revert "add Currency setting"

This reverts commit 407f07c045.
This commit is contained in:
Alexander Medvedev 2026-07-30 12:04:26 +02:00
parent 407f07c045
commit 5c3922f230
12 changed files with 64 additions and 385 deletions

View File

@ -14,7 +14,6 @@ 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';
@ -25,9 +24,6 @@ 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 = () => {
@ -98,7 +94,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 && <CurrencyProvider>{children}</CurrencyProvider>} {!loading && children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };
@ -144,8 +140,7 @@ 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/overview" replace />} /> <Route index element={<Navigate to="/dashboard/plugins" 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 />} />
@ -153,7 +148,6 @@ 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>
@ -174,23 +168,15 @@ 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 (
<AnalyticsContext.Provider value={analytics}> <div className="dashboard-container">
<div className="dashboard-container"> <nav className="dashboard-nav">
<nav className="dashboard-nav"> <NavLink to="/dashboard/plugins">My Plugins</NavLink>
<NavLink to="/dashboard/overview">Overview</NavLink> </nav>
<NavLink to="/dashboard/plugins">My Plugins</NavLink> <div className="dashboard-content">
<NavLink to="/dashboard/audience">Analytics</NavLink> <Outlet />
</nav>
<div className="dashboard-content">
<Outlet />
</div>
</div> </div>
</AnalyticsContext.Provider> </div>
); );
}; };
@ -233,7 +219,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.is_developer || user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && ( {user && (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>}
@ -330,15 +316,4 @@ 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;

View File

@ -1,125 +0,0 @@
/**
* 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

@ -15,15 +15,16 @@ const AuthorProfilePage = () => {
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Fetch author info and plugins in parallel — saves one full round-trip. // Fetch author public info
Promise.all([ api.get(`/user/public/${username}`)
api.get(`/user/public/${username}`), .then(res => {
api.get('/plugins', { params: { dev_name: username, sort: 'newest' } }), setAuthor(res.data);
]) // Fetch author plugins
.then(([authorRes, pluginsRes]) => { return api.get('/plugins', { params: { dev_name: username, sort: 'newest' } });
setAuthor(authorRes.data); })
setPlugins(Array.isArray(pluginsRes.data) ? pluginsRes.data : []); .then(res => {
setPlugins(Array.isArray(res.data) ? res.data : []);
}) })
.catch(err => { .catch(err => {
console.error('Fetch error:', err); console.error('Fetch error:', err);

View File

@ -6,13 +6,10 @@ 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 = () => {
@ -20,16 +17,13 @@ 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.length, newest: !!homeCache?.newest.length, all: !!homeCache?.all.length }); const fetched = useRef({ popular: !!homeCache?.popular, newest: !!homeCache?.newest, all: !!homeCache?.all });
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);
@ -52,23 +46,13 @@ 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', limit: 12 } }), api.get('/plugins', { params: { sort: 'downloads' } }),
api.get('/plugins', { params: { sort: 'newest', limit: 12 } }), api.get('/plugins', { params: { sort: 'newest' } }),
api.get('/plugins', { params: { limit: PAGE_SIZE, offset: 0 } }) api.get('/plugins')
]); ]);
const popData = Array.isArray(popRes.data) ? popRes.data : []; saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
const newData = Array.isArray(newRes.data) ? newRes.data : []; saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
const allData = Array.isArray(allRes.data) ? allRes.data : []; saveSection('all', 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 {
@ -79,25 +63,6 @@ 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]);
@ -274,7 +239,7 @@ const Home = () => {
</section> </section>
<section className="home-section"> <section className="home-section">
<h2 className="section-title"><span>All</span> Plugins ({allPlugins.length}{allHasMore ? '+' : ''})</h2> <h2 className="section-title"><span>All</span> Plugins ({allPlugins.length})</h2>
<div className="home-plugin-grid"> <div className="home-plugin-grid">
{allPlugins.length > 0 ? ( {allPlugins.length > 0 ? (
allPlugins.map((plugin) => ( allPlugins.map((plugin) => (
@ -284,18 +249,6 @@ 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) && (

View File

@ -187,7 +187,7 @@ const PluginDetail = () => {
setCouponMessage(null); setCouponMessage(null);
}; };
// Ownership — resolved from plugin.owned (injected by server) so no extra HTTP call needed // Ownership
const [ownsPlugin, setOwnsPlugin] = useState(false); const [ownsPlugin, setOwnsPlugin] = useState(false);
const [ownershipChecked, setOwnershipChecked] = useState(false); const [ownershipChecked, setOwnershipChecked] = useState(false);
@ -201,7 +201,6 @@ 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);
@ -234,41 +233,19 @@ 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]);
// ── Fetch more plugins by same developer ───────────────────────────────── // ── Check ownership ──────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!plugin?.dev_name || !plugin?.id) return; if (!user || !id) { setOwnershipChecked(true); return; }
api.get('/plugins', { params: { dev_name: plugin.dev_name, limit: 4 } }) api.get(`/plugins/${id}/owned`)
.then(res => { .then(res => setOwnsPlugin(res.data.owned === true))
const data = Array.isArray(res.data) ? res.data : []; .catch(() => setOwnsPlugin(false))
setMoreByDev(data.filter((p: any) => String(p.id) !== String(plugin.id)).slice(0, 3)); .finally(() => setOwnershipChecked(true));
}) }, [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(() => {
@ -1127,32 +1104,6 @@ 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>

View File

@ -4,7 +4,6 @@ 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,
@ -36,7 +35,6 @@ 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();
@ -50,7 +48,7 @@ const ProfilePage = () => {
}, [location]); }, [location]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', email: '', country: '', currency: '', username: '', email: '', country: '',
password: '', currentPassword: '', disable2faPassword: '', password: '', currentPassword: '', disable2faPassword: '',
newPassword: '', confirmPassword: '', newPassword: '', confirmPassword: '',
}); });
@ -81,9 +79,7 @@ const ProfilePage = () => {
marketingEmail: false, marketingEmail: false,
}); });
// Note: We intentionally do NOT call refreshUser() on mount here — the JWT payload already useEffect(() => { refreshUser?.(); }, []);
// 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) {
@ -92,7 +88,6 @@ 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);
} }
@ -106,8 +101,7 @@ const ProfilePage = () => {
}); });
useEffect(() => { useEffect(() => {
// Skip if we already loaded the dev profile or the user isn't a developer. if (activeTab === 'developer' && user?.is_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) {
@ -128,7 +122,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, devProfile]); }, [activeTab, user?.is_developer]);
const handleUpdateDevProfile = async (e: React.FormEvent) => { const handleUpdateDevProfile = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -144,15 +138,14 @@ const ProfilePage = () => {
}; };
useEffect(() => { useEffect(() => {
// Skip if library is already loaded or currently being fetched. if (activeTab !== 'library') return;
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, library.length, libraryLoading]); }, [activeTab]);
const showToast = (text: string, type: 'success' | 'error' = 'success') => { const showToast = (text: string, type: 'success' | 'error' = 'success') => {
setToast({ text, type }); setToast({ text, type });
@ -441,34 +434,6 @@ 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>
)} )}

View File

@ -24,38 +24,18 @@ 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: debouncedQuery || undefined, q: searchQuery || undefined,
sort: debouncedSort || undefined, sort: sort || undefined,
category: debouncedCategory || undefined, category: category || undefined,
type: debouncedType || undefined, type: type || undefined,
min_price: debouncedMinPrice ? parseInt(debouncedMinPrice) * 100 : undefined, min_price: minPrice ? parseInt(minPrice) * 100 : undefined,
max_price: debouncedMaxPrice ? parseInt(debouncedMaxPrice) * 100 : undefined, max_price: maxPrice ? parseInt(maxPrice) * 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 : []);
@ -72,15 +52,15 @@ const SearchResultsPage = () => {
// Update URL params // Update URL params
const newParams: any = {}; const newParams: any = {};
if (debouncedQuery) newParams.q = debouncedQuery; if (searchQuery) newParams.q = searchQuery;
if (debouncedSort) newParams.sort = debouncedSort; if (sort) newParams.sort = sort;
if (debouncedCategory) newParams.category = debouncedCategory; if (category) newParams.category = category;
if (debouncedType) newParams.type = debouncedType; if (type) newParams.type = type;
if (debouncedMinPrice) newParams.min_price = debouncedMinPrice; if (minPrice) newParams.min_price = minPrice;
if (debouncedMaxPrice) newParams.max_price = debouncedMaxPrice; if (maxPrice) newParams.max_price = maxPrice;
setSearchParams(newParams, { replace: true }); setSearchParams(newParams, { replace: true });
}, [debouncedQuery, debouncedSort, debouncedCategory, debouncedType, debouncedMinPrice, debouncedMaxPrice, setSearchParams]); }, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
return ( return (
<div className="container"> <div className="container">
@ -120,7 +100,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"
@ -154,7 +134,6 @@ 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>

View File

@ -1,19 +0,0 @@
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 { 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 { useAnalyticsContext } from './AnalyticsContext'; import { useAnalytics } from './useAnalytics';
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 } = useAnalyticsContext(); const { mapData, loading } = useAnalytics();
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

View File

@ -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 { useAnalyticsContext } from './AnalyticsContext'; import { useAnalytics } from './useAnalytics';
// 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 } = useAnalyticsContext(); const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
const timeframes = ['Today', '7 Days', '1 Month', '1 Year']; const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
return ( return (

View File

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useAnalyticsContext } from './AnalyticsContext'; import { useAnalytics } from './useAnalytics';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react'; import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
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 } = useAnalyticsContext(); const { timeSeries } = useAnalytics();
const navigate = useNavigate(); const navigate = useNavigate();
const [isDevModalOpen, setIsDevModalOpen] = useState(false); const [isDevModalOpen, setIsDevModalOpen] = useState(false);

View File

@ -47,9 +47,8 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
setVersion(''); setVersion('');
setReleaseNotes(''); setReleaseNotes('');
}, 3000); }, 3000);
} catch (err: any) { } catch {
const msg = err?.response?.data || err?.message || 'Publish failed.'; alert('Publish failed.');
alert(typeof msg === 'string' ? msg : JSON.stringify(msg));
} finally { } finally {
setUploading(false); setUploading(false);
} }