diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 0ab39de..729ef9c 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -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 @@ -31,4 +31,4 @@ jobs: source: "dist/*" target: "/var/www/market/frontend/" strip_components: 1 - overwrite: true \ No newline at end of file + overwrite: true diff --git a/package-lock.json b/package-lock.json index 0e175a3..eeec88b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 048b1f3..9fa24b7 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,15 @@ "preview": "vite preview" }, "dependencies": { - "@marsidev/react-turnstile": "^1.5.3", + "@marsidev/react-turnstile": "^1.5.4", "@vnedyalk0v/react19-simple-maps": "^2.0.9", - "axios": "^1.18.1", + "axios": "^1.19.0", "country-list": "^2.4.1", "d3-geo": "^3.1.1", "d3-scale": "^4.0.2", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.27.0", + "lucide-react": "^1.28.0", "or": "^0.2.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", @@ -27,21 +27,21 @@ "react-i18next": "^17.0.11", "react-is": "^19.2.8", "react-markdown": "^10.1.0", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "recharts": "^3.10.1", "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/country-list": "^2.1.4", - "@types/node": "^26.1.1", + "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", + "globals": "^17.8.0", "typescript": "^7.0.2", "typescript-eslint": "^8.65.0", "vite": "^8.1.5" diff --git a/public/robots.txt b/public/robots.txt index aad69b3..0097637 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -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 diff --git a/src/App.tsx b/src/App.tsx index cb628ab..ce33157 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import ProfilePage from './pages/ProfilePage'; import AuthorProfilePage from './pages/AuthorProfilePage'; import SearchResultsPage from './pages/SearchResultsPage'; import DashboardAudience from './pages/dashboard/DashboardAudience'; +import DashboardOverview from './pages/dashboard/DashboardOverview'; import DashboardPlugins from './pages/dashboard/DashboardPlugins'; import ManagePlugin from './pages/dashboard/plugin/ManagePlugin'; import AddPlugin from './pages/dashboard/plugin/AddPlugin'; @@ -24,6 +25,9 @@ import AdminPanel from './pages/admin/AdminPanel'; import './App.css'; import api from './api'; +import { AnalyticsContext } from './pages/dashboard/AnalyticsContext'; +import { useAnalytics } from './pages/dashboard/useAnalytics'; +import { CurrencyProvider } from './context/CurrencyContext'; // --- Scroll To Top Helper --- const ScrollToTop = () => { @@ -94,7 +98,7 @@ const AuthProvider = ({ children }: { children: React.ReactNode }) => { return ( - {!loading && children} + {!loading && {children}} ); }; @@ -140,7 +144,8 @@ const App = () => ( } /> } /> }> - } /> + } /> + } /> } /> } /> } /> @@ -148,6 +153,7 @@ const App = () => ( } /> } /> + } /> @@ -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 ( -
- -
- + +
+ +
+ +
-
+ ); }; @@ -219,7 +233,7 @@ const Navbar = ({ user }: any) => {
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 = () => { )}
+ + {/* MORE BY THIS DEVELOPER */} + {moreByDev.length > 0 && ( +
+
+ More by {plugin.dev_name} +
+
+ {moreByDev.map((p: any) => ( + (e.currentTarget.style.background = 'rgba(255,255,255,0.07)')} + onMouseLeave={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.03)')} + > +
+
+
{p.name}
+
{p.type === 'free' ? 'Free' : `€${((p.price_cents || 0) / 100).toFixed(2)}`}
+
+ + ))} +
+
+ )}
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index fec844a..b7be79d 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -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 = () => { + + +
{ + if (!formData.currency) { + e.preventDefault(); + showToast('Please select a currency.', 'error'); + return; + } + handleUpdate(e, '/user/change-currency', { newCurrency: formData.currency }, 'currency'); + }}> + + + + + +
)} diff --git a/src/pages/SearchResultsPage.tsx b/src/pages/SearchResultsPage.tsx index d0f9f32..d4c9f62 100644 --- a/src/pages/SearchResultsPage.tsx +++ b/src/pages/SearchResultsPage.tsx @@ -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 (
@@ -100,7 +120,7 @@ const SearchResultsPage = () => {
- +
{