setIsMenuOpen(false)}>{t('home')}
- {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') && (
setIsMenuOpen(false)}>{t('dashboard')}
)}
{user &&
setIsMenuOpen(false)}>Publish}
@@ -316,4 +330,15 @@ const Footer = () => (
);
+const NotFoundPage = () => (
+
+
404
+
Page Not Found
+
+ The page you're looking for doesn't exist or has been moved.
+
+
Go Home
+
+);
+
export default App;
\ No newline at end of file
diff --git a/src/context/CurrencyContext.tsx b/src/context/CurrencyContext.tsx
new file mode 100644
index 0000000..fe61fbb
--- /dev/null
+++ b/src/context/CurrencyContext.tsx
@@ -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
= {
+ 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(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(() => {
+ 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 {children};
+};
diff --git a/src/pages/AuthorProfilePage.tsx b/src/pages/AuthorProfilePage.tsx
index 5bd0781..e2ee06e 100644
--- a/src/pages/AuthorProfilePage.tsx
+++ b/src/pages/AuthorProfilePage.tsx
@@ -15,16 +15,15 @@ const AuthorProfilePage = () => {
useEffect(() => {
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);
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index bf575dd..067a207 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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(homeCache?.popular || []);
const [newest, setNewest] = useState(homeCache?.newest || []);
const [allPlugins, setAllPlugins] = useState(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(null);
const newestRef = useRef(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 = () => {
- All Plugins ({allPlugins.length})
+ All Plugins ({allPlugins.length}{allHasMore ? '+' : ''})
{allPlugins.length > 0 ? (
allPlugins.map((plugin) => (
@@ -249,6 +284,18 @@ const Home = () => {
{loading ? 'Loading all plugins...' : 'No plugins available'}
)}
+ {allHasMore && (
+
+
+
+ )}
{(fetched.current?.popular && fetched.current?.newest && popular.length === 0 && newest.length === 0 && allPlugins.length === 0) && (
diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx
index 3d2ebd4..b16454c 100644
--- a/src/pages/PluginDetail.tsx
+++ b/src/pages/PluginDetail.tsx
@@ -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([]);
// Review Report State
const [reviewReportOpen, setReviewReportOpen] = useState(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 = () => {
)}