first commit

This commit is contained in:
Alexander Medvedev 2026-03-21 14:29:39 +01:00
commit 84491ad413
45 changed files with 15660 additions and 0 deletions

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2024,
globals: globals.browser,
},
},
])

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800;900&family=JetBrains+Mono:wght@700&display=swap" rel="stylesheet">
<title>Pumpkin Market</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5282
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.4.2",
"@vnedyalk0v/react19-simple-maps": "^2.0.2",
"axios": "^1.13.6",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"i18next": "^25.8.18",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.576.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.8",
"react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^10.0.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.5.0",
"typescript": "^6.0.0-beta",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.0"
},
"overrides": {
"vite": "^8.0.0"
}
}

3
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

1311
src/App.css Normal file

File diff suppressed because it is too large Load Diff

265
src/App.tsx Normal file
View File

@ -0,0 +1,265 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Home from './pages/Home';
import PluginDetail from './pages/PluginDetail';
import LoginPage from './pages/auth/LoginPage';
import RegisterPage from './pages/auth/RegisterPage';
import VerifyEmailPage from './pages/auth/VerifyEmailPage';
import CheckEmailPage from './pages/auth/CheckEmailPage';
import ProfilePage from './pages/ProfilePage';
import SearchResultsPage from './pages/SearchResultsPage';
import DashboardOverview from './pages/dashboard/DashboardOverview';
import DashboardAudience from './pages/dashboard/DashboardAudience';
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
import AddPlugin from './pages/dashboard/plugin/AddPlugin';
import TermsOfServicePage from './pages/legal/TermsOfServicePage';
import PrivacyPolicyPage from './pages/legal/PrivacyPolicyPage';
import AdminPanel from './pages/admin/AdminPanel';
import './App.css';
import api from './api';
// --- Auth Context ---
const AuthContext = createContext<any>(null);
export const useAuth = () => useContext(AuthContext);
const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
// Helper to decode JWT payload safely
const decodeToken = (token: string) => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
console.error('Failed to decode token', e);
return null;
}
};
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
const payload = decodeToken(token);
if (payload && payload.exp * 1000 > Date.now()) {
setUser(payload);
} else {
localStorage.removeItem('token');
}
}
setLoading(false);
}, []);
const login = (token: string) => {
localStorage.setItem('token', token);
const payload = decodeToken(token);
setUser(payload);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
const refreshUser = async () => {
try {
const res = await api.get('/user/me');
if (res.data.token) {
login(res.data.token);
}
} catch (e) {
console.error('Failed to refresh user', e);
}
};
return (
<AuthContext.Provider value={{ user, login, logout, loading, refreshUser }}>
{!loading && children}
</AuthContext.Provider>
);
};
// --- Protected Route ---
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" />;
}
return children;
};
// --- Admin Route ---
const AdminRoute = ({ children }: { children: React.ReactNode }) => {
const { user } = useAuth();
if (!user || user.role !== 'admin') {
return <Navigate to="/" />;
}
return children;
};
// --- Main App & Layout ---
const App = () => (
<Router>
<AuthProvider>
<Routes>
{/* All routes share MainLayout (navbar + footer) */}
<Route path="/" element={<MainLayout />}>
<Route index element={<Home />} />
<Route path="plugin/:id" element={<PluginDetail />} />
<Route path="search" element={<SearchResultsPage />} />
<Route path="terms" element={<TermsOfServicePage />} />
<Route path="privacy" element={<PrivacyPolicyPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route path="verify-email" element={<VerifyEmailPage />} />
<Route path="check-email" element={<CheckEmailPage />} />
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
<Route index element={<DashboardOverview />} />
<Route path="audience" element={<DashboardAudience />} />
<Route path="plugins" element={<DashboardPlugins />} />
<Route path="add-plugin" element={<AddPlugin />} />
<Route path="manage-plugin/:id" element={<ManagePlugin />} />
</Route>
<Route path="admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
</Route>
</Routes>
</AuthProvider>
</Router>
);
const MainLayout = () => {
const { user } = useAuth();
return (
<>
<Navbar user={user} />
<main><Outlet /></main>
<Footer />
</>
);
};
import './pages/dashboard/Dashboard.css';
const DashboardLayout = () => {
const { t } = useTranslation();
return (
<div className="dashboard-container">
<nav className="dashboard-nav">
<NavLink to="/dashboard" end>{t('dashboard')}</NavLink>
{/* <NavLink to="/dashboard/audience">Audience</NavLink> */}
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
</nav>
<div className="dashboard-content">
<Outlet />
</div>
</div>
);
};
// --- Components ---
const Navbar = ({ user }: any) => {
const { t, i18n } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const navigate = useNavigate();
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
setSearchQuery('');
}
};
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
};
return (
<nav className="navbar">
<div className="logo" style={{ display: 'flex', alignItems: 'center' }}>
<Link to="/">
<img src="/icon.png" alt="Market Logo" style={{ height: '50px', marginRight: '0px', verticalAlign: 'middle' }} />
<span>MARKET</span>
</Link>
</div>
<div className="nav-links">
<NavLink to="/">{t('home')}</NavLink>
{user && <NavLink to="/dashboard">{t('dashboard')}</NavLink>}
{user && user.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
</div>
<div className="nav-search">
<input
type="text"
placeholder={t('search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
/>
</div>
<div className="nav-actions">
{user ? (
<>
<NavLink to="/settings" className="nav-user-link">{user.username}</NavLink>
</>
) : (
<>
<Link to="/login" className="btn btn-secondary">{t('login')}</Link>
<Link to="/register" className="btn">{t('register')}</Link>
</>
)}
</div>
</nav>
);
};
const Footer = () => (
<footer style={{ padding: '16px 24px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
flexWrap: 'wrap',
}}>
{/* Logo inline */}
<Link to="/" style={{ display: 'flex', alignItems: 'center', gap: '6px', textDecoration: 'none', opacity: 0.6 }}>
<img src="/icon.png" alt="Market Logo" style={{ height: '20px', verticalAlign: 'middle' }} />
</Link>
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
<p style={{ fontSize: '12px', opacity: 0.4, margin: 0 }}>
© 2026 PumpkinMC
</p>
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
<Link
to="/terms"
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
>
Terms
</Link>
<Link
to="/privacy"
style={{ fontSize: '12px', color: 'inherit', opacity: 0.4, textDecoration: 'none' }}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.4')}
>
Privacy
</Link>
</div>
</footer>
);
export default App;

17
src/api.ts Normal file
View File

@ -0,0 +1,17 @@
import axios from 'axios';
const API_URL = 'http://localhost:5000/api';
const api = axios.create({
baseURL: API_URL,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

35
src/i18n.ts Normal file
View File

@ -0,0 +1,35 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation files directly to avoid extra HTTP requests
// and ensure we don't have issues with Vite's dev server for static assets
import enTranslation from './locales/en.json';
import esTranslation from './locales/es.json';
const resources = {
en: {
translation: enTranslation,
},
es: {
translation: esTranslation,
},
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
showSupportNotice: false,
interpolation: {
escapeValue: false, // React already escapes values
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag', 'path', 'subdomain'],
caches: ['localStorage', 'cookie'],
},
});
export default i18n;

15
src/index.css Normal file
View File

@ -0,0 +1,15 @@
/* Reset default Vite styles */
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
width: 100%;
}

35
src/locales/en.json Normal file
View File

@ -0,0 +1,35 @@
{
"welcome": "Welcome to PumpkinMarket",
"search": "Search for plugins...",
"login": "Login",
"register": "Register",
"home": "Home",
"dashboard": "Dashboard",
"profile": "Profile",
"logout": "Logout",
"loading": "Loading...",
"error": "Something went wrong. Please try again later.",
"plugins": {
"title": "Browse Plugins",
"upload": "Upload Plugin",
"manage": "Manage Plugin",
"price": "Price",
"downloads": "Downloads",
"views": "Views"
},
"auth": {
"email": "Email Address",
"username": "Username",
"password": "Password",
"confirm_password": "Confirm Password",
"create_account": "Create Account",
"sign_in": "Sign In"
},
"common": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"back": "Back"
}
}

35
src/locales/es.json Normal file
View File

@ -0,0 +1,35 @@
{
"welcome": "Bienvenido a PumpkinMarket",
"search": "Buscar plugins...",
"login": "Iniciar sesión",
"register": "Registrarse",
"home": "Inicio",
"dashboard": "Panel de control",
"profile": "Perfil",
"logout": "Cerrar sesión",
"loading": "Cargando...",
"error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde.",
"plugins": {
"title": "Explorar Plugins",
"upload": "Subir Plugin",
"manage": "Gestionar Plugin",
"price": "Precio",
"downloads": "Descargas",
"views": "Vistas"
},
"auth": {
"email": "Correo electrónico",
"username": "Nombre de usuario",
"password": "Contraseña",
"confirm_password": "Confirmar contraseña",
"create_account": "Crear cuenta",
"sign_in": "Iniciar sesión"
},
"common": {
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"back": "Volver"
}
}

11
src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import './i18n'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

339
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,339 @@
import { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../api';
const API_URL = 'http://localhost:5000';
const Home = () => {
const { t, i18n } = useTranslation();
const [plugins, setPlugins] = useState<any[]>([]);
const hasFetched = useRef(false);
useEffect(() => {
if (hasFetched.current) return;
api.get('/plugins', { params: { q: '', sort: 'newest' } })
.then(res => {
setPlugins(res.data);
hasFetched.current = true;
})
.catch(err => console.error('Fetch error:', err));
}, []);
const getDescription = (translatedStr: string) => {
try {
const data = JSON.parse(translatedStr);
const currentLang = i18n.language.split('-')[0];
return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
} catch {
return 'No description available.';
}
};
const getAccentColor = (name: string) => {
const colors = [
'#4f7eff', '#ff6b6b', '#ffd166', '#06d6a0',
'#a855f7', '#f97316', '#06b6d4', '#ec4899',
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
};
// Compute effective display price respecting active sale
const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; originalDisplay: string } => {
const base = plugin.price_cents ?? 0;
const saleActive = plugin.sale_active && plugin.sale_discount_percent > 0;
if (saleActive) {
const saleCents = Math.round(base * (1 - plugin.sale_discount_percent / 100));
return {
display: `€${(saleCents / 100).toFixed(2)}`,
isSale: true,
originalDisplay: `€${(base / 100).toFixed(2)}`,
};
}
return {
display: `€${(base / 100).toFixed(2)}`,
isSale: false,
originalDisplay: '',
};
};
return (
<>
<style>{`
.home-plugin-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
padding: 2rem 0 4rem;
}
.plugin-card-link { text-decoration: none; display: block; }
.plugin-card-v2 {
position: relative;
border-radius: 10px;
overflow: hidden;
background: var(--mp-surface, #16191f);
border: 1px solid var(--mp-border, rgba(255,255,255,0.07));
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
will-change: transform;
}
.plugin-card-v2:hover {
transform: translateY(-3px);
box-shadow: 0 16px 40px rgba(0,0,0,0.45);
border-color: rgba(255,255,255,0.14);
}
.pcv2-preview {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--mp-bg, #0d0f12);
}
.pcv2-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.35s ease;
}
.plugin-card-v2:hover .pcv2-preview-img { transform: scale(1.04); }
.pcv2-preview-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.8rem;
font-weight: 800;
letter-spacing: -0.04em;
color: rgba(255,255,255,0.9);
font-family: var(--font-display, system-ui);
user-select: none;
}
.pcv2-preview-fade {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 30%, rgba(13,15,18,0.55) 65%, rgba(13,15,18,0.96) 100%);
pointer-events: none;
}
.pcv2-category-badge {
position: absolute;
top: 10px; left: 10px;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
background: rgba(13,15,18,0.72);
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.75);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.pcv2-price-badge {
position: absolute;
top: 10px; right: 10px;
font-size: 0.7rem;
font-weight: 700;
padding: 0.2rem 0.55rem;
border-radius: 4px;
backdrop-filter: blur(6px);
display: flex;
align-items: center;
gap: 0.35rem;
}
.pcv2-price-badge.free {
background: rgba(6,214,160,0.18);
border: 1px solid rgba(6,214,160,0.35);
color: #06d6a0;
}
.pcv2-price-badge.paid {
background: rgba(255,209,102,0.16);
border: 1px solid rgba(255,209,102,0.35);
color: #ffd166;
}
.pcv2-price-badge.sale {
background: rgba(255,107,107,0.18);
border: 1px solid rgba(255,107,107,0.4);
color: #ff6b6b;
}
.pcv2-price-original {
text-decoration: line-through;
opacity: 0.55;
font-weight: 500;
}
.pcv2-sale-pct {
font-size: 0.58rem;
font-weight: 800;
background: rgba(255,107,107,0.25);
border-radius: 3px;
padding: 1px 4px;
letter-spacing: 0.04em;
}
.pcv2-info { padding: 0.85rem 1rem 1rem; }
.pcv2-name {
font-size: 0.95rem;
font-weight: 700;
color: var(--mp-text-1, #f0f0f0);
margin: 0 0 0.15rem;
font-family: var(--font-display, system-ui);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pcv2-dev { font-size: 0.72rem; color: var(--mp-text-3, #5a6070); margin: 0 0 0.55rem; }
.pcv2-desc {
font-size: 0.77rem;
color: var(--mp-text-2, #8b909a);
line-height: 1.55;
margin: 0 0 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pcv2-footer {
display: flex;
align-items: center;
gap: 0.9rem;
padding-top: 0.65rem;
border-top: 1px solid var(--mp-border, rgba(255,255,255,0.06));
}
.pcv2-stat {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.7rem; color: var(--mp-text-3, #5a6070);
}
.pcv2-stat svg { opacity: 0.6; }
.pcv2-accent-bar {
position: absolute; bottom: 0; left: 0; right: 0;
height: 2px; opacity: 0;
transition: opacity 0.2s ease;
}
.plugin-card-v2:hover .pcv2-accent-bar { opacity: 1; }
/* Sale banner strip at bottom of card */
.pcv2-sale-strip {
background: linear-gradient(90deg, rgba(255,107,107,0.15), rgba(255,107,107,0.05));
border-top: 1px solid rgba(255,107,107,0.2);
padding: 0.3rem 1rem;
font-size: 0.65rem;
font-weight: 700;
color: #ff6b6b;
letter-spacing: 0.06em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: 0.4rem;
}
`}</style>
<section className="hero">
<h1>{t('welcome')}</h1>
<p>Explore powerful plugins for your PumpkinMC experience.</p>
</section>
<div className="container" id="browse">
<div className="home-plugin-grid">
{plugins.map((plugin) => {
const accent = getAccentColor(plugin.name);
const desc = getDescription(plugin.translated_descriptions);
const initial = plugin.name.charAt(0).toUpperCase();
const priceInfo = getEffectivePrice(plugin);
const isSale = plugin.type === 'paid' && priceInfo.isSale;
return (
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
<div className="plugin-card-v2">
{/* Preview image */}
<div className="pcv2-preview">
{plugin.preview_path ? (
<img
className="pcv2-preview-img"
src={plugin.preview_path}
alt={plugin.name}
/>
) : (
<div
className="pcv2-preview-fallback"
style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}
>
<span style={{ color: accent }}>{initial}</span>
</div>
)}
<div className="pcv2-preview-fade" />
<span className="pcv2-category-badge">{plugin.category}</span>
{/* Price badge */}
{plugin.type === 'free' ? (
<span className="pcv2-price-badge free">Free</span>
) : isSale ? (
<span className="pcv2-price-badge sale">
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
{priceInfo.display}
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
</span>
) : (
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
)}
</div>
{/* Info */}
<div className="pcv2-info">
<p className="pcv2-name">{plugin.name}</p>
<p className="pcv2-dev">by {plugin.dev_name}</p>
<p className="pcv2-desc">{desc}</p>
<div className="pcv2-footer">
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
{(plugin.downloads ?? 0).toLocaleString()}
</span>
{plugin.rating != null && (
<span className="pcv2-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
</svg>
{Number(plugin.rating).toFixed(1)}
</span>
)}
{plugin.version && (
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
v{plugin.version}
</span>
)}
</div>
</div>
{/* Sale strip at bottom */}
{isSale && (
<div className="pcv2-sale-strip">
🔥 Sale — {plugin.sale_discount_percent}% off
</div>
)}
<div
className="pcv2-accent-bar"
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
/>
</div>
</Link>
);
})}
{plugins.length === 0 && (
<div className="empty-state">
<p>No plugins found. Check back later!</p>
</div>
)}
</div>
</div>
</>
);
};
export default Home;

564
src/pages/PluginDetail.tsx Normal file
View File

@ -0,0 +1,564 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useLocation, Link } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../App';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom';
const API_URL = 'http://localhost:5000';
const REPORT_REASONS = [
'Malware or malicious code',
'Stolen or plagiarised content',
'Misleading description',
'Broken / non-functional',
'Inappropriate content',
'Spam or fake listing',
'Other',
];
const StarRating = ({ rating, max = 5, onRate }: { rating: number; max?: number; onRate?: (n: number) => void }) => (
<div className="star-rating-container">
{[...Array(max)].map((_, i) => (
<span
key={i}
className={`star-icon ${i < rating ? 'filled' : 'empty'} ${onRate ? 'interactive' : ''}`}
onClick={() => onRate && onRate(i + 1)}
>
{i < rating ? '★' : '☆'}
</span>
))}
</div>
);
const PriceDisplay = ({ cents }: { cents: number }) => {
if (!cents && cents !== 0) return null;
const euros = Math.floor(cents / 100);
const remainder = (cents % 100).toString().padStart(2, '0');
return (
<div className="price-container">
<span className="currency-symbol">€</span>
<span className="price-dollars">{euros}</span>
<span className="price-decimal">.</span>
<span className="price-cents">{remainder}</span>
</div>
);
};
const getAvatarClass = (username: string) => {
const classes = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return classes[Math.abs(hash) % classes.length];
};
const getInitials = (username: string) => {
const parts = username.trim().split(/[\s_\-\.]+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return username.slice(0, 2).toUpperCase();
};
const PluginDetail = () => {
const { id } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [plugin, setPlugin] = useState<any>(null);
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
const [adShown, setAdShown] = useState(false);
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState('en');
const [currentDescription, setCurrentDescription] = useState('');
const [loading, setLoading] = useState(true);
// Ownership
const [ownsPlugin, setOwnsPlugin] = useState(false);
const [ownershipChecked, setOwnershipChecked] = useState(false);
// Payment success banner
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
// Report
const [reportOpen, setReportOpen] = useState(false);
const [reportSelected, setReportSelected] = useState<string>('');
const [reportCustom, setReportCustom] = useState('');
const [reportSubmitting, setReportSubmitting] = useState(false);
const [reportDone, setReportDone] = useState(false);
// ── Detect ?payment=success and strip from URL ───────────────────────────
useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('payment') === 'success') {
setShowPaymentSuccess(true);
window.history.replaceState({}, '', location.pathname);
}
}, []);
// ── Fetch plugin ─────────────────────────────────────────────────────────
const fetchPlugin = () => {
api.get(`/plugins/${id}`).then(res => {
setPlugin(res.data);
setLoading(false);
if (res.data.screenshots?.length > 0) {
setMainScreenshot(res.data.screenshots[0].path);
}
});
};
useEffect(() => { fetchPlugin(); }, [id]);
// ── Check ownership ──────────────────────────────────────────────────────
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]);
// ── Translations ─────────────────────────────────────────────────────────
useEffect(() => {
if (plugin?.translated_descriptions) {
try {
const translations = JSON.parse(plugin.translated_descriptions);
setCurrentDescription(
translations[selectedLanguage] ||
translations['en'] ||
Object.values(translations)[0] as string ||
''
);
} catch {
setCurrentDescription('Description not available.');
}
} else {
setCurrentDescription('Description not available.');
}
}, [plugin, selectedLanguage]);
const hasReviewed = useMemo(() => {
if (!user || !plugin?.reviews) return false;
return plugin.reviews.some((r: any) => r.username === user.username);
}, [plugin?.reviews, user]);
const stats = useMemo(() => {
if (!plugin?.reviews || plugin.reviews.length === 0) return null;
const total = plugin.reviews.length;
const distribution = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
let sum = 0;
plugin.reviews.forEach((r: any) => {
distribution[r.rating as keyof typeof distribution]++;
sum += r.rating;
});
return { avg: (sum / total).toFixed(1), total, distribution };
}, [plugin?.reviews]);
// ── Trigger a blob download ──────────────────────────────────────────────
const triggerDownload = async (endpoint: string) => {
const res = await api.get(endpoint, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${plugin.name.replace(/\s+/g, '_')}.wasm`;
a.click();
URL.revokeObjectURL(url);
};
// ── Download / purchase handler ──────────────────────────────────────────
const handleDownload = async () => {
if (plugin.type === 'paid') {
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
// Already owns it — download directly, no checkout
if (ownsPlugin) {
try {
await triggerDownload(`/plugins/${id}/download/premium`);
} catch {
alert('Download failed. Please try again.');
}
return;
}
// Does not own — go to Stripe checkout
try {
setLoading(true);
const res = await api.post(`/plugins/${id}/checkout`);
if (res.data.url) window.location.href = res.data.url;
} catch (err: any) {
if (err?.response?.status === 409) {
// Webhook already recorded purchase before redirect resolved
setOwnsPlugin(true);
alert('You already own this plugin — downloading now.');
await triggerDownload(`/plugins/${id}/download/premium`);
} else {
alert('Checkout failed. Please try again.');
}
} finally {
setLoading(false);
}
return;
}
// Adwall
if (plugin.type === 'adwall' && !adShown) {
alert('Wait 5 seconds to support the developer...');
setTimeout(() => setAdShown(true), 5000);
return;
}
// Free / adwall (after ad)
try {
await triggerDownload(`/plugins/${id}/download`);
} catch {
alert('Download failed. Please try again.');
}
};
const submitReview = async (e: React.FormEvent) => {
e.preventDefault();
if (!reviewForm.comment.trim()) return;
await api.post(`/plugins/${id}/reviews`, reviewForm);
fetchPlugin();
setReviewForm({ rating: 5, comment: '' });
};
const submitReport = async (e: React.FormEvent) => {
e.preventDefault();
const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected;
if (!reason) return;
setReportSubmitting(true);
try {
await api.post(`/plugins/${id}/report`, { reason });
setReportDone(true);
setReportSelected('');
setReportCustom('');
} catch {
alert('Failed to submit report. Please try again.');
} finally {
setReportSubmitting(false);
}
};
const resetReport = () => {
setReportOpen(false);
setReportSelected('');
setReportCustom('');
};
if (!plugin) return <div className="container"><h2>LOADING...</h2></div>;
const downloadLabel = (() => {
if (plugin.type === 'paid') {
if (!ownershipChecked) return 'Loading…';
return ownsPlugin ? 'Download Latest WASM' : 'Purchase Plugin';
}
if (plugin.type === 'adwall' && !adShown) return 'Watch Ad to Download';
return 'Download Latest WASM';
})();
return (
<div className="container">
{/* ── Payment success banner ── */}
{showPaymentSuccess && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
background: 'rgba(74,222,128,0.1)', border: '1px solid rgba(74,222,128,0.35)',
borderRadius: 10, padding: '1rem 1.25rem', marginBottom: '1.5rem',
}}>
<span style={{ fontSize: '1.4rem' }}>🎉</span>
<div>
<div style={{ fontWeight: 700, color: 'var(--success, #4ade80)', marginBottom: '0.15rem' }}>
Purchase successful!
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
You now own <strong>{plugin.name}</strong>. Click the download button to get your WASM file.
</div>
</div>
<button
onClick={() => setShowPaymentSuccess(false)}
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', lineHeight: 1 }}
>×</button>
</div>
)}
{/* HEADER */}
<div className="plugin-detail-header">
<div
className="plugin-icon large"
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
>
{!plugin.preview_path && plugin.name.charAt(0)}
</div>
<div>
<h1 className="plugin-title">{plugin.name}</h1>
<p className="dev-name">by {plugin.dev_name}</p>
</div>
</div>
<div className="plugin-detail-layout">
<div className="plugin-main-content">
{/* SCREENSHOTS */}
{plugin.screenshots && plugin.screenshots.length > 0 && (
<div className="screenshot-gallery">
<img
src={mainScreenshot || plugin.screenshots[0].path}
alt="Main"
className="main-screenshot"
/>
<div className="thumbnail-strip">
{plugin.screenshots.map((shot: any) => (
<img
key={shot.id}
src={shot.path}
alt="Thumbnail"
className={`thumbnail ${mainScreenshot === shot.path ? 'active' : ''}`}
onClick={() => setMainScreenshot(shot.path)}
/>
))}
</div>
</div>
)}
{/* DESCRIPTION */}
<div className="markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDescription}</ReactMarkdown>
</div>
<hr className="divider" />
{/* REVIEWS */}
<section className="reviews-section">
<span className="reviews-section-label">Ratings &amp; Reviews</span>
{stats ? (
<div className="review-summary-grid">
<div className="review-avg-block">
<div className="review-big-avg">{stats.avg}</div>
<div className="review-stars-row">
{[1, 2, 3, 4, 5].map(n => (
<span key={n} className={`review-star-icon ${n <= Math.round(Number(stats.avg)) ? 'filled' : 'empty'}`}>★</span>
))}
</div>
<div className="review-total-label">{stats.total.toLocaleString()} Ratings</div>
</div>
<div className="review-bars-block">
{[5, 4, 3, 2, 1].map(num => (
<div key={num} className="review-bar-row">
<span className="review-bar-num">{num}</span>
<div className="review-bar-track">
<div
className="review-bar-fill"
style={{ width: `${(stats.distribution[num as keyof typeof stats.distribution] / stats.total) * 100}%` }}
/>
</div>
<span className="review-bar-count">
{stats.distribution[num as keyof typeof stats.distribution]}
</span>
</div>
))}
</div>
</div>
) : (
<div className="empty-reviews-prompt">No reviews yet. Be the first to leave one!</div>
)}
<div className="review-list-premium">
{plugin.reviews?.map((r: any) => (
<div key={r.id} className="review-item-premium">
<div className="review-item-top">
<div className={`review-avatar ${getAvatarClass(r.username)}`}>
{getInitials(r.username)}
</div>
<div className="review-item-meta">
<span className="review-item-author">{r.username}</span>
<span className="review-item-date">Verified User</span>
</div>
</div>
<div className="review-item-stars">
{[1, 2, 3, 4, 5].map(n => (
<span key={n} className={`review-star-sm ${n <= r.rating ? 'filled' : 'empty'}`}>★</span>
))}
</div>
<p className="review-item-body">{r.comment}</p>
</div>
))}
</div>
{user ? (
hasReviewed ? (
<div className="review-already-posted">
<span className="review-already-icon">✓</span>
<div>
<div className="review-already-title">Review posted</div>
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
</div>
</div>
) : (
<form onSubmit={submitReview} className="review-submit-form">
<h3 className="review-form-title">Leave a Review</h3>
<div className="form-group">
<label>Your Rating</label>
<StarRating rating={reviewForm.rating} onRate={val => setReviewForm({ ...reviewForm, rating: val })} />
</div>
<div className="form-group">
<label>Your Experience</label>
<textarea
placeholder="What did you like? What could be better?"
value={reviewForm.comment}
onChange={e => setReviewForm({ ...reviewForm, comment: e.target.value })}
required
/>
</div>
<button className="btn btn-primary" type="submit">Post Review</button>
</form>
)
) : (
<div className="auth-notice">
Please <Link to="/login">login</Link> to rate this plugin.
</div>
)}
</section>
</div>
{/* SIDEBAR */}
<aside className="plugin-sidebar">
<div className="sidebar-widget">
{/* Price / owned indicator */}
<div className="price-hero-section">
{plugin.type === 'paid' && ownsPlugin ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--success, #4ade80)', fontWeight: 700, fontSize: '0.9rem' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
Purchased
</div>
) : plugin.type === 'paid' ? (
<div>
{plugin.sale_active && plugin.sale_discount_percent > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
{/* Sale price prominent */}
<PriceDisplay cents={Math.round(plugin.price_cents * (1 - plugin.sale_discount_percent / 100))} />
{/* Original price struck through */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
€{(plugin.price_cents / 100).toFixed(2)}
</span>
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.15)', border: '1px solid rgba(255,107,107,0.35)', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px', letterSpacing: '0.04em' }}>
-{plugin.sale_discount_percent}% SALE
</span>
</div>
</div>
) : (
<PriceDisplay cents={plugin.price_cents} />
)}
</div>
) : (
<div className="price-free">{plugin.type === 'adwall' ? 'AD SUPPORTED' : 'FREE'}</div>
)}
</div>
<button
className="btn btn-download-main"
onClick={handleDownload}
disabled={plugin.type === 'paid' && !ownershipChecked}
>
{downloadLabel}
</button>
{plugin.source_link && (
<a href={plugin.source_link} target="_blank" rel="noopener noreferrer" className="btn btn-outline">
View Source Code
</a>
)}
<div className="sidebar-stats">
<div className="stat-item"><strong>{plugin.downloads.toLocaleString()}</strong> Downloads</div>
<div className="stat-item"><strong>{plugin.views.toLocaleString()}</strong> Views</div>
<div className="stat-item">Category: <strong>{plugin.category}</strong></div>
{/* ADD THESE TWO */}
{plugin.version && (
<div className="stat-item">Version: <strong>{plugin.version}</strong></div>
)}
{plugin.file_size != null && (
<div className="stat-item">
File Size: <strong>
{plugin.file_size >= 1_048_576
? `${(plugin.file_size / 1_048_576).toFixed(2)} MB`
: plugin.file_size >= 1_024
? `${(plugin.file_size / 1_024).toFixed(1)} KB`
: `${plugin.file_size} B`}
</strong>
</div>
)}
<div className="divider-sm" />
{user && !reportDone && (
<>
{!reportOpen ? (
<button className="btn-report" onClick={() => setReportOpen(true)}>
🚩 Report plugin
</button>
) : (
<form onSubmit={submitReport} className="report-form">
<label className="report-form-label">Reason for report</label>
<div className="report-reasons">
{REPORT_REASONS.map(reason => (
<button
key={reason}
type="button"
className={`report-reason-chip ${reportSelected === reason ? 'selected' : ''}`}
onClick={() => { setReportSelected(reason); setReportCustom(''); }}
>
{reason}
</button>
))}
</div>
{reportSelected === 'Other' && (
<textarea
className="report-textarea"
placeholder="Describe the issue…"
value={reportCustom}
onChange={e => setReportCustom(e.target.value)}
rows={3}
autoFocus
/>
)}
<div className="report-form-actions">
<button type="button" className="btn-report-cancel" onClick={resetReport}>
Cancel
</button>
<button
type="submit"
className="btn-report-submit"
disabled={
reportSubmitting ||
!reportSelected ||
(reportSelected === 'Other' && !reportCustom.trim())
}
>
{reportSubmitting ? 'Sending…' : 'Submit'}
</button>
</div>
</form>
)}
</>
)}
{reportDone && (
<p className="report-done">✓ Report submitted. Thanks for keeping the marketplace safe.</p>
)}
</div>
</div>
</aside>
</div>
</div>
);
};
export default PluginDetail;

361
src/pages/ProfilePage.tsx Normal file
View File

@ -0,0 +1,361 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import api from '../api';
import { useAuth } from '../App';
const API_URL = 'http://localhost:5000';
interface LibraryEntry {
plugin_id: number;
name: string;
category: string | null;
preview_path: string | null;
dev_name: string;
amount_cents: number;
purchased_at: string;
}
const ProfilePage = () => {
const { user, login, logout, refreshUser } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [activeTab, setActiveTab] = useState('accountDetails');
useEffect(() => {
const params = new URLSearchParams(location.search);
const tab = params.get('tab');
if (tab === 'payouts') setActiveTab('payouts');
else if (tab === 'security') setActiveTab('security');
else if (tab === 'danger') setActiveTab('danger');
else if (tab === 'library') setActiveTab('library');
else setActiveTab('accountDetails');
}, [location]);
const [formData, setFormData] = useState({ username: '', email: '', password: '' });
const [message, setMessage] = useState({ text: '', type: '' });
const [isStripeLoading, setIsStripeLoading] = useState(false);
// Library state
const [library, setLibrary] = useState<LibraryEntry[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState<string | null>(null);
useEffect(() => { refreshUser?.(); }, []);
useEffect(() => {
if (user) {
setFormData(prev => ({ ...prev, username: user.username || '', email: user.email || '' }));
}
}, [user]);
// Fetch library when tab becomes active
useEffect(() => {
if (activeTab !== 'library') return;
setLibraryLoading(true);
setLibraryError(null);
api.get('/user/library')
.then(res => setLibrary(res.data))
.catch(() => setLibraryError('Failed to load your library. Please try again.'))
.finally(() => setLibraryLoading(false));
}, [activeTab]);
const showMessage = (text: string, isError = false) => {
setMessage({ text, type: isError ? 'error' : 'success' });
setTimeout(() => setMessage({ text: '', type: '' }), 4000);
};
const handleUpdate = async (e: React.FormEvent, endpoint: string, payload: object) => {
e.preventDefault();
try {
const res = await api.post(endpoint, payload);
if (res.data.token) login(res.data.token);
showMessage(res.data.message || 'Updated successfully!');
} catch (err: any) {
showMessage(err.response?.data?.error || 'Update failed.', true);
}
};
const handleLogout = () => { logout(); navigate('/login'); };
const handleConnectStripe = async () => {
setIsStripeLoading(true);
try {
const res = await api.post('/stripe/onboard');
if (res.data.url) window.location.href = res.data.url;
} catch (err: any) {
showMessage(err.response?.data?.error || 'Failed to start Stripe onboarding.', true);
setIsStripeLoading(false);
}
};
const handleGoToStripe = async () => {
setIsStripeLoading(true);
try {
const res = await api.get('/stripe/dashboard');
if (res.data.url) window.location.href = res.data.url;
} catch (err: any) {
showMessage(err.response?.data?.error || 'Failed to open Stripe dashboard.', true);
setIsStripeLoading(false);
}
};
const handleDeleteAccount = async () => {
if (!window.confirm('Are you sure you want to delete your account? This action is irreversible.')) return;
try {
await api.delete('/user');
logout();
navigate('/login');
} catch (err: any) {
showMessage(err.response?.data?.error || 'Failed to delete account.', true);
}
};
return (
<div className="container profile-page">
<h1 className="page-title">User <span>Profile</span></h1>
{message.text && (
<p
className={`form-message ${message.type === 'error' ? 'text-danger' : 'text-success'}`}
style={{ textAlign: 'center', color: message.type === 'error' ? '#ff4d4d' : '#4ade80' }}
>
{message.text}
</p>
)}
<div className="profile-layout">
<nav className="profile-nav">
<button className={`profile-nav-item ${activeTab === 'accountDetails' ? 'active' : ''}`} onClick={() => setActiveTab('accountDetails')}>
Account Details
</button>
<button className={`profile-nav-item ${activeTab === 'library' ? 'active' : ''}`} onClick={() => setActiveTab('library')}>
Library
</button>
<button className={`profile-nav-item ${activeTab === 'payouts' ? 'active' : ''}`} onClick={() => setActiveTab('payouts')}>
Payouts
</button>
<button className={`profile-nav-item ${activeTab === 'security' ? 'active' : ''}`} onClick={() => setActiveTab('security')}>
Security
</button>
<button className={`profile-nav-item ${activeTab === 'danger' ? 'active' : ''}`} onClick={() => setActiveTab('danger')} style={{ color: '#ff4d4d' }}>
Danger Zone
</button>
<button className="profile-nav-item" onClick={handleLogout} style={{ marginTop: 'auto', borderTop: '1px solid #333' }}>
Sign Out
</button>
</nav>
<div className="profile-content">
{/* ── Account Details ── */}
{activeTab === 'accountDetails' && (
<div className="profile-section">
<h2 className="section-title"><span>Change</span> Account Details</h2>
<form onSubmit={(e) => handleUpdate(e, '/user/change-username', { newUsername: formData.username })}>
<div className="form-group">
<label>USERNAME</label>
<input type="text" value={formData.username} onChange={e => setFormData({ ...formData, username: e.target.value })} />
</div>
<button type="submit" className="btn">Update Username</button>
</form>
<form onSubmit={(e) => handleUpdate(e, '/user/change-email', { newEmail: formData.email })} style={{ marginTop: '2rem' }}>
<div className="form-group">
<label>EMAIL</label>
<input type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} />
</div>
<button type="submit" className="btn">Update Email</button>
</form>
</div>
)}
{/* ── Library ── */}
{activeTab === 'library' && (
<div className="profile-section">
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}><span>My</span> Library</h2>
{library.length > 0 && (
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
{library.length} plugin{library.length !== 1 ? 's' : ''}
</span>
)}
</div>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem', fontSize: '0.88rem' }}>
Your purchased plugins. Click to visit the plugin page and download.
</p>
{libraryLoading && (
<div className="library-loading">
{[...Array(3)].map((_, i) => (
<div key={i} className="library-skeleton" style={{ animationDelay: `${i * 0.1}s` }} />
))}
</div>
)}
{libraryError && (
<div className="library-error">
<span style={{ fontSize: '1.5rem' }}>⚠️</span>
<span>{libraryError}</span>
</div>
)}
{!libraryLoading && !libraryError && library.length === 0 && (
<div className="library-empty">
<div className="library-empty-bg" />
<div className="library-empty-icon">📦</div>
<p className="library-empty-title">Your library is empty</p>
<p className="library-empty-sub">Purchased plugins will appear here, ready to download anytime.</p>
<Link to="/" className="btn" style={{ marginTop: '1.25rem', fontSize: '0.8rem', padding: '0.6rem 1.4rem' }}>
Browse Plugins
</Link>
</div>
)}
{!libraryLoading && !libraryError && library.length > 0 && (
<div className="library-grid">
{library.map((entry, idx) => (
<Link
key={entry.plugin_id}
to={`/plugin/${entry.plugin_id}`}
className="library-card"
style={{ animationDelay: `${idx * 0.05}s` }}
>
{/* Preview image */}
<div className="library-card-image">
{entry.preview_path ? (
<img
src={entry.preview_path}
alt={entry.name}
/>
) : (
<div className="library-card-image-fallback">
{entry.name.charAt(0).toUpperCase()}
</div>
)}
{/* Owned badge */}
<div className="library-owned-badge">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Owned
</div>
{/* Category tag */}
{entry.category && (
<div className="library-card-category-tag">{entry.category}</div>
)}
</div>
{/* Info */}
<div className="library-card-body">
<div className="library-card-title-row">
<span className="library-card-name">{entry.name}</span>
</div>
<span className="library-card-dev">by {entry.dev_name}</span>
<div className="library-card-footer">
<span className="library-card-price">
€{(entry.amount_cents / 100).toFixed(2)}
</span>
<span className="library-card-date">
{new Date(entry.purchased_at).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
})}
</span>
</div>
</div>
{/* Hover arrow */}
<div className="library-card-arrow">→</div>
</Link>
))}
</div>
)}
</div>
)}
{/* ── Payouts ── */}
{activeTab === 'payouts' && (
<div className="profile-section">
<h2 className="section-title"><span>Payout</span> Settings</h2>
<p style={{ marginBottom: '1.5rem', color: 'var(--text-muted)' }}>
Connect your Stripe account to receive payouts for your plugin sales.
</p>
<div style={{ background: 'rgba(255,255,255,0.05)', padding: '2rem', borderRadius: '12px', textAlign: 'center', border: '1px solid var(--border)' }}>
{user?.stripe_ready ? (
/* ── Fully connected ── */
<>
<div style={{ color: '#4ade80', marginBottom: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>Stripe account connected</span>
</div>
<button className="btn btn-secondary" onClick={handleGoToStripe} disabled={isStripeLoading} style={{ gap: '10px' }}>
{isStripeLoading ? <><span className="spinner" /> Opening Dashboard…</> : 'Go to Stripe Dashboard'}
</button>
</>
) : user?.stripe_account_id ? (
/* ── Account created but onboarding not finished ── */
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: '#f59e0b', marginBottom: '0.75rem' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span style={{ fontWeight: 700 }}>Finish Stripe connection</span>
</div>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
Your Stripe account was created but hasn't been fully activated yet.<br />
Complete the onboarding to start receiving payouts.
</p>
<button className="btn" style={{ background: '#f59e0b', color: '#000', gap: '10px' }} onClick={handleConnectStripe} disabled={isStripeLoading}>
{isStripeLoading ? <><span className="spinner" /> Redirecting…</> : 'Continue Stripe Setup →'}
</button>
</>
) : (
/* ── No account at all ── */
<>
<div style={{ marginBottom: '1.5rem', opacity: 0.7 }}>No Stripe account linked.</div>
<button className="btn" style={{ background: '#6366f1', gap: '10px' }} onClick={handleConnectStripe} disabled={isStripeLoading}>
{isStripeLoading ? <><span className="spinner" /> Connecting to Stripe…</> : 'Connect Stripe Account'}
</button>
</>
)}
</div>
</div>
)}
{/* ── Security ── */}
{activeTab === 'security' && (
<div className="profile-section">
<h2 className="section-title"><span>Change</span> Password</h2>
<form onSubmit={(e) => handleUpdate(e, '/user/settings', { newPassword: formData.password })}>
<div className="form-group">
<label>NEW PASSWORD</label>
<input type="password" value={formData.password} onChange={e => setFormData({ ...formData, password: e.target.value })} />
</div>
<button type="submit" className="btn">Update Password</button>
</form>
</div>
)}
{/* ── Danger Zone ── */}
{activeTab === 'danger' && (
<div className="profile-section">
<h2 className="section-title"><span>Danger</span> Zone</h2>
<p style={{ marginBottom: '1rem' }}>Proceeding will wipe your balance and all uploaded plugins from PumpkinMC.</p>
<button onClick={handleDeleteAccount} className="btn" style={{ background: '#ff4d4d' }}>
Delete My Account
</button>
</div>
)}
</div>
</div>
</div>
);
};
export default ProfilePage;

View File

@ -0,0 +1,134 @@
import { useState, useEffect } from 'react';
import { useSearchParams, Link } from 'react-router-dom';
import api from '../api';
const API_URL = 'http://localhost:5000';
const SearchResultsPage = () => {
const [searchParams] = useSearchParams();
const searchQuery = searchParams.get('q') || '';
const [plugins, setPlugins] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useState('');
// Fetch ALL plugins whenever sort changes — no search filter sent to API
useEffect(() => {
const fetchPlugins = async () => {
setLoading(true);
setError(null);
try {
const response = await api.get('/plugins', {
params: { sort: sort || undefined },
});
setPlugins(response.data);
} catch (err: any) {
console.error('Error fetching plugins:', err);
setError('Failed to load plugins. Please try again.');
setPlugins([]);
} finally {
setLoading(false);
}
};
fetchPlugins();
}, [sort]);
// Client-side filter by search query
const getDescription = (plugin: any): string => {
try {
const descriptions = JSON.parse(plugin.translated_descriptions || '{}');
return descriptions['en-US'] || descriptions.en || Object.values(descriptions)[0] as string || '';
} catch {
return '';
}
};
const visiblePlugins = searchQuery.trim()
? plugins.filter(p => {
const q = searchQuery.toLowerCase();
return (
p.name?.toLowerCase().includes(q) ||
p.dev_name?.toLowerCase().includes(q) ||
p.category?.toLowerCase().includes(q) ||
p.keywords?.toLowerCase().includes(q) ||
getDescription(p).toLowerCase().includes(q)
);
})
: plugins;
return (
<div className="container">
<h1 className="section-title">
{searchQuery
? <>Search Results <span>for "{searchQuery}"</span></>
: 'All Plugins'
}
</h1>
<div className="search-controls">
<label htmlFor="sort-by">Sort by:</label>
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
<option value="">Relevance</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>
<option value="price-desc">Price (High to Low)</option>
<option value="newest">Newest</option>
</select>
</div>
{error && <p className="error-message">{error}</p>}
{loading ? (
<p>Loading results...</p>
) : (
<div className="plugin-grid">
{visiblePlugins.length > 0 ? (
visiblePlugins.map((plugin) => (
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
<div className="plugin-card">
<div className="plugin-card-header">
<div
className="plugin-icon"
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
>
{!plugin.preview_path && plugin.name.charAt(0)}
</div>
<div>
<h3>{plugin.name}</h3>
<p className="dev-name">by {plugin.dev_name}</p>
</div>
</div>
<p>
{(() => {
const desc = getDescription(plugin);
return desc.length > 100 ? desc.substring(0, 100) + '…' : desc || 'No description provided.';
})()}
</p>
<div className="card-footer">
<span className="plugin-category">{plugin.category}</span>
<span className={`badge badge-${plugin.type}`}>
{plugin.type === 'paid' ? `$${plugin.price}` : plugin.type}
</span>
</div>
</div>
</Link>
))
) : (
<div className="empty-state">
<p>
{searchQuery
? `No plugins found for "${searchQuery}". Try a different search term!`
: 'No plugins available yet.'
}
</p>
</div>
)}
</div>
)}
</div>
);
};
export default SearchResultsPage;

View File

@ -0,0 +1,123 @@
.admin-container {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
color: var(--text, #f0f0f0);
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
}
.admin-header h1 {
margin: 0;
font-size: 2rem;
font-weight: 800;
}
.admin-header h1 span {
color: var(--primary, #4f7eff);
}
.admin-tabs {
display: flex;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-tabs button {
background: none;
border: none;
color: var(--text-muted, #888);
padding: 8px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.admin-tabs button.active {
background: var(--primary, #4f7eff);
color: white;
}
.admin-error {
background: rgba(255, 77, 77, 0.1);
color: #ff4d4d;
padding: 1rem;
border-radius: 8px;
border: 1px solid rgba(255, 77, 77, 0.2);
margin-bottom: 1rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.admin-table th, .admin-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.admin-table th {
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted, #888);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-table tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.role-badge, .type-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.role-badge.admin { background: rgba(255, 107, 107, 0.15); color: #ff6b6b; border: 1px solid rgba(255, 107, 107, 0.3); }
.role-badge.moderator { background: rgba(255, 209, 102, 0.15); color: #ffd166; border: 1px solid rgba(255, 209, 102, 0.3); }
.role-badge.user { background: rgba(79, 126, 255, 0.1); color: #4f7eff; border: 1px solid rgba(79, 126, 255, 0.2); }
.type-badge.paid { background: rgba(255, 209, 102, 0.15); color: #ffd166; border: 1px solid rgba(255, 209, 102, 0.3); }
.type-badge.free { background: rgba(6, 214, 160, 0.15); color: #06d6a0; border: 1px solid rgba(6, 214, 160, 0.3); }
.type-badge.adwall { background: rgba(168, 85, 247, 0.15); color: #a855f7; border: 1px solid rgba(168, 85, 247, 0.3); }
.btn-delete {
background: rgba(255, 77, 77, 0.1);
color: #ff4d4d;
border: 1px solid rgba(255, 77, 77, 0.2);
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-delete:hover {
background: #ff4d4d;
color: white;
}
.admin-loading {
text-align: center;
padding: 4rem;
opacity: 0.6;
}

View File

@ -0,0 +1,145 @@
import React, { useState, useEffect } from 'react';
import api from '../../api';
import './AdminPanel.css';
const AdminPanel = () => {
const [activeTab, setActiveTab] = useState<'users' | 'plugins'>('users');
const [users, setUsers] = useState<any[]>([]);
const [plugins, setPlugins] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, [activeTab]);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
if (activeTab === 'users') {
const res = await api.get('/admin/users');
setUsers(res.data);
} else {
const res = await api.get('/admin/plugins');
setPlugins(res.data);
}
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to fetch data');
} finally {
setLoading(false);
}
};
const deleteUser = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this user and all their plugins?')) return;
try {
await api.delete(`/admin/users/${id}`);
setUsers(users.filter(u => u.id !== id));
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to delete user');
}
};
const deletePlugin = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this plugin?')) return;
try {
await api.delete(`/admin/plugins/${id}`);
setPlugins(plugins.filter(p => p.id !== id));
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to delete plugin');
}
};
return (
<div className="admin-container">
<header className="admin-header">
<h1>Admin <span>Panel</span></h1>
<div className="admin-tabs">
<button
className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')}
>
Users
</button>
<button
className={activeTab === 'plugins' ? 'active' : ''}
onClick={() => setActiveTab('plugins')}
>
Plugins
</button>
</div>
</header>
{error && <div className="admin-error">{error}</div>}
<main className="admin-content">
{loading ? (
<div className="admin-loading">Loading...</div>
) : activeTab === 'users' ? (
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Stripe</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.email}</td>
<td><span className={`role-badge ${u.role}`}>{u.role}</span></td>
<td>{u.stripe_ready ? '✅' : '❌'}</td>
<td>{new Date(u.created_at).toLocaleDateString()}</td>
<td>
<button className="btn-delete" onClick={() => deleteUser(u.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Price</th>
<th>Developer</th>
<th>Downloads</th>
<th>Views</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{plugins.map(p => (
<tr key={p.id}>
<td>{p.id}</td>
<td>{p.name}</td>
<td><span className={`type-badge ${p.type}`}>{p.type}</span></td>
<td>€{(p.price_cents / 100).toFixed(2)}</td>
<td>{p.dev_name}</td>
<td>{p.downloads}</td>
<td>{p.views}</td>
<td>
<button className="btn-delete" onClick={() => deletePlugin(p.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</main>
</div>
);
};
export default AdminPanel;

View File

@ -0,0 +1,52 @@
.check-email-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 70vh;
background-color: #f9f9f9;
font-family: sans-serif;
}
.check-email-box {
background: #fff;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
text-align: center;
max-width: 450px;
width: 100%;
}
.check-email-icon {
margin-bottom: 20px;
color: #4CAF50; /* A nice green */
}
.check-email-box h1 {
font-size: 24px;
margin-bottom: 15px;
color: #333;
}
.check-email-box p {
font-size: 16px;
line-height: 1.6;
color: #666;
margin-bottom: 25px;
}
.check-email-box strong {
color: #333;
font-weight: 600;
}
.check-email-box hr {
border: none;
border-top: 1px solid #eee;
margin: 25px 0;
}
.check-email-footer {
font-size: 14px;
color: #999;
}

View File

@ -0,0 +1,30 @@
import { useLocation } from 'react-router-dom';
import './CheckEmailPage.css';
const CheckEmailPage = () => {
const location = useLocation();
const email = location.state?.email || 'your email address';
return (
<div className="check-email-container">
<div className="check-email-box">
<div className="check-email-icon">
{/* Placeholder for an email icon */}
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</div>
<h1>Check Your Inbox</h1>
<p>We've sent a verification link to <strong>{email}</strong>.</p>
<p>Please click the link in the email to activate your account. The link will expire in 15 minutes.</p>
<hr />
<p className="check-email-footer">
Didn't receive the email? Check your spam folder or try registering again.
</p>
</div>
</div>
);
};
export default CheckEmailPage;

View File

@ -0,0 +1,390 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import api from '../../api';
import { useAuth } from '../../App';
const LoginPage = () => {
const { t } = useTranslation();
const [form, setForm] = useState({ email: '', password: '', website: '' });
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailTouched, setEmailTouched] = useState(false);
const [emailValid, setEmailValid] = useState<boolean | null>(null);
const navigate = useNavigate();
const { login } = useAuth();
const validateEmail = (email: string) =>
Boolean(String(email).toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/));
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setForm({ ...form, email: val });
if (emailTouched) {
setEmailValid(val === '' ? null : validateEmail(val));
}
};
const handleEmailBlur = () => {
setEmailTouched(true);
setEmailValid(form.email === '' ? null : validateEmail(form.email));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (form.website) return;
setEmailTouched(true);
const isEmailValid = validateEmail(form.email);
setEmailValid(isEmailValid);
if (!isEmailValid) {
setError('Please enter a valid email address.');
return;
}
setIsSubmitting(true);
setError('');
try {
const res = await api.post('/auth/login', {
email: form.email,
password: form.password,
});
login(res.data.token);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid credentials.');
} finally {
setIsSubmitting(false);
}
};
const emailIsInvalid = emailTouched && emailValid === false;
const emailIsValid = emailTouched && emailValid === true;
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.login-card {
width: 100%;
max-width: 440px;
background: #ffffff;
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(60, 66, 87, 0.08),
0 2px 4px rgba(60, 66, 87, 0.04),
0 8px 24px rgba(60, 66, 87, 0.08);
padding: 40px;
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.card-header { margin-bottom: 28px; }
.card-title {
font-size: 22px;
font-weight: 600;
color: #0a2540;
letter-spacing: -0.4px;
line-height: 1.3;
}
.card-subtitle {
margin-top: 6px;
font-size: 14px;
color: #697386;
line-height: 1.5;
}
.form-field { margin-bottom: 18px; }
.field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #3c4257;
margin-bottom: 6px;
letter-spacing: 0.01em;
}
.field-input-wrapper { position: relative; }
.login-input {
width: 100%;
height: 44px;
padding: 0 40px 0 14px;
font-size: 15px;
font-family: inherit;
color: #0a2540;
background: #ffffff;
border: 1.5px solid #e0e5ec;
border-radius: 7px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
-webkit-appearance: none;
}
.login-input::placeholder { color: #a3acb9; font-size: 14px; }
.login-input:hover:not(:disabled) { border-color: #c1c9d5; }
.login-input:focus {
border-color: #ff6b00;
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
}
.login-input:disabled {
background: #f7fafc;
color: #a3acb9;
cursor: not-allowed;
}
.login-input.email-invalid {
border-color: #df1b41 !important;
box-shadow: 0 0 0 3px rgba(223, 27, 65, 0.12) !important;
}
.login-input.email-valid {
border-color: #1a9e5f !important;
box-shadow: 0 0 0 3px rgba(26, 158, 95, 0.12) !important;
}
/* no-icon variant (password field) */
.login-input.no-icon { padding-right: 14px; }
.field-status-icon {
position: absolute;
right: 13px;
top: 50%;
transform: translateY(-50%);
width: 17px;
height: 17px;
pointer-events: none;
animation: iconPop 0.2s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes iconPop {
from { opacity: 0; transform: translateY(-50%) scale(0.4); }
to { opacity: 1; transform: translateY(-50%) scale(1); }
}
.field-message {
display: flex;
align-items: center;
gap: 5px;
margin-top: 5px;
font-size: 12.5px;
font-weight: 500;
animation: msgSlide 0.18s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes msgSlide {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.field-message.invalid { color: #df1b41; }
.field-message.valid { color: #1a9e5f; }
.error-banner {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fff8f8;
border: 1.5px solid #fecdd3;
border-radius: 7px;
padding: 12px 14px;
margin-bottom: 20px;
animation: shake 0.3s cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
.error-icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: #df1b41; }
.error-text { font-size: 13px; color: #df1b41; line-height: 1.5; }
.submit-btn {
width: 100%;
height: 46px;
background: #ff6b00;
color: white;
border: none;
border-radius: 7px;
font-size: 15px;
font-weight: 500;
font-family: inherit;
letter-spacing: -0.1px;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.submit-btn:hover:not(:disabled) {
background: #e85d00;
box-shadow: 0 4px 12px rgba(255, 107, 0, 0.35);
transform: translateY(-1px);
}
.submit-btn:active:not(:disabled) { transform: translateY(0); box-shadow: none; }
.submit-btn:disabled {
background: #ffb380;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.divider { height: 1px; background: #e8ecf1; margin: 24px 0; }
.card-footer { text-align: center; font-size: 13.5px; color: #697386; }
.card-footer a {
color: #ff6b00;
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.card-footer a:hover { color: #e85d00; text-decoration: underline; }
`}</style>
<div className="login-page">
<div className="login-card">
<div className="card-header">
<h1 className="card-title">Welcome back</h1>
<p className="card-subtitle">Sign in to your PumpkinMC account.</p>
</div>
<form onSubmit={handleSubmit} noValidate>
{error && (
<div className="error-banner">
<svg className="error-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 6a1 1 0 110 2 1 1 0 010-2z"/>
</svg>
<span className="error-text">{error}</span>
</div>
)}
{/* Honeypot */}
<input type="text" style={{ display: 'none' }} tabIndex={-1}
onChange={e => setForm({ ...form, website: e.target.value })} />
<div className="form-field">
<label className="field-label" htmlFor="email">{t('auth.email')}</label>
<div className="field-input-wrapper">
<input
id="email"
className={`login-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
type="email"
placeholder="you@company.com"
value={form.email}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
required
disabled={isSubmitting}
autoComplete="email"
/>
{emailIsInvalid && (
<svg key="x" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#df1b41" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)}
{emailIsValid && (
<svg key="check" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#1a9e5f" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
{emailIsInvalid && (
<p className="field-message invalid">
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 0a6 6 0 100 12A6 6 0 006 0zm.5 3.5a.5.5 0 00-1 0v3a.5.5 0 001 0v-3zm-.5 5a.75.75 0 110 1.5.75.75 0 010-1.5z"/>
</svg>
Please enter a valid email address
</p>
)}
</div>
<div className="form-field">
<label className="field-label" htmlFor="password">{t('auth.password')}</label>
<div className="field-input-wrapper">
<input
id="password"
className="login-input no-icon"
type="password"
placeholder="••••••••"
onChange={e => setForm({ ...form, password: e.target.value })}
required
disabled={isSubmitting}
autoComplete="current-password"
/>
</div>
</div>
<button className="submit-btn" type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<><span className="spinner" />{t('loading')}</>
) : (
t('auth.sign_in')
)}
</button>
</form>
<div className="divider" />
<div className="card-footer">
Don't have an account? <Link to="/register">Create one</Link>
</div>
</div>
</div>
</>
);
};
export default LoginPage;

View File

@ -0,0 +1,453 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Turnstile } from '@marsidev/react-turnstile';
import api from '../../api';
import { useAuth } from '../../App';
interface PasswordRequirement {
label: string;
test: (pw: string) => boolean;
}
const PASSWORD_REQUIREMENTS: PasswordRequirement[] = [
{ label: 'At least 8 characters', test: pw => pw.length >= 8 },
{ label: 'At least one uppercase letter', test: pw => /[A-Z]/.test(pw) },
{ label: 'At least one lowercase letter', test: pw => /[a-z]/.test(pw) },
{ label: 'At least one number', test: pw => /[0-9]/.test(pw) },
{ label: 'At least one special character', test: pw => /[^A-Za-z0-9]/.test(pw) },
];
function getStrength(pw: string): { score: number; label: string; color: string } {
if (!pw) return { score: 0, label: '', color: '#e0e5ec' };
const passed = PASSWORD_REQUIREMENTS.filter(r => r.test(pw)).length;
if (passed <= 1) return { score: 1, label: 'Very weak', color: '#df1b41' };
if (passed === 2) return { score: 2, label: 'Weak', color: '#f5a623' };
if (passed === 3) return { score: 3, label: 'Fair', color: '#f0c040' };
if (passed === 4) return { score: 4, label: 'Strong', color: '#1a9e5f' };
return { score: 5, label: 'Very strong', color: '#0d7a47' };
}
const RegisterPage = () => {
const [form, setForm] = useState({ username: '', email: '', password: '', website: '' });
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [focusedField, setFocusedField] = useState<string | null>(null);
const [emailTouched, setEmailTouched] = useState(false);
const [emailValid, setEmailValid] = useState<boolean | null>(null);
const [passwordTouched, setPasswordTouched] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
const validateEmail = (email: string) =>
Boolean(String(email).toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/));
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setForm({ ...form, email: val });
if (emailTouched) setEmailValid(val === '' ? null : validateEmail(val));
};
const handleEmailBlur = () => {
setFocusedField(null);
setEmailTouched(true);
setEmailValid(form.email === '' ? null : validateEmail(form.email));
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm({ ...form, password: e.target.value });
if (!passwordTouched && e.target.value.length > 0) setPasswordTouched(true);
};
const strength = getStrength(form.password);
const allRequirementsMet = PASSWORD_REQUIREMENTS.every(r => r.test(form.password));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (form.website) return;
setEmailTouched(true);
const isEmailValid = validateEmail(form.email);
setEmailValid(isEmailValid);
setPasswordTouched(true);
if (!isEmailValid) { setError('Please enter a valid email address.'); return; }
if (!allRequirementsMet) { setError('Please meet all password requirements.'); return; }
if (!captchaToken) { setError('Please complete the security check.'); return; }
setIsSubmitting(true);
setError('');
try {
await api.post('/auth/register', { ...form, captchaToken });
navigate('/check-email', { state: { email: form.email } });
} catch (err: any) {
setError(err.response?.data?.error || 'Registration failed. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const emailIsInvalid = emailTouched && emailValid === false;
const emailIsValid = emailTouched && emailValid === true;
const showStrengthMeter = passwordTouched && form.password.length > 0;
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-font-smoothing: antialiased; }
.register-page {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 40px 20px; position: relative;
}
.register-page::before {
content: ''; position: fixed; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, #ff6b00, #ff8c00, #ffb347);
}
@keyframes fadeDown { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:translateY(0); } }
@keyframes cardIn { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
@keyframes shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-4px)} 40%{transform:translateX(4px)} 60%{transform:translateX(-3px)} 80%{transform:translateX(3px)} }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes iconPop { from{opacity:0;transform:translateY(-50%) scale(0.4)} to{opacity:1;transform:translateY(-50%) scale(1)} }
@keyframes msgSlide { from{opacity:0;transform:translateY(-4px)} to{opacity:1;transform:translateY(0)} }
@keyframes reqIn { from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:translateY(0)} }
@keyframes barFill { from{width:0} }
.register-logo { margin-bottom: 32px; display:flex; align-items:center; gap:10px; animation: fadeDown 0.4s cubic-bezier(0.22,1,0.36,1) both; }
.register-logo-mark { width:36px; height:36px; background:#ff6b00; border-radius:8px; display:flex; align-items:center; justify-content:center; }
.register-logo-mark svg { width:20px; height:20px; fill:white; }
.register-logo-text { font-size:20px; font-weight:600; color:#0a2540; letter-spacing:-0.3px; }
.register-card {
width:100%; max-width:440px; background:#ffffff; border-radius:12px;
box-shadow: 0 0 0 1px rgba(60,66,87,.08), 0 2px 4px rgba(60,66,87,.04), 0 8px 24px rgba(60,66,87,.08);
padding:40px; animation: cardIn 0.35s cubic-bezier(0.22,1,0.36,1) both;
}
.card-header { margin-bottom:28px; }
.card-title { font-size:22px; font-weight:600; color:#0a2540; letter-spacing:-0.4px; line-height:1.3; }
.card-subtitle { margin-top:6px; font-size:14px; color:#697386; line-height:1.5; }
.form-field { margin-bottom:18px; }
.field-label { display:block; font-size:13px; font-weight:500; color:#3c4257; margin-bottom:6px; letter-spacing:0.01em; }
.field-input-wrapper { position:relative; }
.field-input {
width:100%; height:44px; padding:0 40px 0 14px; font-size:15px; font-family:inherit;
color:#0a2540; background:#ffffff; border:1.5px solid #e0e5ec; border-radius:7px;
outline:none; transition:border-color 0.15s ease, box-shadow 0.15s ease; -webkit-appearance:none;
}
.field-input::placeholder { color:#a3acb9; font-size:14px; }
.field-input:hover:not(:disabled) { border-color:#c1c9d5; }
.field-input:focus { border-color:#ff6b00; box-shadow:0 0 0 3px rgba(255,107,0,.16); }
.field-input:disabled { background:#f7fafc; color:#a3acb9; cursor:not-allowed; }
.field-input.email-invalid { border-color:#df1b41 !important; box-shadow:0 0 0 3px rgba(223,27,65,.12) !important; }
.field-input.email-valid { border-color:#1a9e5f !important; box-shadow:0 0 0 3px rgba(26,158,95,.12) !important; }
/* password has extra right padding for the toggle button */
.field-input.password-input { padding-right: 44px; }
.field-status-icon {
position:absolute; right:13px; top:50%; transform:translateY(-50%);
width:17px; height:17px; pointer-events:none;
animation: iconPop 0.2s cubic-bezier(0.34,1.56,0.64,1) both;
}
/* toggle show/hide password */
.password-toggle {
position:absolute; right:12px; top:50%; transform:translateY(-50%);
background:none; border:none; cursor:pointer; padding:2px; color:#a3acb9;
display:flex; align-items:center; justify-content:center;
transition:color 0.15s;
}
.password-toggle:hover { color:#697386; }
.field-message {
display:flex; align-items:center; gap:5px; margin-top:5px;
font-size:12.5px; font-weight:500;
animation: msgSlide 0.18s cubic-bezier(0.22,1,0.36,1) both;
}
.field-message.invalid { color:#df1b41; }
.field-message.valid { color:#1a9e5f; }
/* ── Strength meter ── */
.strength-meter { margin-top:10px; animation: reqIn 0.2s cubic-bezier(0.22,1,0.36,1) both; }
.strength-bars {
display:flex; gap:4px; margin-bottom:6px;
}
.strength-bar {
flex:1; height:4px; border-radius:99px; background:#e0e5ec;
overflow:hidden; position:relative;
}
.strength-bar-fill {
position:absolute; inset:0; border-radius:99px;
transition: background-color 0.3s ease, width 0.4s cubic-bezier(0.22,1,0.36,1);
}
.strength-label-row {
display:flex; justify-content:space-between; align-items:center;
font-size:12px; color:#697386; margin-bottom:8px;
}
.strength-label-text { font-weight:500; transition:color 0.2s; }
/* ── Requirements checklist ── */
.requirements-list { list-style:none; display:flex; flex-direction:column; gap:4px; }
.req-item {
display:flex; align-items:center; gap:6px;
font-size:12.5px; color:#697386;
transition: color 0.2s ease;
}
.req-item.met { color:#1a9e5f; }
.req-item.unmet-touched { color:#df1b41; }
.req-icon {
width:14px; height:14px; flex-shrink:0;
border-radius:50%; display:flex; align-items:center; justify-content:center;
transition: background 0.2s, border-color 0.2s;
border:1.5px solid #c1c9d5;
}
.req-item.met .req-icon { background:#1a9e5f; border-color:#1a9e5f; }
.req-item.unmet-touched .req-icon { border-color:#df1b41; }
/* Error banner */
.error-banner {
display:flex; align-items:flex-start; gap:10px;
background:#fff8f8; border:1.5px solid #fecdd3; border-radius:7px;
padding:12px 14px; margin-bottom:20px;
animation: shake 0.3s cubic-bezier(0.36,0.07,0.19,0.97);
}
.error-icon { flex-shrink:0; width:16px; height:16px; margin-top:1px; color:#df1b41; }
.error-text { font-size:13px; color:#df1b41; line-height:1.5; }
.captcha-container { display:flex; justify-content:center; margin-bottom:20px; margin-top:4px; }
.submit-btn {
width:100%; height:46px; background:#ff6b00; color:white; border:none;
border-radius:7px; font-size:15px; font-weight:500; font-family:inherit;
letter-spacing:-0.1px; cursor:pointer;
transition:background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
display:flex; align-items:center; justify-content:center; gap:8px;
position:relative; overflow:hidden;
}
.submit-btn:hover:not(:disabled) { background:#e85d00; box-shadow:0 4px 12px rgba(255,107,0,.35); transform:translateY(-1px); }
.submit-btn:active:not(:disabled) { transform:translateY(0); box-shadow:none; }
.submit-btn:disabled { background:#ffb380; cursor:not-allowed; transform:none; box-shadow:none; }
.spinner { width:16px; height:16px; border:2px solid rgba(255,255,255,.35); border-top-color:white; border-radius:50%; animation:spin 0.7s linear infinite; }
.divider { height:1px; background:#e8ecf1; margin:24px 0; }
.card-footer { text-align:center; font-size:13.5px; color:#697386; }
.card-footer a { color:#ff6b00; text-decoration:none; font-weight:500; transition:color 0.15s; }
.card-footer a:hover { color:#e85d00; text-decoration:underline; }
.terms-note { text-align:center; font-size:12px; color:#a3acb9; margin-top:16px; line-height:1.6; }
.terms-note a { color:#697386; text-decoration:underline; text-underline-offset:2px; }
`}</style>
<div className="register-page">
<div className="register-card">
<div className="card-header">
<h1 className="card-title">Create your account</h1>
</div>
<form onSubmit={handleSubmit} noValidate>
{error && (
<div className="error-banner">
<svg className="error-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5zm-.75 6a1 1 0 110 2 1 1 0 010-2z"/>
</svg>
<span className="error-text">{error}</span>
</div>
)}
{/* Honeypot */}
<input type="text" style={{ display: 'none' }} tabIndex={-1}
onChange={e => setForm({ ...form, website: e.target.value })} />
{/* Username */}
<div className="form-field">
<label className="field-label" htmlFor="username">Username</label>
<div className="field-input-wrapper">
<input
id="username" className="field-input" type="text" placeholder="johndoe"
onChange={e => setForm({ ...form, username: e.target.value })}
onFocus={() => setFocusedField('username')} onBlur={() => setFocusedField(null)}
required disabled={isSubmitting} autoComplete="username"
autoCapitalize="none" spellCheck={false}
/>
</div>
</div>
{/* Email */}
<div className="form-field">
<label className="field-label" htmlFor="email">Email address</label>
<div className="field-input-wrapper">
<input
id="email"
className={`field-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
type="email" placeholder="you@company.com" value={form.email}
onChange={handleEmailChange}
onFocus={() => setFocusedField('email')}
onBlur={handleEmailBlur}
required disabled={isSubmitting} autoComplete="email"
/>
{emailIsInvalid && (
<svg key="x" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#df1b41" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)}
{emailIsValid && (
<svg key="check" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#1a9e5f" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
{emailIsInvalid && (
<p className="field-message invalid">
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 0a6 6 0 100 12A6 6 0 006 0zm.5 3.5a.5.5 0 00-1 0v3a.5.5 0 001 0v-3zm-.5 5a.75.75 0 110 1.5.75.75 0 010-1.5z"/>
</svg>
Please enter a valid email address
</p>
)}
{emailIsValid && (
<p className="field-message valid">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="5.25" stroke="currentColor" strokeWidth="1.5"/>
<path d="M3.5 6l2 2 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Looks good!
</p>
)}
</div>
{/* Password */}
<div className="form-field">
<label className="field-label" htmlFor="password">Password</label>
<div className="field-input-wrapper">
<input
id="password"
className="field-input password-input"
type={showPassword ? 'text' : 'password'}
placeholder="Create a strong password"
value={form.password}
onChange={handlePasswordChange}
onFocus={() => setFocusedField('password')}
onBlur={() => setFocusedField(null)}
required disabled={isSubmitting} autoComplete="new-password"
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowPassword(s => !s)}
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
/* eye-off icon */
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
) : (
/* eye icon */
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
)}
</button>
</div>
{/* Strength meter — shown as soon as the user starts typing */}
{showStrengthMeter && (
<div className="strength-meter">
{/* 5 segment bars */}
<div className="strength-bars">
{[1,2,3,4,5].map(i => (
<div className="strength-bar" key={i}>
<div
className="strength-bar-fill"
style={{
width: strength.score >= i ? '100%' : '0%',
backgroundColor: strength.score >= i ? strength.color : 'transparent',
}}
/>
</div>
))}
</div>
<div className="strength-label-row">
<span className="strength-label-text" style={{ color: strength.color }}>
{strength.label}
</span>
<span>{PASSWORD_REQUIREMENTS.filter(r => r.test(form.password)).length}/{PASSWORD_REQUIREMENTS.length} requirements met</span>
</div>
{/* Per-requirement checklist */}
<ul className="requirements-list">
{PASSWORD_REQUIREMENTS.map((req, idx) => {
const met = req.test(form.password);
const cls = met
? 'req-item met'
: passwordTouched
? 'req-item unmet-touched'
: 'req-item';
return (
<li key={idx} className={cls}>
<span className="req-icon">
{met && (
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
<path d="M1.5 4l2 2 3-3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</span>
{req.label}
</li>
);
})}
</ul>
</div>
)}
</div>
<div className="captcha-container">
<Turnstile siteKey="0x4AAAAAAClcSibyhKfR0H6o"
onSuccess={(token) => setCaptchaToken(token)} theme="light" />
</div>
<button className="submit-btn" type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<><span className="spinner" />Creating account…</>
) : (
'Create account'
)}
</button>
</form>
<p className="terms-note">
By creating an account, you agree to our{' '}
<Link to="/terms">Terms of Service</Link> and <Link to="/privacy">Privacy Policy</Link>
</p>
<div className="divider" />
<div className="card-footer">
Already have an account? <Link to="/login">Sign in</Link>
</div>
</div>
</div>
</>
);
};
export default RegisterPage;

View File

@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import api from '../../api';
const VerifyEmailPage = () => {
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying');
const [error, setError] = useState<string | null>(null);
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
const verify = async () => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (!token) {
setStatus('error');
setError('No verification token found.');
return;
}
try {
const res = await api.get(`/auth/verify-email?token=${token}`);
// Assuming the API returns a token and user object on success
localStorage.setItem('token', res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user));
setStatus('success');
// Redirect to home page after a short delay
setTimeout(() => {
navigate('/');
}, 3000);
} catch (err: any) {
setStatus('error');
setError(err.response?.data?.error || 'Verification failed. The link may be invalid or expired.');
}
};
verify();
}, [location, navigate]);
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
{status === 'verifying' && <h1>Verifying your email...</h1>}
{status === 'success' && (
<div>
<h1>Email Verified Successfully!</h1>
<p>You are now logged in. Redirecting to the homepage...</p>
</div>
)}
{/* {status === 'error' && (
<div>
<h1>Verification Failed</h1>
<p>{error}</p>
<p>Please try registering again or contact support.</p>
</div>
)} */}
</div>
);
};
export default VerifyEmailPage;

View File

@ -0,0 +1,939 @@
:root {
--dash-bg: #F6F8FA;
--dash-surface: #FFFFFF;
--dash-surface-soft: #F0F2F5; /* New: for softer backgrounds */
--dash-border: #E1E4E8;
--dash-text: #24292E;
--dash-text-muted: #586069;
--dash-text-secondary: #6A737D; /* New: for secondary text */
--dash-primary: #0366D6; /* Stripe-like blue */
--dash-primary-light: #EBF5FF; /* New: Light version of primary color */
--dash-accent: #FF7518; /* Keep a hint of Pumpkin */
--dash-hover: #EAEFF4; /* New: for hover states */
--dash-shadow: 0 1px 3px rgba(0,0,0,0.08); /* New: for subtle shadows */
}
/* Override App.css within the dashboard */
.dashboard-content, .dashboard-nav {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
border-bottom: 1px solid var(--dash-border);
padding-bottom: 1.5rem;
}
.dashboard-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--dash-text);
margin: 0;
}
/* New KPI Card Style */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 2.5rem;
}
.kpi-card {
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 8px;
padding: 1.5rem;
}
.kpi-card h3 {
font-size: 0.9rem;
font-weight: 500;
color: var(--dash-text-muted);
margin: 0 0 0.5rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-card .value {
font-size: 2rem;
font-weight: 600;
color: var(--dash-text);
margin-bottom: 1rem;
}
.kpi-card .comparison {
font-size: 0.85rem;
color: var(--success);
}
.kpi-card .comparison.negative {
color: #D73A49;
}
/* Main Chart Container */
.main-chart-container {
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 8px;
padding: 2rem;
}
/* Donut/Bar Chart Cards */
.sub-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.data-card {
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 8px;
padding: 1.5rem;
}
.data-card h3 {
font-size: 1rem;
font-weight: 600;
color: var(--dash-text);
margin: 0 0 1.5rem 0;
}
/* Data Table */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th, .data-table td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid var(--dash-border);
}
.data-table th {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--dash-text-muted);
font-weight: 500;
}
.data-table td {
font-size: 0.95rem;
color: var(--dash-text);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table .mono {
font-family: var(--font-mono);
font-weight: 600;
}
/* Layout */
.dashboard-grid-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 2.5rem;
align-items: start; /* Changed from flex-start to start for consistency with modern CSS Grid */
}
.activity-feed-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--dash-text);
margin-bottom: 1rem;
border-bottom: 1px solid var(--dash-border);
padding-bottom: 1rem;
}
.activity-feed-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--dash-border);
}
.activity-feed-item:last-child { border-bottom: none; }
.activity-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #EBF5FF;
color: var(--dash-primary);
}
.activity-text strong {
color: var(--dash-text);
}
.activity-text {
color: var(--dash-text-muted);
font-size: 0.9rem;
}
/* Toggle Switch Component */
.switch-field {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: #F6F8FA;
border-radius: 6px;
border: 1px solid var(--dash-border);
}
.switch-label {
font-weight: 500;
color: var(--dash-text);
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--dash-primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
/* Radio Pill Component */
.radio-pill-group {
display: flex;
border: 1px solid var(--dash-border);
border-radius: 6px;
overflow: hidden;
}
.radio-pill {
flex: 1;
text-align: center;
padding: 12px;
cursor: pointer;
background: var(--dash-surface);
color: var(--dash-text-muted);
font-weight: 500;
transition: all 0.2s;
}
.radio-pill:not(:last-child) {
border-right: 1px solid var(--dash-border);
}
.radio-pill.active {
background: #EBF5FF;
color: var(--dash-primary);
font-weight: 600;
}
.radio-pill input {
display: none;
}
/* YouTube-style Form Inputs */
.form-group {
position: relative;
margin-bottom: 2rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 500;
color: var(--dash-text-muted);
}
.form-group input[type="text"], .form-group input[type="number"], .form-group textarea, .form-group select {
width: 100%;
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 6px;
padding: 12px;
margin-top: 0.5rem;
font-size: 1rem;
color: var(--dash-text);
transition: all 0.2s;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
border-color: var(--dash-primary);
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.3);
outline: none;
}
.char-counter {
position: absolute;
bottom: -1.5rem;
right: 0;
font-size: 0.8rem;
color: var(--dash-text-muted);
}
.form-group textarea {
min-height: 120px;
resize: vertical;
}
/* Markdown Editor/Preview Component */
.markdown-editor {
border: 1px solid var(--dash-border);
border-radius: 6px;
overflow: hidden;
}
.markdown-tabs {
display: flex;
background: #F6F8FA;
border-bottom: 1px solid var(--dash-border);
}
.markdown-tab {
padding: 10px 15px;
cursor: pointer;
font-weight: 500;
font-size: 0.9rem;
color: var(--dash-text-muted);
}
.markdown-tab.active {
background: var(--dash-surface);
color: var(--dash-text);
border-right: 1px solid var(--dash-border);
}
.markdown-preview {
padding: 15px;
background: var(--dash-surface);
min-height: 120px;
color: var(--dash-text);
line-height: 1.6;
}
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3 {
border-bottom: 1px solid var(--dash-border);
padding-bottom: 0.3em;
margin-top: 24px;
margin-bottom: 16px;
}
.markdown-preview p { margin: 0 0 16px 0; }
.markdown-preview code {
background-color: #F6F8FA;
padding: .2em .4em;
margin: 0;
font-size: 85%;
border-radius: 3px;
font-family: var(--font-mono);
}
.markdown-preview pre {
background-color: #F6F8FA;
padding: 16px;
border-radius: 3px;
overflow: auto;
}
.markdown-preview pre code {
font-size: 100%;
padding: 0;
margin: 0;
background: transparent;
}
.markdown-preview blockquote {
padding: 0 1em;
color: var(--dash-text-muted);
border-left: 0.25em solid var(--dash-border);
margin: 0 0 16px 0;
}
.markdown-preview ul, .markdown-preview ol { padding-left: 2em; }
/* Icon Upload Preview */
.icon-upload-group {
display: flex;
align-items: center;
gap: 1.5rem;
}
.icon-preview {
width: 80px;
height: 80px;
border-radius: 8px;
border: 2px dashed var(--dash-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--dash-text-muted);
font-size: 0.8rem;
text-align: center;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
/* Screenshot Upload Section */
.screenshot-uploader {
border: 1px solid var(--dash-border);
border-radius: 6px;
padding: 1rem;
background-color: #F6F8FA;
margin-top: 0.5rem;
}
.screenshot-dropzone {
position: relative;
border: 2px dashed var(--dash-border);
border-radius: 6px;
padding: 1rem;
min-height: 160px; /* This sets the height of the drop area */
cursor: pointer;
/* Grid stack: puts both children in the same 'cell' */
display: grid;
align-items: center; /* Vertically centers both layers */
}
.dropzone-instructions {
grid-area: 1 / 1; /* Positioned at the back */
text-align: center;
color: var(--dash-text-muted);
z-index: 1;
pointer-events: none; /* Let clicks pass through to the uploader */
}
.screenshot-dropzone.drag-over {
border-color: var(--dash-primary);
background-color: rgba(3, 102, 214, 0.05);
}
.screenshot-previews {
grid-area: 1 / 1; /* Positioned at the front */
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
/* Align images starting from the LEFT */
justify-content: flex-start;
}
.screenshot-preview-item {
position: relative;
/* This height dictates the row height */
height: 120px;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--dash-border);
background: #fff;
flex-shrink: 0;
box-shadow: var(--dash-shadow);
}
.screenshot-preview-item:hover {
transform: translateY(-2px);
border-color: var(--dash-primary);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.screenshot-preview-item:active {
cursor: grabbing;
}
.screenshot-preview-item.dragging-now {
opacity: 0.4;
scale: 0.95;
border: 2px dashed var(--dash-primary);
}
.screenshot-preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.screenshot-delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
}
.screenshot-order-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.6);
color: white;
font-size: 0.8rem;
text-align: center;
padding: 2px 0;
cursor: grab;
z-index: 10;
}
.input-with-icon {
display: flex;
align-items: center;
background: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 6px;
margin-top: 0.5rem;
transition: all 0.2s;
}
.input-with-icon:focus-within {
border-color: var(--dash-primary);
box-shadow: 0 0 0 3px rgba(3, 102, 214, 0.3);
}
.input-with-icon input[type="url"] {
border: none;
padding: 12px;
flex-grow: 1;
margin-top: 0; /* Override default form-group margin-top */
}
.input-with-icon input[type="url"]:focus {
box-shadow: none;
}
.icon-link::before {
content: "🔗"; /* Unicode link icon */
padding: 0 12px;
color: var(--dash-text-muted);
font-size: 1.2rem;
}
.activity-time {
margin-left: auto;
font-size: 0.85rem;
color: var(--dash-text-muted);
}
/* Timeframe Selector */
.timeframe-selector {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 10px;
background-color: var(--dash-surface-soft);
border-radius: 8px;
box-shadow: var(--dash-shadow);
}
.timeframe-selector button {
background-color: transparent;
border: 1px solid var(--dash-border);
color: var(--dash-text-secondary);
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease-in-out;
}
.timeframe-selector button:hover {
background-color: var(--dash-hover);
color: var(--dash-text);
}
.timeframe-selector button.active {
background-color: var(--dash-primary);
color: white;
border-color: var(--dash-primary);
font-weight: bold;
}
/* Pricing Model Options */
.pricing-model-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-top: 10px;
}
.pricing-option {
background-color: var(--dash-surface);
border: 1px solid var(--dash-border);
border-radius: 8px;
padding: 15px;
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
overflow: hidden;
}
.pricing-option:hover {
border-color: var(--dash-primary);
box-shadow: var(--dash-shadow);
}
.pricing-option.active {
border-color: var(--dash-primary);
background-color: var(--dash-primary-light);
box-shadow: var(--dash-shadow);
}
.pricing-option.payouts-locked {
cursor: default;
}
.pricing-option.payouts-locked:hover {
border-color: var(--dash-border);
box-shadow: none;
}
.pricing-option.payouts-locked .details,
.pricing-option.payouts-locked .icon {
pointer-events: none;
user-select: none;
}
.pricing-option .lock-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
background: rgba(0, 0, 0, 0.2);
}
.pricing-option input[type="radio"] {
position: absolute;
top: 10px;
right: 10px;
opacity: 0;
pointer-events: none;
}
.pricing-option .icon {
color: var(--dash-primary);
margin-bottom: 5px;
}
.pricing-option h4 {
font-size: 1.1rem;
font-weight: 600;
color: var(--dash-text);
margin: 0;
}
.pricing-option .description {
font-size: 0.85rem;
color: var(--dash-text-muted);
line-height: 1.4;
margin: 0;
}
/* Language Selector UI */
.language-selector {
position: relative;
margin-top: 0.5rem;
border: 1px solid var(--dash-border);
border-radius: 6px;
background: var(--dash-surface);
}
.language-tabs {
display: flex;
flex-wrap: wrap;
background: var(--dash-surface-soft);
border-bottom: 1px solid var(--dash-border);
padding: 5px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.language-tab {
padding: 8px 12px;
margin: 3px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
color: var(--dash-text-muted);
background-color: var(--dash-surface);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 5px;
}
.language-tab:hover {
background-color: var(--dash-hover);
color: var(--dash-text);
}
.language-tab.active {
border-color: var(--dash-primary);
background-color: var(--dash-primary-light);
color: var(--dash-primary);
}
.remove-language {
font-size: 0.9rem;
font-weight: 600;
color: var(--dash-text-muted);
margin-left: 5px;
cursor: pointer;
}
.remove-language:hover {
color: var(--dash-text);
}
.add-language-dropdown {
position: relative;
display: inline-block; /* Changed from flex for better dropdown positioning */
margin: 3px;
}
.add-language-dropdown .btn {
padding: 8px 12px;
font-size: 0.85rem;
background-color: var(--dash-surface);
color: var(--dash-text-muted);
border: 1px solid var(--dash-border);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.add-language-dropdown .btn:hover {
background-color: var(--dash-hover);
color: var(--dash-text);
}
.add-language-dropdown .dropdown-content {
display: none; /* Hidden by default */
position: absolute;
background-color: var(--dash-surface);
min-width: 160px;
box-shadow: var(--dash-shadow);
border: 1px solid var(--dash-border);
border-radius: 4px;
z-index: 10;
margin-top: 5px;
left: 0; /* Align with the button */
padding: 5px 0;
}
.add-language-dropdown.show .dropdown-content {
display: block; /* Show when 'show' class is present */
}
.add-language-dropdown .dropdown-content button {
color: var(--dash-text);
padding: 10px 15px;
text-decoration: none;
display: block;
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
}
.add-language-dropdown .dropdown-content button:hover {
background-color: var(--dash-hover);
}
.dashboard-wrapper {
background-color: var(--bg-main);
min-height: 100vh;
padding: 2rem;
font-family: 'Inter', system-ui, sans-serif;
color: var(--text-main);
}
/* Header & Segmented Control */
.dashboard-top-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
}
.dashboard-top-nav h1 { font-size: 1.5rem; font-weight: 700; margin: 0; }
.subtitle { color: var(--text-muted); margin: 4px 0 0 0; font-size: 0.9rem; }
.segment-control {
background: #e2e8f0;
padding: 4px;
border-radius: 8px;
display: flex;
}
.segment-control button {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 0.85rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: 0.2s;
color: var(--text-muted);
}
.segment-control button.active {
background: var(--surface);
color: var(--text-main);
box-shadow: var(--shadow-sm);
}
/* Grid Layout */
.dashboard-grid-v2 {
display: grid;
grid-template-columns: 1fr 320px;
gap: 2rem;
}
/* KPI Cards */
.kpi-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
.kpi-card-v2 {
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.kpi-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.kpi-icon-wrapper {
color: var(--primary);
background: #eef2ff;
padding: 6px;
border-radius: 6px;
display: flex;
}
.kpi-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.kpi-body {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.kpi-value {
font-size: 1.75rem;
font-weight: 700;
margin: 0;
}
.kpi-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
font-weight: 600;
margin-top: 4px;
}
.kpi-trend.up { color: var(--success); }
.kpi-trend.down { color: var(--danger); }
/* Main Chart */
.chart-container-v2 {
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow-sm);
}
.chart-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
/* Sidebar Feed */
.activity-sidebar-v2 {
background: var(--surface);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
height: fit-content;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin: 1.5rem 0;
}
.feed-item-v2 {
display: flex;
align-items: center;
gap: 12px;
}
.feed-avatar {
width: 36px;
height: 36px;
background: #f1f5f9;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
}
.feed-content p { font-size: 0.85rem; margin: 0; }
.feed-content span { font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
.feed-amount { margin-left: auto; font-weight: 600; font-size: 0.85rem; color: var(--success); }
.view-all-btn {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
background: var(--bg-main);
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}

View File

@ -0,0 +1,90 @@
import { useMemo, useState } from 'react';
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
import { scaleLinear } from "d3-scale";
import { useAnalytics } from './useAnalytics';
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
const DashboardAudience = () => {
const { mapData, loading } = useAnalytics();
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
// Memoize scales to prevent recalculation on every render
const colorScales = useMemo(() => ({
visitors: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#8884d8"]),
free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]),
buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]),
}), []);
if (loading || !mapData) {
return (
<div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}>
<div className="pumpkin-spinner"></div>
<p>Analyzing Global Distribution...</p>
</div>
);
}
return (
<div className="analytics-container">
<div className="charts-grid">
<div className="chart-card" style={{ gridColumn: '1 / -1' }}>
<div className="card-header-flex" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<div>
<h3 className="section-title" style={{ margin: 0 }}>Geographic <span>Distribution</span></h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Visualizing user activity across the globe</p>
</div>
<div className="map-filters" style={{ display: 'flex', gap: '8px' }}>
{(['visitors', 'free_downloads', 'buyers'] as const).map(type => (
<button
key={type}
className={`btn-sm ${mapFilter === type ? 'active' : ''}`}
onClick={() => setMapFilter(type)}
>
{type.replace('_', ' ')}
</button>
))}
</div>
</div>
<div className="map-wrapper" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '20px', border: '1px solid var(--border)' }}>
<ComposableMap projectionConfig={{ rotate: [-10, 0, 0], scale: 147 }} width={800} height={400}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => {
// Try different property keys used by various GeoJSON versions
const countryCode = geo.properties.ISO_A2 || geo.properties.iso_a2 || geo.properties.IS_A2;
const d = mapData.find((s: any) => s.country === countryCode);
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={d ? colorScales[mapFilter](d[mapFilter] || 0) : "#1a1a2e"}
stroke="#2a2a40"
strokeWidth={0.5}
style={{
default: { outline: "none" },
hover: { fill: "var(--primary)", outline: "none", cursor: 'pointer' },
pressed: { outline: "none" }
}}
/>
);
})
}
</Geographies>
</ComposableMap>
{mapData.length === 0 && (
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
No geographic data captured yet. (Note: Localhost/VPN IPs may appear as 'Unknown')
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default DashboardAudience;

View File

@ -0,0 +1,213 @@
import React from 'react';
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
LineChart, Line
} from 'recharts';
import {
TrendingUp, TrendingDown, Minus, DollarSign,
Download, MousePointer2, Clock, User
} from 'lucide-react';
import { useAnalytics } from './useAnalytics';
// Helper for formatting large numbers
const formatValue = (val) => {
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`;
return val;
};
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="custom-tooltip-v2">
<p className="label">{label}</p>
<p className="value">${payload[0].value.toLocaleString()}</p>
</div>
);
}
return null;
};
const MiniChart = ({ data, color, dataKey }) => (
<div style={{ width: '80px', height: '40px' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }) => {
// Logic Fix: Handle 0% as neutral
const trendValue = parseFloat(comparison) || 0;
const isPositive = trendValue > 0;
const isNeutral = trendValue === 0;
const trendColor = isNeutral ? '#94a3b8' : (isPositive ? '#10b981' : '#ef4444');
const TrendIcon = () => {
if (isNeutral) return <Minus size={14} />;
return isPositive ? <TrendingUp size={14} /> : <TrendingDown size={14} />;
};
return (
<div className="kpi-card-v2">
<div className="kpi-header">
<div className="kpi-icon-wrapper"><Icon size={16} /></div>
<span className="kpi-title">{title}</span>
</div>
<div className="kpi-body">
<div className="kpi-main">
<h2 className="kpi-value">{value}</h2>
<div
className={`kpi-trend ${isNeutral ? 'neutral' : (isPositive ? 'up' : 'down')}`}
style={{ color: trendColor }}
>
<TrendIcon />
<span>{isNeutral ? '0%' : `${trendValue}%`}</span>
</div>
</div>
<MiniChart data={chartData} dataKey={dataKey} color={trendColor} />
</div>
</div>
);
};
const DashboardOverview = () => {
const { processedData, totals, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
return (
<div className="dashboard-wrapper">
<header className="dashboard-top-nav">
<div>
<h1>Overview</h1>
<p className="subtitle">Real-time performance for the <strong>{timeframe}</strong> period</p>
</div>
<div className="segment-control">
{timeframes.map((tf) => (
<button
key={tf}
className={tf === timeframe ? 'active' : ''}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
</header>
<div className="dashboard-grid-v2">
<main className="main-stats">
<div className="kpi-row">
<KpiCard
title="Revenue"
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
comparison={trends?.earnings}
icon={DollarSign}
chartData={processedData}
dataKey="earnings"
/>
<KpiCard
title="Downloads"
value={totals.downloads.toLocaleString()}
comparison={trends?.downloads}
icon={Download}
chartData={processedData}
dataKey="downloads"
/>
<KpiCard
title="Conversion"
value={`${totals.conversionRate || 0}%`}
comparison={trends?.conversion}
icon={MousePointer2}
chartData={processedData}
dataKey="conversion"
/>
</div>
<div className="chart-container-v2">
<div className="chart-header">
<h3>Revenue Growth</h3>
<div className="chart-legend">
<span className="legend-item earnings">Earnings ($)</span>
</div>
</div>
<ResponsiveContainer width="100%" height={400}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.15}/>
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
tickFormatter={formatValue}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="earnings"
stroke="#6366f1"
strokeWidth={3}
fillOpacity={1}
fill="url(#chartGradient)"
animationDuration={1500}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</main>
<aside className="activity-sidebar-v2">
<div className="sidebar-header">
<h3>Live Activity</h3>
<span className="live-indicator"></span>
</div>
<div className="feed-list">
{recentActivity && recentActivity.length > 0 ? (
recentActivity.map((activity) => (
<div key={activity.id} className="feed-item-v2">
<div className="feed-avatar">
{activity.userInitials || <User size={14} />}
</div>
<div className="feed-content">
<p><strong>{activity.userName}</strong> {activity.action} <strong>{activity.productName}</strong></p>
<span className="feed-time"><Clock size={10} /> {activity.timeAgo}</span>
</div>
<div className={`feed-amount ${activity.type === 'sale' ? 'positive' : ''}`}>
{activity.amount ? `+$${activity.amount}` : '--'}
</div>
</div>
))
) : (
<div className="empty-feed">
<p>No activity in this period.</p>
</div>
)}
</div>
<button className="view-all-btn">View full history</button>
</aside>
</div>
</div>
);
};
export default DashboardOverview;

View File

@ -0,0 +1,120 @@
import { useMemo } from 'react';
import { useAnalytics } from './useAnalytics';
import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink } from 'lucide-react'; // Added some icons for a cleaner UI
// --- Main Component ---
const DashboardPlugins = () => {
const { timeSeries } = useAnalytics();
const navigate = useNavigate();
// Grouping analytics data by plugin ID
const plugins = useMemo(() => {
return Object.values(timeSeries.reduce((acc, curr) => {
// Ensure we use curr.plugin_id or curr.id depending on your hook's structure
const id = curr.plugin_id || curr.id;
if (!acc[id]) {
acc[id] = {
id: id,
name: curr.name,
downloads: 0,
earnings: 0,
createdAt: curr.createdAt,
updatedAt: curr.updatedAt
};
}
acc[id].downloads += (curr.downloads || 0);
acc[id].earnings += (curr.earnings || 0);
return acc;
}, {} as Record<string, any>));
}, [timeSeries]);
const formatDate = (dateStr: string) => {
if (!dateStr) return 'N/A';
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
return (
<div className="dashboard-grid-layout">
<div className="data-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3>My Active Plugins</h3>
<button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}>
<Plus size={18} style={{ marginRight: '8px' }} />
Add New Plugin
</button>
</div>
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Downloads</th>
<th>Earnings</th>
<th>Dates</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{plugins.map((p: any) => (
<tr key={p.id}>
<td>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<strong>{p.name}</strong>
<span style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)' }}>ID: {p.id}</span>
</div>
</td>
<td className="mono">{p.downloads.toLocaleString()}</td>
<td className="mono" style={{ color: 'var(--success)' }}>
+${p.earnings.toFixed(2)}
</td>
<td>
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)' }}>
<div>Created: {formatDate(p.createdAt)}</div>
<div>Updated: {formatDate(p.updatedAt)}</div>
</div>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{/* View on Marketplace */}
<button
className="btn btn-icon-only"
onClick={() => navigate(`/plugin/${p.id}`)}
title="View in Store"
>
<ExternalLink size={16} />
</button>
{/* Manage/Edit Plugin */}
<button
className="btn btn-secondary"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Settings size={16} />
Manage
</button>
</div>
</td>
</tr>
))}
{plugins.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: '3rem' }}>
<div style={{ opacity: 0.6 }}>
<p>No plugins published yet.</p>
<small>Click "Add New Plugin" to get started</small>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default DashboardPlugins;

View File

@ -0,0 +1,343 @@
/* AddPlugin.css — inherits --mp-* variables from ManagePlugin.css */
/* ── Root ───────────────────────────────────────────────────── */
.ap-root {
display: flex;
flex-direction: column;
height: calc(100vh - 133px);
background: var(--mp-bg);
color: var(--mp-text);
font-family: var(--font-display);
overflow: hidden;
}
/* ── Progress header ────────────────────────────────────────── */
.ap-header {
background: var(--mp-surface);
border-bottom: 1px solid var(--mp-border);
padding: 1.25rem 2.5rem 0;
flex-shrink: 0;
}
.ap-header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.ap-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--mp-text);
letter-spacing: -0.02em;
margin: 0;
}
.ap-subtitle {
font-size: 0.78rem;
color: var(--mp-text-3);
margin: 0.2rem 0 0;
}
/* ── Step dots + progress line ──────────────────────────────── */
.ap-steps {
position: relative;
display: flex;
align-items: center;
}
.ap-steps-line {
position: absolute;
left: 14px;
right: 14px;
top: 14px; /* centres on the 28px dot icons */
height: 2px;
background: var(--mp-border);
z-index: 0;
}
.ap-steps-fill {
height: 100%;
background: linear-gradient(90deg, var(--mp-accent), var(--mp-accent-2));
border-radius: 2px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.ap-step-dot {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
background: none;
border: none;
cursor: pointer;
padding: 0 0 0.85rem;
flex: 1;
transition: opacity 0.15s;
}
.ap-step-dot:disabled { cursor: default; opacity: 0.45; }
.ap-step-dot > svg {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--mp-border);
background: var(--mp-surface);
color: var(--mp-text-3);
padding: 5px;
transition: all 0.2s;
flex-shrink: 0;
box-sizing: border-box;
}
.ap-step-dot.active > svg {
border-color: var(--mp-accent);
background: var(--mp-accent-glow);
color: var(--mp-accent);
box-shadow: 0 0 0 3px var(--mp-accent-glow);
}
.ap-step-dot.done > svg {
border-color: var(--mp-success);
background: rgba(62, 207, 142, 0.12);
color: var(--mp-success);
}
.ap-step-dot:not(:disabled):hover > svg {
border-color: var(--mp-accent);
}
.ap-step-label {
font-size: 0.7rem;
font-weight: 500;
color: var(--mp-text-3);
white-space: nowrap;
transition: color 0.15s;
}
.ap-step-dot.active .ap-step-label { color: var(--mp-accent); font-weight: 600; }
.ap-step-dot.done .ap-step-label { color: var(--mp-success); }
/* ── Scrollable body ────────────────────────────────────────── */
.ap-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ap-content {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem;
max-width: 720px;
width: 100%;
margin: 0 auto;
box-sizing: border-box;
}
.ap-content::-webkit-scrollbar { width: 5px; }
.ap-content::-webkit-scrollbar-track { background: transparent; }
.ap-content::-webkit-scrollbar-thumb { background: var(--mp-border-2); border-radius: 3px; }
/* ── Bottom nav bar ─────────────────────────────────────────── */
.ap-nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2.5rem;
border-top: 1px solid var(--mp-border);
background: var(--mp-surface);
flex-shrink: 0;
}
/* ── Required asterisk ──────────────────────────────────────── */
.ap-required { color: var(--mp-error); font-weight: 700; }
/* ── Publisher row ──────────────────────────────────────────── */
.ap-publisher-row {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--mp-surface-2);
border: 1px solid var(--mp-border);
border-radius: var(--mp-radius-sm);
padding: 0.85rem 1rem;
}
.ap-publisher-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--mp-accent), var(--mp-accent-2));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.95rem;
color: #fff;
flex-shrink: 0;
box-shadow: 0 2px 8px var(--mp-accent-glow);
user-select: none;
}
.ap-publisher-info {
flex: 1;
min-width: 0;
}
.ap-publisher-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--mp-text-3);
margin-bottom: 0.15rem;
}
.ap-publisher-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--mp-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-publisher-badge {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--mp-success);
background: rgba(62, 207, 142, 0.1);
border: 1px solid rgba(62, 207, 142, 0.25);
border-radius: 20px;
padding: 0.25rem 0.65rem;
white-space: nowrap;
flex-shrink: 0;
}
/* ── Review grid ────────────────────────────────────────────── */
.ap-review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.ap-review-card {
cursor: pointer;
transition: border-color 0.15s;
padding: 1.1rem 1.25rem;
}
.ap-review-card:hover { border-color: var(--mp-accent); }
.ap-review-section-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mp-text-3);
margin-bottom: 0.75rem;
}
.ap-review-section-label svg { color: var(--mp-accent); }
.ap-review-field {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
font-size: 0.82rem;
margin-bottom: 0.35rem;
}
.ap-review-field span { color: var(--mp-text-3); white-space: nowrap; }
.ap-review-field strong { color: var(--mp-text); text-align: right; word-break: break-word; }
/* ── Language picker ────────────────────────────────────────── */
.ap-lang-picker {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 100;
background: var(--mp-surface);
border: 1px solid var(--mp-border);
border-radius: var(--mp-radius);
width: 260px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.ap-lang-picker-search {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--mp-border);
background: var(--mp-surface-2);
}
.ap-lang-picker-search input {
background: none; border: none; outline: none;
color: var(--mp-text); font-family: var(--font-display);
font-size: 0.82rem; width: 100%;
}
.ap-lang-picker-search input::placeholder { color: var(--mp-text-3); }
.ap-lang-picker-list { max-height: 260px; overflow-y: auto; }
.ap-lang-picker-list::-webkit-scrollbar { width: 4px; }
.ap-lang-picker-list::-webkit-scrollbar-thumb { background: var(--mp-border-2); border-radius: 2px; }
.ap-lang-picker-empty {
padding: 1.5rem; text-align: center;
font-size: 0.8rem; color: var(--mp-text-3);
}
.ap-lang-picker-item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; background: none; border: none;
padding: 0.5rem 0.85rem; cursor: pointer;
color: var(--mp-text-2); font-family: var(--font-display);
font-size: 0.82rem; text-align: left; gap: 0.5rem;
transition: background 0.1s;
}
.ap-lang-picker-item:hover { background: var(--mp-surface-2); }
.ap-lang-code {
font-family: var(--font-mono); font-size: 0.7rem;
color: var(--mp-text-3); flex-shrink: 0;
}
/* ── Success screen ─────────────────────────────────────────── */
.ap-success {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
height: 100%;
text-align: center;
padding: 2rem;
}
.ap-success-icon {
width: 72px; height: 72px; border-radius: 50%;
background: rgba(62, 207, 142, 0.12);
border: 1px solid rgba(62, 207, 142, 0.3);
display: flex; align-items: center; justify-content: center;
margin-bottom: 0.5rem;
}
.ap-success h2 {
font-size: 1.5rem; font-weight: 700;
color: var(--mp-text); margin: 0; letter-spacing: -0.02em;
}
.ap-success p { color: var(--mp-text-2); font-size: 0.9rem; margin: 0; }
/* ── Dashboard padding reset ────────────────────────────────── */
.dashboard-content:has(.ap-root) { padding: 0 !important; margin: 0 !important; overflow: hidden; }
.dashboard-container:has(.ap-root) .dashboard-nav { margin-bottom: 0 !important; }
.dashboard-container:has(.ap-root) .dashboard-content { padding: 0 !important; flex: 1; overflow: hidden; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Trash2, EyeOff, AlertTriangle } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
import { useNavigate } from 'react-router-dom';
type Props = { plugin: PluginData };
const DangerZone = ({ plugin }: Props) => {
const navigate = useNavigate();
const [deleteConfirm, setDeleteConfirm] = useState('');
const [unpublishing, setUnpublishing] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleUnpublish = async () => {
if (!window.confirm('Unpublish this plugin? It will be hidden from the store but not deleted.')) return;
setUnpublishing(true);
try {
const fd = new FormData();
fd.append('status', 'draft');
await api.put(`/plugins/${plugin.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
alert('Plugin unpublished.');
} catch {
alert('Failed to unpublish.');
} finally {
setUnpublishing(false);
}
};
const handleDelete = async () => {
if (deleteConfirm !== plugin.name) return;
setDeleting(true);
try {
await api.delete(`/plugins/${plugin.id}`);
navigate('/dashboard/plugins');
} catch {
alert('Failed to delete plugin.');
setDeleting(false);
}
};
return (
<div>
<div className="mp-tab-header">
<h2>Danger Zone</h2>
<p>Irreversible actions live here. Proceed carefully.</p>
</div>
{/* ── Unpublish ──
<div className="mp-card" style={{borderColor:'rgba(245,166,35,0.3)'}}>
<div className="mp-card-title">
<EyeOff size={14} style={{color:'var(--mp-warn)'}} />
<span style={{color:'var(--mp-warn)'}}>Unpublish Plugin</span>
</div>
<p style={{fontSize:'0.85rem', color:'var(--mp-text-2)', lineHeight:1.6, marginBottom:'1.25rem'}}>
Hides the plugin from the store. Existing users who have already installed it are
<strong> not affected</strong> — they can continue using it. You can re-publish at any time.
</p>
<button
className="mp-btn mp-btn-outline"
onClick={handleUnpublish}
disabled={unpublishing}
style={{borderColor:'var(--mp-warn)', color:'var(--mp-warn)'}}
>
<EyeOff size={15} />
{unpublishing ? 'Unpublishing…' : 'Unpublish Plugin'}
</button>
</div> */}
{/* ── Delete ── */}
<div className="mp-card" style={{borderColor:'rgba(242,65,90,0.3)'}}>
<div className="mp-card-title">
<Trash2 size={14} style={{color:'var(--mp-error)'}} />
<span style={{color:'var(--mp-error)'}}>Delete Plugin</span>
</div>
<div className="mp-banner danger">
<AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} />
<div>
<strong>This is permanent and cannot be undone.</strong> All user installs,
purchase records, screenshots, and binaries will be erased. Active subscribers
will be refunded automatically.
</div>
</div>
<p style={{fontSize:'0.85rem', color:'var(--mp-text-2)', lineHeight:1.6, marginBottom:'1rem'}}>
To confirm, type the plugin name <strong style={{color:'var(--mp-text)', fontFamily:'var(--font-mono)'}}>
{plugin.name}</strong> below:
</p>
<div className="mp-danger-input-row">
<div className="mp-form-group">
<input
className="mp-input"
type="text"
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder={plugin.name}
style={{fontFamily:'var(--font-mono)'}}
/>
</div>
<button
className="mp-btn mp-btn-danger"
onClick={handleDelete}
disabled={deleteConfirm !== plugin.name || deleting}
style={{flexShrink:0}}
>
<Trash2 size={15} />
{deleting ? 'Deleting…' : 'Delete Forever'}
</button>
</div>
</div>
</div>
);
};
export default DangerZone;

View File

@ -0,0 +1,656 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
/* ── Variables ──────────────────────────────────────────────── */
:root {
--mp-bg: #0d0f12;
--mp-surface: #151820;
--mp-surface-2: #1c2030;
--mp-border: #252a38;
--mp-border-2: #2f3649;
--mp-text: #e8eaf2;
--mp-text-2: #8b91a8;
--mp-text-3: #5a6075;
--mp-accent: #4f7eff;
--mp-accent-glow: rgba(79, 126, 255, 0.18);
--mp-accent-2: #7c5cfc;
--mp-success: #3ecf8e;
--mp-warn: #f5a623;
--mp-error: #f2415a;
--mp-muted: #4a5168;
--mp-sidebar-w: 240px;
--mp-radius: 10px;
--mp-radius-sm: 6px;
--font-display: 'DM Sans', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
/* ── Reset / Base ───────────────────────────────────────────── */
.mp-root *, .mp-root *::before, .mp-root *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.mp-root {
display: flex;
/* navbar ~83px + dashboard-nav ~50px */
height: calc(100vh - 133px);
background: var(--mp-bg);
color: var(--mp-text);
font-family: var(--font-display);
overflow: hidden;
}
/* ── Loading ────────────────────────────────────────────────── */
.mp-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 133px);
gap: 1rem;
background: var(--mp-bg);
color: var(--mp-text-2);
font-family: var(--font-display);
font-size: 0.9rem;
}
.mp-loading-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--mp-border-2);
border-top-color: var(--mp-accent);
border-radius: 50%;
animation: mp-spin 0.7s linear infinite;
}
@keyframes mp-spin { to { transform: rotate(360deg); } }
/* ── Sidebar ────────────────────────────────────────────────── */
.mp-sidebar {
width: var(--mp-sidebar-w);
min-width: var(--mp-sidebar-w);
background: var(--mp-surface);
border-right: 1px solid var(--mp-border);
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
}
.mp-back {
display: flex;
align-items: center;
gap: 0.4rem;
background: none;
border: none;
color: var(--mp-text-3);
font-family: var(--font-display);
font-size: 0.78rem;
cursor: pointer;
padding: 0 1.25rem 1rem;
transition: color 0.15s;
letter-spacing: 0.01em;
}
.mp-back:hover { color: var(--mp-text-2); }
.mp-plugin-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--mp-border);
border-bottom: 1px solid var(--mp-border);
margin-bottom: 0.5rem;
}
.mp-plugin-avatar {
width: 38px;
height: 38px;
border-radius: 9px;
background: linear-gradient(135deg, var(--mp-accent), var(--mp-accent-2));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #fff;
flex-shrink: 0;
box-shadow: 0 2px 12px var(--mp-accent-glow);
}
.mp-plugin-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--mp-text);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
.mp-plugin-status {
display: flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.2rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: capitalize;
letter-spacing: 0.02em;
}
/* ── Navigation ─────────────────────────────────────────────── */
.mp-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0.25rem 0.75rem;
flex: 1;
}
.mp-nav-item {
display: flex;
align-items: center;
gap: 0.65rem;
background: none;
border: none;
border-radius: var(--mp-radius-sm);
color: var(--mp-text-2);
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 500;
padding: 0.6rem 0.75rem;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
width: 100%;
}
.mp-nav-item:hover {
background: var(--mp-surface-2);
color: var(--mp-text);
}
.mp-nav-item.active {
background: var(--mp-accent-glow);
color: var(--mp-accent);
font-weight: 600;
}
.mp-nav-item.danger { margin-top: auto; }
.mp-nav-item.danger:hover { color: var(--mp-error); background: rgba(242,65,90,0.08); }
.mp-nav-item.danger.active { color: var(--mp-error); background: rgba(242,65,90,0.12); }
.mp-sidebar-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--mp-border);
margin-top: 0.5rem;
}
.mp-meta-row {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--mp-text-3);
font-family: var(--font-mono);
font-size: 0.72rem;
}
/* ── Main Content Area ──────────────────────────────────────── */
.mp-main {
flex: 1;
overflow-y: auto;
padding: 2.5rem 3rem;
background: var(--mp-bg);
}
/* ── Shared Tab Components ──────────────────────────────────── */
.mp-tab-header {
margin-bottom: 2rem;
}
.mp-tab-header h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--mp-text);
letter-spacing: -0.02em;
}
.mp-tab-header p {
margin-top: 0.35rem;
font-size: 0.85rem;
color: var(--mp-text-2);
line-height: 1.5;
}
.mp-card {
background: var(--mp-surface);
border: 1px solid var(--mp-border);
border-radius: var(--mp-radius);
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.mp-card-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mp-text-3);
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.mp-card-title svg { color: var(--mp-accent); }
/* ── Forms ──────────────────────────────────────────────────── */
.mp-form-group {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1.1rem;
}
.mp-form-group:last-child { margin-bottom: 0; }
.mp-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--mp-text-2);
letter-spacing: 0.01em;
}
.mp-input,
.mp-select,
.mp-textarea {
background: var(--mp-surface-2);
border: 1px solid var(--mp-border-2);
border-radius: var(--mp-radius-sm);
color: var(--mp-text);
font-family: var(--font-display);
font-size: 0.875rem;
padding: 0.6rem 0.85rem;
width: 100%;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
}
.mp-input:focus,
.mp-select:focus,
.mp-textarea:focus {
border-color: var(--mp-accent);
box-shadow: 0 0 0 3px var(--mp-accent-glow);
}
.mp-textarea {
resize: vertical;
min-height: 120px;
line-height: 1.6;
}
.mp-select {
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b91a8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.85rem center;
padding-right: 2.2rem;
}
.mp-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
/* ── Language Tabs ──────────────────────────────────────────── */
.mp-lang-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.mp-lang-tab {
background: var(--mp-surface-2);
border: 1px solid var(--mp-border-2);
border-radius: var(--mp-radius-sm);
color: var(--mp-text-3);
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 500;
padding: 0.3rem 0.65rem;
cursor: pointer;
transition: all 0.15s;
}
.mp-lang-tab.active {
background: var(--mp-accent-glow);
border-color: var(--mp-accent);
color: var(--mp-accent);
}
.mp-lang-tab:hover:not(.active) {
border-color: var(--mp-border-2);
color: var(--mp-text-2);
}
/* ── Buttons ────────────────────────────────────────────────── */
.mp-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: none;
border-radius: var(--mp-radius-sm);
font-family: var(--font-display);
font-size: 0.85rem;
font-weight: 600;
padding: 0.6rem 1.2rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.mp-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mp-btn-primary {
background: var(--mp-accent);
color: #fff;
box-shadow: 0 2px 12px var(--mp-accent-glow);
}
.mp-btn-primary:hover:not(:disabled) {
background: #6b94ff;
box-shadow: 0 4px 20px var(--mp-accent-glow);
transform: translateY(-1px);
}
.mp-btn-success {
background: var(--mp-success);
color: #071a12;
}
.mp-btn-success:hover:not(:disabled) {
background: #4ddfaa;
transform: translateY(-1px);
}
.mp-btn-outline {
background: transparent;
border: 1px solid var(--mp-border-2);
color: var(--mp-text-2);
}
.mp-btn-outline:hover:not(:disabled) {
border-color: var(--mp-text-2);
color: var(--mp-text);
}
.mp-btn-danger {
background: transparent;
border: 1px solid var(--mp-error);
color: var(--mp-error);
}
.mp-btn-danger:hover:not(:disabled) {
background: rgba(242,65,90,0.1);
box-shadow: 0 0 0 3px rgba(242,65,90,0.1);
}
.mp-btn-full { width: 100%; justify-content: center; }
/* ── Screenshot Grid ────────────────────────────────────────── */
.mp-screenshots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem;
margin-top: 0.75rem;
}
.mp-screenshot-item {
position: relative;
border-radius: var(--mp-radius-sm);
overflow: hidden;
aspect-ratio: 16/9;
background: var(--mp-surface-2);
border: 1px solid var(--mp-border);
group: true;
}
.mp-screenshot-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.mp-screenshot-del {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
background: rgba(13,15,18,0.85);
border: 1px solid var(--mp-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--mp-text-2);
font-size: 14px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
line-height: 1;
}
.mp-screenshot-item:hover .mp-screenshot-del {
opacity: 1;
}
.mp-screenshot-del:hover { color: var(--mp-error); }
/* ── File Drop Zone ─────────────────────────────────────────── */
.mp-dropzone {
border: 2px dashed var(--mp-border-2);
border-radius: var(--mp-radius);
padding: 2.5rem 1.5rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
background: var(--mp-surface-2);
}
.mp-dropzone:hover,
.mp-dropzone.drag-over {
border-color: var(--mp-accent);
background: var(--mp-accent-glow);
}
.mp-dropzone input[type="file"] {
display: none;
}
.mp-dropzone-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: var(--mp-surface);
border: 1px solid var(--mp-border-2);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
color: var(--mp-accent);
}
.mp-dropzone p {
font-size: 0.85rem;
color: var(--mp-text-2);
line-height: 1.5;
}
.mp-dropzone strong { color: var(--mp-text); }
.mp-dropzone small {
display: block;
margin-top: 0.35rem;
font-size: 0.75rem;
color: var(--mp-text-3);
font-family: var(--font-mono);
}
.mp-file-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--mp-accent-glow);
border: 1px solid var(--mp-accent);
border-radius: var(--mp-radius-sm);
color: var(--mp-accent);
font-family: var(--font-mono);
font-size: 0.78rem;
padding: 0.45rem 0.85rem;
margin-top: 0.75rem;
}
/* ── Release Track Selector ─────────────────────────────────── */
.mp-tracks {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.mp-track {
border: 1px solid var(--mp-border);
border-radius: var(--mp-radius);
padding: 1rem;
cursor: pointer;
background: var(--mp-surface-2);
text-align: left;
transition: all 0.15s;
}
.mp-track:hover { border-color: var(--mp-border-2); }
.mp-track.active {
border-color: var(--mp-accent);
background: var(--mp-accent-glow);
}
.mp-track-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--mp-text);
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.3rem;
}
.mp-track.active .mp-track-label { color: var(--mp-accent); }
.mp-track-desc {
font-size: 0.76rem;
color: var(--mp-text-3);
line-height: 1.4;
}
.mp-track-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.mp-track-badge.stable { background: rgba(62,207,142,0.15); color: var(--mp-success); }
.mp-track-badge.beta { background: rgba(245,166,35,0.15); color: var(--mp-warn); }
.mp-track-badge.alpha { background: rgba(79,126,255,0.15); color: var(--mp-accent); }
/* ── Pricing Cards ──────────────────────────────────────────── */
.mp-pricing-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.mp-pricing-card {
border: 1px solid var(--mp-border);
border-radius: var(--mp-radius);
padding: 1.1rem;
cursor: pointer;
background: var(--mp-surface-2);
transition: all 0.15s;
text-align: left;
}
.mp-pricing-card:hover { border-color: var(--mp-border-2); }
.mp-pricing-card.selected {
border-color: var(--mp-accent);
background: var(--mp-accent-glow);
}
.mp-pricing-card-name {
font-size: 0.88rem;
font-weight: 700;
color: var(--mp-text);
margin-bottom: 0.3rem;
}
.mp-pricing-card.selected .mp-pricing-card-name { color: var(--mp-accent); }
.mp-pricing-card-desc {
font-size: 0.78rem;
color: var(--mp-text-3);
line-height: 1.4;
}
/* ── Info / Warning Banners ─────────────────────────────────── */
.mp-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border-radius: var(--mp-radius-sm);
padding: 0.85rem 1rem;
font-size: 0.82rem;
line-height: 1.5;
margin-bottom: 1.25rem;
}
.mp-banner.warn {
background: rgba(245,166,35,0.08);
border: 1px solid rgba(245,166,35,0.25);
color: #d49a36;
}
.mp-banner.info {
background: rgba(79,126,255,0.08);
border: 1px solid rgba(79,126,255,0.2);
color: var(--mp-accent);
}
.mp-banner.danger {
background: rgba(242,65,90,0.08);
border: 1px solid rgba(242,65,90,0.25);
color: var(--mp-error);
}
/* ── Danger Zone ────────────────────────────────────────────── */
.mp-danger-input-row {
display: flex;
gap: 0.75rem;
align-items: flex-end;
}
.mp-danger-input-row .mp-form-group { flex: 1; margin: 0; }
/* ── Divider ────────────────────────────────────────────────── */
.mp-divider {
height: 1px;
background: var(--mp-border);
margin: 1.5rem 0;
}
/* ── Dashboard layout reset ─────────────────────────────────── */
/* Strip the dashboard-content padding when ManagePlugin fills it */
.dashboard-content:has(.mp-root) {
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
}
/* Also kill the nav bottom margin and content padding from Dashboard.css */
.dashboard-container:has(.mp-root) .dashboard-nav {
margin-bottom: 0 !important;
}
.dashboard-container:has(.mp-root) {
display: flex;
flex-direction: column;
}
.dashboard-container:has(.mp-root) .dashboard-content {
padding: 0 !important;
flex: 1;
overflow: hidden;
}
/* ── Scrollbar ──────────────────────────────────────────────── */
.mp-main::-webkit-scrollbar,
.mp-sidebar::-webkit-scrollbar { width: 5px; }
.mp-main::-webkit-scrollbar-track,
.mp-sidebar::-webkit-scrollbar-track { background: transparent; }
.mp-main::-webkit-scrollbar-thumb,
.mp-sidebar::-webkit-scrollbar-thumb {
background: var(--mp-border-2);
border-radius: 3px;
}

View File

@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import api from '../../../api';
import {
LayoutGrid, Tag, Upload, DollarSign, Trash2,
ChevronLeft, Circle
} from 'lucide-react';
import StoreListing from './StoreListing';
import PublishUpdate from './PublishUpdate';
import Pricing from './Pricing';
import DangerZone from './DangerZone';
import './ManagePlugin.css';
export type PluginData = {
id: number;
name: string;
translated_descriptions: string;
category: string;
source_link: string;
keywords: string;
price_cents: number;
type: 'free' | 'paid' | 'adwall';
screenshots: { id: number; path: string }[];
version?: string;
status?: 'published' | 'draft' | 'review';
sale_active?: boolean;
sale_discount_percent?: number;
};
const NAV_ITEMS = [
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
{ key: 'update', label: 'Publish Update', icon: Upload },
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
];
const ManagePlugin = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('listing');
const [loading, setLoading] = useState(true);
const [plugin, setPlugin] = useState<PluginData | null>(null);
useEffect(() => {
fetchPlugin();
}, [id]);
const fetchPlugin = async () => {
try {
const res = await api.get(`/plugins/${id}`);
setPlugin(res.data);
} catch {
navigate('/dashboard/plugins');
} finally {
setLoading(false);
}
};
const refreshPlugin = () => fetchPlugin();
if (loading) {
return (
<div className="mp-loading">
<div className="mp-loading-spinner" />
<span>Loading plugin…</span>
</div>
);
}
if (!plugin) return null;
const statusColor = {
published: 'var(--mp-success)',
draft: 'var(--mp-muted)',
review: 'var(--mp-warn)',
}[plugin.status ?? 'published'];
return (
<div className="mp-root">
{/* ── Sidebar ───────────────────────────────────────────── */}
<aside className="mp-sidebar">
<div className="mp-plugin-identity">
<div className="mp-plugin-avatar">
{plugin.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="mp-plugin-name">{plugin.name}</p>
<div className="mp-plugin-status">
<Circle size={7} fill={statusColor} color={statusColor} />
<span style={{ color: statusColor }}>
{plugin.status ?? 'published'}
</span>
</div>
</div>
</div>
<nav className="mp-nav">
{NAV_ITEMS.map(({ key, label, icon: Icon }) => (
<button
key={key}
className={`mp-nav-item ${activeTab === key ? 'active' : ''} ${key === 'danger' ? 'danger' : ''}`}
onClick={() => setActiveTab(key)}
>
<Icon size={16} />
{label}
</button>
))}
</nav>
<div className="mp-sidebar-meta">
<span className="mp-meta-row"><Tag size={12} /> ID: {id}</span>
{plugin.version && (
<span className="mp-meta-row">
<Upload size={12} /> v{plugin.version}
</span>
)}
</div>
</aside>
{/* ── Main Content ──────────────────────────────────────── */}
<main className="mp-main">
{activeTab === 'listing' && (
<StoreListing plugin={plugin} onSaved={refreshPlugin} />
)}
{activeTab === 'update' && (
<PublishUpdate plugin={plugin} onSaved={refreshPlugin} />
)}
{activeTab === 'pricing' && (
<Pricing plugin={plugin} onSaved={refreshPlugin} />
)}
{activeTab === 'danger' && (
<DangerZone plugin={plugin} />
)}
</main>
</div>
);
};
export default ManagePlugin;

View File

@ -0,0 +1,281 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { DollarSign, ExternalLink, Save, Zap, Lock, Percent, Tag, AlertTriangle } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
import { useAuth } from '../../../App';
type LicenseType = 'free' | 'paid';
type Props = { plugin: PluginData; onSaved: () => void };
const PRICING_OPTIONS: { key: LicenseType; label: string; icon: React.ReactNode; desc: string }[] = [
{
key: 'free',
label: 'Free',
icon: <Zap size={18} />,
desc: 'Anyone can install with no payment required. Maximises reach.',
},
{
key: 'paid',
label: 'Paid',
icon: <Lock size={18} />,
desc: 'One-time purchase via Stripe. Requires a connected Stripe account.',
},
];
const Pricing = ({ plugin, onSaved }: Props) => {
const navigate = useNavigate();
const { user } = useAuth();
const [saving, setSaving] = useState(false);
const [licenseType, setLicenseType] = useState<LicenseType>(plugin.type === 'paid' ? 'paid' : 'free');
const [price, setPrice] = useState((plugin.price_cents ?? 0) / 100 || 4.99);
const [isSaleActive, setIsSaleActive] = useState(plugin.sale_active ?? false);
const [discountPercent, setDiscountPercent] = useState(plugin.sale_discount_percent || 20);
const [priceTooLow, setPriceTooLow] = useState(false);
const stripeConnected = user.stripe_ready;
// Derived — all math in integer cents to avoid float errors
const priceCents = Math.round(price * 100);
const salePriceCents = Math.round(priceCents * (1 - discountPercent / 100));
const salePriceDisplay = (salePriceCents / 100).toFixed(2);
const savingsDisplay = ((priceCents - salePriceCents) / 100).toFixed(2);
// Stripe fee: 1.5% + €0.25, platform: 30%
const calcEarnings = (cents: number) => {
const stripeCents = Math.ceil(cents * 0.015 + 25);
const platformCents = Math.round(cents * 0.30);
return Math.max(0, cents - stripeCents - platformCents);
};
const baseEarnings = calcEarnings(priceCents);
const saleEarnings = calcEarnings(salePriceCents);
const fmt = (c: number) => (c / 100).toFixed(2);
const handlePriceChange = (val: string) => {
const n = parseFloat(val) || 0;
setPrice(n);
setPriceTooLow(n < 0.99);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (licenseType === 'paid' && !stripeConnected) return;
if (licenseType === 'paid' && price < 0.99) { setPriceTooLow(true); return; }
setSaving(true);
const fd = new FormData();
fd.append('type', licenseType);
fd.append('price', (licenseType === 'paid' ? priceCents : 0).toString());
if (licenseType === 'paid') {
fd.append('sale_active', isSaleActive.toString());
fd.append('sale_discount_percent', discountPercent.toString());
}
try {
await api.put(`/plugins/${plugin.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
onSaved();
} catch {
alert('Failed to save pricing.');
} finally {
setSaving(false);
}
};
return (
<div>
<div className="mp-tab-header">
<h2>Pricing</h2>
<p>Manage your plugin's price and run manual sales.</p>
</div>
<form onSubmit={handleSave}>
{/* ── Model selector ── */}
<div className="mp-card">
<div className="mp-card-title">
<DollarSign size={14} />Pricing Model
</div>
<div className="mp-pricing-options">
{PRICING_OPTIONS.map(opt => {
const isLocked = opt.key === 'paid' && !stripeConnected;
return (
<div key={opt.key} style={{ position: 'relative' }}>
<button
type="button"
className={`mp-pricing-card ${licenseType === opt.key ? 'selected' : ''}`}
onClick={() => !isLocked && setLicenseType(opt.key)}
style={{ width: '100%', height: '100%', opacity: isLocked ? 0.6 : 1, cursor: isLocked ? 'default' : 'pointer' }}
>
<div style={{ color: licenseType === opt.key ? 'var(--mp-accent)' : 'var(--mp-text-2)', marginBottom: '0.5rem' }}>
{opt.icon}
</div>
<div className="mp-pricing-card-name">{opt.label}</div>
<div className="mp-pricing-card-desc">{opt.desc}</div>
</button>
{isLocked && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(13,15,18,0.1)', backdropFilter: 'blur(1px)', borderRadius: 'var(--mp-radius)' }}>
<button
type="button"
className="mp-btn mp-btn-primary"
style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }}
onClick={() => navigate('/settings?tab=payouts')}
>
<ExternalLink size={12} /> Unlock
</button>
</div>
)}
</div>
);
})}
</div>
</div>
{licenseType === 'paid' && !stripeConnected && (
<div className="mp-banner warn">
<Lock size={16} style={{ flexShrink: 0 }} />
<span>Connect your Stripe account before configuring paid pricing.</span>
</div>
)}
{licenseType === 'paid' && stripeConnected && (
<>
{/* ── Base Price ── */}
<div className="mp-card">
<div className="mp-card-title"><DollarSign size={14} />Base Price</div>
{priceTooLow && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.75rem', padding: '0.45rem 0.65rem', background: 'rgba(255,77,79,0.1)', border: '1px solid rgba(255,77,79,0.35)', borderRadius: 6, fontSize: '0.75rem', color: 'var(--mp-error, #ff4d4f)' }}>
<AlertTriangle size={13} style={{ flexShrink: 0 }} />
Minimum price is €0.99.
</div>
)}
<div className="mp-form-group" style={{ marginBottom: 0 }}>
<label className="mp-label">Price (EUR)</label>
<div style={{ position: 'relative', maxWidth: 180 }}>
<span style={{ position: 'absolute', left: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: priceTooLow ? 'var(--mp-error, #ff4d4f)' : 'var(--mp-text-3)', fontSize: '0.85rem' }}>€</span>
<input
className="mp-input"
type="number"
step="0.01"
min="0.99"
value={price}
onChange={e => handlePriceChange(e.target.value)}
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }}
/>
</div>
</div>
{/* Earnings breakdown */}
<div style={{ marginTop: '0.85rem', padding: '0.75rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', marginBottom: '0.3rem' }}>
<span style={{ color: 'var(--mp-text-3)' }}>Platform fee (30%)</span>
<span style={{ color: 'var(--mp-red)' }}>-€{fmt(Math.round(priceCents * 0.30))}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', marginBottom: '0.5rem', paddingBottom: '0.5rem', borderBottom: '1px solid var(--mp-border)' }}>
<span style={{ color: 'var(--mp-text-3)' }}>Stripe fee (1.5% + €0.25)</span>
<span style={{ color: 'var(--mp-red)' }}>-€{fmt(Math.ceil(priceCents * 0.015 + 25))}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', fontWeight: 600 }}>
<span style={{ color: 'var(--mp-text-1)' }}>Your earnings</span>
<span style={{ color: baseEarnings > 0 ? 'var(--mp-green)' : 'var(--mp-error)' }}>+€{fmt(baseEarnings)}</span>
</div>
</div>
</div>
{/* ── Sale ── */}
<div className="mp-card">
<div className="mp-card-title"><Tag size={14} />Sale</div>
{/* Discount % — always visible when paid */}
<div className="mp-form-group">
<label className="mp-label">Discount</label>
<div style={{ position: 'relative', maxWidth: 180 }}>
<input
className="mp-input"
type="number"
min="1"
max="99"
value={discountPercent}
onChange={e => setDiscountPercent(Math.min(99, Math.max(1, parseInt(e.target.value) || 0)))}
style={{ paddingRight: '2.2rem' }}
/>
<span style={{ position: 'absolute', right: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--mp-text-3)', fontSize: '0.85rem' }}>%</span>
</div>
</div>
{/* Sale price preview — always shown */}
<div style={{
padding: '0.85rem 1rem',
background: isSaleActive ? 'rgba(255,107,107,0.06)' : 'rgba(79,126,255,0.06)',
border: `1px solid ${isSaleActive ? 'rgba(255,107,107,0.25)' : 'rgba(79,126,255,0.2)'}`,
borderRadius: 8,
marginBottom: '1rem',
transition: 'background 0.2s, border-color 0.2s',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>€{price.toFixed(2)}</span>
<span style={{ fontSize: '1.1rem', fontWeight: 700, color: isSaleActive ? '#ff6b6b' : 'var(--mp-accent)', fontFamily: 'var(--font-mono)' }}>€{salePriceDisplay}</span>
<span style={{ fontSize: '0.65rem', fontWeight: 700, background: isSaleActive ? 'rgba(255,107,107,0.15)' : 'rgba(79,126,255,0.15)', color: isSaleActive ? '#ff6b6b' : 'var(--mp-accent)', border: `1px solid ${isSaleActive ? 'rgba(255,107,107,0.3)' : 'rgba(79,126,255,0.3)'}`, borderRadius: 4, padding: '1px 6px' }}>
-{discountPercent}%
</span>
</div>
<span style={{ fontSize: '0.72rem', color: 'var(--mp-text-3)' }}>Save €{savingsDisplay}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.78rem', paddingTop: '0.5rem', borderTop: '1px solid var(--mp-border)' }}>
<span style={{ color: 'var(--mp-text-3)' }}>Your earnings at sale price</span>
<span style={{ color: saleEarnings > 0 ? 'var(--mp-green)' : 'var(--mp-error)', fontWeight: 600 }}>+€{fmt(saleEarnings)}</span>
</div>
</div>
{/* Activate / End toggle */}
{!isSaleActive ? (
<button
type="button"
className="mp-btn mp-btn-outline"
onClick={() => setIsSaleActive(true)}
style={{ borderStyle: 'dashed', width: '100%', justifyContent: 'center', padding: '1rem', gap: '0.5rem' }}
>
<Percent size={15} />
Start Sale at -{discountPercent}%
</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.25)', borderRadius: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', fontWeight: 700, color: '#ff6b6b' }}>
🔥 Sale is live — {discountPercent}% off
</div>
<button
type="button"
onClick={() => setIsSaleActive(false)}
className="mp-btn mp-btn-outline"
style={{ fontSize: '0.72rem', color: 'var(--mp-error, #ff4d4f)', borderColor: 'rgba(255,77,79,0.3)', padding: '0.3rem 0.75rem' }}
>
End Sale
</button>
</div>
)}
</div>
</>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '2rem' }}>
<button
type="submit"
className="mp-btn mp-btn-primary"
disabled={saving || (licenseType === 'paid' && !stripeConnected) || priceTooLow}
>
<Save size={15} />
{saving ? 'Saving…' : 'Save Pricing'}
</button>
</div>
</form>
</div>
);
};
export default Pricing;

View File

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
type ReleaseTrack = 'stable' | 'beta' | 'alpha';
type Props = { plugin: PluginData; onSaved: () => void };
const TRACKS: { key: ReleaseTrack; label: string; desc: string }[] = [
{ key: 'stable', label: 'Stable', desc: 'Released to all users. Thoroughly tested.' },
{ key: 'beta', label: 'Beta', desc: 'Opt-in testers only. Wider feedback loop.' },
{ key: 'alpha', label: 'Internal', desc: 'Team or whitelist access only.' },
];
const PublishUpdate = ({ plugin, onSaved }: Props) => {
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [track, setTrack] = useState<ReleaseTrack>('stable');
const [version, setVersion] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [published, setPublished] = useState(false);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.wasm')) setWasmFile(file);
else alert('Please drop a valid .wasm file.');
};
const handlePublish = async () => {
if (!wasmFile) return;
setUploading(true);
const fd = new FormData();
fd.append('wasm', wasmFile);
fd.append('track', track);
if (version) fd.append('version', version);
if (releaseNotes) fd.append('release_notes', releaseNotes);
try {
await api.put(`/plugins/${plugin.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setPublished(true);
onSaved();
setTimeout(() => {
setPublished(false);
setWasmFile(null);
setVersion('');
setReleaseNotes('');
}, 3000);
} catch {
alert('Publish failed.');
} finally {
setUploading(false);
}
};
if (published) {
return (
<div>
<div className="mp-tab-header">
<h2>Publish Update</h2>
</div>
<div className="mp-card" style={{
display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'center', gap:'1rem', padding:'3rem', textAlign:'center'
}}>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: 'rgba(62,207,142,0.12)', border: '1px solid rgba(62,207,142,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<CheckCircle size={26} color="var(--mp-success)" />
</div>
<div>
<p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p>
<p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}>
Your new version is being rolled out on the <strong>{track}</strong> track.
</p>
</div>
</div>
</div>
);
}
return (
<div>
<div className="mp-tab-header">
<h2>Publish Update</h2>
<p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p>
</div>
{/* ── Release Track ──
<div className="mp-card">
<div className="mp-card-title">
<Send size={14} />
Release Track
</div>
<div className="mp-tracks">
{TRACKS.map(t => (
<button
key={t.key}
type="button"
className={`mp-track ${track === t.key ? 'active' : ''}`}
onClick={() => setTrack(t.key)}
>
<div className="mp-track-label">
{t.label}
<span className={`mp-track-badge ${t.key}`}>{t.key}</span>
</div>
<div className="mp-track-desc">{t.desc}</div>
</button>
))}
</div>
{track === 'stable' && (
<div className="mp-banner warn">
<AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} />
<span>Stable releases are immediately visible to all users. Make sure your build is production-ready.</span>
</div>
)}
{track === 'alpha' && (
<div className="mp-banner info">
<Clock size={16} style={{flexShrink:0, marginTop:1}} />
<span>Internal track is only visible to whitelisted user IDs. Configure access in your plugin settings.</span>
</div>
)}
</div> */}
{/* ── Version & Notes ── */}
<div className="mp-card">
<div className="mp-card-title">
<FileCode size={14} />
Version Details
</div>
<div className="mp-form-group">
<label className="mp-label">
Version Tag <span style={{color:'var(--mp-text-3)', fontWeight:400}}>(optional — e.g. 1.2.3)</span>
</label>
<input
className="mp-input"
type="text"
value={version}
onChange={e => setVersion(e.target.value)}
placeholder="1.0.0"
style={{fontFamily:'var(--font-mono)'}}
/>
</div>
<div className="mp-form-group">
<label className="mp-label">Release Notes</label>
<textarea
className="mp-textarea"
rows={5}
value={releaseNotes}
onChange={e => setReleaseNotes(e.target.value)}
placeholder={`What's new in this release?\n\n- Fixed crash on startup\n- Added support for multi-world\n- Improved performance by 30%`}
/>
</div>
</div>
{/* ── WASM Upload ── */}
<div className="mp-card">
<div className="mp-card-title">
<Upload size={14} />
Plugin Binary
</div>
<div
className={`mp-dropzone ${dragOver ? 'drag-over' : ''}`}
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => document.getElementById('wasm-upload-input')?.click()}
>
<div className="mp-dropzone-icon">
<Upload size={20} />
</div>
<p>
<strong>Drag & drop your .wasm file</strong> or click to browse
</p>
<small>.wasm only</small>
<input
id="wasm-upload-input"
type="file"
accept=".wasm"
onChange={e => setWasmFile(e.target.files?.[0] ?? null)}
/>
</div>
{wasmFile && (
<div className="mp-file-badge">
<FileCode size={14} />
{wasmFile.name}
<span style={{marginLeft:'auto', color:'var(--mp-text-3)'}}>
{(wasmFile.size / 1024).toFixed(1)} KB
</span>
</div>
)}
</div>
<div style={{display:'flex', justifyContent:'flex-end', gap:'0.75rem'}}>
<button
className="mp-btn mp-btn-primary"
onClick={handlePublish}
disabled={!wasmFile || uploading}
>
<Send size={15} />
{uploading ? 'Publishing…' : `Publish to ${track.charAt(0).toUpperCase() + track.slice(1)}`}
</button>
</div>
</div>
);
};
export default PublishUpdate;

View File

@ -0,0 +1,403 @@
import React, { useState, useRef, useEffect } from 'react';
import { Globe, ImageIcon, Plus, Save, Search, X } from 'lucide-react';
import api from '../../../api';
import type { PluginData } from './ManagePlugin';
const PLUGIN_CATEGORIES = [
"Admin Tools","Economy","Fun","World Management","Utilities","Chat","Other"
];
// ── Full locale list via Intl.DisplayNames (no npm package needed) ──────────
const DISPLAY_NAMES = new Intl.DisplayNames(['en'], { type: 'language' });
const ALL_LOCALES: { code: string; name: string }[] = [
'af','sq','am','ar','ar-SA','ar-EG','ar-AE','ar-MA','hy','az',
'eu','be','bn','bs','bg','ca',
'zh','zh-CN','zh-TW','zh-HK',
'hr','cs','da','nl','nl-NL','nl-BE',
'en-US','en-GB','en-AU','en-CA','en-IN',
'et','fi','fr','fr-FR','fr-CA','fr-BE',
'gl','ka','de','de-DE','de-AT','de-CH',
'el','gu','ht','ha','he','hi','hu','is','id','ga','it',
'ja','kn','kk','km','ko','ku','ky','lo','lv','lt','lb',
'mk','mg','ms','ml','mt','mi','mr','mn','ne',
'nb','nn','no','ps','fa','pl',
'pt','pt-BR','pt-PT',
'pa','ro','ru','sm',
'sr','sr-Latn','sr-Cyrl',
'sn','sd','si','sk','sl','so','st',
'es','es-ES','es-MX','es-AR','es-CO','es-CL',
'su','sw','sv','tg','tl','ta','tt','te','th','tr','tk',
'uk','ur','ug','uz','vi','cy','xh','yi','yo','zu',
].map(code => {
try {
return { code, name: DISPLAY_NAMES.of(code) ?? code };
} catch {
return { code, name: code };
}
}).sort((a, b) => a.name.localeCompare(b.name));
const DEFAULT_LOCALE = 'en-US';
// ── Language Picker Dropdown ─────────────────────────────────────────────────
type PickerProps = {
usedCodes: string[];
onAdd: (code: string) => void;
onClose: () => void;
};
const LangPicker = ({ usedCodes, onAdd, onClose }: PickerProps) => {
const [search, setSearch] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
inputRef.current?.focus();
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
const filtered = ALL_LOCALES.filter(
l => !usedCodes.includes(l.code) &&
(l.name.toLowerCase().includes(search.toLowerCase()) ||
l.code.toLowerCase().includes(search.toLowerCase()))
);
return (
<div ref={ref} style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 100,
background: 'var(--mp-surface)', border: '1px solid var(--mp-border)',
borderRadius: 'var(--mp-radius)', width: 260,
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}>
{/* Search */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.6rem 0.75rem',
borderBottom: '1px solid var(--mp-border)',
background: 'var(--mp-surface-2)',
}}>
<Search size={13} color="var(--mp-text-3)" style={{flexShrink:0}} />
<input
ref={inputRef}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search language…"
style={{
background: 'none', border: 'none', outline: 'none',
color: 'var(--mp-text)', fontFamily: 'var(--font-display)',
fontSize: '0.82rem', width: '100%',
}}
/>
</div>
{/* List */}
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
{filtered.length === 0 ? (
<div style={{
padding: '1.5rem', textAlign: 'center',
fontSize: '0.8rem', color: 'var(--mp-text-3)',
}}>
No languages found
</div>
) : filtered.map(l => (
<button
key={l.code}
type="button"
onClick={() => { onAdd(l.code); onClose(); }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', background: 'none', border: 'none',
padding: '0.5rem 0.85rem', cursor: 'pointer',
color: 'var(--mp-text-2)', fontFamily: 'var(--font-display)',
fontSize: '0.82rem', textAlign: 'left', gap: '0.5rem',
transition: 'background 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--mp-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>
<span>{l.name}</span>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: '0.7rem',
color: 'var(--mp-text-3)', flexShrink: 0,
}}>{l.code}</span>
</button>
))}
</div>
</div>
);
};
// ── Main Component ────────────────────────────────────────────────────────────
type Props = { plugin: PluginData; onSaved: () => void };
const StoreListing = ({ plugin, onSaved }: Props) => {
const [saving, setSaving] = useState(false);
const [name, setName] = useState(plugin.name);
const [descriptions, setDescriptions] = useState<Record<string, string>>(() => {
const parsed = JSON.parse(plugin.translated_descriptions || '{}');
// Ensure default locale always exists
if (!parsed[DEFAULT_LOCALE]) parsed[DEFAULT_LOCALE] = parsed['en'] ?? '';
return parsed;
});
const [activeLocale, setActiveLocale] = useState(DEFAULT_LOCALE);
const [category, setCategory] = useState(plugin.category);
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
const [keywords, setKeywords] = useState(plugin.keywords || '');
const [screenshots, setScreenshots] = useState(plugin.screenshots || []);
const [pickerOpen, setPickerOpen] = useState(false);
const usedCodes = Object.keys(descriptions);
const addLanguage = (code: string) => {
setDescriptions(prev => ({ ...prev, [code]: '' }));
setActiveLocale(code);
};
const removeLanguage = (code: string) => {
if (code === DEFAULT_LOCALE) return; // guard
setDescriptions(prev => {
const next = { ...prev };
delete next[code];
return next;
});
if (activeLocale === code) setActiveLocale(DEFAULT_LOCALE);
};
const getLocaleName = (code: string) => {
try { return DISPLAY_NAMES.of(code) ?? code; } catch { return code; }
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
const fd = new FormData();
fd.append('name', name);
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('keywords', keywords);
try {
await api.put(`/plugins/${plugin.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
onSaved();
} catch {
alert('Failed to save.');
} finally {
setSaving(false);
}
};
const deleteScreenshot = async (screenId: number) => {
if (!window.confirm('Delete this screenshot?')) return;
try {
await api.delete(`/plugins/screenshots/${screenId}`);
setScreenshots(prev => prev.filter(s => s.id !== screenId));
} catch {
alert('Failed to delete screenshot.');
}
};
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const fd = new FormData();
Array.from(files).forEach(f => fd.append('screenshots', f));
try {
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
} catch {
alert('Screenshot upload failed.');
}
};
return (
<div>
<div className="mp-tab-header">
<h2>Store Listing</h2>
<p>Manage what users see on your plugin's store page — name, description, screenshots, and discoverability.</p>
</div>
<form onSubmit={handleSave}>
{/* ── General Details ── */}
<div className="mp-card">
<div className="mp-card-title"><Globe size={14} />General Details</div>
<div className="mp-form-group">
<label className="mp-label">Plugin Name</label>
<input className="mp-input" type="text" value={name}
onChange={e => setName(e.target.value)} required />
</div>
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">Category</label>
<select className="mp-select" value={category} onChange={e => setCategory(e.target.value)}>
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="mp-form-group">
<label className="mp-label">Source Link</label>
<input className="mp-input" type="url" value={sourceLink}
onChange={e => setSourceLink(e.target.value)}
placeholder="https://github.com/you/plugin" />
</div>
</div>
<div className="mp-form-group">
<label className="mp-label">
Keywords <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(comma-separated)</span>
</label>
<input className="mp-input" type="text" value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency" />
</div>
</div>
{/* ── Descriptions ── */}
<div className="mp-card">
<div className="mp-card-title"><Globe size={14} />Descriptions</div>
{/* Tab bar */}
<div style={{display:'flex', alignItems:'center', gap:'4px', flexWrap:'wrap', marginBottom:'0.75rem'}}>
{usedCodes.map(code => (
<div
key={code}
className={`mp-lang-tab ${activeLocale === code ? 'active' : ''}`}
style={{display:'flex', alignItems:'center', gap:'0.3rem', paddingRight: code === DEFAULT_LOCALE ? undefined : '0.3rem'}}
>
<button
type="button"
style={{
background:'none', border:'none', cursor:'pointer',
color:'inherit', fontFamily:'var(--font-display)',
fontSize:'0.75rem', fontWeight:500, padding:0,
display:'flex', alignItems:'center', gap:'0.3rem',
}}
onClick={() => setActiveLocale(code)}
>
{getLocaleName(code)}
{code === DEFAULT_LOCALE && (
<span style={{
fontSize:'0.6rem', fontWeight:700,
background:'rgba(79,126,255,0.2)',
color:'var(--mp-accent)',
padding:'0.05rem 0.3rem', borderRadius:3,
letterSpacing:'0.05em', textTransform:'uppercase',
}}>default</span>
)}
</button>
{/* Remove button — only for non-default locales */}
{code !== DEFAULT_LOCALE && (
<button
type="button"
onClick={() => removeLanguage(code)}
title={`Remove ${getLocaleName(code)}`}
style={{
background:'none', border:'none', cursor:'pointer',
color:'var(--mp-text-3)', padding:0, lineHeight:1,
display:'flex', alignItems:'center',
transition:'color 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--mp-error)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--mp-text-3)')}
>
<X size={11} />
</button>
)}
</div>
))}
{/* Add language button */}
<div style={{position:'relative'}}>
<button
type="button"
className="mp-lang-tab"
onClick={() => setPickerOpen(v => !v)}
style={{display:'flex', alignItems:'center', gap:'0.3rem'}}
>
<Plus size={11} />
Add language
</button>
{pickerOpen && (
<LangPicker
usedCodes={usedCodes}
onAdd={addLanguage}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
</div>
{/* Textarea for active locale */}
<textarea
className="mp-textarea"
rows={8}
value={descriptions[activeLocale] ?? ''}
onChange={e => setDescriptions(prev => ({ ...prev, [activeLocale]: e.target.value }))}
placeholder={`Write your plugin description in ${getLocaleName(activeLocale)}…`}
/>
<p style={{marginTop:'0.5rem', fontSize:'0.74rem', color:'var(--mp-text-3)'}}>
Editing: <span style={{fontFamily:'var(--font-mono)', color:'var(--mp-text-2)'}}>{activeLocale}</span>
{activeLocale === DEFAULT_LOCALE && ' · This is the default language shown when no translation is available.'}
</p>
</div>
<div style={{display:'flex', justifyContent:'flex-end'}}>
<button type="submit" className="mp-btn mp-btn-primary" disabled={saving}>
<Save size={15} />
{saving ? 'Saving…' : 'Save Listing'}
</button>
</div>
</form>
<div className="mp-divider" />
{/* ── Screenshots ── */}
<div className="mp-card">
<div className="mp-card-title"><ImageIcon size={14} />Screenshots</div>
<div className="mp-screenshots">
{screenshots.map(s => (
<div key={s.id} className="mp-screenshot-item">
<img src={s.path} alt="screenshot" />
<button className="mp-screenshot-del" type="button"
onClick={() => deleteScreenshot(s.id)}>×</button>
</div>
))}
<label
htmlFor="screenshot-upload"
style={{
aspectRatio:'16/9', border:'2px dashed var(--mp-border-2)',
borderRadius:'var(--mp-radius-sm)', display:'flex',
flexDirection:'column', alignItems:'center', justifyContent:'center',
gap:'0.35rem', cursor:'pointer', color:'var(--mp-text-3)',
fontSize:'0.75rem', transition:'border-color 0.15s, color 0.15s',
background:'var(--mp-surface-2)',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor='var(--mp-accent)'; e.currentTarget.style.color='var(--mp-accent)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor='var(--mp-border-2)'; e.currentTarget.style.color='var(--mp-text-3)'; }}
>
<Plus size={18} />
Add
</label>
<input id="screenshot-upload" type="file" accept="image/*" multiple
style={{display:'none'}} onChange={handleScreenshotUpload} />
</div>
<p style={{marginTop:'0.75rem', fontSize:'0.76rem', color:'var(--mp-text-3)'}}>
Recommended: 16:9 aspect ratio, min 1280×720px. PNG or JPEG.
</p>
</div>
</div>
);
};
export default StoreListing;

View File

@ -0,0 +1,81 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../api';
export const useAnalytics = () => {
const [timeSeries, setTimeSeries] = useState<any[]>([]);
const [mapData, setMapData] = useState<any[]>([]);
const [breakdowns, setBreakdowns] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [timeframe, setTimeframe] = useState<string>('Lifetime');
const fetchData = useCallback(async (selectedTimeframe: string) => {
setLoading(true);
try {
// PRO TIP: Check if your 'api' instance already prepends '/api'
// If it doesn't, use '/api/dev/analytics'
const res = await api.get(`/user/analytics`, {
params: { timeframe: selectedTimeframe }
});
// Ensure we are accessing the correct keys from your Express res.json
setTimeSeries(res.data.timeSeries || []);
setMapData(res.data.mapData || []);
setBreakdowns(res.data.breakdowns || []);
} catch (error) {
console.error("Analytics Fetch Error:", error);
// Reset data on error so the UI doesn't show stale/broken stats
setTimeSeries([]);
setMapData([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData(timeframe);
}, [timeframe, fetchData]);
// Merge multiple plugin entries into single daily totals for the chart
const processedData = useMemo(() => {
if (!timeSeries.length) return [];
const dailyMap = timeSeries.reduce((acc, curr) => {
const day = curr.date || 'Unknown';
if (!acc[day]) {
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0 };
}
acc[day].views += Number(curr.views || 0);
acc[day].downloads += Number(curr.downloads || 0);
acc[day].earnings += Number(curr.earnings || 0);
return acc;
}, {} as Record<string, any>);
return Object.values(dailyMap).sort((a: any, b: any) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
}, [timeSeries]);
// Calculate global totals across all plugins
const totals = useMemo(() => {
return timeSeries.reduce((acc, curr) => {
acc.views += Number(curr.views || 0);
acc.downloads += Number(curr.downloads || 0);
acc.earnings += Number(curr.earnings || 0);
return acc;
}, { views: 0, downloads: 0, earnings: 0 });
}, [timeSeries]);
return {
timeSeries,
mapData,
breakdowns,
processedData,
totals,
loading,
timeframe,
setTimeframe,
recentActivity: [], // Placeholder for activity feed
trends: { earnings: 0, downloads: 0, conversion: 0 }, // Placeholder for trend percentages
refresh: () => fetchData(timeframe)
};
};

View File

@ -0,0 +1,565 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
const sections = [
{
id: 'overview',
title: '1. Overview',
content: `PumpkinMC ("we", "us", or "our") operates the PumpkinMC Plugin Marketplace and related services (collectively, the "Service"). This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you visit our website or use our Service.
We are committed to protecting your privacy. We will never sell your personal information to third parties, and we only collect what is necessary to operate and improve the Service. By using the Service, you agree to the collection and use of information in accordance with this policy.
If you have questions or concerns about this policy, please contact us at privacy@pumpkinmc.net.`
},
{
id: 'information-collected',
title: '2. Information We Collect',
content: `We collect the following categories of information:
Account Information
When you register, we collect your username, email address, and a hashed version of your password. Sellers may additionally provide payment and payout details required to process transactions.
Transaction Data
When you purchase or sell a Plugin, we collect records of those transactions including the Plugin name, price, date, and payment method type (we do not store full card numbers — these are handled by our payment processor).
Usage Data
We automatically collect certain information when you visit the Service, including your IP address, browser type, operating system, referring URLs, pages visited, and timestamps. This data helps us understand how the Service is used and identify issues.
Communications
If you contact our support team or post in community areas, we retain those communications to resolve disputes and improve our service.
Cookies & Similar Technologies
We use cookies and similar tracking technologies to maintain your session, remember your preferences, and analyze usage patterns. See Section 7 for more detail.`
},
{
id: 'how-we-use',
title: '3. How We Use Your Information',
content: `We use the information we collect to:
• Operate and maintain the Marketplace, including processing purchases and payouts.
• Create and manage your account and authenticate your identity.
• Send transactional emails such as purchase confirmations, account verification, and password resets.
• Respond to support requests, disputes, and inquiries.
• Detect, investigate, and prevent fraudulent transactions, abuse, and violations of our Terms of Service.
• Improve the Service through analytics and usage research.
• Send optional product updates and newsletters — you may opt out at any time.
• Comply with legal obligations and enforce our agreements.
We do not use your data for automated decision-making or profiling that produces significant legal effects.`
},
{
id: 'sharing',
title: '4. How We Share Your Information',
content: `We do not sell, trade, or rent your personal information. We may share your information only in the following limited circumstances:
Service Providers
We work with trusted third-party vendors to help operate the Service, including payment processors, email delivery services, and hosting providers. These parties are contractually obligated to protect your information and may only use it to perform services on our behalf.
Sellers & Buyers
When you purchase a Plugin, your username and order details are shared with the selling developer to facilitate support. Your email address is not shared with sellers without your consent.
Legal Requirements
We may disclose your information if required to do so by law, subpoena, or other legal process, or if we believe in good faith that disclosure is necessary to protect our rights, protect your safety or the safety of others, or investigate fraud.
Business Transfers
If PumpkinMC is acquired by or merged with another company, your information may be transferred as part of that transaction. We will notify you before your information becomes subject to a materially different privacy policy.
With Your Consent
We may share your information in other ways with your explicit consent.`
},
{
id: 'data-retention',
title: '5. Data Retention',
content: `We retain your personal information for as long as your account is active or as needed to provide the Service. Specifically:
• Account data is retained for the lifetime of your account and for up to 90 days after account deletion to allow for dispute resolution.
• Transaction records are retained for up to 7 years to comply with financial and tax regulations.
• Support communications are retained for up to 3 years.
• Usage and analytics data is retained in aggregated, anonymized form indefinitely.
You may request deletion of your account and associated personal data at any time by contacting privacy@pumpkinmc.net. Note that we may be unable to delete information that we are legally required to retain.`
},
{
id: 'security',
title: '6. Data Security',
content: `We implement industry-standard security measures to protect your information, including:
• Passwords are stored using strong one-way hashing (bcrypt).
• All data transmitted between your browser and our servers is encrypted using TLS.
• Payment processing is handled by PCI-DSS compliant third-party processors — we do not store raw card numbers on our servers.
• Access to personal data is restricted to authorized personnel who require it to perform their job functions.
• We conduct regular security reviews and promptly address identified vulnerabilities.
Despite these measures, no method of transmission over the internet is 100% secure. We cannot guarantee absolute security, and you use the Service at your own risk. If you believe your account has been compromised, contact support@pumpkinmc.net immediately.`
},
{
id: 'cookies',
title: '7. Cookies & Tracking',
content: `We use the following types of cookies and tracking technologies:
Essential Cookies
Required for the Service to function. These include session cookies that keep you logged in and security tokens that protect against cross-site request forgery. You cannot opt out of these without disabling the Service.
Analytics Cookies
We use privacy-respecting analytics tools to understand how users interact with the Service — pages visited, session duration, and feature usage. This data is aggregated and anonymized where possible.
Preference Cookies
These remember your settings and preferences, such as your display language or UI preferences, to personalize your experience.
You can control cookies through your browser settings. Disabling certain cookies may affect the functionality of the Service. We do not use advertising or third-party tracking cookies.`
},
{
id: 'children',
title: '8. Children\'s Privacy',
content: `The Service is not directed at children under the age of 13. We do not knowingly collect personal information from children under 13. If we become aware that we have collected personal data from a child under 13 without verifiable parental consent, we will take steps to delete that information as quickly as possible.
If you are a parent or guardian and believe your child has provided us with personal information without your consent, please contact us at privacy@pumpkinmc.net.`
},
{
id: 'rights',
title: '9. Your Rights & Choices',
content: `Depending on your location, you may have the following rights with respect to your personal information:
Access & Portability
You may request a copy of the personal information we hold about you and, where technically feasible, receive it in a structured, machine-readable format.
Correction
You may update or correct inaccurate account information at any time through your account settings or by contacting us.
Deletion
You may request deletion of your personal data, subject to our legal retention obligations described in Section 5.
Opt-Out of Marketing
You may unsubscribe from marketing emails at any time using the unsubscribe link in any marketing email or by contacting us. You will continue to receive transactional emails necessary to operate your account.
Restriction & Objection
In certain circumstances, you may request that we restrict or stop processing your personal data.
To exercise any of these rights, contact privacy@pumpkinmc.net. We will respond within 30 days. We may need to verify your identity before processing your request.`
},
{
id: 'third-party',
title: '10. Third-Party Links & Services',
content: `The Service may contain links to third-party websites, plugins, or services that are not owned or controlled by PumpkinMC. This Privacy Policy applies only to the PumpkinMC Service.
We have no control over and assume no responsibility for the privacy practices of any third-party sites or services. We encourage you to review the privacy policy of every site you visit or service you use.
Plugin developers are independent third parties. PumpkinMC is not responsible for data collection or privacy practices embedded within third-party Plugins you download. Review each Plugin's documentation before use.`
},
{
id: 'international',
title: '11. International Transfers',
content: `PumpkinMC operates globally and your information may be transferred to and processed in countries other than the country in which you reside. These countries may have data protection laws that are different from the laws of your country.
Where required, we use appropriate safeguards — such as standard contractual clauses — to ensure that your information receives an adequate level of protection wherever it is processed.`
},
{
id: 'changes',
title: '12. Changes to This Policy',
content: `We may update this Privacy Policy from time to time. When we do, we will revise the "Last updated" date at the top of this page. For material changes, we will provide more prominent notice, such as an email notification to registered users or a banner on the Service.
We encourage you to review this Privacy Policy periodically to stay informed about how we are protecting your information. Your continued use of the Service after any changes constitutes your acceptance of the updated policy.`
},
{
id: 'contact',
title: '13. Contact Us',
content: `If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please reach out to us:
Email: privacy@pumpkinmc.net
Support: support@pumpkinmc.net
Discord: discord.gg/pumpkinmc
Website: pumpkinmc.net
We take privacy seriously and will respond to all inquiries within 5 business days.`
}
];
const PrivacyPolicyPage = () => {
const [activeSection, setActiveSection] = useState<string | null>(null);
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Nunito', sans-serif;
-webkit-font-smoothing: antialiased;
background: #0f1117;
}
.pp-page {
min-height: 100vh;
background-color: #0f1117;
background-image: radial-gradient(ellipse 80% 40% at 50% 0%, rgba(255, 107, 0, 0.12) 0%, transparent 65%);
color: #c8cdd8;
}
/* ── Nav ── */
.pp-nav {
position: sticky;
top: 0;
z-index: 50;
background: rgba(15, 17, 23, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 107, 0, 0.1);
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
}
.nav-brand-icon { font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,107,0,0.5)); }
.nav-brand-text {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.3px;
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-back {
font-size: 13px;
font-weight: 600;
color: #4a5568;
text-decoration: none;
display: flex;
align-items: center;
gap: 5px;
transition: color 0.15s;
}
.nav-back:hover { color: #ff6b00; }
/* ── Hero ── */
.pp-hero {
text-align: center;
padding: 64px 24px 48px;
border-bottom: 1px solid #1a2030;
}
.pp-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 107, 0, 0.1);
border: 1px solid rgba(255, 107, 0, 0.2);
border-radius: 100px;
padding: 5px 14px;
font-size: 12px;
font-weight: 700;
color: #ff8c00;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 20px;
}
.pp-title {
font-size: clamp(28px, 5vw, 42px);
font-weight: 800;
color: #f0f2f5;
letter-spacing: -0.5px;
line-height: 1.2;
margin-bottom: 14px;
}
.pp-title span {
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.pp-subtitle {
font-size: 15px;
color: #4a5568;
font-weight: 600;
max-width: 520px;
margin: 0 auto;
line-height: 1.6;
}
.pp-date {
margin-top: 16px;
font-size: 12.5px;
color: #3d4a5e;
font-weight: 600;
}
/* ── Highlight banner ── */
.pp-highlight {
max-width: 700px;
margin: 32px auto 0;
background: rgba(255, 107, 0, 0.06);
border: 1px solid rgba(255, 107, 0, 0.18);
border-radius: 10px;
padding: 14px 20px;
font-size: 13.5px;
font-weight: 600;
color: #ff8c00;
display: flex;
align-items: center;
gap: 10px;
line-height: 1.5;
}
.pp-highlight-icon { font-size: 18px; flex-shrink: 0; }
/* ── Layout ── */
.pp-layout {
display: grid;
grid-template-columns: 240px 1fr;
max-width: 1100px;
margin: 0 auto;
padding: 40px 24px 80px;
gap: 40px;
align-items: start;
}
@media (max-width: 768px) {
.pp-layout { grid-template-columns: 1fr; }
.pp-sidebar { display: none; }
}
/* ── Sidebar ── */
.pp-sidebar {
position: sticky;
top: 76px;
}
.sidebar-label {
font-size: 11px;
font-weight: 700;
color: #3d4a5e;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 10px;
padding-left: 10px;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar-link {
font-size: 13px;
font-weight: 600;
color: #3d4a5e;
text-decoration: none;
padding: 7px 10px;
border-radius: 6px;
transition: all 0.15s;
border-left: 2px solid transparent;
line-height: 1.4;
}
.sidebar-link:hover {
color: #c8cdd8;
background: rgba(255, 107, 0, 0.06);
border-left-color: rgba(255, 107, 0, 0.3);
}
.sidebar-link.active {
color: #ff8c00;
background: rgba(255, 107, 0, 0.08);
border-left-color: #ff6b00;
}
/* ── Content ── */
.pp-content { min-width: 0; }
.pp-section {
background: #161b26;
border: 1px solid #1e2535;
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 16px;
transition: border-color 0.2s;
scroll-margin-top: 80px;
cursor: default;
}
.pp-section:hover { border-color: rgba(255, 107, 0, 0.2); }
.pp-section.active { border-color: rgba(255, 107, 0, 0.35); }
.section-title {
font-size: 16px;
font-weight: 800;
color: #e8eaf0;
margin-bottom: 14px;
letter-spacing: -0.2px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: '';
display: inline-block;
width: 3px;
height: 18px;
background: linear-gradient(180deg, #ff8c00, #ff6b00);
border-radius: 2px;
flex-shrink: 0;
}
.section-body {
font-size: 14px;
line-height: 1.8;
color: #6b7a94;
font-weight: 500;
white-space: pre-line;
}
/* Sub-headings within body */
.section-body strong {
display: block;
color: #c8cdd8;
font-weight: 700;
margin-top: 14px;
margin-bottom: 2px;
}
.section-body strong:first-child { margin-top: 0; }
/* ── Footer ── */
.pp-footer {
border-top: 1px solid #1a2030;
padding: 32px 24px;
text-align: center;
}
.pp-footer-text {
font-size: 13px;
color: #3d4a5e;
font-weight: 600;
line-height: 1.7;
}
.pp-footer-text a {
color: #ff6b00;
text-decoration: none;
font-weight: 700;
}
.pp-footer-text a:hover { text-decoration: underline; text-underline-offset: 2px; }
.pp-footer-links {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 20px;
flex-wrap: wrap;
}
.pp-footer-link {
font-size: 13px;
font-weight: 700;
color: #3d4a5e;
text-decoration: none;
transition: color 0.15s;
}
.pp-footer-link:hover { color: #ff6b00; }
.pp-footer-sep {
width: 3px;
height: 3px;
background: #2a3347;
border-radius: 50%;
}
`}</style>
<div className="pp-page">
{/* Hero */}
<div className="pp-hero">
<div className="pp-badge">🔒 Privacy</div>
<h1 className="pp-title">
Your Privacy,<br /><span>Our Commitment</span>
</h1>
<p className="pp-subtitle">
We believe in being transparent about how we collect and use your data.
Here's everything you need to know.
</p>
<p className="pp-date">Last updated: March 3, 2026 · Effective immediately</p>
</div>
{/* Layout */}
<div className="pp-layout">
{/* Sidebar */}
<aside className="pp-sidebar">
<p className="sidebar-label">On this page</p>
<nav className="sidebar-nav">
{sections.map(s => (
<a
key={s.id}
href={`#${s.id}`}
className={`sidebar-link${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.title}
</a>
))}
</nav>
</aside>
{/* Sections */}
<main className="pp-content">
{sections.map(s => (
<section
key={s.id}
id={s.id}
className={`pp-section${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
<h2 className="section-title">{s.title}</h2>
<div className="section-body">
{s.content.split('\n').map((line, i) => {
// Render sub-headings (lines not starting with bullet and followed by blank line pattern)
const isSubheading = line.length > 0 && !line.startsWith('•') && !line.startsWith(' ') && i > 0 && s.content.split('\n')[i - 1] === '';
return isSubheading
? <strong key={i}>{line}</strong>
: <span key={i}>{line}{'\n'}</span>;
})}
</div>
</section>
))}
</main>
</div>
{/* Footer */}
<footer className="pp-footer">
<p className="pp-footer-text">
Questions about your privacy? Contact us at{' '}
<a href="mailto:privacy@pumpkinmc.net">privacy@pumpkinmc.net</a>
</p>
<div className="pp-footer-links">
<Link to="/terms" className="pp-footer-link">Terms of Service</Link>
<span className="pp-footer-sep" />
<Link to="/marketplace" className="pp-footer-link">Marketplace</Link>
<span className="pp-footer-sep" />
<a href="https://discord.gg/pumpkinmc" className="pp-footer-link">Discord</a>
<span className="pp-footer-sep" />
<Link to="/register" className="pp-footer-link">Create Account</Link>
</div>
</footer>
</div>
</>
);
};
export default PrivacyPolicyPage;

View File

@ -0,0 +1,493 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
const sections = [
{
id: 'acceptance',
title: '1. Acceptance of Terms',
content: `By accessing or using the PumpkinMC Plugin Marketplace ("Marketplace"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, you may not access or use the Marketplace. These Terms apply to all visitors, buyers, sellers, and developers who access the Marketplace.
PumpkinMC reserves the right to update or modify these Terms at any time. Continued use of the Marketplace after any changes constitutes your acceptance of the new Terms. We will notify registered users of material changes via email or a prominent notice on the platform.`
},
{
id: 'eligibility',
title: '2. Eligibility',
content: `You must be at least 13 years of age to use the Marketplace. If you are under 18, you must have the consent of a parent or legal guardian. By using the Marketplace, you represent and warrant that you meet these eligibility requirements.
You may not use the Marketplace if you have previously been banned or suspended from PumpkinMC or any related service, or if your use would violate any applicable law or regulation.`
},
{
id: 'accounts',
title: '3. Accounts & Registration',
content: `To access certain features of the Marketplace, you must register for an account. You agree to provide accurate, current, and complete information during registration and to keep your account information up to date.
You are responsible for safeguarding your password and for any activity that occurs under your account. You must notify PumpkinMC immediately of any unauthorized use. PumpkinMC is not liable for any loss or damage arising from your failure to protect your account credentials.
Accounts are personal and non-transferable. You may not create multiple accounts to circumvent bans, purchase limits, or any other restrictions.`
},
{
id: 'plugins',
title: '4. Plugin Listings & Content',
content: `Sellers may submit Minecraft plugins and related digital goods ("Plugins") for listing on the Marketplace subject to review and approval. By submitting a Plugin, you represent and warrant that:
• You are the original author or have the legal right to sell the Plugin.
• The Plugin does not infringe upon any third-party intellectual property rights, including those of Mojang Studios or Microsoft.
• The Plugin does not contain malicious code, backdoors, exploits, or any functionality designed to harm servers, players, or end users.
• All Plugin descriptions, screenshots, and metadata are accurate and not misleading.
• The Plugin complies with Mojang's End User License Agreement (EULA) and any applicable Minecraft usage policies.
PumpkinMC reserves the right to remove any Plugin listing at any time, without notice, for any reason including but not limited to violation of these Terms, community reports, or suspected malicious activity.`
},
{
id: 'prohibited',
title: '5. Prohibited Content',
content: `The following content is strictly prohibited on the Marketplace:
• Plugins containing malware, ransomware, spyware, or any malicious code.
• Plugins that steal player data, credentials, or personal information.
• Duplicate or plagiarized plugins submitted without the original author's consent.
• Plugins that enable griefing, cheating, or exploiting of unrelated servers or players.
• Adult, explicit, or NSFW content of any kind.
• Plugins that violate Mojang's EULA, including those that grant gameplay advantages for real-money purchases in non-compliant ways.
• Content that promotes hate speech, harassment, or discrimination.
• Any Plugin misrepresented as an official PumpkinMC, Mojang, or Microsoft product.
Violation of these prohibitions may result in immediate account termination and, where applicable, referral to relevant authorities.`
},
{
id: 'transactions',
title: '6. Purchases & Payments',
content: `All purchases made through the Marketplace are final unless otherwise stated or required by applicable law. By completing a purchase, you authorize PumpkinMC to charge your selected payment method for the listed price.
Prices are displayed in EUR and may be subject to applicable taxes depending on your jurisdiction. PumpkinMC is not responsible for currency conversion fees charged by your bank or payment provider.
In the event of a disputed charge, please contact support@pumpkinmc.net before initiating a chargeback with your payment provider. Chargebacks initiated without prior contact may result in account suspension.`
},
{
id: 'refunds',
title: '7. Refunds & Disputes',
content: `Due to the digital nature of Plugins, all sales are generally non-refundable. Refunds may be issued at PumpkinMC's sole discretion in the following circumstances:
• The Plugin does not function as described and the seller has not resolved the issue within 72 hours of a support request.
• The Plugin contains malicious code or functionality that harms your server or players.
• A duplicate purchase was made in error within 24 hours.
To request a refund, contact support@pumpkinmc.net with your order details and a description of the issue. PumpkinMC will review all refund requests and respond within 5 business days.`
},
{
id: 'sellers',
title: '8. Seller Obligations',
content: `Sellers are responsible for maintaining and supporting their Plugins for a reasonable period following sale. This includes providing updates to maintain compatibility with reasonably current versions of Minecraft and Spigot/Paper API where applicable.
Sellers receive payouts according to the PumpkinMC revenue share agreement communicated at the time of seller onboarding. PumpkinMC reserves the right to adjust revenue share rates with 30 days' notice to active sellers.
Sellers must respond to buyer support inquiries within 5 business days. Failure to maintain adequate support may result in Plugin delisting or seller account review.
Sellers may not solicit buyers to transact outside of the Marketplace in order to circumvent fees or protections.`
},
{
id: 'ip',
title: '9. Intellectual Property',
content: `Sellers retain ownership of their Plugin source code and associated assets. By listing a Plugin on the Marketplace, sellers grant PumpkinMC a non-exclusive, royalty-free license to display, reproduce, and distribute the Plugin solely for the purpose of operating the Marketplace.
Buyers receive a limited, non-exclusive, non-transferable license to use purchased Plugins on their own Minecraft servers. Buyers may not resell, redistribute, decompile, reverse-engineer, or sublicense Plugins unless explicitly permitted by the seller's stated license terms.
The PumpkinMC name, logo, and branding are the exclusive property of PumpkinMC and may not be used without prior written consent.`
},
{
id: 'privacy',
title: '10. Privacy',
content: `Your use of the Marketplace is also governed by our Privacy Policy, which is incorporated into these Terms by reference. By using the Marketplace, you consent to the collection and use of your information as described in our Privacy Policy.
We collect information necessary to operate the Marketplace, process transactions, and provide support. We do not sell your personal information to third parties. Seller payout information may be shared with payment processors as necessary to complete transactions.`
},
{
id: 'disclaimer',
title: '11. Disclaimer of Warranties',
content: `The Marketplace is provided on an "as is" and "as available" basis without warranties of any kind, either express or implied. PumpkinMC does not warrant that the Marketplace will be uninterrupted, error-free, or free of viruses or other harmful components.
PumpkinMC does not endorse, verify, or take responsibility for any Plugin listed by third-party sellers. You download and install Plugins at your own risk. PumpkinMC strongly recommends reviewing Plugin source code and testing in a non-production environment before deploying to a live server.`
},
{
id: 'liability',
title: '12. Limitation of Liability',
content: `To the fullest extent permitted by applicable law, PumpkinMC and its affiliates, officers, employees, and agents shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising out of or related to your use of the Marketplace, including but not limited to server damage, data loss, or loss of profits.
In no event shall PumpkinMC's total liability to you for all claims exceed the greater of the amount you paid to PumpkinMC in the twelve months preceding the claim or USD $50.`
},
{
id: 'termination',
title: '13. Termination',
content: `PumpkinMC may suspend or terminate your account at any time, with or without notice, for conduct that violates these Terms or is harmful to other users, PumpkinMC, or third parties, or for any other reason at PumpkinMC's sole discretion.
Upon termination, your right to use the Marketplace ceases immediately. Provisions of these Terms that by their nature should survive termination — including intellectual property, disclaimers, and limitations of liability — shall survive.
You may close your account at any time by contacting support@pumpkinmc.net. Closing your account does not entitle you to a refund of any purchases.`
},
{
id: 'governing',
title: '14. Governing Law',
content: `These Terms are governed by and construed in accordance with applicable law. Any disputes arising under or in connection with these Terms shall be resolved through binding arbitration, except that either party may seek injunctive or other equitable relief in a court of competent jurisdiction.
If any provision of these Terms is found to be unenforceable, the remaining provisions shall continue in full force and effect.`
},
{
id: 'contact',
title: '15. Contact',
content: `If you have any questions about these Terms or the Marketplace, please contact us at:
Email: support@pumpkinmc.net
Discord: discord.gg/pumpkinmc
Website: pumpkinmc.net
We aim to respond to all inquiries within 3 business days.`
}
];
const TermsOfServicePage = () => {
const [activeSection, setActiveSection] = useState<string | null>(null);
return (
<>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Nunito', sans-serif;
-webkit-font-smoothing: antialiased;
background: #0f1117;
}
.tos-page {
min-height: 100vh;
background-color: #0f1117;
background-image: radial-gradient(ellipse 80% 40% at 50% 0%, rgba(255, 107, 0, 0.12) 0%, transparent 65%);
color: #c8cdd8;
}
/* ── Nav ── */
.tos-nav {
position: sticky;
top: 0;
z-index: 50;
background: rgba(15, 17, 23, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 107, 0, 0.1);
padding: 0 32px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
}
.nav-brand-icon { font-size: 24px; filter: drop-shadow(0 0 8px rgba(255,107,0,0.5)); }
.nav-brand-text {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.3px;
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-back {
font-size: 13px;
font-weight: 600;
color: #4a5568;
text-decoration: none;
display: flex;
align-items: center;
gap: 5px;
transition: color 0.15s;
}
.nav-back:hover { color: #ff6b00; }
/* ── Hero ── */
.tos-hero {
text-align: center;
padding: 64px 24px 48px;
border-bottom: 1px solid #1a2030;
}
.tos-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 107, 0, 0.1);
border: 1px solid rgba(255, 107, 0, 0.2);
border-radius: 100px;
padding: 5px 14px;
font-size: 12px;
font-weight: 700;
color: #ff8c00;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 20px;
}
.tos-title {
font-size: clamp(28px, 5vw, 42px);
font-weight: 800;
color: #f0f2f5;
letter-spacing: -0.5px;
line-height: 1.2;
margin-bottom: 14px;
}
.tos-title span {
background: linear-gradient(135deg, #ff8c00, #ff6b00);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.tos-subtitle {
font-size: 15px;
color: #4a5568;
font-weight: 600;
max-width: 500px;
margin: 0 auto;
line-height: 1.6;
}
.tos-date {
margin-top: 16px;
font-size: 12.5px;
color: #3d4a5e;
font-weight: 600;
}
/* ── Layout ── */
.tos-layout {
display: grid;
grid-template-columns: 240px 1fr;
max-width: 1100px;
margin: 0 auto;
padding: 40px 24px 80px;
gap: 40px;
align-items: start;
}
@media (max-width: 768px) {
.tos-layout { grid-template-columns: 1fr; }
.tos-sidebar { display: none; }
}
/* ── Sidebar ── */
.tos-sidebar {
position: sticky;
top: 76px;
}
.sidebar-label {
font-size: 11px;
font-weight: 700;
color: #3d4a5e;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 10px;
padding-left: 10px;
}
.sidebar-nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar-link {
font-size: 13px;
font-weight: 600;
color: #3d4a5e;
text-decoration: none;
padding: 7px 10px;
border-radius: 6px;
transition: all 0.15s;
border-left: 2px solid transparent;
line-height: 1.4;
}
.sidebar-link:hover {
color: #c8cdd8;
background: rgba(255, 107, 0, 0.06);
border-left-color: rgba(255, 107, 0, 0.3);
}
.sidebar-link.active {
color: #ff8c00;
background: rgba(255, 107, 0, 0.08);
border-left-color: #ff6b00;
}
/* ── Content ── */
.tos-content { min-width: 0; }
.tos-section {
background: #161b26;
border: 1px solid #1e2535;
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 16px;
transition: border-color 0.2s;
scroll-margin-top: 80px;
}
.tos-section:hover { border-color: rgba(255, 107, 0, 0.2); }
.tos-section.active { border-color: rgba(255, 107, 0, 0.35); }
.section-title {
font-size: 16px;
font-weight: 800;
color: #e8eaf0;
margin-bottom: 14px;
letter-spacing: -0.2px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::before {
content: '';
display: inline-block;
width: 3px;
height: 18px;
background: linear-gradient(180deg, #ff8c00, #ff6b00);
border-radius: 2px;
flex-shrink: 0;
}
.section-body {
font-size: 14px;
line-height: 1.8;
color: #6b7a94;
font-weight: 500;
white-space: pre-line;
}
.section-body strong { color: #c8cdd8; font-weight: 700; }
/* ── Footer ── */
.tos-footer {
border-top: 1px solid #1a2030;
padding: 32px 24px;
text-align: center;
}
.tos-footer-text {
font-size: 13px;
color: #3d4a5e;
font-weight: 600;
line-height: 1.7;
}
.tos-footer-text a {
color: #ff6b00;
text-decoration: none;
font-weight: 700;
}
.tos-footer-text a:hover { text-decoration: underline; text-underline-offset: 2px; }
.accept-btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 20px;
padding: 12px 28px;
background: linear-gradient(135deg, #ff6b00, #e85d00);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 12px rgba(255, 107, 0, 0.3);
transition: all 0.15s;
}
.accept-btn:hover {
background: linear-gradient(135deg, #ff7a1a, #f06000);
box-shadow: 0 4px 20px rgba(255, 107, 0, 0.4);
transform: translateY(-1px);
}
`}</style>
<div className="tos-page">
{/* Hero */}
<div className="tos-hero">
<div className="tos-badge">📜 Legal</div>
<h1 className="tos-title">
Plugin Marketplace<br /><span>Terms of Service</span>
</h1>
<p className="tos-subtitle">
Please read these terms carefully before buying or selling plugins on PumpkinMC.
</p>
<p className="tos-date">Last updated: March 3, 2026 · Effective immediately</p>
</div>
{/* Layout */}
<div className="tos-layout">
{/* Sidebar */}
<aside className="tos-sidebar">
<p className="sidebar-label">On this page</p>
<nav className="sidebar-nav">
{sections.map(s => (
<a
key={s.id}
href={`#${s.id}`}
className={`sidebar-link${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.title}
</a>
))}
</nav>
</aside>
{/* Sections */}
<main className="tos-content">
{sections.map(s => (
<section
key={s.id}
id={s.id}
className={`tos-section${activeSection === s.id ? ' active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
<h2 className="section-title">{s.title}</h2>
<div className="section-body">{s.content}</div>
</section>
))}
</main>
</div>
{/* Footer */}
<footer className="tos-footer">
<p className="tos-footer-text">
Questions about these terms? Reach us at{' '}
<a href="mailto:support@pumpkinmc.net">support@pumpkinmc.net</a>{' '}
or join our <a href="https://discord.gg/pumpkinmc">Discord server</a>.
</p>
<Link to="/register" className="accept-btn">
🎃 Create an account
</Link>
</footer>
</div>
</>
);
};
export default TermsOfServicePage;

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2024",
"useDefineForClassFields": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2024",
"lib": ["ES2024"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})