chore: some more dev stuff
This commit is contained in:
parent
d6f351e735
commit
8e395aa8bb
36
package-lock.json
generated
36
package-lock.json
generated
@ -8,10 +8,8 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/dm-sans": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@fontsource/inter": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
||||||
"@fontsource/nunito": "^5.3.0",
|
|
||||||
"@marsidev/react-turnstile": "^1.6.1",
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||||
@ -426,37 +424,19 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/dm-sans": {
|
"node_modules/@fontsource-variable/inter": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
"integrity": "sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==",
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/inter": {
|
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
||||||
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
|
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fontsource/jetbrains-mono": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
|
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@fontsource/nunito": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/nunito/-/nunito-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-vw9TaTQJ/zpEpKrsODuPmOvaVjXgdda6B+xXA4YqjrdeJ62MLgVoDerdRXFQHyBnSCt2yQ2nHKCHPP/Pv7Xq4Q==",
|
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
|||||||
@ -14,10 +14,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/dm-sans": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"@fontsource/inter": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
||||||
"@fontsource/nunito": "^5.3.0",
|
|
||||||
"@marsidev/react-turnstile": "^1.6.1",
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
"@vnedyalk0v/react19-simple-maps": "^2.0.10",
|
||||||
|
|||||||
44
src/App.css
44
src/App.css
@ -1212,16 +1212,14 @@ h3 {
|
|||||||
background: #16191f;
|
background: #16191f;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
will-change: transform;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-card-v2:hover {
|
.plugin-card-v2:hover {
|
||||||
transform: translateY(-3px);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
|
||||||
border-color: rgba(255, 255, 255, 0.14);
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1239,11 +1237,7 @@ h3 {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
transition: transform 0.35s ease, opacity 0.3s ease-in-out;
|
transition: opacity 0.3s ease-in-out;
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card-v2:hover .pcv2-preview-img {
|
|
||||||
transform: scale(1.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pcv2-preview-fallback {
|
.pcv2-preview-fallback {
|
||||||
@ -1386,16 +1380,6 @@ h3 {
|
|||||||
opacity: 0.6;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pcv2-sale-strip {
|
.pcv2-sale-strip {
|
||||||
background: linear-gradient(90deg, rgba(255, 107, 107, 0.15), rgba(255, 107, 107, 0.05));
|
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);
|
border-top: 1px solid rgba(255, 107, 107, 0.2);
|
||||||
@ -1453,6 +1437,28 @@ h3 {
|
|||||||
box-shadow: 0 0 8px var(--primary);
|
box-shadow: 0 0 8px var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video Showcase on Hover */
|
||||||
|
.pcv2-video-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-video-iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate(-50%, -50%) scale(1.02);
|
||||||
|
border: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.pcv2-preview-fallback {
|
.pcv2-preview-fallback {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
|
|||||||
114
src/App.tsx
114
src/App.tsx
@ -1,34 +1,41 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useEffect, Suspense } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Store } from 'lucide-react';
|
import { Store } from 'lucide-react';
|
||||||
import Home from './pages/Home';
|
|
||||||
import PluginDetail from './pages/PluginDetail';
|
|
||||||
import LoginPage from './pages/auth/LoginPage';
|
|
||||||
import RegisterPage from './pages/auth/RegisterPage';
|
|
||||||
import ForgotPasswordPage from './pages/auth/ForgotPasswordPage';
|
|
||||||
import ResetPasswordPage from './pages/auth/ResetPasswordPage';
|
|
||||||
import VerifyEmailPage from './pages/auth/VerifyEmailPage';
|
|
||||||
import ConfirmEmailChangePage from './pages/auth/ConfirmEmailChangePage';
|
|
||||||
import CheckEmailPage from './pages/auth/CheckEmailPage';
|
|
||||||
import ProfilePage from './pages/ProfilePage';
|
|
||||||
import AuthorProfilePage from './pages/AuthorProfilePage';
|
|
||||||
import SearchResultsPage from './pages/SearchResultsPage';
|
|
||||||
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 GuidelinesPage from './pages/legal/GuidelinesPage';
|
|
||||||
import DeveloperTermsPage from './pages/legal/DeveloperTermsPage';
|
|
||||||
import LegalNoticePage from './pages/legal/LegalNoticePage';
|
|
||||||
import AdminPanel from './pages/admin/AdminPanel';
|
|
||||||
import { CookieBanner } from './components/CookieBanner';
|
import { CookieBanner } from './components/CookieBanner';
|
||||||
import { openCookiePreferencesModal } from './utils/consent';
|
import { openCookiePreferencesModal } from './utils/consent';
|
||||||
import { NavSearch } from './components/NavSearch';
|
import { NavSearch } from './components/NavSearch';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
// --- Lazy Loaded Pages ---
|
||||||
|
const Home = React.lazy(() => import('./pages/Home'));
|
||||||
|
const PluginDetail = React.lazy(() => import('./pages/PluginDetail'));
|
||||||
|
const LoginPage = React.lazy(() => import('./pages/auth/LoginPage'));
|
||||||
|
const RegisterPage = React.lazy(() => import('./pages/auth/RegisterPage'));
|
||||||
|
const ForgotPasswordPage = React.lazy(() => import('./pages/auth/ForgotPasswordPage'));
|
||||||
|
const ResetPasswordPage = React.lazy(() => import('./pages/auth/ResetPasswordPage'));
|
||||||
|
const VerifyEmailPage = React.lazy(() => import('./pages/auth/VerifyEmailPage'));
|
||||||
|
const ConfirmEmailChangePage = React.lazy(() => import('./pages/auth/ConfirmEmailChangePage'));
|
||||||
|
const CheckEmailPage = React.lazy(() => import('./pages/auth/CheckEmailPage'));
|
||||||
|
const ProfilePage = React.lazy(() => import('./pages/ProfilePage'));
|
||||||
|
const AuthorProfilePage = React.lazy(() => import('./pages/AuthorProfilePage'));
|
||||||
|
const SearchResultsPage = React.lazy(() => import('./pages/SearchResultsPage'));
|
||||||
|
const DashboardAudience = React.lazy(() => import('./pages/dashboard/DashboardAudience'));
|
||||||
|
const DashboardPlugins = React.lazy(() => import('./pages/dashboard/DashboardPlugins'));
|
||||||
|
const ManagePlugin = React.lazy(() => import('./pages/dashboard/plugin/ManagePlugin'));
|
||||||
|
const TermsOfServicePage = React.lazy(() => import('./pages/legal/TermsOfServicePage'));
|
||||||
|
const PrivacyPolicyPage = React.lazy(() => import('./pages/legal/PrivacyPolicyPage'));
|
||||||
|
const GuidelinesPage = React.lazy(() => import('./pages/legal/GuidelinesPage'));
|
||||||
|
const DeveloperTermsPage = React.lazy(() => import('./pages/legal/DeveloperTermsPage'));
|
||||||
|
const LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage'));
|
||||||
|
const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel'));
|
||||||
|
|
||||||
|
const PageLoader = () => (
|
||||||
|
<div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
import api from './api';
|
import api from './api';
|
||||||
|
|
||||||
// --- Scroll To Top Helper ---
|
// --- Scroll To Top Helper ---
|
||||||
@ -182,7 +189,7 @@ const App = () => (
|
|||||||
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
||||||
<Route path="audience" element={<DashboardAudience />} />
|
<Route path="audience" element={<DashboardAudience />} />
|
||||||
<Route path="plugins" element={<DashboardPlugins />} />
|
<Route path="plugins" element={<DashboardPlugins />} />
|
||||||
<Route path="add-plugin" element={<AddPlugin />} />
|
<Route path="add-plugin" element={<Navigate to="/dashboard/plugins?create=true" replace />} />
|
||||||
<Route path="manage-plugin/:id" element={<ManagePlugin />} />
|
<Route path="manage-plugin/:id" element={<ManagePlugin />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
@ -195,7 +202,11 @@ const MainLayout = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar user={user} />
|
<Navbar user={user} />
|
||||||
<main><Outlet /></main>
|
<main>
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -228,9 +239,13 @@ const DeveloperStudioNavbar = ({ user }: any) => {
|
|||||||
|
|
||||||
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
<NavLink to="/dashboard/plugins" onClick={() => setIsMenuOpen(false)}>Plugins</NavLink>
|
{user?.is_developer && (
|
||||||
<NavLink to="/dashboard/audience" onClick={() => setIsMenuOpen(false)}>Audience</NavLink>
|
<>
|
||||||
<NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>
|
<NavLink to="/dashboard/plugins" onClick={() => setIsMenuOpen(false)}>Plugins</NavLink>
|
||||||
|
<NavLink to="/dashboard/audience" onClick={() => setIsMenuOpen(false)}>Audience</NavLink>
|
||||||
|
<NavLink to="/dashboard/plugins?create=true" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{user && (user.role === 'admin' || user.role === 'moderator') && (
|
{user && (user.role === 'admin' || user.role === 'moderator') && (
|
||||||
<NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>
|
<NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>
|
||||||
)}
|
)}
|
||||||
@ -243,36 +258,23 @@ const DeveloperStudioNavbar = ({ user }: any) => {
|
|||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '6px',
|
gap: '0.45rem',
|
||||||
color: 'var(--primary)',
|
color: 'var(--text-muted, #94a3b8)',
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
textTransform: 'uppercase',
|
fontWeight: 500,
|
||||||
letterSpacing: '0.03em',
|
textDecoration: 'none',
|
||||||
padding: '6px 14px',
|
padding: '0.45rem 0.85rem',
|
||||||
borderRadius: '20px',
|
borderRadius: '8px',
|
||||||
background: 'rgba(255, 117, 24, 0.1)',
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
border: '1px solid rgba(255, 117, 24, 0.3)',
|
background: 'rgba(255, 255, 255, 0.03)',
|
||||||
transition: 'all 0.2s ease'
|
transition: 'all 0.2s ease',
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.background = 'rgba(255, 117, 24, 0.2)';
|
|
||||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.background = 'rgba(255, 117, 24, 0.1)';
|
|
||||||
e.currentTarget.style.borderColor = 'rgba(255, 117, 24, 0.3)';
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Store size={14} />
|
<Store size={15} />
|
||||||
<span>Marketplace</span>
|
<span>Store</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{user && (
|
||||||
{user ? (
|
|
||||||
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
||||||
) : (
|
|
||||||
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>Login</Link>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -286,7 +288,9 @@ const DashboardLayout = () => {
|
|||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
<DeveloperStudioNavbar user={user} />
|
<DeveloperStudioNavbar user={user} />
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
<Outlet />
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -323,7 +327,7 @@ const Navbar = ({ user }: any) => {
|
|||||||
{user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
|
{user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
|
||||||
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('nav.dashboard')}</NavLink>
|
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('nav.dashboard')}</NavLink>
|
||||||
)}
|
)}
|
||||||
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
{user && <NavLink to={user.is_developer ? "/dashboard/plugins?create=true" : "/dashboard/plugins"} onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
|
||||||
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,12 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-onboarding-embedded {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 580px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dev-modal-container {
|
.dev-modal-container {
|
||||||
background: var(--bg-card, #16181d);
|
background: var(--bg-card, #16181d);
|
||||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
@ -108,6 +114,94 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-account-types-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card:hover,
|
||||||
|
.dev-account-card.selected {
|
||||||
|
border-color: rgba(249, 115, 22, 0.6);
|
||||||
|
background: rgba(249, 115, 22, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-muted, #94a3b8);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-field label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-get-started-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.35rem;
|
||||||
|
background: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-get-started-btn:hover:not(:disabled) {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-account-get-started-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.dev-entity-grid {
|
.dev-entity-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@ -188,6 +282,25 @@
|
|||||||
border-color: #f97316;
|
border-color: #f97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dev-input-error {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
background: rgba(239, 68, 68, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-input-error:focus {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
box-shadow: 0 0 0 1px #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-field-error {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #ef4444;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dev-select {
|
.dev-select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getCodeList } from 'country-list';
|
import { getCodeList } from 'country-list';
|
||||||
import {
|
import {
|
||||||
Building2, User, Globe, Mail, CheckCircle2, ChevronRight,
|
Globe, Mail, CheckCircle2, ChevronRight,
|
||||||
AlertCircle, CreditCard, Sparkles, Code2, ShieldCheck, MapPin,
|
AlertCircle, CreditCard, Code2, ShieldCheck, MapPin,
|
||||||
Gift, DollarSign,
|
Gift,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAuth } from '../App';
|
import { useAuth } from '../App';
|
||||||
@ -12,22 +12,27 @@ import { SecuritySetupCards } from './SecuritySetupCards';
|
|||||||
import './DeveloperOnboardingModal.css';
|
import './DeveloperOnboardingModal.css';
|
||||||
|
|
||||||
interface DeveloperOnboardingModalProps {
|
interface DeveloperOnboardingModalProps {
|
||||||
isOpen: boolean;
|
isOpen?: boolean;
|
||||||
onClose: () => void;
|
onClose?: () => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
embedded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> = ({
|
export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> = ({
|
||||||
isOpen,
|
isOpen = true,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
embedded = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, login } = useAuth();
|
const { user, login } = useAuth();
|
||||||
const [step, setStep] = useState<number>(1);
|
const [step, setStep] = useState<number>(1);
|
||||||
const [entityType, setEntityType] = useState<'individual' | 'organization'>('individual');
|
const [entityType, setEntityType] = useState<'individual' | 'organization'>('individual');
|
||||||
|
const [orgType, setOrgType] = useState<string>('');
|
||||||
const [displayName, setDisplayName] = useState(user?.username || '');
|
const [displayName, setDisplayName] = useState(user?.username || '');
|
||||||
const [isSellingPaid, setIsSellingPaid] = useState<boolean>(false);
|
const [nameChecking, setNameChecking] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
const [isSellingPaid, setIsSellingPaid] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Legal / Address (for paid sellers)
|
// Legal / Address (for paid sellers)
|
||||||
const [legalName, setLegalName] = useState('');
|
const [legalName, setLegalName] = useState('');
|
||||||
@ -46,12 +51,37 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Debounced check for developer display name availability
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = displayName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setNameError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setNameChecking(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/user/developer/check-name?name=${encodeURIComponent(trimmed)}`);
|
||||||
|
if (res.data?.available === false) {
|
||||||
|
setNameError(res.data.message || t('developer.onboarding.name_taken_error'));
|
||||||
|
} else {
|
||||||
|
setNameError(null);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.data?.error) {
|
||||||
|
setNameError(err.response.data.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setNameChecking(false);
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [displayName, t]);
|
||||||
|
|
||||||
const hasSecureAuth = Boolean(user?.totp_enabled || (user?.passkey_count && user.passkey_count > 0) || user?.has_passkey);
|
const hasSecureAuth = Boolean(user?.totp_enabled || (user?.passkey_count && user.passkey_count > 0) || user?.has_passkey);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!embedded && !isOpen) return null;
|
||||||
|
|
||||||
// Max steps: 3 for Free, 4 for Paid
|
|
||||||
const totalSteps = isSellingPaid ? 4 : 3;
|
|
||||||
|
|
||||||
const handleCompleteOnboarding = async (connectStripe: boolean) => {
|
const handleCompleteOnboarding = async (connectStripe: boolean) => {
|
||||||
if (!acceptedTerms) {
|
if (!acceptedTerms) {
|
||||||
@ -90,7 +120,7 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose?.();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Failed to complete developer onboarding');
|
setError(err.response?.data?.error || 'Failed to complete developer onboarding');
|
||||||
} finally {
|
} finally {
|
||||||
@ -106,14 +136,47 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
setStep(s => Math.max(1, s - 1));
|
setStep(s => Math.max(1, s - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleStep2Next = async () => {
|
||||||
<div className="dev-modal-overlay">
|
const trimmed = displayName.trim();
|
||||||
<div className="dev-modal-container">
|
if (!trimmed) return;
|
||||||
|
setNameChecking(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/user/developer/check-name?name=${encodeURIComponent(trimmed)}`);
|
||||||
|
if (res.data?.available === false) {
|
||||||
|
setNameError(res.data.message || t('developer.onboarding.name_taken_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNameError(null);
|
||||||
|
nextStep();
|
||||||
|
} catch (err: any) {
|
||||||
|
setNameError(err.response?.data?.error || 'Failed to verify developer name');
|
||||||
|
} finally {
|
||||||
|
setNameChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStep2Valid = Boolean(displayName.trim() && !nameError && !nameChecking);
|
||||||
|
|
||||||
|
const isStep3Valid = Boolean(
|
||||||
|
isSellingPaid !== null &&
|
||||||
|
(isSellingPaid === false ||
|
||||||
|
(isSellingPaid === true &&
|
||||||
|
legalName.trim() &&
|
||||||
|
streetAddress.trim() &&
|
||||||
|
city.trim() &&
|
||||||
|
postalCode.trim() &&
|
||||||
|
country))
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dev-modal-container">
|
||||||
|
{!embedded && onClose && (
|
||||||
<button className="dev-modal-close" onClick={onClose} aria-label="Close modal">×</button>
|
<button className="dev-modal-close" onClick={onClose} aria-label="Close modal">×</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="dev-modal-header">
|
<div className="dev-modal-header">
|
||||||
<div className="dev-modal-badge">
|
<div className="dev-modal-badge">
|
||||||
<Sparkles size={14} /> {t('developer.onboarding.badge')}
|
{t('developer.onboarding.badge')}
|
||||||
</div>
|
</div>
|
||||||
<h2>{t('developer.onboarding.title')}</h2>
|
<h2>{t('developer.onboarding.title')}</h2>
|
||||||
<p>
|
<p>
|
||||||
@ -125,21 +188,15 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
|
|
||||||
{/* Step Indicator */}
|
{/* Step Indicator */}
|
||||||
<div className="dev-steps-nav">
|
<div className="dev-steps-nav">
|
||||||
<div className={`dev-step-pill ${step >= 1 ? 'active' : ''}`}>{t('developer.onboarding.step_profile')}</div>
|
<div className={`dev-step-pill ${step >= 1 ? 'active' : ''}`}>1. {t('developer.onboarding.step_account_type')}</div>
|
||||||
<div className="dev-step-line" />
|
<div className="dev-step-line" />
|
||||||
{isSellingPaid && (
|
<div className={`dev-step-pill ${step >= 2 ? 'active' : ''}`}>2. {t('developer.onboarding.step_name')}</div>
|
||||||
<>
|
|
||||||
<div className={`dev-step-pill ${step >= 2 ? 'active' : ''}`}>{t('developer.onboarding.step_seller')}</div>
|
|
||||||
<div className="dev-step-line" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className={`dev-step-pill ${step >= (isSellingPaid ? 3 : 2) ? 'active' : ''}`}>
|
|
||||||
{isSellingPaid ? `3. ${t('developer.onboarding.step_links')}` : `2. ${t('developer.onboarding.step_links')}`}
|
|
||||||
</div>
|
|
||||||
<div className="dev-step-line" />
|
<div className="dev-step-line" />
|
||||||
<div className={`dev-step-pill ${step >= totalSteps ? 'active' : ''}`}>
|
<div className={`dev-step-pill ${step >= 3 ? 'active' : ''}`}>3. {t('developer.onboarding.step_monetization')}</div>
|
||||||
{totalSteps}. {t('developer.onboarding.step_finish')}
|
<div className="dev-step-line" />
|
||||||
</div>
|
<div className={`dev-step-pill ${step >= 4 ? 'active' : ''}`}>4. {t('developer.onboarding.step_links')}</div>
|
||||||
|
<div className="dev-step-line" />
|
||||||
|
<div className={`dev-step-pill ${step >= 5 ? 'active' : ''}`}>5. {t('developer.onboarding.step_finish')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -149,34 +206,89 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── STEP 1: Entity Type, Public Name & Plan ── */}
|
{/* ── STEP 1: Account Type (Google Play Style) ── */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="dev-step-content">
|
<div className="dev-step-content">
|
||||||
<label className="dev-field-label">{t('developer.onboarding.entity_question')}</label>
|
<div className="dev-account-types-container">
|
||||||
<div className="dev-entity-grid">
|
{/* An organization */}
|
||||||
<button
|
<div className={`dev-account-card ${entityType === 'organization' ? 'selected' : ''}`}>
|
||||||
type="button"
|
<div className="dev-account-card-header">
|
||||||
className={`dev-entity-card ${entityType === 'individual' ? 'selected' : ''}`}
|
<h3 className="dev-account-card-title">{t('developer.onboarding.org_title')}</h3>
|
||||||
onClick={() => setEntityType('individual')}
|
<p className="dev-account-card-desc">
|
||||||
>
|
{t('developer.onboarding.org_desc')}
|
||||||
<div className="dev-entity-icon"><User size={22} /></div>
|
</p>
|
||||||
<div>
|
|
||||||
<h4>{t('developer.onboarding.individual_title')}</h4>
|
|
||||||
<p>{t('developer.onboarding.individual_desc')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<div className="dev-account-card-field">
|
||||||
type="button"
|
<label htmlFor="orgTypeSelect">{t('developer.onboarding.org_type_label')}</label>
|
||||||
className={`dev-entity-card ${entityType === 'organization' ? 'selected' : ''}`}
|
<select
|
||||||
onClick={() => setEntityType('organization')}
|
id="orgTypeSelect"
|
||||||
>
|
className="dev-input dev-select"
|
||||||
<div className="dev-entity-icon"><Building2 size={22} /></div>
|
value={orgType}
|
||||||
<div>
|
onChange={e => {
|
||||||
<h4>{t('developer.onboarding.org_title')}</h4>
|
setOrgType(e.target.value);
|
||||||
<p>{t('developer.onboarding.org_desc')}</p>
|
setEntityType('organization');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t('developer.onboarding.org_type_placeholder')}</option>
|
||||||
|
<option value="company">Company / Studio</option>
|
||||||
|
<option value="nonprofit">Non-profit / Open Source Organization</option>
|
||||||
|
<option value="education">Educational Institution</option>
|
||||||
|
<option value="other">Other entity</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
<div className="dev-account-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dev-account-get-started-btn"
|
||||||
|
disabled={!orgType}
|
||||||
|
onClick={() => {
|
||||||
|
setEntityType('organization');
|
||||||
|
nextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('developer.onboarding.btn_get_started')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Yourself */}
|
||||||
|
<div className={`dev-account-card ${entityType === 'individual' ? 'selected' : ''}`}>
|
||||||
|
<div className="dev-account-card-header">
|
||||||
|
<h3 className="dev-account-card-title">{t('developer.onboarding.individual_title')}</h3>
|
||||||
|
<p className="dev-account-card-desc">
|
||||||
|
{t('developer.onboarding.individual_desc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dev-account-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dev-account-get-started-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setEntityType('individual');
|
||||||
|
nextStep();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('developer.onboarding.btn_get_started')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── STEP 2: Developer Public Name / Brand ── */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="dev-step-content">
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, color: '#fff', marginBottom: '0.35rem' }}>
|
||||||
|
{t('developer.onboarding.dev_name_title')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted, #94a3b8)', lineHeight: 1.5, margin: 0 }}>
|
||||||
|
{t('developer.onboarding.dev_name_desc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dev-form-group">
|
<div className="dev-form-group">
|
||||||
@ -185,162 +297,221 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
id="onboardingDisplayName"
|
id="onboardingDisplayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
className="dev-input"
|
className={`dev-input ${nameError ? 'dev-input-error' : ''}`}
|
||||||
placeholder={t('developer.onboarding.display_name_placeholder')}
|
placeholder={entityType === 'organization' ? 'e.g. PixelCraft Studios' : t('developer.onboarding.display_name_placeholder')}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={e => setDisplayName(e.target.value)}
|
onChange={e => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
setNameError(null);
|
||||||
|
}}
|
||||||
autoComplete="nickname"
|
autoComplete="nickname"
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<span className="dev-hint">{t('developer.onboarding.display_name_hint')}</span>
|
{nameError ? (
|
||||||
|
<span className="dev-field-error">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>{nameError}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="dev-hint">{t('developer.onboarding.display_name_hint')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dev-form-group">
|
<div className="dev-btn-row" style={{ marginTop: '1.5rem' }}>
|
||||||
<label>{t('developer.onboarding.intent_label')}</label>
|
<button className="dev-btn dev-btn-secondary" onClick={prevStep}>
|
||||||
<div className="dev-entity-grid">
|
{t('developer.onboarding.btn_back')}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
className={`dev-entity-card ${!isSellingPaid ? 'active' : ''}`}
|
className="dev-btn dev-btn-primary"
|
||||||
onClick={() => setIsSellingPaid(false)}
|
disabled={!isStep2Valid}
|
||||||
>
|
onClick={handleStep2Next}
|
||||||
<div className="dev-entity-icon"><Gift size={22} color="#10b981" /></div>
|
>
|
||||||
<div>
|
{nameChecking ? 'Checking...' : t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
|
||||||
<h4>{t('developer.onboarding.intent_free')}</h4>
|
</button>
|
||||||
<p>{t('developer.onboarding.intent_free_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`dev-entity-card ${isSellingPaid ? 'active' : ''}`}
|
|
||||||
onClick={() => setIsSellingPaid(true)}
|
|
||||||
>
|
|
||||||
<div className="dev-entity-icon"><DollarSign size={22} color="#f97316" /></div>
|
|
||||||
<div>
|
|
||||||
<h4>{t('developer.onboarding.intent_paid')}</h4>
|
|
||||||
<p>{t('developer.onboarding.intent_paid_desc')}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className="dev-btn dev-btn-primary"
|
|
||||||
style={{ marginTop: '1.25rem' }}
|
|
||||||
disabled={!displayName.trim()}
|
|
||||||
onClick={nextStep}
|
|
||||||
>
|
|
||||||
{t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── STEP 2 (Paid Only): Legal Seller Identity & Address ── */}
|
{/* ── STEP 3: Monetization & Seller Details ── */}
|
||||||
{isSellingPaid && step === 2 && (
|
{step === 3 && (
|
||||||
<div className="dev-step-content">
|
<div className="dev-step-content">
|
||||||
|
{/* Required Radio Question: Do you plan to publish Paid plugins? */}
|
||||||
<div className="dev-form-group">
|
<div className="dev-form-group">
|
||||||
<label className="dev-field-label" htmlFor="onboardingLegalName">
|
<label className="dev-field-label" style={{ marginBottom: '0.6rem', display: 'block', fontWeight: 600 }}>
|
||||||
<ShieldCheck size={14} /> {t('developer.onboarding.legal_name_label')}
|
{t('developer.onboarding.plan_paid_question')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
|
||||||
id="onboardingLegalName"
|
|
||||||
name="legalName"
|
|
||||||
type="text"
|
|
||||||
className="dev-input"
|
|
||||||
value={legalName}
|
|
||||||
onChange={e => setLegalName(e.target.value)}
|
|
||||||
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dev-form-group">
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
<label className="dev-field-label" htmlFor="onboardingStreetAddress"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
|
<label style={{
|
||||||
<input
|
display: 'inline-flex',
|
||||||
id="onboardingStreetAddress"
|
alignItems: 'center',
|
||||||
name="streetAddress"
|
gap: '0.55rem',
|
||||||
type="text"
|
cursor: 'pointer',
|
||||||
className="dev-input"
|
padding: '0.6rem 1.25rem',
|
||||||
value={streetAddress}
|
borderRadius: '8px',
|
||||||
onChange={e => setStreetAddress(e.target.value)}
|
background: isSellingPaid === true ? 'rgba(249, 115, 22, 0.12)' : 'rgba(255, 255, 255, 0.03)',
|
||||||
placeholder="123 Main St, Suite 400"
|
border: `1px solid ${isSellingPaid === true ? '#f97316' : 'rgba(255, 255, 255, 0.1)'}`,
|
||||||
autoComplete="street-address"
|
color: isSellingPaid === true ? '#fff' : '#cbd5e1',
|
||||||
required
|
fontWeight: 600,
|
||||||
/>
|
fontSize: '0.9rem',
|
||||||
</div>
|
transition: 'all 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="planPaidChoice"
|
||||||
|
checked={isSellingPaid === true}
|
||||||
|
onChange={() => setIsSellingPaid(true)}
|
||||||
|
style={{ accentColor: '#f97316', width: 16, height: 16, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
{t('developer.onboarding.option_yes')}
|
||||||
|
</label>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
<label style={{
|
||||||
<div className="dev-form-group">
|
display: 'inline-flex',
|
||||||
<label className="dev-field-label" htmlFor="onboardingCity">{t('developer.onboarding.city_label')}</label>
|
alignItems: 'center',
|
||||||
<input
|
gap: '0.55rem',
|
||||||
id="onboardingCity"
|
cursor: 'pointer',
|
||||||
name="city"
|
padding: '0.6rem 1.25rem',
|
||||||
type="text"
|
borderRadius: '8px',
|
||||||
className="dev-input"
|
background: isSellingPaid === false ? 'rgba(249, 115, 22, 0.12)' : 'rgba(255, 255, 255, 0.03)',
|
||||||
value={city}
|
border: `1px solid ${isSellingPaid === false ? '#f97316' : 'rgba(255, 255, 255, 0.1)'}`,
|
||||||
onChange={e => setCity(e.target.value)}
|
color: isSellingPaid === false ? '#fff' : '#cbd5e1',
|
||||||
placeholder="City"
|
fontWeight: 600,
|
||||||
autoComplete="address-level2"
|
fontSize: '0.9rem',
|
||||||
required
|
transition: 'all 0.2s ease',
|
||||||
/>
|
}}>
|
||||||
</div>
|
<input
|
||||||
<div className="dev-form-group">
|
type="radio"
|
||||||
<label className="dev-field-label" htmlFor="onboardingPostalCode">{t('developer.onboarding.postal_label')}</label>
|
name="planPaidChoice"
|
||||||
<input
|
checked={isSellingPaid === false}
|
||||||
id="onboardingPostalCode"
|
onChange={() => setIsSellingPaid(false)}
|
||||||
name="postalCode"
|
style={{ accentColor: '#f97316', width: 16, height: 16, cursor: 'pointer' }}
|
||||||
type="text"
|
/>
|
||||||
className="dev-input"
|
{t('developer.onboarding.option_no')}
|
||||||
value={postalCode}
|
</label>
|
||||||
onChange={e => setPostalCode(e.target.value)}
|
|
||||||
placeholder="10001"
|
|
||||||
autoComplete="postal-code"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
|
||||||
<div className="dev-form-group">
|
|
||||||
<label className="dev-field-label" htmlFor="onboardingCountry"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
|
|
||||||
<select
|
|
||||||
id="onboardingCountry"
|
|
||||||
name="country"
|
|
||||||
className="dev-input dev-select"
|
|
||||||
value={country}
|
|
||||||
onChange={e => setCountry(e.target.value)}
|
|
||||||
autoComplete="country"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select country...</option>
|
|
||||||
{Object.entries(getCodeList()).map(([code, name]) => (
|
|
||||||
<option key={code} value={code}>{name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dev-form-group">
|
{/* If clicked yes, display all the new required seller fields below */}
|
||||||
<label className="dev-field-label" htmlFor="onboardingVatId">{t('developer.onboarding.vat_label')}</label>
|
{isSellingPaid === true && (
|
||||||
<input
|
<div style={{
|
||||||
id="onboardingVatId"
|
marginTop: '1.25rem',
|
||||||
name="vatId"
|
padding: '1.25rem',
|
||||||
type="text"
|
background: 'rgba(249, 115, 22, 0.04)',
|
||||||
className="dev-input"
|
border: '1px solid rgba(249, 115, 22, 0.25)',
|
||||||
value={vatId}
|
borderRadius: '10px',
|
||||||
onChange={e => setVatId(e.target.value)}
|
}}>
|
||||||
placeholder="EU123456789 or Tax ID"
|
<div className="dev-form-group">
|
||||||
autoComplete="off"
|
<label className="dev-field-label" htmlFor="onboardingLegalName">
|
||||||
/>
|
<ShieldCheck size={14} /> {t('developer.onboarding.legal_name_label')}
|
||||||
</div>
|
</label>
|
||||||
|
<input
|
||||||
|
id="onboardingLegalName"
|
||||||
|
name="legalName"
|
||||||
|
type="text"
|
||||||
|
className="dev-input"
|
||||||
|
value={legalName}
|
||||||
|
onChange={e => setLegalName(e.target.value)}
|
||||||
|
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label className="dev-field-label" htmlFor="onboardingStreetAddress">
|
||||||
|
<MapPin size={14} /> {t('developer.onboarding.street_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="onboardingStreetAddress"
|
||||||
|
name="streetAddress"
|
||||||
|
type="text"
|
||||||
|
className="dev-input"
|
||||||
|
value={streetAddress}
|
||||||
|
onChange={e => setStreetAddress(e.target.value)}
|
||||||
|
placeholder="123 Main St, Suite 400"
|
||||||
|
autoComplete="street-address"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label className="dev-field-label" htmlFor="onboardingCity">{t('developer.onboarding.city_label')}</label>
|
||||||
|
<input
|
||||||
|
id="onboardingCity"
|
||||||
|
name="city"
|
||||||
|
type="text"
|
||||||
|
className="dev-input"
|
||||||
|
value={city}
|
||||||
|
onChange={e => setCity(e.target.value)}
|
||||||
|
placeholder="City"
|
||||||
|
autoComplete="address-level2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label className="dev-field-label" htmlFor="onboardingPostalCode">{t('developer.onboarding.postal_label')}</label>
|
||||||
|
<input
|
||||||
|
id="onboardingPostalCode"
|
||||||
|
name="postalCode"
|
||||||
|
type="text"
|
||||||
|
className="dev-input"
|
||||||
|
value={postalCode}
|
||||||
|
onChange={e => setPostalCode(e.target.value)}
|
||||||
|
placeholder="10001"
|
||||||
|
autoComplete="postal-code"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label className="dev-field-label" htmlFor="onboardingCountry">
|
||||||
|
<Globe size={14} /> {t('developer.onboarding.country_label')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="onboardingCountry"
|
||||||
|
name="country"
|
||||||
|
className="dev-input dev-select"
|
||||||
|
value={country}
|
||||||
|
onChange={e => setCountry(e.target.value)}
|
||||||
|
autoComplete="country"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select country...</option>
|
||||||
|
{Object.entries(getCodeList()).map(([code, name]) => (
|
||||||
|
<option key={code} value={code}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label className="dev-field-label" htmlFor="onboardingVatId">{t('developer.onboarding.vat_label')}</label>
|
||||||
|
<input
|
||||||
|
id="onboardingVatId"
|
||||||
|
name="vatId"
|
||||||
|
type="text"
|
||||||
|
className="dev-input"
|
||||||
|
value={vatId}
|
||||||
|
onChange={e => setVatId(e.target.value)}
|
||||||
|
placeholder="EU123456789 or Tax ID"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dev-btn-row" style={{ marginTop: '1.25rem' }}>
|
<div className="dev-btn-row" style={{ marginTop: '1.25rem' }}>
|
||||||
<button className="dev-btn dev-btn-secondary" onClick={prevStep}>{t('developer.onboarding.btn_back')}</button>
|
<button className="dev-btn dev-btn-secondary" onClick={prevStep}>
|
||||||
|
{t('developer.onboarding.btn_back')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dev-btn dev-btn-primary"
|
className="dev-btn dev-btn-primary"
|
||||||
disabled={!legalName.trim() || !streetAddress.trim() || !city.trim() || !postalCode.trim() || !country}
|
disabled={!isStep3Valid}
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
>
|
>
|
||||||
{t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
|
{t('developer.onboarding.btn_continue')} <ChevronRight size={16} />
|
||||||
@ -349,8 +520,8 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── STEP (Free: Step 2, Paid: Step 3): Support Contact & Portfolio Links ── */}
|
{/* ── STEP 4: Support Contact & Portfolio Links ── */}
|
||||||
{((!isSellingPaid && step === 2) || (isSellingPaid && step === 3)) && (
|
{step === 4 && (
|
||||||
<div className="dev-step-content">
|
<div className="dev-step-content">
|
||||||
<div className="dev-form-group">
|
<div className="dev-form-group">
|
||||||
<label className="dev-field-label" htmlFor="onboardingSupportEmail"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
|
<label className="dev-field-label" htmlFor="onboardingSupportEmail"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
|
||||||
@ -408,8 +579,8 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── STEP (Free: Step 3, Paid: Step 4): Finish & Confirmation ── */}
|
{/* ── STEP 5: Finish & Confirmation ── */}
|
||||||
{step === totalSteps && (
|
{step === 5 && (
|
||||||
<div className="dev-step-content">
|
<div className="dev-step-content">
|
||||||
{!isSellingPaid ? (
|
{!isSellingPaid ? (
|
||||||
<>
|
<>
|
||||||
@ -545,7 +716,20 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div className="dev-onboarding-embedded">
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dev-modal-overlay">
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getPluginUrl } from '../utils/url';
|
import { getPluginUrl } from '../utils/url';
|
||||||
|
import { extractYoutubeVideoId } from './PluginVideoPlayer';
|
||||||
|
|
||||||
const getAccentColor = (name: string) => {
|
const getAccentColor = (name: string) => {
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -31,8 +31,7 @@ const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; ori
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDescription?: boolean }) => {
|
const PluginCard = ({ plugin }: { plugin: any; hideDescription?: boolean }) => {
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const timerRef = useRef<any>(null);
|
const timerRef = useRef<any>(null);
|
||||||
@ -40,41 +39,37 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
|||||||
|
|
||||||
const accent = useMemo(() => getAccentColor(plugin?.name || ''), [plugin?.name]);
|
const accent = useMemo(() => getAccentColor(plugin?.name || ''), [plugin?.name]);
|
||||||
|
|
||||||
const getDescription = (translatedStr?: string) => {
|
|
||||||
if (!translatedStr) return 'No description available.';
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(translatedStr);
|
|
||||||
const currentLang = (i18n.language || 'en').split('-')[0];
|
|
||||||
return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
|
|
||||||
} catch {
|
|
||||||
return 'No description available.';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const priceInfo = useMemo(() => (plugin ? getEffectivePrice(plugin) : { display: '€0.00', isSale: false, originalDisplay: '' }), [plugin]);
|
const priceInfo = useMemo(() => (plugin ? getEffectivePrice(plugin) : { display: '€0.00', isSale: false, originalDisplay: '' }), [plugin]);
|
||||||
const isSale = plugin?.type === 'paid' && priceInfo.isSale;
|
const isSale = plugin?.type === 'paid' && priceInfo.isSale;
|
||||||
const desc = getDescription(plugin?.translated_descriptions);
|
|
||||||
const initial = (plugin?.name || '?').charAt(0).toUpperCase();
|
const initial = (plugin?.name || '?').charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const videoId = useMemo(() => {
|
||||||
|
return extractYoutubeVideoId(plugin?.youtube_video_url);
|
||||||
|
}, [plugin?.youtube_video_url]);
|
||||||
|
|
||||||
const slideshowImages = useMemo(() => {
|
const slideshowImages = useMemo(() => {
|
||||||
if (!plugin) return [];
|
if (!plugin) return [];
|
||||||
const images = [];
|
const images: string[] = [];
|
||||||
if (plugin.preview_path) images.push(plugin.preview_path);
|
if (plugin.preview_path) {
|
||||||
|
images.push(plugin.preview_path);
|
||||||
|
} else if (videoId) {
|
||||||
|
images.push(`https://img.youtube.com/vi/${videoId}/hqdefault.jpg`);
|
||||||
|
}
|
||||||
if (plugin.screenshots && plugin.screenshots.length > 0) {
|
if (plugin.screenshots && plugin.screenshots.length > 0) {
|
||||||
images.push(...plugin.screenshots);
|
images.push(...plugin.screenshots);
|
||||||
}
|
}
|
||||||
return images;
|
return images;
|
||||||
}, [plugin?.preview_path, plugin?.screenshots]);
|
}, [plugin?.preview_path, plugin?.screenshots, videoId]);
|
||||||
|
|
||||||
if (!plugin) return null;
|
if (!plugin) return null;
|
||||||
|
|
||||||
const startHover = () => {
|
const startHover = () => {
|
||||||
setIsHovering(true);
|
setIsHovering(true);
|
||||||
|
if (videoId) return;
|
||||||
if (slideshowImages.length <= 1) return;
|
if (slideshowImages.length <= 1) return;
|
||||||
|
|
||||||
const next = () => setCurrentIndex(prev => (prev + 1) % slideshowImages.length);
|
const next = () => setCurrentIndex(prev => (prev + 1) % slideshowImages.length);
|
||||||
|
next();
|
||||||
next(); // Trigger first transition instantly
|
|
||||||
cycleRef.current = setInterval(next, 2000);
|
cycleRef.current = setInterval(next, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -89,7 +84,17 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
|||||||
<Link to={getPluginUrl(plugin)} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
|
<Link to={getPluginUrl(plugin)} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
|
||||||
<div className="plugin-card-v2">
|
<div className="plugin-card-v2">
|
||||||
<div className="pcv2-preview">
|
<div className="pcv2-preview">
|
||||||
{slideshowImages.length > 0 ? (
|
{isHovering && videoId ? (
|
||||||
|
<div className="pcv2-video-container">
|
||||||
|
<iframe
|
||||||
|
className="pcv2-video-iframe"
|
||||||
|
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&controls=0&loop=1&playlist=${videoId}&playsinline=1&rel=0&modestbranding=1&disablekb=1&fs=0&showinfo=0&iv_load_policy=3`}
|
||||||
|
title={plugin.name}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : slideshowImages.length > 0 ? (
|
||||||
<div className="pcv2-slideshow">
|
<div className="pcv2-slideshow">
|
||||||
{slideshowImages.map((src: string, i: number) => (
|
{slideshowImages.map((src: string, i: number) => (
|
||||||
<img
|
<img
|
||||||
@ -97,10 +102,11 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
|||||||
src={src}
|
src={src}
|
||||||
className={`pcv2-slideshow-img ${i === currentIndex ? 'active' : ''}`}
|
className={`pcv2-slideshow-img ${i === currentIndex ? 'active' : ''}`}
|
||||||
alt={plugin.name}
|
alt={plugin.name}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isHovering && slideshowImages.length > 1 && (
|
{isHovering && slideshowImages.length > 1 && !videoId && (
|
||||||
<div className="pcv2-slideshow-progress">
|
<div className="pcv2-slideshow-progress">
|
||||||
{slideshowImages.map((_: any, i: number) => (
|
{slideshowImages.map((_: any, i: number) => (
|
||||||
<div key={i} className={`pcv2-progress-dot ${i === currentIndex ? 'active' : ''}`} />
|
<div key={i} className={`pcv2-progress-dot ${i === currentIndex ? 'active' : ''}`} />
|
||||||
@ -142,7 +148,6 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
|||||||
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
|
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="pcv2-dev">by {plugin.dev_name}</p>
|
<p className="pcv2-dev">by {plugin.dev_name}</p>
|
||||||
{!hideDescription && <p className="pcv2-desc">{desc}</p>}
|
|
||||||
|
|
||||||
<div className="pcv2-footer">
|
<div className="pcv2-footer">
|
||||||
<span className="pcv2-stat">
|
<span className="pcv2-stat">
|
||||||
@ -172,11 +177,6 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
|||||||
Sale — {plugin.sale_discount_percent}% off
|
Sale — {plugin.sale_discount_percent}% off
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
|
||||||
className="pcv2-accent-bar"
|
|
||||||
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ShieldAlert, Download, Copy, Check, Printer } from 'lucide-react';
|
import { ShieldAlert, Download, Copy, Check, Printer, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface RecoveryCodesModalProps {
|
interface RecoveryCodesModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -26,14 +26,14 @@ export const RecoveryCodesModal: React.FC<RecoveryCodesModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const text = `PUMPKIN MARKET - EMERGENCY RECOVERY CODES\nGenerated on: ${new Date().toLocaleString()}\n\nEach code can be used once if you lose access to your Passkey or 2FA Authenticator:\n\n` +
|
const text = `PUMPKIN MARKET - EMERGENCY RECOVERY CODES\nGenerated on: ${new Date().toISOString()}\n\nEach code can be used once if you lose access to your Passkey or 2FA Authenticator:\n\n` +
|
||||||
codes.map((c, i) => `${i + 1}. ${c}`).join('\n') +
|
codes.map((c, i) => `${i + 1}. ${c}`).join('\n') +
|
||||||
`\n\nKeep these codes in a secure location (e.g., password manager).`;
|
`\n\nStore these codes safely. Never share them with anyone.`;
|
||||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `pumpkin-market-recovery-codes-${new Date().toISOString().slice(0, 10)}.txt`;
|
a.download = `pumpkin-market-recovery-codes-${Date.now()}.txt`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
@ -45,48 +45,32 @@ export const RecoveryCodesModal: React.FC<RecoveryCodesModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="settings-modal-overlay" style={{ zIndex: 9999 }}>
|
||||||
position: 'fixed',
|
<div className="settings-modal-card" style={{ maxWidth: 520, backgroundColor: 'var(--mp-surface, #14171c)', borderRadius: 16, border: '1px solid var(--mp-border, rgba(255,255,255,0.12))', padding: '2rem', color: 'var(--mp-text, #f8fafc)' }}>
|
||||||
inset: 0,
|
|
||||||
zIndex: 9999,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
||||||
backdropFilter: 'blur(6px)',
|
|
||||||
padding: '1rem',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
maxWidth: 540,
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: 'var(--mp-surface, #14171c)',
|
|
||||||
borderRadius: 16,
|
|
||||||
border: '1px solid var(--mp-border, rgba(255,255,255,0.12))',
|
|
||||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.7)',
|
|
||||||
padding: '2rem',
|
|
||||||
color: 'var(--mp-text, #f8fafc)',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 52,
|
width: 44,
|
||||||
height: 52,
|
height: 44,
|
||||||
borderRadius: '50%',
|
borderRadius: 12,
|
||||||
backgroundColor: 'rgba(249, 115, 22, 0.15)',
|
backgroundColor: 'rgba(239, 68, 68, 0.12)',
|
||||||
color: 'var(--mp-accent, #f97316)',
|
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||||
display: 'inline-flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginBottom: '0.75rem',
|
flexShrink: 0,
|
||||||
|
color: '#ef4444',
|
||||||
}}>
|
}}>
|
||||||
<ShieldAlert size={26} />
|
<ShieldAlert size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 0.25rem', fontSize: '1.15rem', fontWeight: 700, color: 'var(--mp-text, #f8fafc)' }}>
|
||||||
|
Save Your Recovery Codes
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--mp-muted, #94a3b8)', lineHeight: 1.4 }}>
|
||||||
|
If you ever lose access to your authenticator app or security keys, you can use these 10 one-time codes to regain access to your account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: '0 0 0.4rem 0' }}>
|
|
||||||
Save Your Emergency Recovery Codes
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: '0.88rem', color: 'var(--mp-muted, #94a3b8)', lineHeight: 1.5, margin: 0 }}>
|
|
||||||
If you lose access to your device or authenticator app, these <strong>one-time backup codes</strong> are the only way to regain access to your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning Alert */}
|
{/* Warning Alert */}
|
||||||
@ -99,8 +83,12 @@ export const RecoveryCodesModal: React.FC<RecoveryCodesModalProps> = ({
|
|||||||
color: '#fca5a5',
|
color: '#fca5a5',
|
||||||
marginBottom: '1.25rem',
|
marginBottom: '1.25rem',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
}}>
|
}}>
|
||||||
⚠️ <strong>Important:</strong> We do not store these codes in plaintext and cannot recover them for you. Save them in a password manager now.
|
<AlertTriangle size={16} style={{ flexShrink: 0 }} />
|
||||||
|
<span><strong>Important:</strong> We do not store these codes in plaintext and cannot recover them for you. Save them in a password manager now.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Codes Grid */}
|
{/* Codes Grid */}
|
||||||
|
|||||||
@ -322,7 +322,7 @@ export const SecuritySetupCards: React.FC<SecuritySetupCardsProps> = ({
|
|||||||
color: 'var(--mp-muted, #94a3b8)',
|
color: 'var(--mp-muted, #94a3b8)',
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}>
|
}}>
|
||||||
💡 <strong>How it works:</strong> Passkeys replace passwords and SMS codes with cryptographic keys stored securely in your device’s keychain (Apple Keychain, Google Password Manager, Windows Hello, 1Password, or YubiKey). They are 100% phishing-proof.
|
<strong>How it works:</strong> Passkeys replace passwords and SMS codes with cryptographic keys stored securely in your device’s keychain (Apple Keychain, Google Password Manager, Windows Hello, 1Password, or YubiKey). They are 100% phishing-proof.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passkeyError && (
|
{passkeyError && (
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Entwicklerportal",
|
"badge": "Entwicklerportal",
|
||||||
"subtitle_paid": "Richte dein Creator-Profil und deine Verkäufer-Identität ein, um Plugins zu verkaufen.",
|
"subtitle_paid": "Richte dein Creator-Profil und deine Verkäufer-Identität ein, um Plugins zu verkaufen.",
|
||||||
"subtitle_free": "Richte dein Creator-Profil ein, um kostenlose Plugins für die Community zu veröffentlichen.",
|
"subtitle_free": "Richte dein Creator-Profil ein, um kostenlose Plugins für die Community zu veröffentlichen.",
|
||||||
"step_profile": "1. Profil",
|
"step_profile": "Profil",
|
||||||
"step_seller": "2. Verkäuferdaten",
|
"step_seller": "Verkäuferdaten",
|
||||||
"step_links": "Links",
|
"step_links": "Links",
|
||||||
"step_finish": "Fertigstellen",
|
"step_finish": "Fertigstellen",
|
||||||
"entity_question": "Veröffentlichst du als Einzelperson oder Organisation?",
|
"entity_question": "Veröffentlichst du als Einzelperson oder Organisation?",
|
||||||
"individual_title": "Einzelentwickler",
|
"individual_title": "Einzelentwickler",
|
||||||
"individual_desc": "Unabhängiger Entwickler oder Hobbyist.",
|
"individual_desc": "Ideal für unabhängige Entwickler, Studenten und Hobby-Programmierer. Veröffentliche kostenlose oder Premium-WebAssembly-Plugins unter deinem persönlichen Profil.",
|
||||||
"org_title": "Organisation / Studio",
|
"org_title": "Organisation oder Studio",
|
||||||
"org_desc": "Unternehmen, Team oder Studio.",
|
"org_desc": "Ideal für Entwicklerteams, Studios oder registrierte Unternehmen, die gemeinsam Plugins veröffentlichen. Verwalte deine Markenidentität und Teamauszahlungen.",
|
||||||
"display_name_label": "Öffentlicher Entwicklername *",
|
"display_name_label": "Öffentlicher Entwicklername *",
|
||||||
"display_name_placeholder": "z. B. PixelCraft Studios, MaxDev",
|
"display_name_placeholder": "z. B. PixelCraft Studios, MaxDev",
|
||||||
"display_name_hint": "Dieser Name wird öffentlich auf allen deinen Plugins angezeigt.",
|
"display_name_hint": "Dieser Name wird öffentlich auf allen deinen Plugins angezeigt.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Weiter ->",
|
"btn_continue": "Weiter ->",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_complete": "Onboarding abschließen",
|
"btn_complete": "Onboarding abschließen",
|
||||||
"btn_connect_stripe": "Speichern & Stripe verbinden ->"
|
"btn_connect_stripe": "Speichern & Stripe verbinden ->",
|
||||||
|
"step_account_type": "Kontotyp",
|
||||||
|
"org_type_label": "Organisationstyp:",
|
||||||
|
"org_type_placeholder": "Wähle deinen Organisationstyp ▾",
|
||||||
|
"btn_get_started": "Loslegen ->",
|
||||||
|
"plan_paid_question": "Planst du, kostenpflichtige Plugins zu veröffentlichen? *",
|
||||||
|
"option_yes": "Ja",
|
||||||
|
"option_no": "Nein",
|
||||||
|
"step_name": "Name",
|
||||||
|
"step_monetization": "Monetarisierung",
|
||||||
|
"dev_name_title": "Wähle deinen Entwicklernamen",
|
||||||
|
"dev_name_desc": "Diesen Namen sehen Spieler und Server-Besitzer auf Pumpkin Market bei deinen Plugins. Er kann sich von deinem rechtlichen Namen unterscheiden und jederzeit geändert werden.",
|
||||||
|
"name_taken_error": "Dieser Entwicklername ist bereits vergeben. Bitte wähle einen anderen Namen."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe-Auszahlungen",
|
"payouts": "Stripe-Auszahlungen",
|
||||||
"payouts_status": "Stripe Connect Status",
|
"payouts_status": "Stripe Connect Status",
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
"setup_stripe": "Stripe-Auszahlungen einrichten"
|
"setup_stripe": "Stripe-Auszahlungen einrichten",
|
||||||
|
"create_plugin_title": "Neues Plugin erstellen",
|
||||||
|
"create_plugin_desc": "Erstelle deinen Plugin-Entwurf. Du kannst deinen Store-Eintrag bearbeiten, Builds hochladen und offline testen, bevor du veröffentlichst.",
|
||||||
|
"btn_create_draft": "Entwurf erstellen & verwalten ->",
|
||||||
|
"status_draft": "Offline (Entwurf)",
|
||||||
|
"status_published": "Veröffentlicht",
|
||||||
|
"btn_publish_store": "Im Store veröffentlichen",
|
||||||
|
"btn_unpublish_store": "Offline nehmen",
|
||||||
|
"publish_checklist_title": "Auf dem Marktplatz veröffentlichen",
|
||||||
|
"publish_checklist_desc": "Überprüfe die Anforderungen, bevor du dein Plugin auf dem Pumpkin Market veröffentlichst.",
|
||||||
|
"publish_ready_banner": "Alle Anforderungen erfüllt! Dein Plugin kann veröffentlicht werden.",
|
||||||
|
"publish_missing_banner": "Bitte erfülle alle fehlenden Anforderungen vor der Veröffentlichung.",
|
||||||
|
"btn_confirm_publish": "Bestätigen & im Store freigeben"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Neues Plugin veröffentlichen",
|
"title": "Neues Plugin veröffentlichen",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Developer Portal",
|
"badge": "Developer Portal",
|
||||||
"subtitle_paid": "Set up your creator profile and seller identity to publish and sell plugins.",
|
"subtitle_paid": "Set up your creator profile and seller identity to publish and sell plugins.",
|
||||||
"subtitle_free": "Set up your creator profile to publish free plugins for the Pumpkin community.",
|
"subtitle_free": "Set up your creator profile to publish free plugins for the Pumpkin community.",
|
||||||
"step_profile": "1. Profile",
|
"step_profile": "Profile",
|
||||||
"step_seller": "2. Seller Info",
|
"step_seller": "Seller Info",
|
||||||
"step_links": "Links",
|
"step_links": "Links",
|
||||||
"step_finish": "Finish",
|
"step_finish": "Finish",
|
||||||
"entity_question": "Are you publishing as an individual or organization?",
|
"entity_question": "Are you publishing as an individual or organization?",
|
||||||
"individual_title": "Individual Creator",
|
"individual_title": "Individual Creator",
|
||||||
"individual_desc": "Independent developer or hobbyist.",
|
"individual_desc": "Ideal for independent developers, students, and hobbyist creators. Publish free or premium WebAssembly plugins under your personal creator profile.",
|
||||||
"org_title": "Organization / Studio",
|
"org_title": "Organization or Studio",
|
||||||
"org_desc": "Company, team, or studio.",
|
"org_desc": "Ideal for development teams, studios, or registered companies publishing plugins together. Manage your brand identity and organizational payouts.",
|
||||||
"display_name_label": "Developer / Studio Display Name *",
|
"display_name_label": "Developer / Studio Display Name *",
|
||||||
"display_name_placeholder": "e.g. PixelCraft Studios, JohnDoeDev",
|
"display_name_placeholder": "e.g. PixelCraft Studios, JohnDoeDev",
|
||||||
"display_name_hint": "This name will be publicly visible on all your published plugins.",
|
"display_name_hint": "This name will be publicly visible on all your published plugins.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Continue ->",
|
"btn_continue": "Continue ->",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_complete": "Complete Onboarding",
|
"btn_complete": "Complete Onboarding",
|
||||||
"btn_connect_stripe": "Save & Connect Stripe ->"
|
"btn_connect_stripe": "Save & Connect Stripe ->",
|
||||||
|
"step_account_type": "Account Type",
|
||||||
|
"org_type_label": "Organization type:",
|
||||||
|
"org_type_placeholder": "Select your organization type ▾",
|
||||||
|
"btn_get_started": "Get started ->",
|
||||||
|
"plan_paid_question": "Do you plan to publish Paid plugins? *",
|
||||||
|
"option_yes": "Yes",
|
||||||
|
"option_no": "No",
|
||||||
|
"step_name": "Name",
|
||||||
|
"step_monetization": "Monetization",
|
||||||
|
"dev_name_title": "Choose your developer name",
|
||||||
|
"dev_name_desc": "This is what players and server owners will see across Pumpkin Market to represent your profile and plugins. It can be different from your legal name, and you can change it at any time.",
|
||||||
|
"name_taken_error": "This developer name is already taken. Please choose another name."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe Payouts",
|
"payouts": "Stripe Payouts",
|
||||||
"payouts_status": "Stripe Connect Status",
|
"payouts_status": "Stripe Connect Status",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"setup_stripe": "Set Up Stripe Payouts"
|
"setup_stripe": "Set Up Stripe Payouts",
|
||||||
|
"create_plugin_title": "Create New Plugin",
|
||||||
|
"create_plugin_desc": "Set up your plugin draft. You can configure your store listing, upload builds, and test offline before publishing.",
|
||||||
|
"btn_create_draft": "Create Draft & Manage ->",
|
||||||
|
"status_draft": "Offline (Draft)",
|
||||||
|
"status_published": "Published",
|
||||||
|
"btn_publish_store": "Publish to Store",
|
||||||
|
"btn_unpublish_store": "Take Offline",
|
||||||
|
"publish_checklist_title": "Publish to Marketplace",
|
||||||
|
"publish_checklist_desc": "Review requirements before releasing your plugin to the Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "All requirements are met! Your plugin is ready to go live.",
|
||||||
|
"publish_missing_banner": "Please complete the missing requirements before publishing.",
|
||||||
|
"btn_confirm_publish": "Confirm & Release to Store"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Publish a New Plugin",
|
"title": "Publish a New Plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Portal de Desarrollador",
|
"badge": "Portal de Desarrollador",
|
||||||
"subtitle_paid": "Configura tu perfil de creador e identidad fiscal para vender plugins.",
|
"subtitle_paid": "Configura tu perfil de creador e identidad fiscal para vender plugins.",
|
||||||
"subtitle_free": "Configura tu perfil de creador para publicar plugins gratuitos para la comunidad.",
|
"subtitle_free": "Configura tu perfil de creador para publicar plugins gratuitos para la comunidad.",
|
||||||
"step_profile": "1. Perfil",
|
"step_profile": "Perfil",
|
||||||
"step_seller": "2. Datos de Vendedor",
|
"step_seller": "Datos de vendedor",
|
||||||
"step_links": "Enlaces",
|
"step_links": "Enlaces",
|
||||||
"step_finish": "Finalizar",
|
"step_finish": "Finalizar",
|
||||||
"entity_question": "¿Publicas como particular u organización?",
|
"entity_question": "¿Publicas como particular u organización?",
|
||||||
"individual_title": "Creador Individual",
|
"individual_title": "Creador Individual",
|
||||||
"individual_desc": "Desarrollador independiente o aficionado.",
|
"individual_desc": "Ideal para desarrolladores independientes, estudiantes y aficionados. Publica plugins WebAssembly gratuitos o premium bajo tu perfil personal.",
|
||||||
"org_title": "Organización / Estudio",
|
"org_title": "Organización o Estudio",
|
||||||
"org_desc": "Empresa, equipo o estudio.",
|
"org_desc": "Ideal para equipos de desarrollo, estudios o empresas que publican plugins conjuntamente. Gestiona la identidad de tu marca y los pagos de la organización.",
|
||||||
"display_name_label": "Nombre público de Desarrollador / Estudio *",
|
"display_name_label": "Nombre público de Desarrollador / Estudio *",
|
||||||
"display_name_placeholder": "ej. PixelCraft Studios, JuanDev",
|
"display_name_placeholder": "ej. PixelCraft Studios, JuanDev",
|
||||||
"display_name_hint": "Este nombre será visible públicamente en todos tus plugins.",
|
"display_name_hint": "Este nombre será visible públicamente en todos tus plugins.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Continuar ->",
|
"btn_continue": "Continuar ->",
|
||||||
"btn_back": "Atrás",
|
"btn_back": "Atrás",
|
||||||
"btn_complete": "Completar Registro",
|
"btn_complete": "Completar Registro",
|
||||||
"btn_connect_stripe": "Guardar y Conectar Stripe ->"
|
"btn_connect_stripe": "Guardar y Conectar Stripe ->",
|
||||||
|
"step_account_type": "Tipo de cuenta",
|
||||||
|
"org_type_label": "Tipo de organización:",
|
||||||
|
"org_type_placeholder": "Selecciona tu tipo de organización ▾",
|
||||||
|
"btn_get_started": "Empezar ->",
|
||||||
|
"plan_paid_question": "¿Planeas publicar plugins de pago? *",
|
||||||
|
"option_yes": "Sí",
|
||||||
|
"option_no": "No",
|
||||||
|
"step_name": "Nombre",
|
||||||
|
"step_monetization": "Monetización",
|
||||||
|
"dev_name_title": "Elige tu nombre de desarrollador",
|
||||||
|
"dev_name_desc": "Este es el nombre que verán los jugadores y administradores en Pumpkin Market para tus plugins. Puede ser diferente de tu nombre legal y puedes cambiarlo en cualquier momento.",
|
||||||
|
"name_taken_error": "Este nombre de desarrollador ya está en uso. Por favor, elige otro."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Resumen",
|
"overview": "Resumen",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Pagos con Stripe",
|
"payouts": "Pagos con Stripe",
|
||||||
"payouts_status": "Estado de Stripe Connect",
|
"payouts_status": "Estado de Stripe Connect",
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"setup_stripe": "Configurar Pagos Stripe"
|
"setup_stripe": "Configurar Pagos Stripe",
|
||||||
|
"create_plugin_title": "Crear nuevo plugin",
|
||||||
|
"create_plugin_desc": "Configura el borrador de tu plugin. Puedes editar la ficha, subir compilaciones y probar sin conexión antes de publicar.",
|
||||||
|
"btn_create_draft": "Crear borrador y gestionar ->",
|
||||||
|
"status_draft": "Desconectado (Borrador)",
|
||||||
|
"status_published": "Publicado",
|
||||||
|
"btn_publish_store": "Publicar en la tienda",
|
||||||
|
"btn_unpublish_store": "Poner fuera de línea",
|
||||||
|
"publish_checklist_title": "Publicar en el mercado",
|
||||||
|
"publish_checklist_desc": "Revisa los requisitos antes de lanzar tu plugin en Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "¡Todos los requisitos están completos! Tu plugin está listo para publicarse.",
|
||||||
|
"publish_missing_banner": "Por favor, completa los requisitos pendientes antes de publicar.",
|
||||||
|
"btn_confirm_publish": "Confirmar y lanzar a la tienda"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Publicar un Nuevo Plugin",
|
"title": "Publicar un Nuevo Plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Portail Développeur",
|
"badge": "Portail Développeur",
|
||||||
"subtitle_paid": "Configurez votre profil de créateur et vos coordonnées de vendeur pour distribuer et vendre des plugins.",
|
"subtitle_paid": "Configurez votre profil de créateur et vos coordonnées de vendeur pour distribuer et vendre des plugins.",
|
||||||
"subtitle_free": "Configurez votre profil de créateur pour publier des plugins gratuits pour la communauté Pumpkin.",
|
"subtitle_free": "Configurez votre profil de créateur pour publier des plugins gratuits pour la communauté Pumpkin.",
|
||||||
"step_profile": "1. Profil",
|
"step_profile": "Profil",
|
||||||
"step_seller": "2. Vendeur",
|
"step_seller": "Infos vendeur",
|
||||||
"step_links": "Liens",
|
"step_links": "Liens",
|
||||||
"step_finish": "Terminer",
|
"step_finish": "Terminer",
|
||||||
"entity_question": "Publiez-vous en tant que particulier ou organisation ?",
|
"entity_question": "Publiez-vous en tant que particulier ou organisation ?",
|
||||||
"individual_title": "Créateur individuel",
|
"individual_title": "Créateur individuel",
|
||||||
"individual_desc": "Développeur indépendant ou passionné.",
|
"individual_desc": "Idéal pour les développeurs indépendants, étudiants et créateurs passionnés. Publiez des plugins WebAssembly gratuits ou payants sous votre profil personnel.",
|
||||||
"org_title": "Organisation / Studio",
|
"org_title": "Organisation ou studio",
|
||||||
"org_desc": "Entreprise, équipe ou studio.",
|
"org_desc": "Idéal pour les équipes de développement, studios ou entreprises publiant des plugins ensemble. Gérez l'identité de votre marque et vos paiements professionnels.",
|
||||||
"display_name_label": "Nom public du développeur / studio *",
|
"display_name_label": "Nom public du développeur / studio *",
|
||||||
"display_name_placeholder": "ex : PixelCraft Studios, AlexDev",
|
"display_name_placeholder": "ex : PixelCraft Studios, AlexDev",
|
||||||
"display_name_hint": "Ce nom apparaîtra sur tous vos plugins publiés.",
|
"display_name_hint": "Ce nom apparaîtra sur tous vos plugins publiés.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Continuer ->",
|
"btn_continue": "Continuer ->",
|
||||||
"btn_back": "Retour",
|
"btn_back": "Retour",
|
||||||
"btn_complete": "Terminer l'inscription",
|
"btn_complete": "Terminer l'inscription",
|
||||||
"btn_connect_stripe": "Enregistrer & Connecter Stripe ->"
|
"btn_connect_stripe": "Enregistrer & Connecter Stripe ->",
|
||||||
|
"step_account_type": "Type de compte",
|
||||||
|
"org_type_label": "Type d'organisation :",
|
||||||
|
"org_type_placeholder": "Sélectionnez votre type d'organisation ▾",
|
||||||
|
"btn_get_started": "Commencer ->",
|
||||||
|
"plan_paid_question": "Prévoyez-vous de publier des plugins payants ? *",
|
||||||
|
"option_yes": "Oui",
|
||||||
|
"option_no": "Non",
|
||||||
|
"step_name": "Nom",
|
||||||
|
"step_monetization": "Monétisation",
|
||||||
|
"dev_name_title": "Choisissez votre nom de développeur",
|
||||||
|
"dev_name_desc": "C'est le nom que les joueurs et administrateurs verront sur Pumpkin Market pour vos plugins. Il peut être différent de votre nom légal et pourra être modifié à tout moment.",
|
||||||
|
"name_taken_error": "Ce nom de développeur est déjà pris. Veuillez en choisir un autre."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Aperçu",
|
"overview": "Aperçu",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Virements Stripe",
|
"payouts": "Virements Stripe",
|
||||||
"payouts_status": "Statut Stripe Connect",
|
"payouts_status": "Statut Stripe Connect",
|
||||||
"connected": "Connecté",
|
"connected": "Connecté",
|
||||||
"setup_stripe": "Configurer les virements Stripe"
|
"setup_stripe": "Configurer les virements Stripe",
|
||||||
|
"create_plugin_title": "Créer un nouveau plugin",
|
||||||
|
"create_plugin_desc": "Configurez votre brouillon de plugin. Vous pouvez modifier la fiche de store, importer des builds et tester hors ligne avant de publier.",
|
||||||
|
"btn_create_draft": "Créer le brouillon & gérer ->",
|
||||||
|
"status_draft": "Hors ligne (Brouillon)",
|
||||||
|
"status_published": "Publié",
|
||||||
|
"btn_publish_store": "Publier sur le Store",
|
||||||
|
"btn_unpublish_store": "Mettre hors ligne",
|
||||||
|
"publish_checklist_title": "Publier sur le marché",
|
||||||
|
"publish_checklist_desc": "Vérifiez les prérequis avant de publier votre plugin sur Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Tous les prérequis sont validés ! Votre plugin est prêt à être publié.",
|
||||||
|
"publish_missing_banner": "Veuillez compléter les prérequis manquants avant de publier.",
|
||||||
|
"btn_confirm_publish": "Confirmer & publier sur le Store"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Publier un nouveau plugin",
|
"title": "Publier un nouveau plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Portale Sviluppatore",
|
"badge": "Portale Sviluppatore",
|
||||||
"subtitle_paid": "Configura il tuo profilo creator e i tuoi dati fiscali per pubblicare e vendere plugin.",
|
"subtitle_paid": "Configura il tuo profilo creator e i tuoi dati fiscali per pubblicare e vendere plugin.",
|
||||||
"subtitle_free": "Configura il tuo profilo creator per pubblicare plugin gratuiti per la community.",
|
"subtitle_free": "Configura il tuo profilo creator per pubblicare plugin gratuiti per la community.",
|
||||||
"step_profile": "1. Profilo",
|
"step_profile": "Profilo",
|
||||||
"step_seller": "2. Dati Venditore",
|
"step_seller": "Dati venditore",
|
||||||
"step_links": "Link",
|
"step_links": "Link",
|
||||||
"step_finish": "Completa",
|
"step_finish": "Completa",
|
||||||
"entity_question": "Pubblichi come persona fisica o organizzazione?",
|
"entity_question": "Pubblichi come persona fisica o organizzazione?",
|
||||||
"individual_title": "Creator Singolo",
|
"individual_title": "Creatore Individuale",
|
||||||
"individual_desc": "Sviluppatore indipendente o hobbista.",
|
"individual_desc": "Ideale per sviluppatori indipendenti, studenti e appassionati. Pubblica plugin WebAssembly gratuiti o a pagamento con il tuo profilo personale.",
|
||||||
"org_title": "Organizzazione / Studio",
|
"org_title": "Organizzazione o Studio",
|
||||||
"org_desc": "Azienda, team o studio di sviluppo.",
|
"org_desc": "Ideale per team di sviluppo, studi o aziende che pubblicano plugin insieme. Gestisci l'identità del brand e i pagamenti dell'organizzazione.",
|
||||||
"display_name_label": "Nome Pubblico Sviluppatore / Studio *",
|
"display_name_label": "Nome Pubblico Sviluppatore / Studio *",
|
||||||
"display_name_placeholder": "es. PixelCraft Studios, MarcoDev",
|
"display_name_placeholder": "es. PixelCraft Studios, MarcoDev",
|
||||||
"display_name_hint": "Questo nome sarà visibile pubblicamente su tutti i tuoi plugin.",
|
"display_name_hint": "Questo nome sarà visibile pubblicamente su tutti i tuoi plugin.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Continua ->",
|
"btn_continue": "Continua ->",
|
||||||
"btn_back": "Indietro",
|
"btn_back": "Indietro",
|
||||||
"btn_complete": "Completa Registrazione",
|
"btn_complete": "Completa Registrazione",
|
||||||
"btn_connect_stripe": "Salva e Collega Stripe ->"
|
"btn_connect_stripe": "Salva e Collega Stripe ->",
|
||||||
|
"step_account_type": "Tipo di account",
|
||||||
|
"org_type_label": "Tipo di organizzazione:",
|
||||||
|
"org_type_placeholder": "Seleziona il tipo di organizzazione ▾",
|
||||||
|
"btn_get_started": "Inizia ->",
|
||||||
|
"plan_paid_question": "Hai intenzione di pubblicare plugin a pagamento? *",
|
||||||
|
"option_yes": "Sì",
|
||||||
|
"option_no": "No",
|
||||||
|
"step_name": "Nome",
|
||||||
|
"step_monetization": "Monetizzazione",
|
||||||
|
"dev_name_title": "Scegli il tuo nome sviluppatore",
|
||||||
|
"dev_name_desc": "Questo è il nome che i giocatori e i proprietari di server vedranno su Pumpkin Market per i tuoi plugin. Può essere diverso dal tuo nome legale e potrai modificarlo in qualsiasi momento.",
|
||||||
|
"name_taken_error": "Questo nome sviluppatore è già in uso. Scegline un altro."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Panoramica",
|
"overview": "Panoramica",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Pagamenti Stripe",
|
"payouts": "Pagamenti Stripe",
|
||||||
"payouts_status": "Stato Stripe Connect",
|
"payouts_status": "Stato Stripe Connect",
|
||||||
"connected": "Collegato",
|
"connected": "Collegato",
|
||||||
"setup_stripe": "Configura Pagamenti Stripe"
|
"setup_stripe": "Configura Pagamenti Stripe",
|
||||||
|
"create_plugin_title": "Crea nuovo plugin",
|
||||||
|
"create_plugin_desc": "Configura la bozza del tuo plugin. Puoi modificare la scheda dello store, caricare build e testare offline prima della pubblicazione.",
|
||||||
|
"btn_create_draft": "Crea bozza e gestisci ->",
|
||||||
|
"status_draft": "Offline (Bozza)",
|
||||||
|
"status_published": "Pubblicato",
|
||||||
|
"btn_publish_store": "Pubblica sullo Store",
|
||||||
|
"btn_unpublish_store": "Metti offline",
|
||||||
|
"publish_checklist_title": "Pubblica sul Marketplace",
|
||||||
|
"publish_checklist_desc": "Verifica i requisiti prima di rilasciare il plugin su Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Tutti i requisiti sono soddisfatti! Il tuo plugin è pronto per la pubblicazione.",
|
||||||
|
"publish_missing_banner": "Completa i requisiti mancanti prima di pubblicare.",
|
||||||
|
"btn_confirm_publish": "Conferma e pubblica sullo Store"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Pubblica un Nuovo Plugin",
|
"title": "Pubblica un Nuovo Plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "開発者ポータル",
|
"badge": "開発者ポータル",
|
||||||
"subtitle_paid": "プラグインを販売・配信するためのクリエイタープロフィールと事業者情報を設定します。",
|
"subtitle_paid": "プラグインを販売・配信するためのクリエイタープロフィールと事業者情報を設定します。",
|
||||||
"subtitle_free": "Pumpkin コミュニティ向けに無料プラグインを公開するためのクリエイタープロフィールを設定します。",
|
"subtitle_free": "Pumpkin コミュニティ向けに無料プラグインを公開するためのクリエイタープロフィールを設定します。",
|
||||||
"step_profile": "1. プロフィール",
|
"step_profile": "プロフィール",
|
||||||
"step_seller": "2. 販売者情報",
|
"step_seller": "販売者情報",
|
||||||
"step_links": "リンク",
|
"step_links": "リンク",
|
||||||
"step_finish": "完了",
|
"step_finish": "完了",
|
||||||
"entity_question": "個人として公開しますか、それとも組織・スタジオとして公開しますか?",
|
"entity_question": "個人として公開しますか、それとも組織・スタジオとして公開しますか?",
|
||||||
"individual_title": "個人クリエイター",
|
"individual_title": "個人クリエイター",
|
||||||
"individual_desc": "個人開発者またはホビー制作者。",
|
"individual_desc": "個人開発者、学生、コミュニティクリエイターに最適です。無料または有料のWebAssemblyプラグインを個人プロフィールで公開できます。",
|
||||||
"org_title": "組織 / スタジオ",
|
"org_title": "組織またはスタジオ",
|
||||||
"org_desc": "企業、チーム、またはスタジオ。",
|
"org_desc": "共同でプラグインを配信する開発チーム、スタジオ、法人に最適です。ブランドの管理や組織向け支払い設定が行えます。",
|
||||||
"display_name_label": "公開開発者 / スタジオ名 *",
|
"display_name_label": "公開開発者 / スタジオ名 *",
|
||||||
"display_name_placeholder": "例: PixelCraft Studios, TanakaDev",
|
"display_name_placeholder": "例: PixelCraft Studios, TanakaDev",
|
||||||
"display_name_hint": "この名前は公開するすべてのプラグインに表示されます。",
|
"display_name_hint": "この名前は公開するすべてのプラグインに表示されます。",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "次へ進む ->",
|
"btn_continue": "次へ進む ->",
|
||||||
"btn_back": "戻る",
|
"btn_back": "戻る",
|
||||||
"btn_complete": "登録を完了する",
|
"btn_complete": "登録を完了する",
|
||||||
"btn_connect_stripe": "保存して Stripe と連携 ->"
|
"btn_connect_stripe": "保存して Stripe と連携 ->",
|
||||||
|
"step_account_type": "アカウントの種類",
|
||||||
|
"org_type_label": "組織の種類:",
|
||||||
|
"org_type_placeholder": "組織の種類を選択 ▾",
|
||||||
|
"btn_get_started": "使ってみる ->",
|
||||||
|
"plan_paid_question": "有料プラグインの公開を予定していますか? *",
|
||||||
|
"option_yes": "はい",
|
||||||
|
"option_no": "いいえ",
|
||||||
|
"step_name": "名前",
|
||||||
|
"step_monetization": "収益化",
|
||||||
|
"dev_name_title": "開発者名を設定してください",
|
||||||
|
"dev_name_desc": "これは、Pumpkin Market 上でプレイヤーやサーバー管理者に公開される名前です。本名と異なっていても問題なく、いつでも変更できます。",
|
||||||
|
"name_taken_error": "この開発者名は既に使用されています。別の名前を選択してください。"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "概要",
|
"overview": "概要",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe 送金",
|
"payouts": "Stripe 送金",
|
||||||
"payouts_status": "Stripe Connect ステータス",
|
"payouts_status": "Stripe Connect ステータス",
|
||||||
"connected": "連携済み",
|
"connected": "連携済み",
|
||||||
"setup_stripe": "Stripe 送金を設定"
|
"setup_stripe": "Stripe 送金を設定",
|
||||||
|
"create_plugin_title": "新規プラグイン作成",
|
||||||
|
"create_plugin_desc": "プラグインの下書きを設定します。公開前にストア掲載情報を編集し、ビルドをアップロードしてテストできます。",
|
||||||
|
"btn_create_draft": "下書きを作成して管理 ->",
|
||||||
|
"status_draft": "オフライン (下書き)",
|
||||||
|
"status_published": "公開済み",
|
||||||
|
"btn_publish_store": "ストアに公開",
|
||||||
|
"btn_unpublish_store": "非公開にする",
|
||||||
|
"publish_checklist_title": "マーケットプレイスに公開",
|
||||||
|
"publish_checklist_desc": "Pumpkin Market にプラグインをリリースする前に必須要件を確認してください。",
|
||||||
|
"publish_ready_banner": "すべての要件を満たしています!プラグインを公開する準備が整いました。",
|
||||||
|
"publish_missing_banner": "公開する前に未完了の要件を完了してください。",
|
||||||
|
"btn_confirm_publish": "確認してストアに公開"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "新しいプラグインの公開",
|
"title": "新しいプラグインの公開",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "개발자 포털",
|
"badge": "개발자 포털",
|
||||||
"subtitle_paid": "플러그인을 출시하고 판매하기 위해 크리에이터 프로필과 판매자 정보를 설정하세요.",
|
"subtitle_paid": "플러그인을 출시하고 판매하기 위해 크리에이터 프로필과 판매자 정보를 설정하세요.",
|
||||||
"subtitle_free": "Pumpkin 커뮤니티에 무료 플러그인을 공개하기 위해 크리에이터 프로필을 설정하세요.",
|
"subtitle_free": "Pumpkin 커뮤니티에 무료 플러그인을 공개하기 위해 크리에이터 프로필을 설정하세요.",
|
||||||
"step_profile": "1. 프로필",
|
"step_profile": "프로필",
|
||||||
"step_seller": "2. 판매자 정보",
|
"step_seller": "판매자 정보",
|
||||||
"step_links": "소셜 링크",
|
"step_links": "소셜 링크",
|
||||||
"step_finish": "완료",
|
"step_finish": "완료",
|
||||||
"entity_question": "개인 자격으로 활동하십니까, 아니면 스튜디오/법인입니까?",
|
"entity_question": "개인 자격으로 활동하십니까, 아니면 스튜디오/법인입니까?",
|
||||||
"individual_title": "개인 크리에이터",
|
"individual_title": "개인 크리에이터",
|
||||||
"individual_desc": "독립 개발자 또는 개인 개발자.",
|
"individual_desc": "개인 개발자, 학생 및 취미 개발자에게 적합합니다. 개인 프로필을 통해 무료 또는 유료 WebAssembly 플러그인을 배포할 수 있습니다.",
|
||||||
"org_title": "기업 / 스튜디오",
|
"org_title": "조직 또는 스튜디오",
|
||||||
"org_desc": "회사, 팀 또는 스튜디오.",
|
"org_desc": "플러그인을 함께 배포하는 개발팀, 스튜디오 또는 기업에 적합합니다. 브랜드 아이덴티티와 조직 정산을 관리하세요.",
|
||||||
"display_name_label": "개발자 / 스튜디오 공개 표시 이름 *",
|
"display_name_label": "개발자 / 스튜디오 공개 표시 이름 *",
|
||||||
"display_name_placeholder": "예: PixelCraft Studios, KimDev",
|
"display_name_placeholder": "예: PixelCraft Studios, KimDev",
|
||||||
"display_name_hint": "이 이름은 출시하는 모든 플러그인에 공개적으로 표시됩니다.",
|
"display_name_hint": "이 이름은 출시하는 모든 플러그인에 공개적으로 표시됩니다.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "계속 ->",
|
"btn_continue": "계속 ->",
|
||||||
"btn_back": "이전",
|
"btn_back": "이전",
|
||||||
"btn_complete": "온보딩 완료",
|
"btn_complete": "온보딩 완료",
|
||||||
"btn_connect_stripe": "저장 및 Stripe 연결 ->"
|
"btn_connect_stripe": "저장 및 Stripe 연결 ->",
|
||||||
|
"step_account_type": "계정 유형",
|
||||||
|
"org_type_label": "조직 유형:",
|
||||||
|
"org_type_placeholder": "조직 유형을 선택하세요 ▾",
|
||||||
|
"btn_get_started": "시작하기 ->",
|
||||||
|
"plan_paid_question": "유료 플러그인을 배포할 계획이 있으신가요? *",
|
||||||
|
"option_yes": "예",
|
||||||
|
"option_no": "아니요",
|
||||||
|
"step_name": "이름",
|
||||||
|
"step_monetization": "수익 창출",
|
||||||
|
"dev_name_title": "개발자 이름을 설정하세요",
|
||||||
|
"dev_name_desc": "플레이어와 서버 관리자가 Pumpkin Market에서 귀하의 플러그인을 볼 때 표시되는 공개 이름입니다. 실명과 달라도 되며 언제든지 변경할 수 있습니다.",
|
||||||
|
"name_taken_error": "이 개발자 이름은 이미 사용 중입니다. 다른 이름을 선택하세요."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "개요",
|
"overview": "개요",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe 정산",
|
"payouts": "Stripe 정산",
|
||||||
"payouts_status": "Stripe Connect 상태",
|
"payouts_status": "Stripe Connect 상태",
|
||||||
"connected": "연결됨",
|
"connected": "연결됨",
|
||||||
"setup_stripe": "Stripe 정산 설정"
|
"setup_stripe": "Stripe 정산 설정",
|
||||||
|
"create_plugin_title": "새 플러그인 만들기",
|
||||||
|
"create_plugin_desc": "플러그인 초안을 생성합니다. 게시 전에 스토어 등록정보를 편집하고 빌드를 업로드하여 오프라인으로 테스트할 수 있습니다.",
|
||||||
|
"btn_create_draft": "초안 생성 및 관리 ->",
|
||||||
|
"status_draft": "오프라인 (초안)",
|
||||||
|
"status_published": "게시됨",
|
||||||
|
"btn_publish_store": "스토어에 게시",
|
||||||
|
"btn_unpublish_store": "게시 취소 (오프라인)",
|
||||||
|
"publish_checklist_title": "마켓플레이스에 게시",
|
||||||
|
"publish_checklist_desc": "Pumpkin Market에 플러그인을 출시하기 전에 요구사항을 검토하세요.",
|
||||||
|
"publish_ready_banner": "모든 요구사항을 충족했습니다! 플러그인을 출시할 준비가 완료되었습니다.",
|
||||||
|
"publish_missing_banner": "게시하기 전에 누락된 요구사항을 완료해 주세요.",
|
||||||
|
"btn_confirm_publish": "확인 및 스토어에 출시"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "새 플러그인 출시",
|
"title": "새 플러그인 출시",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Ontwikkelaarsportaal",
|
"badge": "Ontwikkelaarsportaal",
|
||||||
"subtitle_paid": "Stel je creatorprofiel en verkopersgegevens in om plugins te publiceren en verkopen.",
|
"subtitle_paid": "Stel je creatorprofiel en verkopersgegevens in om plugins te publiceren en verkopen.",
|
||||||
"subtitle_free": "Stel je creatorprofiel in om gratis plugins te delen met de Pumpkin-community.",
|
"subtitle_free": "Stel je creatorprofiel in om gratis plugins te delen met de Pumpkin-community.",
|
||||||
"step_profile": "1. Profiel",
|
"step_profile": "Profiel",
|
||||||
"step_seller": "2. Verkopersgegevens",
|
"step_seller": "Verkopergegevens",
|
||||||
"step_links": "Links",
|
"step_links": "Links",
|
||||||
"step_finish": "Afronden",
|
"step_finish": "Afronden",
|
||||||
"entity_question": "Publiceer je als individu of als organisatie?",
|
"entity_question": "Publiceer je als individu of als organisatie?",
|
||||||
"individual_title": "Individuele Maker",
|
"individual_title": "Individuele maker",
|
||||||
"individual_desc": "Zelfstandige ontwikkelaar of hobbyist.",
|
"individual_desc": "Ideaal voor zelfstandige ontwikkelaars, studenten en hobbyisten. Publiceer gratis of betaalde WebAssembly-plugins onder je persoonlijke profiel.",
|
||||||
"org_title": "Organisatie / Studio",
|
"org_title": "Organisatie of Studio",
|
||||||
"org_desc": "Bedrijf, team of ontwikkelstudio.",
|
"org_desc": "Ideaal voor ontwikkelingsteams, studio's of bedrijven die samen plugins publiceren. Beheer je merkidentiteit en zakelijke uitbetalingen.",
|
||||||
"display_name_label": "Openbare Ontwikkelaars- / Studionaam *",
|
"display_name_label": "Openbare Ontwikkelaars- / Studionaam *",
|
||||||
"display_name_placeholder": "bijv. PixelCraft Studios, JanDev",
|
"display_name_placeholder": "bijv. PixelCraft Studios, JanDev",
|
||||||
"display_name_hint": "Deze naam wordt openbaar getoond op al je gepubliceerde plugins.",
|
"display_name_hint": "Deze naam wordt openbaar getoond op al je gepubliceerde plugins.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Doorgaan ->",
|
"btn_continue": "Doorgaan ->",
|
||||||
"btn_back": "Terug",
|
"btn_back": "Terug",
|
||||||
"btn_complete": "Registratie Voltooien",
|
"btn_complete": "Registratie Voltooien",
|
||||||
"btn_connect_stripe": "Opslaan en Stripe Koppelen ->"
|
"btn_connect_stripe": "Opslaan en Stripe Koppelen ->",
|
||||||
|
"step_account_type": "Accounttype",
|
||||||
|
"org_type_label": "Organisatietype:",
|
||||||
|
"org_type_placeholder": "Selecteer je organisatietype ▾",
|
||||||
|
"btn_get_started": "Aan de slag ->",
|
||||||
|
"plan_paid_question": "Ben je van plan betaalde plugins te publiceren? *",
|
||||||
|
"option_yes": "Ja",
|
||||||
|
"option_no": "Nee",
|
||||||
|
"step_name": "Naam",
|
||||||
|
"step_monetization": "Monetisatie",
|
||||||
|
"dev_name_title": "Kies je ontwikkelaarsnaam",
|
||||||
|
"dev_name_desc": "Dit is de naam die spelers en servereigenaren op Pumpkin Market zien bij je plugins. Deze mag afwijken van je officiële naam en kan op elk moment worden gewijzigd.",
|
||||||
|
"name_taken_error": "Deze ontwikkelaarsnaam is al in gebruik. Kies een andere naam."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Overzicht",
|
"overview": "Overzicht",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe Uitbetalingen",
|
"payouts": "Stripe Uitbetalingen",
|
||||||
"payouts_status": "Stripe Connect Status",
|
"payouts_status": "Stripe Connect Status",
|
||||||
"connected": "Verbonden",
|
"connected": "Verbonden",
|
||||||
"setup_stripe": "Stripe Uitbetalingen Instellen"
|
"setup_stripe": "Stripe Uitbetalingen Instellen",
|
||||||
|
"create_plugin_title": "Nieuwe plug-in maken",
|
||||||
|
"create_plugin_desc": "Stel je concept-plug-in in. Je kunt de winkelvermelding bewerken, builds uploaden en offline testen voordat je publiceert.",
|
||||||
|
"btn_create_draft": "Concept maken & beheren ->",
|
||||||
|
"status_draft": "Offline (Concept)",
|
||||||
|
"status_published": "Gepubliceerd",
|
||||||
|
"btn_publish_store": "Publiceren in winkel",
|
||||||
|
"btn_unpublish_store": "Offline halen",
|
||||||
|
"publish_checklist_title": "Publiceren op marktplaats",
|
||||||
|
"publish_checklist_desc": "Controleer de vereisten voordat je je plug-in uitbrengt op Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Aan alle vereisten is voldaan! Je plug-in is klaar voor publicatie.",
|
||||||
|
"publish_missing_banner": "Voldoe aan de ontbrekende vereisten voordat je publiceert.",
|
||||||
|
"btn_confirm_publish": "Bevestigen & in winkel uitbrengen"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Nieuwe Plugin Publiceren",
|
"title": "Nieuwe Plugin Publiceren",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Panel Dewelopera",
|
"badge": "Panel Dewelopera",
|
||||||
"subtitle_paid": "Skonfiguruj profil twórcy i dane sprzedawcy, aby publikować i sprzedawać pluginy.",
|
"subtitle_paid": "Skonfiguruj profil twórcy i dane sprzedawcy, aby publikować i sprzedawać pluginy.",
|
||||||
"subtitle_free": "Skonfiguruj profil twórcy, aby publikować darmowe pluginy dla społeczności Pumpkin.",
|
"subtitle_free": "Skonfiguruj profil twórcy, aby publikować darmowe pluginy dla społeczności Pumpkin.",
|
||||||
"step_profile": "1. Profil",
|
"step_profile": "Profil",
|
||||||
"step_seller": "2. Dane Sprzedawcy",
|
"step_seller": "Dane sprzedawcy",
|
||||||
"step_links": "Linki",
|
"step_links": "Linki",
|
||||||
"step_finish": "Koniec",
|
"step_finish": "Koniec",
|
||||||
"entity_question": "Publikujesz jako osoba prywatna czy organizacja?",
|
"entity_question": "Publikujesz jako osoba prywatna czy organizacja?",
|
||||||
"individual_title": "Niezależny Twórca",
|
"individual_title": "Indywidualny twórca",
|
||||||
"individual_desc": "Niezależny deweloper lub hobbysta.",
|
"individual_desc": "Idealne dla niezależnych programistów, studentów i pasjonatów. Publikuj darmowe lub płatne pluginy WebAssembly pod własnym profilem.",
|
||||||
"org_title": "Organizacja / Studio",
|
"org_title": "Organizacja lub Studio",
|
||||||
"org_desc": "Firma, zespół lub studio.",
|
"org_desc": "Idealne dla zespołów deweloperskich, studiów lub firm wspólnie publikujących pluginy. Zarządzaj marką i wypłatami organizacji.",
|
||||||
"display_name_label": "Publiczna Nazwa Dewelopera / Studia *",
|
"display_name_label": "Publiczna Nazwa Dewelopera / Studia *",
|
||||||
"display_name_placeholder": "np. PixelCraft Studios, JanDev",
|
"display_name_placeholder": "np. PixelCraft Studios, JanDev",
|
||||||
"display_name_hint": "Ta nazwa będzie widoczna publicznie przy wszystkich Twoich pluginach.",
|
"display_name_hint": "Ta nazwa będzie widoczna publicznie przy wszystkich Twoich pluginach.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Dalej ->",
|
"btn_continue": "Dalej ->",
|
||||||
"btn_back": "Wstecz",
|
"btn_back": "Wstecz",
|
||||||
"btn_complete": "Zakończ Rejestrację",
|
"btn_complete": "Zakończ Rejestrację",
|
||||||
"btn_connect_stripe": "Zapisz i Połącz Stripe ->"
|
"btn_connect_stripe": "Zapisz i Połącz Stripe ->",
|
||||||
|
"step_account_type": "Typ konta",
|
||||||
|
"org_type_label": "Typ organizacji:",
|
||||||
|
"org_type_placeholder": "Wybierz typ organizacji ▾",
|
||||||
|
"btn_get_started": "Rozpocznij ->",
|
||||||
|
"plan_paid_question": "Czy planujesz publikować płatne pluginy? *",
|
||||||
|
"option_yes": "Tak",
|
||||||
|
"option_no": "Nie",
|
||||||
|
"step_name": "Nazwa",
|
||||||
|
"step_monetization": "Monetyzacja",
|
||||||
|
"dev_name_title": "Wybierz swoją nazwę dewelopera",
|
||||||
|
"dev_name_desc": "To jest nazwa, którą gracze i właściciele serwerów zobaczą na Pumpkin Market przy Twoich pluginach. Może różnić się od Twojego oficjalnego nazwiska i możesz ją zmienić w dowolnym momencie.",
|
||||||
|
"name_taken_error": "Ta nazwa dewelopera jest już zajęta. Wybierz inną."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Przegląd",
|
"overview": "Przegląd",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Wypłaty Stripe",
|
"payouts": "Wypłaty Stripe",
|
||||||
"payouts_status": "Status Stripe Connect",
|
"payouts_status": "Status Stripe Connect",
|
||||||
"connected": "Połączono",
|
"connected": "Połączono",
|
||||||
"setup_stripe": "Skonfiguruj Wypłaty Stripe"
|
"setup_stripe": "Skonfiguruj Wypłaty Stripe",
|
||||||
|
"create_plugin_title": "Utwórz nowy plugin",
|
||||||
|
"create_plugin_desc": "Skonfiguruj wersję roboczą pluginu. Możesz edytować stronę w sklepie, wgrywać kompilacje i testować offline przed publikacją.",
|
||||||
|
"btn_create_draft": "Utwórz szkic i zarządzaj ->",
|
||||||
|
"status_draft": "Offline (Szkic)",
|
||||||
|
"status_published": "Opublikowany",
|
||||||
|
"btn_publish_store": "Opublikuj w sklepie",
|
||||||
|
"btn_unpublish_store": "Wyłącz ze sklepu",
|
||||||
|
"publish_checklist_title": "Publikacja na rynku",
|
||||||
|
"publish_checklist_desc": "Sprawdź wymagania przed publikacją pluginu na Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Wszystkie wymagania spełnione! Twój plugin jest gotowy do publikacji.",
|
||||||
|
"publish_missing_banner": "Uzupełnij brakujące wymagania przed publikacją.",
|
||||||
|
"btn_confirm_publish": "Potwierdź i opublikuj w sklepie"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Opublikuj Nowy Plugin",
|
"title": "Opublikuj Nowy Plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Portal do Desenvolvedor",
|
"badge": "Portal do Desenvolvedor",
|
||||||
"subtitle_paid": "Configure seu perfil de criador e dados fiscais para publicar e vender plugins.",
|
"subtitle_paid": "Configure seu perfil de criador e dados fiscais para publicar e vender plugins.",
|
||||||
"subtitle_free": "Configure seu perfil de criador para publicar plugins gratuitos para a comunidade Pumpkin.",
|
"subtitle_free": "Configure seu perfil de criador para publicar plugins gratuitos para a comunidade Pumpkin.",
|
||||||
"step_profile": "1. Perfil",
|
"step_profile": "Perfil",
|
||||||
"step_seller": "2. Dados de Vendedor",
|
"step_seller": "Informações do vendedor",
|
||||||
"step_links": "Links",
|
"step_links": "Links",
|
||||||
"step_finish": "Finalizar",
|
"step_finish": "Finalizar",
|
||||||
"entity_question": "Você publica como pessoa física ou organização?",
|
"entity_question": "Você publica como pessoa física ou organização?",
|
||||||
"individual_title": "Criador Individual",
|
"individual_title": "Criador Individual",
|
||||||
"individual_desc": "Desenvolvedor independente ou entusiasta.",
|
"individual_desc": "Ideal para desenvolvedores independentes, estudantes e criadores amadores. Publique plugins WebAssembly gratuitos ou premium em seu perfil pessoal.",
|
||||||
"org_title": "Organização / Estúdio",
|
"org_title": "Organização ou Estúdio",
|
||||||
"org_desc": "Empresa, equipe ou estúdio.",
|
"org_desc": "Ideal para equipes de desenvolvimento, estúdios ou empresas que publicam plugins juntos. Gerencie a identidade da marca e os pagamentos corporativos.",
|
||||||
"display_name_label": "Nome Público de Desenvolvedor / Estúdio *",
|
"display_name_label": "Nome Público de Desenvolvedor / Estúdio *",
|
||||||
"display_name_placeholder": "ex: PixelCraft Studios, DevAlex",
|
"display_name_placeholder": "ex: PixelCraft Studios, DevAlex",
|
||||||
"display_name_hint": "Este nome será visível publicamente em todos os seus plugins.",
|
"display_name_hint": "Este nome será visível publicamente em todos os seus plugins.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Continuar ->",
|
"btn_continue": "Continuar ->",
|
||||||
"btn_back": "Voltar",
|
"btn_back": "Voltar",
|
||||||
"btn_complete": "Concluir Cadastro",
|
"btn_complete": "Concluir Cadastro",
|
||||||
"btn_connect_stripe": "Salvar e Conectar Stripe ->"
|
"btn_connect_stripe": "Salvar e Conectar Stripe ->",
|
||||||
|
"step_account_type": "Tipo de conta",
|
||||||
|
"org_type_label": "Tipo de organização:",
|
||||||
|
"org_type_placeholder": "Selecione o tipo de organização ▾",
|
||||||
|
"btn_get_started": "Começar ->",
|
||||||
|
"plan_paid_question": "Você planeja publicar plugins pagos? *",
|
||||||
|
"option_yes": "Sim",
|
||||||
|
"option_no": "Não",
|
||||||
|
"step_name": "Nome",
|
||||||
|
"step_monetization": "Monetização",
|
||||||
|
"dev_name_title": "Escolha o seu nome de desenvolvedor",
|
||||||
|
"dev_name_desc": "Este é o nome que jogadores e administradores verão no Pumpkin Market nos seus plugins. Pode ser diferente do seu nome legal e pode ser alterado a qualquer momento.",
|
||||||
|
"name_taken_error": "Este nome de desenvolvedor já está em uso. Por favor, escolha outro."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Visão Geral",
|
"overview": "Visão Geral",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Repasses Stripe",
|
"payouts": "Repasses Stripe",
|
||||||
"payouts_status": "Status do Stripe Connect",
|
"payouts_status": "Status do Stripe Connect",
|
||||||
"connected": "Conectado",
|
"connected": "Conectado",
|
||||||
"setup_stripe": "Configurar Repasses Stripe"
|
"setup_stripe": "Configurar Repasses Stripe",
|
||||||
|
"create_plugin_title": "Criar novo plugin",
|
||||||
|
"create_plugin_desc": "Configure seu rascunho de plugin. Você pode editar a página da loja, enviar compilações e testar offline antes de publicar.",
|
||||||
|
"btn_create_draft": "Criar rascunho e gerenciar ->",
|
||||||
|
"status_draft": "Offline (Rascunho)",
|
||||||
|
"status_published": "Publicado",
|
||||||
|
"btn_publish_store": "Publicar na loja",
|
||||||
|
"btn_unpublish_store": "Tirar do ar",
|
||||||
|
"publish_checklist_title": "Publicar no Marketplace",
|
||||||
|
"publish_checklist_desc": "Revise os requisitos antes de lançar seu plugin no Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Todos os requisitos foram atendidos! Seu plugin está pronto para ser lançado.",
|
||||||
|
"publish_missing_banner": "Por favor, complete os requisitos pendentes antes de publicar.",
|
||||||
|
"btn_confirm_publish": "Confirmar e lançar na loja"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Publicar Novo Plugin",
|
"title": "Publicar Novo Plugin",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Портал разработчика",
|
"badge": "Портал разработчика",
|
||||||
"subtitle_paid": "Настройте профиль автора и данные продавца для публикации и продажи плагинов.",
|
"subtitle_paid": "Настройте профиль автора и данные продавца для публикации и продажи плагинов.",
|
||||||
"subtitle_free": "Настройте профиль автора для публикации бесплатных плагинов для сообщества Pumpkin.",
|
"subtitle_free": "Настройте профиль автора для публикации бесплатных плагинов для сообщества Pumpkin.",
|
||||||
"step_profile": "1. Профиль",
|
"step_profile": "Профиль",
|
||||||
"step_seller": "2. Данные продавца",
|
"step_seller": "Данные продавца",
|
||||||
"step_links": "Ссылки",
|
"step_links": "Ссылки",
|
||||||
"step_finish": "Завершение",
|
"step_finish": "Завершение",
|
||||||
"entity_question": "Вы публикуете плагины как физлицо или организация?",
|
"entity_question": "Вы публикуете плагины как физлицо или организация?",
|
||||||
"individual_title": "Индивидуальный автор",
|
"individual_title": "Индивидуальный разработчик",
|
||||||
"individual_desc": "Независимый разработчик или энтузиаст.",
|
"individual_desc": "Идеально для независимых разработчиков, студентов и энтузиастов. Публикуйте бесплатные или платные WebAssembly-плагины под личным профилем.",
|
||||||
"org_title": "Организация / Студия",
|
"org_title": "Организация или студия",
|
||||||
"org_desc": "Компания, команда или студия.",
|
"org_desc": "Идеально для команд разработчиков, студий или компаний, публикующих плагины вместе. Управляйте брендом и выплатами организации.",
|
||||||
"display_name_label": "Публичное имя разработчика / студии *",
|
"display_name_label": "Публичное имя разработчика / студии *",
|
||||||
"display_name_placeholder": "напр. PixelCraft Studios, AlexDev",
|
"display_name_placeholder": "напр. PixelCraft Studios, AlexDev",
|
||||||
"display_name_hint": "Это имя будет отображаться на всех ваших опубликованных плагинах.",
|
"display_name_hint": "Это имя будет отображаться на всех ваших опубликованных плагинах.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Продолжить ->",
|
"btn_continue": "Продолжить ->",
|
||||||
"btn_back": "Назад",
|
"btn_back": "Назад",
|
||||||
"btn_complete": "Завершить регистрацию",
|
"btn_complete": "Завершить регистрацию",
|
||||||
"btn_connect_stripe": "Сохранить и подключить Stripe ->"
|
"btn_connect_stripe": "Сохранить и подключить Stripe ->",
|
||||||
|
"step_account_type": "Тип аккаунта",
|
||||||
|
"org_type_label": "Тип организации:",
|
||||||
|
"org_type_placeholder": "Выберите тип организации ▾",
|
||||||
|
"btn_get_started": "Начать ->",
|
||||||
|
"plan_paid_question": "Планируете ли вы публиковать платные плагины? *",
|
||||||
|
"option_yes": "Да",
|
||||||
|
"option_no": "Нет",
|
||||||
|
"step_name": "Имя",
|
||||||
|
"step_monetization": "Монетизация",
|
||||||
|
"dev_name_title": "Выберите имя разработчика",
|
||||||
|
"dev_name_desc": "Это имя игроки и владельцы серверов увидят в Pumpkin Market на ваших плагинах. Оно может отличаться от юридического имени, и его можно изменить в любое время.",
|
||||||
|
"name_taken_error": "Это имя разработчика уже занято. Пожалуйста, выберите другое."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Обзор",
|
"overview": "Обзор",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Выплаты Stripe",
|
"payouts": "Выплаты Stripe",
|
||||||
"payouts_status": "Статус Stripe Connect",
|
"payouts_status": "Статус Stripe Connect",
|
||||||
"connected": "Подключено",
|
"connected": "Подключено",
|
||||||
"setup_stripe": "Настроить выплаты Stripe"
|
"setup_stripe": "Настроить выплаты Stripe",
|
||||||
|
"create_plugin_title": "Создать новый плагин",
|
||||||
|
"create_plugin_desc": "Создайте черновик плагина. Вы можете настроить страницу в магазине, загрузить сборки и протестировать перед публикацией.",
|
||||||
|
"btn_create_draft": "Создать черновик и перейти ->",
|
||||||
|
"status_draft": "Оффлайн (Черновик)",
|
||||||
|
"status_published": "Опубликован",
|
||||||
|
"btn_publish_store": "Опубликовать в магазине",
|
||||||
|
"btn_unpublish_store": "Снять с публикации",
|
||||||
|
"publish_checklist_title": "Публикация на торговой площадке",
|
||||||
|
"publish_checklist_desc": "Проверьте требования перед выпуском плагина в Pumpkin Market.",
|
||||||
|
"publish_ready_banner": "Все требования выполнены! Ваш плагин готов к публикации.",
|
||||||
|
"publish_missing_banner": "Пожалуйста, выполните недостающие требования перед публикацией.",
|
||||||
|
"btn_confirm_publish": "Подтвердить и выпустить в магазин"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Публикация нового плагина",
|
"title": "Публикация нового плагина",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "Geliştirici Portalı",
|
"badge": "Geliştirici Portalı",
|
||||||
"subtitle_paid": "Eklenti yayınlamak ve satmak için üretici profilinizi ve satıcı bilgilerinizi ayarlayın.",
|
"subtitle_paid": "Eklenti yayınlamak ve satmak için üretici profilinizi ve satıcı bilgilerinizi ayarlayın.",
|
||||||
"subtitle_free": "Pumpkin topluluğu için ücretsiz eklentiler yayınlamak amacıyla profilinizi oluşturun.",
|
"subtitle_free": "Pumpkin topluluğu için ücretsiz eklentiler yayınlamak amacıyla profilinizi oluşturun.",
|
||||||
"step_profile": "1. Profil",
|
"step_profile": "Profil",
|
||||||
"step_seller": "2. Satıcı Bilgileri",
|
"step_seller": "Satıcı bilgileri",
|
||||||
"step_links": "Bağlantılar",
|
"step_links": "Bağlantılar",
|
||||||
"step_finish": "Tamamla",
|
"step_finish": "Tamamla",
|
||||||
"entity_question": "Bireysel mi yoksa kuruluş olarak mı yayınlıyorsunuz?",
|
"entity_question": "Bireysel mi yoksa kuruluş olarak mı yayınlıyorsunuz?",
|
||||||
"individual_title": "Bireysel Geliştirici",
|
"individual_title": "Bireysel Geliştirici",
|
||||||
"individual_desc": "Bağımsız geliştirici veya hobi üreticisi.",
|
"individual_desc": "Bağımsız geliştiriciler, öğrenciler ve hobi amaçlı içerik üreticileri için uygundur. Ücretsiz veya ücretli WebAssembly eklentilerini kişisel profilinizle yayınlayın.",
|
||||||
"org_title": "Kuruluş / Stüdyo",
|
"org_title": "Kuruluş veya Stüdyo",
|
||||||
"org_desc": "Şirket, ekip veya stüdyo.",
|
"org_desc": "Birlikte eklenti yayınlayan geliştirici ekipleri, stüdyolar veya şirketler için uygundur. Marka kimliğinizi ve kurumsal ödemelerinizi yönetin.",
|
||||||
"display_name_label": "Geliştirici / Stüdyo Görünen Adı *",
|
"display_name_label": "Geliştirici / Stüdyo Görünen Adı *",
|
||||||
"display_name_placeholder": "örn. PixelCraft Studios, CanDev",
|
"display_name_placeholder": "örn. PixelCraft Studios, CanDev",
|
||||||
"display_name_hint": "Bu isim yayınladığınız tüm eklentilerde herkese açık olarak görünecektir.",
|
"display_name_hint": "Bu isim yayınladığınız tüm eklentilerde herkese açık olarak görünecektir.",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "Devam Et ->",
|
"btn_continue": "Devam Et ->",
|
||||||
"btn_back": "Geri",
|
"btn_back": "Geri",
|
||||||
"btn_complete": "Kaydı Tamamla",
|
"btn_complete": "Kaydı Tamamla",
|
||||||
"btn_connect_stripe": "Kaydet ve Stripe'a Bağla ->"
|
"btn_connect_stripe": "Kaydet ve Stripe'a Bağla ->",
|
||||||
|
"step_account_type": "Hesap türü",
|
||||||
|
"org_type_label": "Kuruluş türü:",
|
||||||
|
"org_type_placeholder": "Kuruluş türünüzü seçin ▾",
|
||||||
|
"btn_get_started": "Başlayın ->",
|
||||||
|
"plan_paid_question": "Ücretli eklentiler yayınlamayı planlıyor musunuz? *",
|
||||||
|
"option_yes": "Evet",
|
||||||
|
"option_no": "Hayır",
|
||||||
|
"step_name": "Ad",
|
||||||
|
"step_monetization": "Para Kazanma",
|
||||||
|
"dev_name_title": "Geliştirici adınızı seçin",
|
||||||
|
"dev_name_desc": "Bu isim, oyuncuların ve sunucu sahiplerinin Pumpkin Market üzerinde eklentilerinizde göreceği isimdir. Yasal adınızdan farklı olabilir ve istediğiniz zaman değiştirebilirsiniz.",
|
||||||
|
"name_taken_error": "Bu geliştirici adı zaten alınmış. Lütfen başka bir ad seçin."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "Genel Bakış",
|
"overview": "Genel Bakış",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe Ödemeleri",
|
"payouts": "Stripe Ödemeleri",
|
||||||
"payouts_status": "Stripe Connect Durumu",
|
"payouts_status": "Stripe Connect Durumu",
|
||||||
"connected": "Bağlandı",
|
"connected": "Bağlandı",
|
||||||
"setup_stripe": "Stripe Ödemelerini Ayarla"
|
"setup_stripe": "Stripe Ödemelerini Ayarla",
|
||||||
|
"create_plugin_title": "Yeni Eklenti Oluştur",
|
||||||
|
"create_plugin_desc": "Eklenti taslağınızı oluşturun. Yayınlamadan önce mağaza sayfasını düzenleyebilir, derlemeleri yükleyebilir ve çevrimdışı test edebilirsiniz.",
|
||||||
|
"btn_create_draft": "Taslak Oluştur & Yönet ->",
|
||||||
|
"status_draft": "Çevrimdışı (Taslak)",
|
||||||
|
"status_published": "Yayınlandı",
|
||||||
|
"btn_publish_store": "Mağazada Yayınla",
|
||||||
|
"btn_unpublish_store": "Yayından Kaldır",
|
||||||
|
"publish_checklist_title": "Pazaryerinde Yayınla",
|
||||||
|
"publish_checklist_desc": "Eklentinizi Pumpkin Market'te yayınlamadan önce gereksinimleri kontrol edin.",
|
||||||
|
"publish_ready_banner": "Tüm gereksinimler karşılandı! Eklentiniz yayına hazır.",
|
||||||
|
"publish_missing_banner": "Lütfen yayınlamadan önce eksik gereksinimleri tamamlayın.",
|
||||||
|
"btn_confirm_publish": "Onayla ve Mağazada Yayınla"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "Yeni Eklenti Yayınla",
|
"title": "Yeni Eklenti Yayınla",
|
||||||
|
|||||||
@ -112,15 +112,15 @@
|
|||||||
"badge": "开发者门户",
|
"badge": "开发者门户",
|
||||||
"subtitle_paid": "设置您的创作者档案和商家信息以发布并销售插件。",
|
"subtitle_paid": "设置您的创作者档案和商家信息以发布并销售插件。",
|
||||||
"subtitle_free": "设置您的创作者档案以为 Pumpkin 社区发布免费开源插件。",
|
"subtitle_free": "设置您的创作者档案以为 Pumpkin 社区发布免费开源插件。",
|
||||||
"step_profile": "1. 基本档案",
|
"step_profile": "个人资料",
|
||||||
"step_seller": "2. 商家信息",
|
"step_seller": "卖家信息",
|
||||||
"step_links": "社交链接",
|
"step_links": "社交链接",
|
||||||
"step_finish": "完成",
|
"step_finish": "完成",
|
||||||
"entity_question": "您是以个人还是组织/工作室名义发布?",
|
"entity_question": "您是以个人还是组织/工作室名义发布?",
|
||||||
"individual_title": "独立创作者",
|
"individual_title": "个人创作者",
|
||||||
"individual_desc": "独立开发者或个人爱好者。",
|
"individual_desc": "适合独立开发者、学生和社区爱好者。以个人身份发布免费或付费 WebAssembly 插件并获取收益。",
|
||||||
"org_title": "组织 / 工作室",
|
"org_title": "组织或工作室",
|
||||||
"org_desc": "公司、团队或工作室。",
|
"org_desc": "适合共同发布插件的开发团队、工作室或注册企业。管理团队品牌标识与组织结算。",
|
||||||
"display_name_label": "开发者 / 工作室公开显示名称 *",
|
"display_name_label": "开发者 / 工作室公开显示名称 *",
|
||||||
"display_name_placeholder": "例如:PixelCraft Studios, AlexDev",
|
"display_name_placeholder": "例如:PixelCraft Studios, AlexDev",
|
||||||
"display_name_hint": "此名称将公开显示在您发布的所有插件页面上。",
|
"display_name_hint": "此名称将公开显示在您发布的所有插件页面上。",
|
||||||
@ -144,7 +144,19 @@
|
|||||||
"btn_continue": "继续 ->",
|
"btn_continue": "继续 ->",
|
||||||
"btn_back": "返回",
|
"btn_back": "返回",
|
||||||
"btn_complete": "完成注册",
|
"btn_complete": "完成注册",
|
||||||
"btn_connect_stripe": "保存并绑定 Stripe ->"
|
"btn_connect_stripe": "保存并绑定 Stripe ->",
|
||||||
|
"step_account_type": "账户类型",
|
||||||
|
"org_type_label": "组织类型:",
|
||||||
|
"org_type_placeholder": "选择您的组织类型 ▾",
|
||||||
|
"btn_get_started": "立即开始 ->",
|
||||||
|
"plan_paid_question": "您是否计划发布付费插件? *",
|
||||||
|
"option_yes": "是",
|
||||||
|
"option_no": "否",
|
||||||
|
"step_name": "名称",
|
||||||
|
"step_monetization": "变现设置",
|
||||||
|
"dev_name_title": "设置您的开发者名称",
|
||||||
|
"dev_name_desc": "这是玩家和服主在 Pumpkin Market 上浏览您的插件时看到的公开名称。它无需与您的真实姓名相同,且日后随时可以修改。",
|
||||||
|
"name_taken_error": "此开发者名称已被占用,请选择其他名称。"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"overview": "数据概览",
|
"overview": "数据概览",
|
||||||
@ -163,7 +175,19 @@
|
|||||||
"payouts": "Stripe 结算",
|
"payouts": "Stripe 结算",
|
||||||
"payouts_status": "Stripe Connect 状态",
|
"payouts_status": "Stripe Connect 状态",
|
||||||
"connected": "已连接",
|
"connected": "已连接",
|
||||||
"setup_stripe": "设置 Stripe 结算账户"
|
"setup_stripe": "设置 Stripe 结算账户",
|
||||||
|
"create_plugin_title": "创建新插件",
|
||||||
|
"create_plugin_desc": "设置您的插件草稿。您可以在发布前配置商店页面、上传构建文件并进行离线测试。",
|
||||||
|
"btn_create_draft": "创建草稿并管理 ->",
|
||||||
|
"status_draft": "离线 (草稿)",
|
||||||
|
"status_published": "已发布",
|
||||||
|
"btn_publish_store": "发布至商店",
|
||||||
|
"btn_unpublish_store": "下架离线",
|
||||||
|
"publish_checklist_title": "发布至市场",
|
||||||
|
"publish_checklist_desc": "在将插件发布到 Pumpkin Market 前检查所有发布要求。",
|
||||||
|
"publish_ready_banner": "所有要求均已达成!您的插件已准备好上线发布。",
|
||||||
|
"publish_missing_banner": "请在发布前完善所有缺失的要求。",
|
||||||
|
"btn_confirm_publish": "确认并正式发布至商店"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"title": "发布新插件",
|
"title": "发布新插件",
|
||||||
|
|||||||
17
src/main.tsx
17
src/main.tsx
@ -1,20 +1,7 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import '@fontsource/inter/400.css'
|
import '@fontsource-variable/inter'
|
||||||
import '@fontsource/inter/500.css'
|
import '@fontsource-variable/jetbrains-mono'
|
||||||
import '@fontsource/inter/600.css'
|
|
||||||
import '@fontsource/inter/700.css'
|
|
||||||
import '@fontsource/inter/800.css'
|
|
||||||
import '@fontsource/inter/900.css'
|
|
||||||
import '@fontsource/jetbrains-mono/700.css'
|
|
||||||
import '@fontsource/nunito/400.css'
|
|
||||||
import '@fontsource/nunito/500.css'
|
|
||||||
import '@fontsource/nunito/600.css'
|
|
||||||
import '@fontsource/nunito/700.css'
|
|
||||||
import '@fontsource/nunito/800.css'
|
|
||||||
import '@fontsource/dm-sans/400.css'
|
|
||||||
import '@fontsource/dm-sans/500.css'
|
|
||||||
import '@fontsource/dm-sans/700.css'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import SEO from '../components/SEO';
|
import SEO from '../components/SEO';
|
||||||
import { getPluginUrl } from '../utils/url';
|
import PluginCard from '../components/PluginCard';
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
const AuthorProfilePage = () => {
|
const AuthorProfilePage = () => {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const [author, setAuthor] = useState<any>(null);
|
const [author, setAuthor] = useState<any>(null);
|
||||||
const [plugins, setPlugins] = useState<any[]>([]);
|
const [plugins, setPlugins] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -36,44 +44,6 @@ const AuthorProfilePage = () => {
|
|||||||
});
|
});
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
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: '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <div className="container"><h2>LOADING...</h2></div>;
|
if (loading) return <div className="container"><h2>LOADING...</h2></div>;
|
||||||
if (error || !author) return <div className="container"><h2>{error || 'User not found'}</h2></div>;
|
if (error || !author) return <div className="container"><h2>{error || 'User not found'}</h2></div>;
|
||||||
|
|
||||||
@ -130,65 +100,9 @@ const AuthorProfilePage = () => {
|
|||||||
<h2 style={{ marginBottom: '1.5rem' }}>Plugins by {author.username}</h2>
|
<h2 style={{ marginBottom: '1.5rem' }}>Plugins by {author.username}</h2>
|
||||||
|
|
||||||
<div className="home-plugin-grid">
|
<div className="home-plugin-grid">
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => (
|
||||||
const accent = getAccentColor(plugin.name);
|
<PluginCard key={plugin.id} plugin={plugin} />
|
||||||
const desc = getDescription(plugin.translated_descriptions);
|
))}
|
||||||
const initial = plugin.name.charAt(0).toUpperCase();
|
|
||||||
const priceInfo = getEffectivePrice(plugin);
|
|
||||||
const isSale = plugin.type === 'paid' && priceInfo.isSale;
|
|
||||||
const imageUrl = plugin.preview_path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={getPluginUrl(plugin)} key={plugin.id} className="plugin-card-link">
|
|
||||||
<div className="plugin-card-v2">
|
|
||||||
<div className="pcv2-preview">
|
|
||||||
{imageUrl ? (
|
|
||||||
<img className="pcv2-preview-img" src={imageUrl} 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>
|
|
||||||
{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>
|
|
||||||
<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.version && (
|
|
||||||
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
|
|
||||||
v{plugin.version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{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 && (
|
{plugins.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
@ -659,10 +659,9 @@ const PluginDetail = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '1.2rem',
|
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
⏳
|
<Clock size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#f97316', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#f97316', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
|||||||
@ -83,11 +83,12 @@ const Field: React.FC<FieldProps> = ({ label, hint, children }) => (
|
|||||||
|
|
||||||
interface SaveBtnProps {
|
interface SaveBtnProps {
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SaveBtn: React.FC<SaveBtnProps> = ({ isSaving = false, label = 'Save Changes' }) => (
|
const SaveBtn: React.FC<SaveBtnProps> = ({ isSaving = false, disabled = false, label = 'Save Changes' }) => (
|
||||||
<button type="submit" className="settings-btn settings-btn-primary" disabled={isSaving}>
|
<button type="submit" className="settings-btn settings-btn-primary" disabled={isSaving || disabled}>
|
||||||
{isSaving ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
|
{isSaving ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -149,7 +150,7 @@ const PwInput: React.FC<PwInputProps> = ({ placeholder, value, onChange, show, o
|
|||||||
|
|
||||||
const ProfilePage = () => {
|
const ProfilePage = () => {
|
||||||
const { user, login, logout, refreshUser } = useAuth();
|
const { user, login, logout, refreshUser } = useAuth();
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@ -211,6 +212,54 @@ const ProfilePage = () => {
|
|||||||
const [devProfileForm, setDevProfileForm] = useState({
|
const [devProfileForm, setDevProfileForm] = useState({
|
||||||
displayName: '', legalName: '', streetAddress: '', city: '', postalCode: '', vatId: '', publishingIntent: '', supportEmail: '', websiteUrl: '', githubUrl: '',
|
displayName: '', legalName: '', streetAddress: '', city: '', postalCode: '', vatId: '', publishingIntent: '', supportEmail: '', websiteUrl: '', githubUrl: '',
|
||||||
});
|
});
|
||||||
|
const [devNameError, setDevNameError] = useState<string | null>(null);
|
||||||
|
const [devNameChecking, setDevNameChecking] = useState(false);
|
||||||
|
const [devNameAvailable, setDevNameAvailable] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = devProfileForm.displayName.trim();
|
||||||
|
if (!trimmed || (devProfile && trimmed.toLowerCase() === (devProfile.display_name || '').toLowerCase())) {
|
||||||
|
setDevNameError(null);
|
||||||
|
setDevNameChecking(false);
|
||||||
|
setDevNameAvailable(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
setDevNameError('Developer name must be at least 2 characters');
|
||||||
|
setDevNameChecking(false);
|
||||||
|
setDevNameAvailable(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.length > 50) {
|
||||||
|
setDevNameError('Developer name cannot exceed 50 characters');
|
||||||
|
setDevNameChecking(false);
|
||||||
|
setDevNameAvailable(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDevNameChecking(true);
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/user/developer/check-name?name=${encodeURIComponent(trimmed)}`);
|
||||||
|
if (res.data.available) {
|
||||||
|
setDevNameError(null);
|
||||||
|
setDevNameAvailable(true);
|
||||||
|
} else {
|
||||||
|
setDevNameError(t('developer.onboarding.name_taken_error', 'This developer name is already taken. Please choose another name.'));
|
||||||
|
setDevNameAvailable(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDevNameError(null);
|
||||||
|
setDevNameAvailable(null);
|
||||||
|
} finally {
|
||||||
|
setDevNameChecking(false);
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [devProfileForm.displayName, devProfile, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'developer' && user?.is_developer) {
|
if (activeTab === 'developer' && user?.is_developer) {
|
||||||
@ -238,6 +287,10 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
const handleUpdateDevProfile = async (e: React.FormEvent) => {
|
const handleUpdateDevProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (devNameError || devNameChecking) {
|
||||||
|
showToast(devNameError || 'Checking developer name availability...', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await withSaving('devProfile', async () => {
|
await withSaving('devProfile', async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.put('/user/developer/profile', devProfileForm);
|
const res = await api.put('/user/developer/profile', devProfileForm);
|
||||||
@ -1232,17 +1285,33 @@ const ProfilePage = () => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Public Display Name">
|
<Field label="Public Display Name" hint="Shown on your public developer profile and plugin listings.">
|
||||||
<input
|
<input
|
||||||
id="devDisplayName"
|
id="devDisplayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
|
style={devNameError ? { borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.05)' } : {}}
|
||||||
type="text"
|
type="text"
|
||||||
value={devProfileForm.displayName}
|
value={devProfileForm.displayName}
|
||||||
onChange={e => setDevProfileForm({ ...devProfileForm, displayName: e.target.value })}
|
onChange={e => setDevProfileForm({ ...devProfileForm, displayName: e.target.value })}
|
||||||
autoComplete="nickname"
|
autoComplete="nickname"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{devNameChecking && (
|
||||||
|
<span style={{ fontSize: '0.76rem', color: 'var(--text-muted)', marginTop: '4px', display: 'block' }}>
|
||||||
|
Checking availability…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{devNameError && (
|
||||||
|
<span style={{ fontSize: '0.76rem', color: '#ef4444', marginTop: '4px', display: 'block' }}>
|
||||||
|
{devNameError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{devNameAvailable && !devNameChecking && !devNameError && (
|
||||||
|
<span style={{ fontSize: '0.76rem', color: '#10b981', marginTop: '4px', display: 'block' }}>
|
||||||
|
Developer name is available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Legal Full Name / Business Name" hint="Used for internal verification & tax compliance">
|
<Field label="Legal Full Name / Business Name" hint="Used for internal verification & tax compliance">
|
||||||
@ -1347,7 +1416,7 @@ const ProfilePage = () => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<SaveBtn isSaving={saving['devProfile']} label="Save Developer Info" />
|
<SaveBtn isSaving={saving['devProfile']} disabled={Boolean(devNameError) || devNameChecking} label="Save Developer Info" />
|
||||||
</form>
|
</form>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1394,3 +1394,150 @@ input:checked + .slider:before {
|
|||||||
background: var(--dash-hover);
|
background: var(--dash-hover);
|
||||||
border-color: var(--dash-primary);
|
border-color: var(--dash-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Create Plugin Modal ─────────────────────────────────────── */
|
||||||
|
.create-plugin-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-modal {
|
||||||
|
background: var(--dash-surface, #141414);
|
||||||
|
border: 1px solid var(--dash-border, #2a2a2a);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
color: var(--dash-text, #eaeaea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--dash-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-header h3 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-header p {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--dash-text-muted, #888);
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-field label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-input, .create-plugin-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid var(--dash-border, #2a2a2a);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-input:focus, .create-plugin-select:focus {
|
||||||
|
border-color: var(--dash-primary, #ff7518);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-card {
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 2px solid var(--dash-border, #2a2a2a);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-card:hover {
|
||||||
|
border-color: rgba(255, 117, 24, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-card.selected {
|
||||||
|
border-color: var(--dash-primary, #ff7518);
|
||||||
|
background: rgba(255, 117, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-card h5 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-type-card p {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--dash-text-muted, #888);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-plugins-status-dot.draft {
|
||||||
|
background: #94a3b8;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.dev-plugins-visibility-status.draft {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,25 +1,43 @@
|
|||||||
import { useMemo, useState, useEffect } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalytics } from './useAnalytics';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package, CheckCircle, X, ArrowRightLeft } from 'lucide-react';
|
import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package, CheckCircle, X, AlertTriangle } from 'lucide-react';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useAuth } from '../../App';
|
import { useAuth } from '../../App';
|
||||||
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
||||||
import { getPluginUrl } from '../../utils/url';
|
import { getPluginUrl } from '../../utils/url';
|
||||||
|
|
||||||
|
const PLUGIN_CATEGORIES = ['Admin Tools', 'Economy', 'Fun', 'World Management', 'Utilities', 'Chat', 'Other'];
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
const DashboardPlugins = () => {
|
const DashboardPlugins = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { timeSeries } = useAnalytics();
|
const { timeSeries } = useAnalytics();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string>('all');
|
const [filterType, setFilterType] = useState<string>('all');
|
||||||
const [incomingTransfers, setIncomingTransfers] = useState<any[]>([]);
|
const [incomingTransfers, setIncomingTransfers] = useState<any[]>([]);
|
||||||
const [transferLoadingId, setTransferLoadingId] = useState<number | null>(null);
|
const [transferLoadingId, setTransferLoadingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Create Draft Plugin modal state
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [newPluginName, setNewPluginName] = useState('');
|
||||||
|
const [newPluginCategory, setNewPluginCategory] = useState('Utilities');
|
||||||
|
const [newPluginType, setNewPluginType] = useState<'free' | 'paid'>('free');
|
||||||
|
const [creatingPlugin, setCreatingPlugin] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('create') === 'true') {
|
||||||
|
setIsCreateModalOpen(true);
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchIncomingTransfers();
|
fetchIncomingTransfers();
|
||||||
}, []);
|
}, []);
|
||||||
@ -59,6 +77,28 @@ const DashboardPlugins = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateDraft = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = newPluginName.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setCreatingPlugin(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/plugins/draft', {
|
||||||
|
name: trimmed,
|
||||||
|
category: newPluginCategory,
|
||||||
|
type: newPluginType,
|
||||||
|
});
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
setNewPluginName('');
|
||||||
|
navigate(`/dashboard/manage-plugin/${res.data.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setCreateError(err.response?.data?.error || err.response?.data?.message || 'Failed to create plugin draft.');
|
||||||
|
} finally {
|
||||||
|
setCreatingPlugin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Grouping analytics data by plugin ID
|
// Grouping analytics data by plugin ID
|
||||||
const plugins = useMemo(() => {
|
const plugins = useMemo(() => {
|
||||||
return Object.values(timeSeries.reduce((acc, curr) => {
|
return Object.values(timeSeries.reduce((acc, curr) => {
|
||||||
@ -73,6 +113,7 @@ const DashboardPlugins = () => {
|
|||||||
previewPath: curr.previewPath || curr.preview_path,
|
previewPath: curr.previewPath || curr.preview_path,
|
||||||
priceCents: curr.priceCents || curr.price_cents || 0,
|
priceCents: curr.priceCents || curr.price_cents || 0,
|
||||||
isEarlyAccess: curr.isEarlyAccess || curr.is_early_access || false,
|
isEarlyAccess: curr.isEarlyAccess || curr.is_early_access || false,
|
||||||
|
status: curr.status || 'published',
|
||||||
downloads: 0,
|
downloads: 0,
|
||||||
earnings: 0,
|
earnings: 0,
|
||||||
views: 0,
|
views: 0,
|
||||||
@ -169,7 +210,7 @@ const DashboardPlugins = () => {
|
|||||||
<h3 className="dev-plugins-title">{t('developer.dashboard.plugins')}</h3>
|
<h3 className="dev-plugins-title">{t('developer.dashboard.plugins')}</h3>
|
||||||
<span className="dev-plugins-count">{plugins.length}</span>
|
<span className="dev-plugins-count">{plugins.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}>
|
<button className="btn btn-primary" onClick={() => setIsCreateModalOpen(true)}>
|
||||||
<Plus size={18} style={{ marginRight: '8px' }} />
|
<Plus size={18} style={{ marginRight: '8px' }} />
|
||||||
{t('developer.dashboard.add_plugin')}
|
{t('developer.dashboard.add_plugin')}
|
||||||
</button>
|
</button>
|
||||||
@ -199,50 +240,47 @@ const DashboardPlugins = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
fontSize: '1.25rem',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#f97316',
|
color: '#f97316',
|
||||||
fontSize: '1.2rem',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
border: '1px solid rgba(249, 115, 22, 0.3)'
|
border: '1px solid rgba(249, 115, 22, 0.3)'
|
||||||
}}>
|
}}>
|
||||||
{!req.preview_url && req.plugin_name.charAt(0).toUpperCase()}
|
{!req.preview_url && req.plugin_name?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--dash-text-primary)' }}>
|
<span style={{ fontWeight: 600, fontSize: '1rem' }}>{req.plugin_name}</span>
|
||||||
{req.plugin_name}
|
{req.plugin_category && (
|
||||||
</span>
|
<span className="dev-plugins-badge" style={{ fontSize: '0.7rem' }}>
|
||||||
<span className="dev-plugins-badge" style={{ background: 'rgba(249, 115, 22, 0.15)', color: '#f97316', border: '1px solid rgba(249, 115, 22, 0.3)' }}>
|
{req.plugin_category}
|
||||||
<ArrowRightLeft size={11} style={{ marginRight: 4 }} /> Ownership Transfer Request
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)', marginTop: '0.2rem' }}>
|
||||||
|
Transfer request from <strong>@{req.sender_name}</strong>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '0.25rem 0 0', fontSize: '0.82rem', color: 'var(--dash-text-muted)' }}>
|
|
||||||
<strong>@{req.sender_name}</strong> has requested to transfer ownership of this plugin to your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-secondary"
|
||||||
style={{ padding: '0.5rem 1.1rem', fontSize: '0.82rem', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
|
||||||
onClick={() => handleAcceptTransfer(req.id)}
|
|
||||||
disabled={transferLoadingId === req.id}
|
disabled={transferLoadingId === req.id}
|
||||||
|
onClick={() => handleRejectTransfer(req.id)}
|
||||||
|
style={{ padding: '0.45rem 0.9rem', fontSize: '0.82rem', color: '#f87171' }}
|
||||||
>
|
>
|
||||||
<CheckCircle size={15} />
|
<X size={14} style={{ marginRight: '4px' }} />
|
||||||
{transferLoadingId === req.id ? 'Accepting…' : 'Accept Transfer'}
|
Decline
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn btn-primary"
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem', fontSize: '0.82rem',
|
|
||||||
background: 'var(--dash-surface-2)', border: '1px solid var(--dash-border)',
|
|
||||||
color: 'var(--dash-text-muted)', display: 'inline-flex', alignItems: 'center', gap: '6px'
|
|
||||||
}}
|
|
||||||
onClick={() => handleRejectTransfer(req.id)}
|
|
||||||
disabled={transferLoadingId === req.id}
|
disabled={transferLoadingId === req.id}
|
||||||
|
onClick={() => handleAcceptTransfer(req.id)}
|
||||||
|
style={{ padding: '0.45rem 0.9rem', fontSize: '0.82rem' }}
|
||||||
>
|
>
|
||||||
<X size={15} /> Decline
|
<CheckCircle size={14} style={{ marginRight: '4px' }} />
|
||||||
|
{transferLoadingId === req.id ? 'Accepting…' : 'Accept Transfer'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -317,6 +355,7 @@ const DashboardPlugins = () => {
|
|||||||
const isPaid = pluginType === 'paid' || (p.priceCents && p.priceCents > 0);
|
const isPaid = pluginType === 'paid' || (p.priceCents && p.priceCents > 0);
|
||||||
const priceFormatted = isPaid ? `€${((p.priceCents || 0) / 100).toFixed(2)}` : 'Free';
|
const priceFormatted = isPaid ? `€${((p.priceCents || 0) / 100).toFixed(2)}` : 'Free';
|
||||||
const initial = (p.name || '?').charAt(0).toUpperCase();
|
const initial = (p.name || '?').charAt(0).toUpperCase();
|
||||||
|
const isDraft = p.status === 'draft';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={p.id} className="dev-plugins-row">
|
<tr key={p.id} className="dev-plugins-row">
|
||||||
@ -403,9 +442,9 @@ const DashboardPlugins = () => {
|
|||||||
{/* Visibility & Type */}
|
{/* Visibility & Type */}
|
||||||
<td>
|
<td>
|
||||||
<div className="dev-plugins-visibility-cell">
|
<div className="dev-plugins-visibility-cell">
|
||||||
<div className="dev-plugins-visibility-status">
|
<div className={`dev-plugins-visibility-status ${isDraft ? 'draft' : ''}`}>
|
||||||
<span className="dev-plugins-status-dot" />
|
<span className={`dev-plugins-status-dot ${isDraft ? 'draft' : ''}`} />
|
||||||
<span>Public</span>
|
<span>{isDraft ? t('developer.dashboard.status_draft', 'Offline (Draft)') : t('developer.dashboard.status_published', 'Published')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="dev-plugins-type-pill">
|
<span className="dev-plugins-type-pill">
|
||||||
{isPaid ? priceFormatted : 'Free'}
|
{isPaid ? priceFormatted : 'Free'}
|
||||||
@ -468,10 +507,10 @@ const DashboardPlugins = () => {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', opacity: 0.7 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', opacity: 0.7 }}>
|
||||||
<Package size={36} color="var(--dash-text-muted)" />
|
<Package size={36} color="var(--dash-text-muted)" />
|
||||||
<p style={{ margin: 0, fontWeight: 600, fontSize: '0.95rem' }}>
|
<p style={{ margin: 0, fontWeight: 600, fontSize: '0.95rem' }}>
|
||||||
{searchTerm ? 'No plugins matched your filter.' : 'No plugins published yet.'}
|
{searchTerm ? 'No plugins matched your filter.' : 'No plugins created yet.'}
|
||||||
</p>
|
</p>
|
||||||
<small style={{ color: 'var(--dash-text-muted)' }}>
|
<small style={{ color: 'var(--dash-text-muted)' }}>
|
||||||
{searchTerm ? 'Try adjusting your search or category filter.' : 'Click "Add New Plugin" to publish your first creation.'}
|
{searchTerm ? 'Try adjusting your search or category filter.' : 'Click "Add New Plugin" to create your first plugin.'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -481,6 +520,108 @@ const DashboardPlugins = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create New Plugin Draft Modal */}
|
||||||
|
{isCreateModalOpen && (
|
||||||
|
<div className="create-plugin-overlay" onClick={() => setIsCreateModalOpen(false)}>
|
||||||
|
<div className="create-plugin-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="create-plugin-close" onClick={() => setIsCreateModalOpen(false)} aria-label="Close">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="create-plugin-header">
|
||||||
|
<h3>{t('developer.dashboard.create_plugin_title', 'Create New Plugin')}</h3>
|
||||||
|
<p>{t('developer.dashboard.create_plugin_desc', 'Set up your plugin draft. You can configure your store listing, upload builds, and test offline before publishing.')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>{createError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateDraft} className="create-plugin-form">
|
||||||
|
<div className="create-plugin-field">
|
||||||
|
<label htmlFor="newPluginName">Plugin Name *</label>
|
||||||
|
<input
|
||||||
|
id="newPluginName"
|
||||||
|
type="text"
|
||||||
|
className="create-plugin-input"
|
||||||
|
placeholder="e.g. QuantumVault, DragonFly"
|
||||||
|
value={newPluginName}
|
||||||
|
onChange={e => setNewPluginName(e.target.value)}
|
||||||
|
maxLength={64}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="create-plugin-field">
|
||||||
|
<label htmlFor="newPluginCategory">Category *</label>
|
||||||
|
<select
|
||||||
|
id="newPluginCategory"
|
||||||
|
className="create-plugin-select"
|
||||||
|
value={newPluginCategory}
|
||||||
|
onChange={e => setNewPluginCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
{PLUGIN_CATEGORIES.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="create-plugin-field">
|
||||||
|
<label>Distribution Type</label>
|
||||||
|
<div className="create-plugin-type-grid">
|
||||||
|
<div
|
||||||
|
className={`create-plugin-type-card ${newPluginType === 'free' ? 'selected' : ''}`}
|
||||||
|
onClick={() => setNewPluginType('free')}
|
||||||
|
>
|
||||||
|
<h5>Free Plugin</h5>
|
||||||
|
<p>Available to the entire community for free.</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`create-plugin-type-card ${newPluginType === 'paid' ? 'selected' : ''}`}
|
||||||
|
onClick={() => setNewPluginType('paid')}
|
||||||
|
>
|
||||||
|
<h5>Paid / Premium</h5>
|
||||||
|
<p>Sell licenses with instant Stripe payouts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="create-plugin-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setIsCreateModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!newPluginName.trim() || creatingPlugin}
|
||||||
|
>
|
||||||
|
{creatingPlugin ? 'Creating Draft…' : t('developer.dashboard.btn_create_draft', 'Create Draft & Manage →')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Globe, ImageIcon, Plus, Search, X, Upload, FileCode,
|
Globe, ImageIcon, Plus, Search, X, Upload, FileCode,
|
||||||
DollarSign, CheckCircle, ChevronRight, ChevronLeft,
|
DollarSign, CheckCircle, ChevronRight, ChevronLeft,
|
||||||
Zap, Lock, AlertTriangle, Tag, Store, User, Sparkles,
|
Zap, Lock, AlertTriangle, Tag, Store, User,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
@ -168,7 +168,6 @@ const LangPicker = ({ usedCodes, onAdd, onClose }: { usedCodes: string[]; onAdd:
|
|||||||
const AddPlugin = () => {
|
const AddPlugin = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@ -184,38 +183,10 @@ const AddPlugin = () => {
|
|||||||
if (user && !user.is_developer) {
|
if (user && !user.is_developer) {
|
||||||
return (
|
return (
|
||||||
<div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}>
|
<div className="ap-root" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh', padding: '2rem 1rem' }}>
|
||||||
<div style={{
|
<DeveloperOnboardingModal
|
||||||
maxWidth: 520, width: '100%', padding: '2.5rem', background: 'var(--mp-surface, #14171c)',
|
embedded={true}
|
||||||
borderRadius: 16, border: '1px solid var(--mp-border, rgba(255,255,255,0.1))', textAlign: 'center',
|
onSuccess={() => refreshUser?.()}
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.3)'
|
/>
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 60, height: 60, borderRadius: '50%', background: 'rgba(249, 115, 22, 0.15)',
|
|
||||||
color: 'var(--mp-accent, #f97316)', display: 'inline-flex', alignItems: 'center',
|
|
||||||
justifyContent: 'center', marginBottom: '1.25rem'
|
|
||||||
}}>
|
|
||||||
<Sparkles size={28} />
|
|
||||||
</div>
|
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem 0', color: 'var(--mp-text, #fff)' }}>
|
|
||||||
Developer Profile Required
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: 'var(--mp-muted, #94a3b8)', fontSize: '0.9rem', lineHeight: 1.5, marginBottom: '1.75rem' }}>
|
|
||||||
Before publishing plugins on Pumpkin Market, you must complete your developer profile. It takes less than a minute!
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
className="mp-btn mp-btn-primary"
|
|
||||||
style={{ width: '100%', padding: '0.85rem', fontSize: '0.95rem', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', cursor: 'pointer' }}
|
|
||||||
onClick={() => setIsDevModalOpen(true)}
|
|
||||||
>
|
|
||||||
<Sparkles size={16} /> Set Up Developer Profile
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DeveloperOnboardingModal
|
|
||||||
isOpen={isDevModalOpen}
|
|
||||||
onClose={() => setIsDevModalOpen(false)}
|
|
||||||
onSuccess={() => refreshUser?.()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -208,6 +208,110 @@
|
|||||||
background: var(--mp-bg);
|
background: var(--mp-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Top Plugin Management Status Bar ────────────────────────── */
|
||||||
|
.mp-top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem 1.75rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
background: var(--mp-surface);
|
||||||
|
border: 1px solid var(--mp-border);
|
||||||
|
border-radius: var(--mp-radius);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-top-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-status-pill.published {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-status-pill.draft {
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-status-dot-pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-top-bar-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--mp-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-top-bar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: var(--mp-radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-action-btn-primary {
|
||||||
|
background: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.mp-action-btn-primary:hover {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-action-btn-secondary {
|
||||||
|
background: var(--mp-surface-2);
|
||||||
|
border-color: var(--mp-border);
|
||||||
|
color: var(--mp-text);
|
||||||
|
}
|
||||||
|
.mp-action-btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--mp-border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-action-btn-danger {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.mp-action-btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shared Tab Components ──────────────────────────────────── */
|
/* ── Shared Tab Components ──────────────────────────────────── */
|
||||||
.mp-tab-header {
|
.mp-tab-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { useAuth } from '../../../App';
|
import { useAuth } from '../../../App';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
||||||
Circle, BarChart3, Key, Star, ArrowRightLeft
|
Circle, BarChart3, Key, Star, ArrowRightLeft,
|
||||||
|
Rocket, Eye, PowerOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import StoreListing from './StoreListing';
|
import StoreListing from './StoreListing';
|
||||||
@ -16,6 +18,8 @@ import PluginReviewsTab from './PluginReviewsTab';
|
|||||||
import TransferOwnership from './TransferOwnership';
|
import TransferOwnership from './TransferOwnership';
|
||||||
import DangerZone from './DangerZone';
|
import DangerZone from './DangerZone';
|
||||||
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
||||||
|
import PublishReadinessModal from './PublishReadinessModal';
|
||||||
|
import { getPluginUrl } from '../../../utils/url';
|
||||||
import './ManagePlugin.css';
|
import './ManagePlugin.css';
|
||||||
|
|
||||||
export type PluginData = {
|
export type PluginData = {
|
||||||
@ -53,12 +57,15 @@ const NAV_ITEMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ManagePlugin = () => {
|
const ManagePlugin = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState('listing');
|
const [activeTab, setActiveTab] = useState('listing');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [plugin, setPlugin] = useState<PluginData | null>(null);
|
const [plugin, setPlugin] = useState<PluginData | null>(null);
|
||||||
|
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
|
||||||
|
const [unpublishing, setUnpublishing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@ -84,6 +91,24 @@ const ManagePlugin = () => {
|
|||||||
|
|
||||||
const refreshPlugin = () => fetchPlugin();
|
const refreshPlugin = () => fetchPlugin();
|
||||||
|
|
||||||
|
const handleUnpublish = async () => {
|
||||||
|
if (!plugin) return;
|
||||||
|
const confirm = window.confirm(
|
||||||
|
`Take "${plugin.name}" offline?\n\nThis will remove the plugin from marketplace search and category browsing until you publish it again.`
|
||||||
|
);
|
||||||
|
if (!confirm) return;
|
||||||
|
|
||||||
|
setUnpublishing(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/plugins/${plugin.id}/unpublish`);
|
||||||
|
await fetchPlugin();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.response?.data?.error || 'Failed to unpublish plugin.');
|
||||||
|
} finally {
|
||||||
|
setUnpublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="mp-loading">
|
<div className="mp-loading">
|
||||||
@ -95,11 +120,7 @@ const ManagePlugin = () => {
|
|||||||
|
|
||||||
if (!plugin) return null;
|
if (!plugin) return null;
|
||||||
|
|
||||||
const statusColor = {
|
const isLive = plugin.status === 'published';
|
||||||
published: 'var(--mp-success)',
|
|
||||||
draft: 'var(--mp-muted)',
|
|
||||||
review: 'var(--mp-warn)',
|
|
||||||
}[plugin.status ?? 'published'];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mp-root">
|
<div className="mp-root">
|
||||||
@ -112,9 +133,9 @@ const ManagePlugin = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="mp-plugin-name">{plugin.name}</p>
|
<p className="mp-plugin-name">{plugin.name}</p>
|
||||||
<div className="mp-plugin-status">
|
<div className="mp-plugin-status">
|
||||||
<Circle size={7} fill={statusColor} color={statusColor} />
|
<Circle size={7} fill={isLive ? 'var(--mp-success)' : 'var(--mp-muted)'} color={isLive ? 'var(--mp-success)' : 'var(--mp-muted)'} />
|
||||||
<span style={{ color: statusColor }}>
|
<span style={{ color: isLive ? 'var(--mp-success)' : 'var(--mp-muted)' }}>
|
||||||
{plugin.status ?? 'published'}
|
{isLive ? t('developer.dashboard.status_published', 'Published') : t('developer.dashboard.status_draft', 'Offline (Draft)')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -145,6 +166,62 @@ const ManagePlugin = () => {
|
|||||||
|
|
||||||
{/* ── Main Content ──────────────────────────────────────── */}
|
{/* ── Main Content ──────────────────────────────────────── */}
|
||||||
<main className="mp-main">
|
<main className="mp-main">
|
||||||
|
{/* Top Google/Apple/Steam style publishing & status bar */}
|
||||||
|
<div className="mp-top-bar">
|
||||||
|
<div className="mp-top-bar-left">
|
||||||
|
<div className={`mp-status-pill ${isLive ? 'published' : 'draft'}`}>
|
||||||
|
<span className="mp-status-dot-pulse" />
|
||||||
|
<span>{isLive ? t('developer.dashboard.status_published', 'Published') : t('developer.dashboard.status_draft', 'Offline (Draft)')}</span>
|
||||||
|
</div>
|
||||||
|
<span className="mp-top-bar-desc">
|
||||||
|
{isLive
|
||||||
|
? 'This plugin is public. You can publish updates or manage store details.'
|
||||||
|
: 'Draft changes are private. Complete your store listing & binary build, then publish.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mp-top-bar-actions">
|
||||||
|
{!isLive ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="mp-action-btn mp-action-btn-secondary"
|
||||||
|
onClick={() => setIsPublishModalOpen(true)}
|
||||||
|
title="Check what requirements remain before publishing"
|
||||||
|
>
|
||||||
|
Check Readiness
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mp-action-btn mp-action-btn-primary"
|
||||||
|
onClick={() => setIsPublishModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Rocket size={15} />
|
||||||
|
{t('developer.dashboard.btn_publish_store', 'Publish to Store')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to={getPluginUrl(plugin)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mp-action-btn mp-action-btn-secondary"
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
View on Store
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="mp-action-btn mp-action-btn-danger"
|
||||||
|
disabled={unpublishing}
|
||||||
|
onClick={handleUnpublish}
|
||||||
|
>
|
||||||
|
<PowerOff size={15} />
|
||||||
|
{unpublishing ? 'Unpublishing…' : t('developer.dashboard.btn_unpublish_store', 'Take Offline')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{activeTab === 'listing' && (
|
{activeTab === 'listing' && (
|
||||||
<StoreListing plugin={plugin} onSaved={refreshPlugin} />
|
<StoreListing plugin={plugin} onSaved={refreshPlugin} />
|
||||||
)}
|
)}
|
||||||
@ -173,6 +250,17 @@ const ManagePlugin = () => {
|
|||||||
<DangerZone plugin={plugin} />
|
<DangerZone plugin={plugin} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Readiness & Publish Modal */}
|
||||||
|
<PublishReadinessModal
|
||||||
|
isOpen={isPublishModalOpen}
|
||||||
|
pluginId={plugin.id}
|
||||||
|
pluginName={plugin.name}
|
||||||
|
isPublished={isLive}
|
||||||
|
onClose={() => setIsPublishModalOpen(false)}
|
||||||
|
onPublished={refreshPlugin}
|
||||||
|
onNavigateTab={(tabKey) => setActiveTab(tabKey)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
277
src/pages/dashboard/plugin/PublishReadinessModal.css
Normal file
277
src/pages/dashboard/plugin/PublishReadinessModal.css
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
.prm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-container {
|
||||||
|
background: var(--mp-surface, #151820);
|
||||||
|
border: 1px solid var(--mp-border, #252a38);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 620px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
color: var(--mp-text, #e8eaf2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--mp-text-3, #5a6075);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-close-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-header {
|
||||||
|
padding: 1.5rem 1.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--mp-border, #252a38);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-header-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
border: 1px solid rgba(249, 115, 22, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--mp-text-2, #8b91a8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-alert {
|
||||||
|
margin: 1rem 1.75rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-alert-error {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-alert-success {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-loading {
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--mp-text-2, #8b91a8);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-spin {
|
||||||
|
animation: prm-rotate 1s linear infinite;
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prm-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-body {
|
||||||
|
padding: 1.25rem 1.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-status-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-status-banner h4 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-status-banner p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-status-banner-ready {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-status-banner-missing {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-checklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--mp-surface-2, #1c2030);
|
||||||
|
border: 1px solid var(--mp-border, #252a38);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-row.ready {
|
||||||
|
background: rgba(16, 185, 129, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-row.missing {
|
||||||
|
background: rgba(239, 68, 68, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mp-text, #e8eaf2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-check-desc {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--mp-text-2, #8b91a8);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-fix-btn {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f97316;
|
||||||
|
background: rgba(249, 115, 22, 0.1);
|
||||||
|
border: 1px solid rgba(249, 115, 22, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-fix-btn:hover {
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-footer {
|
||||||
|
padding: 1.25rem 1.75rem;
|
||||||
|
border-top: 1px solid var(--mp-border, #252a38);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--mp-surface, #151820);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--mp-text, #e8eaf2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn-primary {
|
||||||
|
background: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn-primary:hover:not(:disabled) {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prm-btn-primary:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
294
src/pages/dashboard/plugin/PublishReadinessModal.tsx
Normal file
294
src/pages/dashboard/plugin/PublishReadinessModal.tsx
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle2, XCircle, AlertTriangle, Rocket, X, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import './PublishReadinessModal.css';
|
||||||
|
|
||||||
|
export interface PublishReadiness {
|
||||||
|
nameReady: boolean;
|
||||||
|
categoryReady: boolean;
|
||||||
|
descriptionReady: boolean;
|
||||||
|
previewReady: boolean;
|
||||||
|
screenshotsReady: boolean;
|
||||||
|
screenshotsCount: number;
|
||||||
|
binaryReady: boolean;
|
||||||
|
binaryVersion: string | null;
|
||||||
|
pricingReady: boolean;
|
||||||
|
securityReady: boolean;
|
||||||
|
canPublish: boolean;
|
||||||
|
missingRequirements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
pluginId: number;
|
||||||
|
pluginName: string;
|
||||||
|
isPublished?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPublished: () => void;
|
||||||
|
onNavigateTab: (tabKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PublishReadinessModal: React.FC<Props> = ({
|
||||||
|
isOpen,
|
||||||
|
pluginId,
|
||||||
|
pluginName,
|
||||||
|
onClose,
|
||||||
|
onPublished,
|
||||||
|
onNavigateTab,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [readiness, setReadiness] = useState<PublishReadiness | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchReadiness();
|
||||||
|
}
|
||||||
|
}, [isOpen, pluginId]);
|
||||||
|
|
||||||
|
const fetchReadiness = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.get<PublishReadiness>(`/plugins/${pluginId}/readiness`);
|
||||||
|
setReadiness(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to check publish readiness');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
setPublishing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.post(`/plugins/${pluginId}/publish`);
|
||||||
|
setSuccessMsg('Plugin published successfully and is now live!');
|
||||||
|
onPublished();
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 1800);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || err.response?.data?.message || 'Failed to publish plugin');
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prm-overlay" onClick={onClose}>
|
||||||
|
<div className="prm-container" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="prm-close-btn" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="prm-header">
|
||||||
|
<div className="prm-header-icon">
|
||||||
|
<Rocket size={22} color="#f97316" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="prm-title">{t('developer.dashboard.publish_checklist_title', 'Publish to Marketplace')}</h3>
|
||||||
|
<p className="prm-subtitle">
|
||||||
|
{t('developer.dashboard.publish_checklist_desc', 'Review requirements before releasing your plugin to the Pumpkin Market.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="prm-alert prm-alert-error">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMsg && (
|
||||||
|
<div className="prm-alert prm-alert-success">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<span>{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="prm-loading">
|
||||||
|
<RefreshCw size={24} className="prm-spin" />
|
||||||
|
<span>Checking store requirements…</span>
|
||||||
|
</div>
|
||||||
|
) : readiness ? (
|
||||||
|
<div className="prm-body">
|
||||||
|
{/* Overall status banner */}
|
||||||
|
{readiness.canPublish ? (
|
||||||
|
<div className="prm-status-banner prm-status-banner-ready">
|
||||||
|
<CheckCircle2 size={20} color="#10b981" />
|
||||||
|
<div>
|
||||||
|
<h4>{t('developer.dashboard.publish_ready_banner', 'All requirements are met! Your plugin is ready to go live.')}</h4>
|
||||||
|
<p>Your store page, assets, and WebAssembly binary are all validated.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prm-status-banner prm-status-banner-missing">
|
||||||
|
<AlertTriangle size={20} color="#f59e0b" />
|
||||||
|
<div>
|
||||||
|
<h4>{t('developer.dashboard.publish_missing_banner', 'Please complete the missing requirements before publishing.')}</h4>
|
||||||
|
<p>{readiness.missingRequirements.length} item(s) need attention before release.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checklist items */}
|
||||||
|
<div className="prm-checklist">
|
||||||
|
{/* 1. Name */}
|
||||||
|
<div className={`prm-check-row ${readiness.nameReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.nameReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Plugin Name & Identity</div>
|
||||||
|
<div className="prm-check-desc">{readiness.nameReady ? pluginName : 'Plugin name is missing or empty'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.nameReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('listing'); onClose(); }}>
|
||||||
|
Fix in Listing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Category */}
|
||||||
|
<div className={`prm-check-row ${readiness.categoryReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.categoryReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Category</div>
|
||||||
|
<div className="prm-check-desc">{readiness.categoryReady ? 'Valid category selected' : 'No category selected'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.categoryReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('listing'); onClose(); }}>
|
||||||
|
Fix in Listing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Description */}
|
||||||
|
<div className={`prm-check-row ${readiness.descriptionReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.descriptionReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Store Description</div>
|
||||||
|
<div className="prm-check-desc">{readiness.descriptionReady ? 'Description provided in at least 1 language' : 'Missing store description'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.descriptionReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('listing'); onClose(); }}>
|
||||||
|
Fix in Listing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Preview Icon */}
|
||||||
|
<div className={`prm-check-row ${readiness.previewReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.previewReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Store Icon / Avatar</div>
|
||||||
|
<div className="prm-check-desc">{readiness.previewReady ? 'Icon uploaded' : 'Store icon image is required'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.previewReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('listing'); onClose(); }}>
|
||||||
|
Upload Icon
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Screenshots */}
|
||||||
|
<div className={`prm-check-row ${readiness.screenshotsReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.screenshotsReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Screenshots</div>
|
||||||
|
<div className="prm-check-desc">{readiness.screenshotsReady ? `${readiness.screenshotsCount} screenshot(s) uploaded` : 'At least 1 screenshot is required'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.screenshotsReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('listing'); onClose(); }}>
|
||||||
|
Add Screenshots
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Binary */}
|
||||||
|
<div className={`prm-check-row ${readiness.binaryReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.binaryReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">WebAssembly Binary (.wasm)</div>
|
||||||
|
<div className="prm-check-desc">{readiness.binaryReady ? `Valid binary uploaded (${readiness.binaryVersion ? `v${readiness.binaryVersion}` : 'Ready'})` : 'No .wasm binary uploaded yet'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.binaryReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('update'); onClose(); }}>
|
||||||
|
Upload Binary
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7. Pricing */}
|
||||||
|
<div className={`prm-check-row ${readiness.pricingReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.pricingReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Pricing & Payouts</div>
|
||||||
|
<div className="prm-check-desc">{readiness.pricingReady ? 'Valid pricing configuration' : 'Paid plugin requires price > 0 and connected Stripe account'}</div>
|
||||||
|
</div>
|
||||||
|
{!readiness.pricingReady && (
|
||||||
|
<button className="prm-fix-btn" onClick={() => { onNavigateTab('pricing'); onClose(); }}>
|
||||||
|
Configure Price
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 8. Security */}
|
||||||
|
<div className={`prm-check-row ${readiness.securityReady ? 'ready' : 'missing'}`}>
|
||||||
|
<div className="prm-check-status">
|
||||||
|
{readiness.securityReady ? <CheckCircle2 size={16} color="#10b981" /> : <XCircle size={16} color="#ef4444" />}
|
||||||
|
</div>
|
||||||
|
<div className="prm-check-info">
|
||||||
|
<div className="prm-check-title">Account Security</div>
|
||||||
|
<div className="prm-check-desc">{readiness.securityReady ? '2FA or Passkey verified' : '2FA or Passkey required in user settings'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="prm-footer">
|
||||||
|
<button className="prm-btn prm-btn-secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="prm-btn prm-btn-primary"
|
||||||
|
disabled={!readiness?.canPublish || publishing || Boolean(successMsg)}
|
||||||
|
onClick={handlePublish}
|
||||||
|
>
|
||||||
|
<Rocket size={16} />
|
||||||
|
{publishing ? 'Publishing…' : t('developer.dashboard.btn_confirm_publish', 'Confirm & Release to Store')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublishReadinessModal;
|
||||||
Loading…
Reference in New Issue
Block a user