parent
407f07c045
commit
5c3922f230
45
src/App.tsx
45
src/App.tsx
@ -14,7 +14,6 @@ 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';
|
||||
@ -25,9 +24,6 @@ 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 = () => {
|
||||
@ -98,7 +94,7 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
|
||||
{!loading && <CurrencyProvider>{children}</CurrencyProvider>}
|
||||
{!loading && children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -144,8 +140,7 @@ 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/overview" replace />} />
|
||||
<Route path="overview" element={<DashboardOverview />} />
|
||||
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
||||
<Route path="audience" element={<DashboardAudience />} />
|
||||
<Route path="plugins" element={<DashboardPlugins />} />
|
||||
<Route path="add-plugin" element={<AddPlugin />} />
|
||||
@ -153,7 +148,6 @@ const App = () => (
|
||||
</Route>
|
||||
<Route path="staff" element={<StaffRoute><AdminPanel /></StaffRoute>} />
|
||||
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
@ -174,23 +168,15 @@ 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 (
|
||||
<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 className="dashboard-container">
|
||||
<nav className="dashboard-nav">
|
||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||
</nav>
|
||||
<div className="dashboard-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</AnalyticsContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -233,7 +219,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.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>
|
||||
)}
|
||||
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
||||
@ -330,15 +316,4 @@ 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;
|
||||
@ -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>;
|
||||
};
|
||||
@ -15,15 +15,16 @@ const AuthorProfilePage = () => {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 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 : []);
|
||||
|
||||
// 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 : []);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Fetch error:', err);
|
||||
|
||||
@ -6,13 +6,10 @@ 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 = () => {
|
||||
@ -20,16 +17,13 @@ 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.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[]) => {
|
||||
if (key === 'popular') setPopular(data);
|
||||
@ -52,23 +46,13 @@ const Home = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [popRes, newRes, allRes] = await Promise.all([
|
||||
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 } })
|
||||
api.get('/plugins', { params: { sort: 'downloads' } }),
|
||||
api.get('/plugins', { params: { sort: 'newest' } }),
|
||||
api.get('/plugins')
|
||||
]);
|
||||
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 };
|
||||
saveSection('popular', Array.isArray(popRes.data) ? popRes.data : []);
|
||||
saveSection('newest', Array.isArray(newRes.data) ? newRes.data : []);
|
||||
saveSection('all', Array.isArray(allRes.data) ? allRes.data : []);
|
||||
} catch (err) {
|
||||
console.error('Fetch error for home sections:', err);
|
||||
} finally {
|
||||
@ -79,25 +63,6 @@ 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]);
|
||||
|
||||
@ -274,7 +239,7 @@ const 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">
|
||||
{allPlugins.length > 0 ? (
|
||||
allPlugins.map((plugin) => (
|
||||
@ -284,18 +249,6 @@ 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 — resolved from plugin.owned (injected by server) so no extra HTTP call needed
|
||||
// Ownership
|
||||
const [ownsPlugin, setOwnsPlugin] = useState(false);
|
||||
const [ownershipChecked, setOwnershipChecked] = useState(false);
|
||||
|
||||
@ -201,7 +201,6 @@ 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);
|
||||
@ -234,41 +233,19 @@ 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]);
|
||||
|
||||
// ── Fetch more plugins by same developer ─────────────────────────────────
|
||||
// ── Check ownership ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
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]);
|
||||
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]);
|
||||
|
||||
// ── Translations ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@ -1127,32 +1104,6 @@ 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,7 +4,6 @@ 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,
|
||||
@ -36,7 +35,6 @@ 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();
|
||||
|
||||
@ -50,7 +48,7 @@ const ProfilePage = () => {
|
||||
}, [location]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '', email: '', country: '', currency: '',
|
||||
username: '', email: '', country: '',
|
||||
password: '', currentPassword: '', disable2faPassword: '',
|
||||
newPassword: '', confirmPassword: '',
|
||||
});
|
||||
@ -81,9 +79,7 @@ const ProfilePage = () => {
|
||||
marketingEmail: false,
|
||||
});
|
||||
|
||||
// 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(() => { refreshUser?.(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@ -92,7 +88,6 @@ const ProfilePage = () => {
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
country: user.country || '',
|
||||
currency: user.preferred_currency || currentCurrency || 'EUR',
|
||||
}));
|
||||
setIs2faEnabled(user.totp_enabled || false);
|
||||
}
|
||||
@ -106,8 +101,7 @@ const ProfilePage = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if we already loaded the dev profile or the user isn't a developer.
|
||||
if (activeTab === 'developer' && user?.is_developer && devProfile === null) {
|
||||
if (activeTab === 'developer' && user?.is_developer) {
|
||||
api.get('/user/developer/profile')
|
||||
.then(res => {
|
||||
if (res.data.profile) {
|
||||
@ -128,7 +122,7 @@ const ProfilePage = () => {
|
||||
})
|
||||
.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) => {
|
||||
e.preventDefault();
|
||||
@ -144,15 +138,14 @@ const ProfilePage = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if library is already loaded or currently being fetched.
|
||||
if (activeTab !== 'library' || library.length > 0 || libraryLoading) return;
|
||||
if (activeTab !== 'library') 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, library.length, libraryLoading]);
|
||||
}, [activeTab]);
|
||||
|
||||
const showToast = (text: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ text, type });
|
||||
@ -441,34 +434,6 @@ 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,38 +24,18 @@ 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: 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,
|
||||
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,
|
||||
};
|
||||
const response = await api.get('/plugins', { params });
|
||||
setPlugins(Array.isArray(response.data) ? response.data : []);
|
||||
@ -72,15 +52,15 @@ const SearchResultsPage = () => {
|
||||
|
||||
// Update URL params
|
||||
const newParams: any = {};
|
||||
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;
|
||||
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;
|
||||
setSearchParams(newParams, { replace: true });
|
||||
|
||||
}, [debouncedQuery, debouncedSort, debouncedCategory, debouncedType, debouncedMinPrice, debouncedMaxPrice, setSearchParams]);
|
||||
}, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
@ -120,7 +100,7 @@ const SearchResultsPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Price Range (€)</label>
|
||||
<label>Price Range ($)</label>
|
||||
<div className="price-range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
@ -154,7 +134,6 @@ 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>
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -1,12 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
||||
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 DashboardAudience = () => {
|
||||
const { mapData, loading } = useAnalyticsContext();
|
||||
const { mapData, loading } = useAnalytics();
|
||||
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 { useAnalyticsContext } from './AnalyticsContext';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
|
||||
// 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 } = useAnalyticsContext();
|
||||
const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
|
||||
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAnalyticsContext } from './AnalyticsContext';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
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 } = useAnalyticsContext();
|
||||
const { timeSeries } = useAnalytics();
|
||||
const navigate = useNavigate();
|
||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||
|
||||
|
||||
@ -47,9 +47,8 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
setVersion('');
|
||||
setReleaseNotes('');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data || err?.message || 'Publish failed.';
|
||||
alert(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
} catch {
|
||||
alert('Publish failed.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user