new things

This commit is contained in:
Alexander Medvedev 2026-07-26 15:14:26 +02:00
parent a363168a0b
commit ef5092ee4b
15 changed files with 2285 additions and 339 deletions

34
package-lock.json generated
View File

@ -16,7 +16,7 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.26.0", "lucide-react": "^1.27.0",
"or": "^0.2.0", "or": "^0.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.8", "react": "^19.2.8",
@ -25,7 +25,7 @@
"react-is": "^19.2.8", "react-is": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.10.0", "recharts": "^3.10.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -35,7 +35,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.7.0", "eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
@ -385,9 +385,9 @@
} }
}, },
"node_modules/@eslint/config-helpers": { "node_modules/@eslint/config-helpers": {
"version": "0.6.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -2436,9 +2436,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "10.7.0", "version": "10.8.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -2448,7 +2448,7 @@
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5", "@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.6.0", "@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1", "@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2", "@eslint/plugin-kit": "^0.7.2",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
@ -2472,7 +2472,7 @@
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4", "minimatch": "^10.2.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3"
}, },
@ -3549,9 +3549,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "1.26.0", "version": "1.27.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
"integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@ -4843,9 +4843,9 @@
} }
}, },
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.10.0", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"www" "www"

View File

@ -19,7 +19,7 @@
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.26.0", "lucide-react": "^1.27.0",
"or": "^0.2.0", "or": "^0.2.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^19.2.8", "react": "^19.2.8",
@ -28,7 +28,7 @@
"react-is": "^19.2.8", "react-is": "^19.2.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"recharts": "^3.10.0", "recharts": "^3.10.1",
"remark-gfm": "^4.0.1" "remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
@ -38,7 +38,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.7.0", "eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",

View File

@ -3131,3 +3131,441 @@ h3 {
.lightbox-dot:hover:not(.active) { .lightbox-dot:hover:not(.active) {
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
} }
/* ═══════════════════════════════════════════════════════════════════════════
SETTINGS PAGE (ProfilePage.tsx)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Nav sidebar enhancements ── */
.settings-nav-user {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.5rem 1rem;
}
.settings-nav-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 60%, #fff));
color: #fff;
font-weight: 800;
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-nav-meta { overflow: hidden; }
.settings-nav-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--text);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-nav-email {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0.1rem 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-nav-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.profile-nav-item {
display: flex;
align-items: center;
gap: 0.6rem;
}
.profile-nav-item.danger { color: #ef4444; }
.profile-nav-item.danger:hover { background: rgba(239, 68, 68, 0.1); }
.profile-nav-item.danger.active { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
/* ── Section header ── */
.settings-section-header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
}
.settings-section-sub {
color: var(--text-muted);
font-size: 0.88rem;
margin: 0.4rem 0 0;
line-height: 1.5;
}
/* ── Settings card ── */
.settings-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 1.25rem;
overflow: hidden;
transition: border-color 0.2s;
}
.settings-card:hover { border-color: color-mix(in srgb, var(--border) 60%, var(--primary)); }
.settings-card-header {
display: flex;
align-items: flex-start;
gap: 0.85rem;
padding: 1.15rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.settings-card-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-card-title {
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.settings-card-desc {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0.2rem 0 0;
line-height: 1.45;
}
.settings-card-body {
padding: 1.5rem;
}
/* ── Field ── */
.settings-field {
margin-bottom: 1.25rem;
}
.settings-field:last-of-type { margin-bottom: 1.5rem; }
.settings-label {
display: block;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.4rem;
}
.settings-hint {
font-size: 0.78rem;
color: var(--text-muted);
margin: 0 0 0.5rem;
font-style: italic;
}
.settings-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.92rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.settings-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
}
.settings-select { cursor: pointer; appearance: auto; }
/* ── Password input with eye toggle ── */
.settings-pw-wrap {
position: relative;
}
.settings-pw-wrap .settings-input { padding-right: 44px; }
.settings-pw-eye {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
transition: color 0.15s;
}
.settings-pw-eye:hover { color: var(--text); }
/* ── Buttons ── */
.settings-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 9px 18px;
border-radius: 8px;
border: none;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: all 0.18s;
text-decoration: none;
}
.settings-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.settings-btn-primary {
background: var(--primary);
color: #fff;
}
.settings-btn-primary:hover:not(:disabled) {
filter: brightness(1.12);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
}
.settings-btn-secondary {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
.settings-btn-secondary:hover:not(:disabled) {
background: var(--bg);
border-color: var(--primary);
color: var(--primary);
}
.settings-btn-danger {
background: #ef4444;
color: #fff;
}
.settings-btn-danger:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
}
.settings-btn-danger-outline {
background: transparent;
color: #ef4444;
border: 1px solid #ef4444;
}
.settings-btn-danger-outline:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
.settings-btn-warn {
background: #f59e0b;
color: #000;
}
.settings-btn-warn:hover:not(:disabled) {
background: #d97706;
transform: translateY(-1px);
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
display: inline-block;
}
/* ── Status rows (success / warn / error) ── */
.settings-status-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.5;
}
.settings-status-row.success {
background: rgba(74, 222, 128, 0.08);
border: 1px solid rgba(74, 222, 128, 0.25);
color: #4ade80;
}
.settings-status-row.warn {
background: rgba(245, 158, 11, 0.08);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
}
.settings-status-row.error {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #ef4444;
}
/* ── Inline error ── */
.settings-inline-error {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: #ef4444;
margin: -0.75rem 0 1rem;
}
/* ── Toast ── */
.settings-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.8rem 1.25rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 600;
z-index: 9999;
animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
max-width: 340px;
}
.settings-toast.success {
background: #18221a;
border: 1px solid rgba(74, 222, 128, 0.35);
color: #4ade80;
}
.settings-toast.error {
background: #1f1215;
border: 1px solid rgba(239, 68, 68, 0.35);
color: #f87171;
}
@keyframes toastIn {
from { opacity: 0; transform: translateY(12px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── Toggle switch ── */
.settings-toggles { display: flex; flex-direction: column; gap: 0; }
.settings-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
}
.settings-toggle-row:last-child { border-bottom: none; }
.settings-toggle-label { font-size: 0.9rem; font-weight: 600; color: var(--text); margin: 0; }
.settings-toggle-desc { font-size: 0.8rem; color: var(--text-muted); margin: 0.2rem 0 0; }
.settings-toggle {
position: relative;
width: 44px;
height: 24px;
border-radius: 99px;
background: var(--border);
border: none;
cursor: pointer;
transition: background 0.22s;
flex-shrink: 0;
padding: 0;
}
.settings-toggle.on { background: var(--primary); }
.settings-toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.settings-toggle.on .settings-toggle-thumb { transform: translateX(20px); }
/* ── 2FA setup ── */
.settings-2fa-setup { animation: fadeIn 0.2s ease; }
.settings-2fa-step {
display: flex;
align-items: center;
gap: 0.65rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
margin-bottom: 1rem;
}
.settings-2fa-step span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(var(--primary-rgb), 0.18);
color: var(--primary);
font-size: 0.78rem;
font-weight: 800;
flex-shrink: 0;
}
.settings-qr-wrap {
display: inline-block;
padding: 12px;
background: #fff;
border-radius: 10px;
margin-bottom: 1rem;
}
.settings-2fa-manual {
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.6;
}
.settings-secret {
font-family: var(--font-mono);
font-size: 0.88rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 8px;
color: var(--primary);
letter-spacing: 0.05em;
display: inline-block;
margin-top: 0.3rem;
word-break: break-all;
}
.settings-code-input {
font-family: var(--font-mono);
font-size: 1.3rem;
letter-spacing: 0.25em;
text-align: center;
max-width: 160px;
}
/* ── Stripe section ── */
.settings-stripe-box { }
.settings-stripe-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
color: var(--text-muted);
font-size: 0.88rem;
margin-bottom: 1rem;
}
/* ── FAQ ── */
.settings-faq { display: flex; flex-direction: column; gap: 0; }
.settings-faq-item {
padding: 0.9rem 0;
border-bottom: 1px solid var(--border);
}
.settings-faq-item:last-child { border-bottom: none; padding-bottom: 0; }
.settings-faq-q { font-size: 0.88rem; font-weight: 700; color: var(--text); margin: 0 0 0.3rem; }
.settings-faq-a { font-size: 0.82rem; color: var(--text-muted); margin: 0; line-height: 1.55; }
/* ── Danger box ── */
.settings-danger-box { }
/* ── Responsive ── */
@media (max-width: 768px) {
.settings-toast { right: 1rem; left: 1rem; max-width: none; }
}

View File

@ -13,7 +13,6 @@ import CheckEmailPage from './pages/auth/CheckEmailPage';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import AuthorProfilePage from './pages/AuthorProfilePage'; import AuthorProfilePage from './pages/AuthorProfilePage';
import SearchResultsPage from './pages/SearchResultsPage'; import SearchResultsPage from './pages/SearchResultsPage';
import DashboardOverview from './pages/dashboard/DashboardOverview';
import DashboardAudience from './pages/dashboard/DashboardAudience'; import DashboardAudience from './pages/dashboard/DashboardAudience';
import DashboardPlugins from './pages/dashboard/DashboardPlugins'; import DashboardPlugins from './pages/dashboard/DashboardPlugins';
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin'; import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
@ -141,7 +140,7 @@ const App = () => (
<Route path="confirm-email" element={<ConfirmEmailChangePage />} /> <Route path="confirm-email" element={<ConfirmEmailChangePage />} />
<Route path="check-email" element={<CheckEmailPage />} /> <Route path="check-email" element={<CheckEmailPage />} />
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}> <Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
<Route index element={<DashboardOverview />} /> <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={<AddPlugin />} />
@ -169,12 +168,9 @@ const MainLayout = () => {
import './pages/dashboard/Dashboard.css'; import './pages/dashboard/Dashboard.css';
const DashboardLayout = () => { const DashboardLayout = () => {
const { t } = useTranslation();
return ( return (
<div className="dashboard-container"> <div className="dashboard-container">
<nav className="dashboard-nav"> <nav className="dashboard-nav">
<NavLink to="/dashboard" end>{t('dashboard')}</NavLink>
{/* <NavLink to="/dashboard/audience">Audience</NavLink> */}
<NavLink to="/dashboard/plugins">My Plugins</NavLink> <NavLink to="/dashboard/plugins">My Plugins</NavLink>
</nav> </nav>
<div className="dashboard-content"> <div className="dashboard-content">

View File

@ -0,0 +1,304 @@
.dev-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.dev-modal-container {
background: var(--bg-card, #16181d);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 16px;
width: 100%;
max-width: 580px;
padding: 2.25rem;
position: relative;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
color: var(--text-color, #e2e8f0);
}
.dev-modal-close {
position: absolute;
top: 1.25rem;
right: 1.25rem;
background: transparent;
border: none;
color: var(--text-muted, #94a3b8);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.dev-modal-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.dev-modal-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
color: #f97316;
background: rgba(249, 115, 22, 0.12);
padding: 0.25rem 0.6rem;
border-radius: 20px;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dev-modal-header h2 {
font-size: 1.6rem;
font-weight: 700;
margin: 0 0 0.4rem 0;
}
.dev-modal-header p {
color: var(--text-muted, #94a3b8);
font-size: 0.9rem;
margin: 0;
}
.dev-steps-nav {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1.5rem 0 1.75rem 0;
}
.dev-step-pill {
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.75rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted, #94a3b8);
}
.dev-step-pill.active {
background: #f97316;
color: #fff;
}
.dev-step-line {
flex: 1;
height: 2px;
background: rgba(255, 255, 255, 0.08);
}
.dev-modal-error {
display: flex;
align-items: center;
gap: 0.6rem;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.25rem;
}
.dev-entity-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.75rem;
}
.dev-entity-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
padding: 1.25rem;
background: rgba(255, 255, 255, 0.03);
border: 2px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
color: inherit;
}
.dev-entity-card:hover {
border-color: rgba(249, 115, 22, 0.5);
background: rgba(249, 115, 22, 0.04);
}
.dev-entity-card.selected {
border-color: #f97316;
background: rgba(249, 115, 22, 0.08);
}
.dev-entity-icon {
color: #f97316;
}
.dev-entity-card h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
font-weight: 600;
}
.dev-entity-card p {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
line-height: 1.3;
}
.dev-form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1rem;
}
.dev-field-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
}
.dev-input {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #fff;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s ease;
}
.dev-input:focus {
border-color: #f97316;
}
.dev-select {
appearance: none;
cursor: pointer;
}
.dev-hint {
font-size: 0.78rem;
color: var(--text-muted, #94a3b8);
}
.dev-btn-row {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.dev-btn-row-stacked {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dev-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.4rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.dev-btn-primary {
background: #f97316;
color: #fff;
}
.dev-btn-primary:hover:not(:disabled) {
background: #ea580c;
}
.dev-btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.dev-btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.dev-btn-stripe {
background: #6366f1;
color: #fff;
}
.dev-btn-stripe:hover:not(:disabled) {
background: #4f46e5;
}
.dev-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dev-monetization-box {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1.25rem;
}
.dev-monetization-header {
display: flex;
gap: 0.75rem;
align-items: flex-start;
color: #f97316;
}
.dev-monetization-header h4 {
margin: 0 0 0.2rem 0;
color: #fff;
font-size: 1rem;
}
.dev-monetization-header p {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted, #94a3b8);
}
.dev-check-list {
list-style: none;
padding: 0;
margin: 1rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dev-check-list li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #e2e8f0;
}
.dev-check-list svg {
color: #22c55e;
}

View File

@ -0,0 +1,369 @@
import React, { useState } from 'react';
import { getCodeList } from 'country-list';
import { Building2, User, Globe, Mail, CheckCircle2, ChevronRight, AlertCircle, CreditCard, Sparkles, Code2, ShieldCheck, MapPin, FileText } from 'lucide-react';
import api from '../api';
import { useAuth } from '../App';
import './DeveloperOnboardingModal.css';
interface DeveloperOnboardingModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> = ({ isOpen, onClose, onSuccess }) => {
const { user, login } = useAuth();
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const [entityType, setEntityType] = useState<'individual' | 'organization'>('individual');
const [displayName, setDisplayName] = useState(user?.username || '');
const [legalName, setLegalName] = useState('');
const [publishingIntent, setPublishingIntent] = useState('');
// Legal / Address
const [streetAddress, setStreetAddress] = useState('');
const [city, setCity] = useState('');
const [postalCode, setPostalCode] = useState('');
const [vatId, setVatId] = useState('');
const [country, setCountry] = useState(user?.country || '');
// Contact / Social
const [supportEmail, setSupportEmail] = useState(user?.email || '');
const [websiteUrl, setWebsiteUrl] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleCompleteOnboarding = async (connectStripe: boolean) => {
setLoading(true);
setError(null);
try {
const res = await api.post('/user/developer/onboard', {
entityType,
displayName,
legalName: legalName || null,
streetAddress: streetAddress || null,
city: city || null,
postalCode: postalCode || null,
vatId: vatId || null,
publishingIntent: publishingIntent || null,
supportEmail: supportEmail || null,
websiteUrl: websiteUrl || null,
githubUrl: githubUrl || null,
country: country || null,
});
if (res.data.token) {
login(res.data.token);
}
if (connectStripe) {
const stripeRes = await api.post('/stripe/onboard');
if (stripeRes.data.url) {
window.location.href = stripeRes.data.url;
return;
}
}
onSuccess?.();
onClose();
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to complete developer onboarding');
} finally {
setLoading(false);
}
};
return (
<div className="dev-modal-overlay">
<div className="dev-modal-container">
<button className="dev-modal-close" onClick={onClose}>✕</button>
<div className="dev-modal-header">
<div className="dev-modal-badge">
<Sparkles size={14} /> Developer Portal
</div>
<h2>Become a Verified Developer</h2>
<p>Set up your creator profile and legal seller identity to publish plugins on PumpkinMarket.</p>
</div>
{/* Step Indicator */}
<div className="dev-steps-nav">
<div className={`dev-step-pill ${step >= 1 ? 'active' : ''}`}>1. Entity</div>
<div className="dev-step-line" />
<div className={`dev-step-pill ${step >= 2 ? 'active' : ''}`}>2. Legal</div>
<div className="dev-step-line" />
<div className={`dev-step-pill ${step >= 3 ? 'active' : ''}`}>3. Support</div>
<div className="dev-step-line" />
<div className={`dev-step-pill ${step >= 4 ? 'active' : ''}`}>4. Finish</div>
</div>
{error && (
<div className="dev-modal-error">
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
{/* STEP 1: Entity Type & Public Name */}
{step === 1 && (
<div className="dev-step-content">
<label className="dev-field-label">How will you be publishing plugins?</label>
<div className="dev-entity-grid">
<button
type="button"
className={`dev-entity-card ${entityType === 'individual' ? 'selected' : ''}`}
onClick={() => setEntityType('individual')}
>
<div className="dev-entity-icon"><User size={24} /></div>
<div>
<h4>Individual / Solo Developer</h4>
<p>Publishing as an independent creator or hobbyist.</p>
</div>
</button>
<button
type="button"
className={`dev-entity-card ${entityType === 'organization' ? 'selected' : ''}`}
onClick={() => setEntityType('organization')}
>
<div className="dev-entity-icon"><Building2 size={24} /></div>
<div>
<h4>Organization / Studio</h4>
<p>Publishing on behalf of a company, team, or studio.</p>
</div>
</button>
</div>
<div className="dev-form-group" style={{ marginTop: '1.25rem' }}>
<label className="dev-field-label">
{entityType === 'individual' ? 'Public Creator Name' : 'Studio / Organization Name'} *
</label>
<input
type="text"
className="dev-input"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder={entityType === 'individual' ? 'e.g. John Doe or AlexDev' : 'e.g. Acme Studio'}
required
/>
<span className="dev-hint">This name will be publicly shown on all your plugin pages.</span>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><FileText size={14} /> What type of plugins do you plan to publish?</label>
<input
type="text"
className="dev-input"
value={publishingIntent}
onChange={e => setPublishingIntent(e.target.value)}
placeholder="e.g. Economy utilities, Chat moderation tools, Mini-games"
/>
</div>
<button
className="dev-btn dev-btn-primary"
style={{ marginTop: '1.25rem' }}
disabled={!displayName.trim()}
onClick={() => setStep(2)}
>
Continue <ChevronRight size={16} />
</button>
</div>
)}
{/* STEP 2: Legal Identity & Physical Address */}
{step === 2 && (
<div className="dev-step-content">
<div className="dev-form-group">
<label className="dev-field-label">
<ShieldCheck size={14} /> {entityType === 'individual' ? 'Full Legal Name' : 'Registered Business Name'} *
</label>
<input
type="text"
className="dev-input"
value={legalName}
onChange={e => setLegalName(e.target.value)}
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
required
/>
<span className="dev-hint">Used for verification, legal compliance, and tax records.</span>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><MapPin size={14} /> Street Address *</label>
<input
type="text"
className="dev-input"
value={streetAddress}
onChange={e => setStreetAddress(e.target.value)}
placeholder="123 Main St, Suite 400"
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group">
<label className="dev-field-label">City *</label>
<input
type="text"
className="dev-input"
value={city}
onChange={e => setCity(e.target.value)}
placeholder="City"
required
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label">Postal / ZIP Code *</label>
<input
type="text"
className="dev-input"
value={postalCode}
onChange={e => setPostalCode(e.target.value)}
placeholder="10001"
required
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> Country / Region *</label>
<select
className="dev-input dev-select"
value={country}
onChange={e => setCountry(e.target.value)}
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">VAT / Tax ID <span className="dev-hint">(optional)</span></label>
<input
type="text"
className="dev-input"
value={vatId}
onChange={e => setVatId(e.target.value)}
placeholder="EU123456789 or Tax ID"
/>
</div>
</div>
<div className="dev-btn-row" style={{ marginTop: '1.25rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(1)}>Back</button>
<button
className="dev-btn dev-btn-primary"
disabled={!legalName.trim() || !streetAddress.trim() || !city.trim() || !postalCode.trim() || !country}
onClick={() => setStep(3)}
>
Continue <ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* STEP 3: Support Contact & Portfolio Links */}
{step === 3 && (
<div className="dev-step-content">
<div className="dev-form-group">
<label className="dev-field-label"><Mail size={14} /> Support / Public Contact Email *</label>
<input
type="email"
className="dev-input"
value={supportEmail}
onChange={e => setSupportEmail(e.target.value)}
placeholder="support@yourdomain.com"
required
/>
<span className="dev-hint">Where users can reach you for plugin help and questions.</span>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> Website / Portfolio URL</label>
<input
type="url"
className="dev-input"
value={websiteUrl}
onChange={e => setWebsiteUrl(e.target.value)}
placeholder="https://yourwebsite.com"
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><Code2 size={14} /> GitHub Profile / Organization</label>
<input
type="text"
className="dev-input"
value={githubUrl}
onChange={e => setGithubUrl(e.target.value)}
placeholder="https://github.com/yourname"
/>
</div>
<div className="dev-btn-row" style={{ marginTop: '1.5rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(2)}>Back</button>
<button
className="dev-btn dev-btn-primary"
disabled={!supportEmail.trim()}
onClick={() => setStep(4)}
>
Continue <ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* STEP 4: Monetization & Confirmation */}
{step === 4 && (
<div className="dev-step-content">
<div className="dev-monetization-box">
<div className="dev-monetization-header">
<CreditCard size={20} />
<div>
<h4>Publishing & Payouts</h4>
<p>You can publish <strong>Free plugins immediately</strong> without linking a bank account.</p>
</div>
</div>
<ul className="dev-check-list">
<li><CheckCircle2 size={16} /> Publish unlimited free plugins ($0 upload fee)</li>
<li><CheckCircle2 size={16} /> Option to sell paid plugins via Stripe Connect</li>
<li><CheckCircle2 size={16} /> Access full analytics and reviews dashboard</li>
</ul>
</div>
<div className="dev-btn-row-stacked" style={{ marginTop: '1.5rem' }}>
<button
className="dev-btn dev-btn-primary"
disabled={loading}
onClick={() => handleCompleteOnboarding(false)}
>
{loading ? 'Completing setup...' : 'Complete Registration & Start Free Uploads'}
</button>
<button
className="dev-btn dev-btn-stripe"
disabled={loading}
onClick={() => handleCompleteOnboarding(true)}
>
<CreditCard size={16} /> Connect Stripe for Paid Plugins & Complete Setup
</button>
</div>
<div className="dev-btn-row" style={{ marginTop: '1rem' }}>
<button className="dev-btn dev-btn-secondary" onClick={() => setStep(3)}>Back</button>
</div>
</div>
)}
</div>
</div>
);
};
export default DeveloperOnboardingModal;

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import {
} from 'recharts'; } from 'recharts';
import { import {
TrendingUp, TrendingDown, Minus, DollarSign, TrendingUp, TrendingDown, Minus, DollarSign,
Download, MousePointer2, Clock, User Download, MousePointer2, Clock, User, Star
} from 'lucide-react'; } from 'lucide-react';
import { useAnalytics } from './useAnalytics'; import { useAnalytics } from './useAnalytics';
@ -19,7 +19,11 @@ const CustomTooltip = ({ active, payload, label }: any) => {
return ( return (
<div className="custom-tooltip-v2"> <div className="custom-tooltip-v2">
<p className="label">{label}</p> <p className="label">{label}</p>
<p className="value">${payload[0].value.toLocaleString()}</p> {payload.map((entry: any, index: number) => (
<p key={index} className="value" style={{ color: entry.color }}>
{entry.name}: {entry.dataKey === 'earnings' ? `$${entry.value.toLocaleString()}` : entry.value}
</p>
))}
</div> </div>
); );
} }
@ -44,7 +48,6 @@ const MiniChart = ({ data, color, dataKey }: any) => (
); );
const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => { const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => {
// Logic Fix: Handle 0% as neutral
const trendValue = parseFloat(comparison) || 0; const trendValue = parseFloat(comparison) || 0;
const isPositive = trendValue > 0; const isPositive = trendValue > 0;
const isNeutral = trendValue === 0; const isNeutral = trendValue === 0;
@ -80,7 +83,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
}; };
const DashboardOverview = () => { const DashboardOverview = () => {
const { processedData, totals, timeframe, setTimeframe, recentActivity, trends } = useAnalytics(); const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
const timeframes = ['Today', '7 Days', '1 Month', '1 Year']; const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
return ( return (
@ -105,7 +108,7 @@ const DashboardOverview = () => {
<div className="dashboard-grid-v2"> <div className="dashboard-grid-v2">
<main className="main-stats"> <main className="main-stats">
<div className="kpi-row"> <div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
<KpiCard <KpiCard
title="Revenue" title="Revenue"
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`} value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
@ -130,16 +133,24 @@ const DashboardOverview = () => {
chartData={processedData} chartData={processedData}
dataKey="conversion" dataKey="conversion"
/> />
<KpiCard
title="Avg Rating"
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'}
comparison={0}
icon={Star}
chartData={processedData}
dataKey="avgRating"
/>
</div> </div>
<div className="chart-container-v2"> <div className="chart-container-v2" style={{ marginBottom: '1.5rem' }}>
<div className="chart-header"> <div className="chart-header">
<h3>Revenue Growth</h3> <h3>Revenue Growth</h3>
<div className="chart-legend"> <div className="chart-legend">
<span className="legend-item earnings">Earnings ($)</span> <span className="legend-item earnings">Earnings ($)</span>
</div> </div>
</div> </div>
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={320}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}> <AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs> <defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
@ -164,6 +175,7 @@ const DashboardOverview = () => {
<Area <Area
type="monotone" type="monotone"
dataKey="earnings" dataKey="earnings"
name="Earnings ($)"
stroke="#6366f1" stroke="#6366f1"
strokeWidth={3} strokeWidth={3}
fillOpacity={1} fillOpacity={1}
@ -173,9 +185,79 @@ const DashboardOverview = () => {
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* ── Rating Over Time Chart ── */}
<div className="chart-container-v2">
<div className="chart-header">
<h3>Rating Over Time</h3>
<div className="chart-legend">
<span className="legend-item" style={{ color: '#f59e0b' }}>★ Daily Rating (1-5)</span>
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
dy={10}
/>
<YAxis
domain={[1, 5]}
axisLine={false}
tickLine={false}
tick={{fill: '#94a3b8', fontSize: 12}}
/>
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Line
type="monotone"
dataKey="avgRating"
name="Rating"
stroke="#f59e0b"
strokeWidth={3}
dot={{ r: 4, fill: '#f59e0b' }}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
</div>
</main> </main>
<aside className="activity-sidebar-v2"> <aside className="activity-sidebar-v2">
{/* Rating Breakdown Widget */}
{ratingSummary && (
<div className="rating-breakdown-card" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '1.25rem', border: '1px solid var(--border)', marginBottom: '1.5rem' }}>
<h3 style={{ fontSize: '0.95rem', margin: '0 0 1rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Star size={16} fill="#f59e0b" color="#f59e0b" /> Rating Breakdown
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', marginBottom: '1.25rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '2.2rem', fontWeight: 800, color: 'var(--text)' }}>
{ratingSummary.averageRating.toFixed(1)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{ratingSummary.totalReviews} total reviews
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '6px' }}>
{ratingSummary.breakdown?.map((b: any) => {
const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0;
return (
<div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.78rem' }}>
<span style={{ width: '32px', color: 'var(--text-muted)' }}>{b.stars} ★</span>
<div style={{ flex: 1, height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '3px' }} />
</div>
<span style={{ width: '24px', textAlign: 'right', color: 'var(--text-muted)' }}>{b.count}</span>
</div>
);
})}
</div>
</div>
</div>
)}
<div className="sidebar-header"> <div className="sidebar-header">
<h3>Live Activity</h3> <h3>Live Activity</h3>
<span className="live-indicator"></span> <span className="live-indicator"></span>

View File

@ -1,17 +1,20 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { useAnalytics } from './useAnalytics'; import { useAnalytics } from './useAnalytics';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink } from 'lucide-react'; // Added some icons for a cleaner UI import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
import { useAuth } from '../../App';
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
// --- Main Component --- // --- Main Component ---
const DashboardPlugins = () => { const DashboardPlugins = () => {
const { user } = useAuth();
const { timeSeries } = useAnalytics(); const { timeSeries } = useAnalytics();
const navigate = useNavigate(); const navigate = useNavigate();
const [isDevModalOpen, setIsDevModalOpen] = useState(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) => {
// Ensure we use curr.plugin_id or curr.id depending on your hook's structure
const id = curr.plugin_id || curr.id; const id = curr.plugin_id || curr.id;
if (!acc[id]) { if (!acc[id]) {
acc[id] = { acc[id] = {
@ -39,6 +42,40 @@ const DashboardPlugins = () => {
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}; };
if (!user?.is_developer) {
return (
<div className="dashboard-grid-layout">
<div className="data-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'rgba(249, 115, 22, 0.12)', border: '1px solid rgba(249, 115, 22, 0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1.25rem'
}}>
<Sparkles size={32} color="#f97316" />
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem' }}>Developer Onboarding Required</h2>
<p style={{ color: 'var(--dash-text-muted)', maxWidth: '520px', margin: '0 auto 1.75rem', lineHeight: 1.6 }}>
To manage existing plugins or publish new ones, please complete the free <strong>Developer Onboarding</strong> wizard to set up your creator & legal profile.
</p>
<button
className="btn btn-primary"
style={{ padding: '0.75rem 1.75rem', fontSize: '1rem' }}
onClick={() => setIsDevModalOpen(true)}
>
<Sparkles size={18} style={{ marginRight: '8px' }} />
Complete Free Developer Onboarding
</button>
<DeveloperOnboardingModal
isOpen={isDevModalOpen}
onClose={() => setIsDevModalOpen(false)}
onSuccess={() => setIsDevModalOpen(false)}
/>
</div>
</div>
);
}
return ( return (
<div className="dashboard-grid-layout"> <div className="dashboard-grid-layout">
<div className="data-card"> <div className="data-card">

View File

@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import api from '../../../api'; import api from '../../../api';
import { import {
LayoutGrid, Tag, Upload, DollarSign, Trash2, LayoutGrid, Tag, Upload, DollarSign, Trash2,
Circle Circle, BarChart3
} from 'lucide-react'; } from 'lucide-react';
import StoreListing from './StoreListing'; import StoreListing from './StoreListing';
@ -11,6 +11,7 @@ import PublishUpdate from './PublishUpdate';
import Pricing from './Pricing'; import Pricing from './Pricing';
import Coupons from './Coupons'; import Coupons from './Coupons';
import DangerZone from './DangerZone'; import DangerZone from './DangerZone';
import PluginAnalyticsTab from './PluginAnalyticsTab';
import './ManagePlugin.css'; import './ManagePlugin.css';
export type PluginData = { export type PluginData = {
@ -32,11 +33,12 @@ export type PluginData = {
}; };
const NAV_ITEMS = [ const NAV_ITEMS = [
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid }, { key: 'listing', label: 'Store Listing', icon: LayoutGrid },
{ key: 'update', label: 'Publish Update', icon: Upload }, { key: 'update', label: 'Publish Update', icon: Upload },
{ key: 'pricing', label: 'Pricing', icon: DollarSign }, { key: 'analytics', label: 'Analytics', icon: BarChart3 },
{ key: 'coupons', label: 'Coupons', icon: Tag }, { key: 'pricing', label: 'Pricing', icon: DollarSign },
{ key: 'danger', label: 'Danger Zone', icon: Trash2 }, { key: 'coupons', label: 'Coupons', icon: Tag },
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
]; ];
const ManagePlugin = () => { const ManagePlugin = () => {
@ -130,6 +132,9 @@ const ManagePlugin = () => {
{activeTab === 'update' && ( {activeTab === 'update' && (
<PublishUpdate plugin={plugin} onSaved={refreshPlugin} /> <PublishUpdate plugin={plugin} onSaved={refreshPlugin} />
)} )}
{activeTab === 'analytics' && (
<PluginAnalyticsTab pluginId={plugin.id} pluginName={plugin.name} />
)}
{activeTab === 'pricing' && ( {activeTab === 'pricing' && (
<Pricing plugin={plugin} onSaved={refreshPlugin} /> <Pricing plugin={plugin} onSaved={refreshPlugin} />
)} )}

View File

@ -0,0 +1,249 @@
import React from 'react';
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
LineChart, Line
} from 'recharts';
import {
TrendingUp, TrendingDown, Minus, DollarSign,
Download, MousePointer2, Star, BarChart3
} from 'lucide-react';
import { useAnalytics } from '../useAnalytics';
type Props = {
pluginId: number;
pluginName: string;
};
const formatValue = (val: number) => {
if (val >= 1000) return `${(val / 1000).toFixed(1)}k`;
return val.toString();
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="custom-tooltip-v2">
<p className="label">{label}</p>
{payload.map((entry: any, index: number) => (
<p key={index} className="value" style={{ color: entry.color }}>
{entry.name}: {entry.dataKey === 'earnings' ? `$${entry.value.toLocaleString()}` : entry.value}
</p>
))}
</div>
);
}
return null;
};
const MiniChart = ({ data, color, dataKey }: any) => (
<div style={{ width: '80px', height: '40px' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
dot={false}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => {
const trendValue = parseFloat(comparison) || 0;
const isPositive = trendValue > 0;
const isNeutral = trendValue === 0;
const trendColor = isNeutral ? '#94a3b8' : (isPositive ? '#10b981' : '#ef4444');
const TrendIcon = () => {
if (isNeutral) return <Minus size={14} />;
return isPositive ? <TrendingUp size={14} /> : <TrendingDown size={14} />;
};
return (
<div className="kpi-card-v2">
<div className="kpi-header">
<div className="kpi-icon-wrapper"><Icon size={16} /></div>
<span className="kpi-title">{title}</span>
</div>
<div className="kpi-body">
<div className="kpi-main">
<h2 className="kpi-value">{value}</h2>
<div
className={`kpi-trend ${isNeutral ? 'neutral' : (isPositive ? 'up' : 'down')}`}
style={{ color: trendColor }}
>
<TrendIcon />
<span>{isNeutral ? '0%' : `${trendValue}%`}</span>
</div>
</div>
<MiniChart data={chartData} dataKey={dataKey} color={trendColor} />
</div>
</div>
);
};
export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) => {
const { processedData, totals, ratingSummary, timeframe, setTimeframe, loading } = useAnalytics(pluginId);
const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime'];
if (loading) {
return (
<div className="mp-loading">
<div className="mp-loading-spinner" />
<span>Loading analytics for {pluginName}…</span>
</div>
);
}
return (
<div>
<div className="mp-tab-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2>Analytics & Performance</h2>
<p>Track unique downloads, revenue, conversion rates, and review ratings specifically for <strong>{pluginName}</strong>.</p>
</div>
<div className="segment-control">
{timeframes.map((tf) => (
<button
key={tf}
className={tf === timeframe ? 'active' : ''}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
</div>
<div className="mp-card" style={{ padding: '1.25rem' }}>
<div className="kpi-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '1rem' }}>
<KpiCard
title="Revenue"
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
comparison={0}
icon={DollarSign}
chartData={processedData}
dataKey="earnings"
/>
<KpiCard
title="Unique Downloads"
value={totals.downloads.toLocaleString()}
comparison={0}
icon={Download}
chartData={processedData}
dataKey="downloads"
/>
<KpiCard
title="Views"
value={totals.views.toLocaleString()}
comparison={0}
icon={MousePointer2}
chartData={processedData}
dataKey="views"
/>
<KpiCard
title="Conversion Rate"
value={`${totals.conversionRate || 0}%`}
comparison={0}
icon={MousePointer2}
chartData={processedData}
dataKey="conversionRate"
/>
<KpiCard
title="Avg Rating"
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'}
comparison={0}
icon={Star}
chartData={processedData}
dataKey="avgRating"
/>
</div>
</div>
{/* Grid: Charts & Rating Breakdown */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{/* Revenue & Download Growth */}
<div className="mp-card">
<div className="mp-card-title">
<BarChart3 size={16} />
Downloads & Revenue Over Time
</div>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="dlGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3ecf8e" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#3ecf8e" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} tickFormatter={formatValue} />
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Area type="monotone" dataKey="downloads" name="Downloads" stroke="#3ecf8e" strokeWidth={3} fillOpacity={1} fill="url(#dlGradient)" animationDuration={1200} />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Rating History */}
<div className="mp-card">
<div className="mp-card-title">
<Star size={16} color="#f59e0b" />
Rating Trend Over Time
</div>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} dy={10} />
<YAxis domain={[1, 5]} axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
<Line type="monotone" dataKey="avgRating" name="Rating" stroke="#f59e0b" strokeWidth={3} dot={{ r: 4, fill: '#f59e0b' }} animationDuration={1200} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Right Sidebar: Rating Histogram */}
<div>
{ratingSummary && (
<div className="mp-card">
<div className="mp-card-title">
<Star size={16} fill="#f59e0b" color="#f59e0b" />
Rating Breakdown
</div>
<div style={{ textAlign: 'center', margin: '1rem 0' }}>
<div style={{ fontSize: '2.5rem', fontWeight: 800, color: 'var(--mp-text)' }}>
{ratingSummary.averageRating.toFixed(1)}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-2)' }}>
{ratingSummary.totalReviews} customer reviews
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '1.25rem' }}>
{ratingSummary.breakdown?.map((b: any) => {
const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0;
return (
<div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.8rem' }}>
<span style={{ width: '32px', color: 'var(--mp-text-2)' }}>{b.stars} ★</span>
<div style={{ flex: 1, height: '7px', background: 'rgba(255,255,255,0.06)', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '4px' }} />
</div>
<span style={{ width: '28px', textAlign: 'right', color: 'var(--mp-text-3)' }}>{b.count}</span>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default PluginAnalyticsTab;

View File

@ -74,9 +74,7 @@ const Pricing = ({ plugin, onSaved }: Props) => {
} }
try { try {
await api.put(`/plugins/${plugin.id}`, fd, { await api.put(`/plugins/${plugin.id}`, fd);
headers: { 'Content-Type': 'multipart/form-data' },
});
onSaved(); onSaved();
} catch { } catch {
alert('Failed to save pricing.'); alert('Failed to save pricing.');

View File

@ -1,15 +1,20 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Upload, FileCode, Send, CheckCircle} from 'lucide-react'; import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react';
import api from '../../../api'; import api from '../../../api';
import type { PluginData } from './ManagePlugin'; import type { PluginData } from './ManagePlugin';
type ReleaseTrack = 'stable' | 'beta' | 'alpha';
type Props = { plugin: PluginData; onSaved: () => void }; type Props = { plugin: PluginData; onSaved: () => void };
const TRACKS = [
{ key: 'stable', label: 'Production (Stable)', desc: 'Available to all users across the marketplace.' },
{ key: 'beta', label: 'Beta', desc: 'Public beta release for community testing.' },
{ key: 'alpha', label: 'Alpha / Testing', desc: 'Early access build for testers.' },
] as const;
const PublishUpdate = ({ plugin, onSaved }: Props) => { const PublishUpdate = ({ plugin, onSaved }: Props) => {
const [wasmFile, setWasmFile] = useState<File | null>(null); const [wasmFile, setWasmFile] = useState<File | null>(null);
const [track] = useState<ReleaseTrack>('stable');
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [track, setTrack] = useState<'stable' | 'beta' | 'alpha'>('stable');
const [releaseNotes, setReleaseNotes] = useState(''); const [releaseNotes, setReleaseNotes] = useState('');
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -28,14 +33,12 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
setUploading(true); setUploading(true);
const fd = new FormData(); const fd = new FormData();
fd.append('wasm', wasmFile); fd.append('wasm', wasmFile);
fd.append('track', track);
if (version) fd.append('version', version); if (version) fd.append('version', version);
fd.append('track', track);
if (releaseNotes) fd.append('release_notes', releaseNotes); if (releaseNotes) fd.append('release_notes', releaseNotes);
try { try {
await api.put(`/plugins/${plugin.id}`, fd, { await api.put(`/plugins/${plugin.id}`, fd);
headers: { 'Content-Type': 'multipart/form-data' },
});
setPublished(true); setPublished(true);
onSaved(); onSaved();
setTimeout(() => { setTimeout(() => {
@ -71,7 +74,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
<div> <div>
<p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p> <p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p>
<p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}> <p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}>
Your new version is being rolled out on the <strong>{track}</strong> track. Your new version has been published successfully to the <strong>{track}</strong> track.
</p> </p>
</div> </div>
</div> </div>
@ -86,7 +89,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
<p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p> <p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p>
</div> </div>
{/* ── Release Track ── {/* ── Release Track ── */}
<div className="mp-card"> <div className="mp-card">
<div className="mp-card-title"> <div className="mp-card-title">
<Send size={14} /> <Send size={14} />
@ -110,18 +113,18 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
</div> </div>
{track === 'stable' && ( {track === 'stable' && (
<div className="mp-banner warn"> <div className="mp-banner warn" style={{ marginTop: '1rem' }}>
<AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} /> <AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} />
<span>Stable releases are immediately visible to all users. Make sure your build is production-ready.</span> <span>Production releases are immediately visible to all users. Make sure your build is tested.</span>
</div> </div>
)} )}
{track === 'alpha' && ( {track !== 'stable' && (
<div className="mp-banner info"> <div className="mp-banner info" style={{ marginTop: '1rem' }}>
<Clock size={16} style={{flexShrink:0, marginTop:1}} /> <Clock size={16} style={{flexShrink:0, marginTop:1}} />
<span>Internal track is only visible to whitelisted user IDs. Configure access in your plugin settings.</span> <span>Pre-release ({track}) tracks allow community testing before promoting to production.</span>
</div> </div>
)} )}
</div> */} </div>
{/* ── Version & Notes ── */} {/* ── Version & Notes ── */}
<div className="mp-card"> <div className="mp-card">
@ -203,7 +206,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
disabled={!wasmFile || uploading} disabled={!wasmFile || uploading}
> >
<Send size={15} /> <Send size={15} />
{uploading ? 'Publishing…' : `Publish to ${track.charAt(0).toUpperCase() + track.slice(1)}`} {uploading ? 'Publishing…' : 'Publish Update'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -186,9 +186,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
fd.append('keywords', keywords); fd.append('keywords', keywords);
fd.append('is_early_access', String(isEarlyAccess)); fd.append('is_early_access', String(isEarlyAccess));
try { try {
await api.put(`/plugins/${plugin.id}`, fd, { await api.put(`/plugins/${plugin.id}`, fd);
headers: { 'Content-Type': 'multipart/form-data' },
});
onSaved(); onSaved();
} catch { } catch {
alert('Failed to save.'); alert('Failed to save.');
@ -213,9 +211,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const fd = new FormData(); const fd = new FormData();
Array.from(files).forEach(f => fd.append('screenshots', f)); Array.from(files).forEach(f => fd.append('screenshots', f));
try { try {
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd, { const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd);
headers: { 'Content-Type': 'multipart/form-data' },
});
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]); setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
} catch { } catch {
alert('Screenshot upload failed.'); alert('Screenshot upload failed.');

View File

@ -1,8 +1,9 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import api from '../../api'; import api from '../../api';
export const useAnalytics = () => { export const useAnalytics = (pluginId?: number) => {
const [timeSeries, setTimeSeries] = useState<any[]>([]); const [timeSeries, setTimeSeries] = useState<any[]>([]);
const [ratingSummary, setRatingSummary] = useState<any>(null);
const [mapData, setMapData] = useState<any[]>([]); const [mapData, setMapData] = useState<any[]>([]);
const [breakdowns, setBreakdowns] = useState<any[]>([]); const [breakdowns, setBreakdowns] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -11,26 +12,26 @@ export const useAnalytics = () => {
const fetchData = useCallback(async (selectedTimeframe: string) => { const fetchData = useCallback(async (selectedTimeframe: string) => {
setLoading(true); setLoading(true);
try { try {
// PRO TIP: Check if your 'api' instance already prepends '/api' const params: any = { timeframe: selectedTimeframe };
// If it doesn't, use '/api/dev/analytics' if (pluginId) {
const res = await api.get(`/user/analytics`, { params.plugin_id = pluginId;
params: { timeframe: selectedTimeframe } }
}); const res = await api.get(`/user/analytics`, { params });
// Ensure we are accessing the correct keys from your Express res.json
setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []); setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []);
setRatingSummary(res.data.ratingSummary || null);
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []); setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []); setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
} catch (error) { } catch (error) {
console.error("Analytics Fetch Error:", error); console.error("Analytics Fetch Error:", error);
// Reset data on error so the UI doesn't show stale/broken stats
setTimeSeries([]); setTimeSeries([]);
setRatingSummary(null);
setMapData([]); setMapData([]);
setBreakdowns([]); setBreakdowns([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [pluginId]);
useEffect(() => { useEffect(() => {
fetchData(timeframe); fetchData(timeframe);
@ -43,11 +44,18 @@ export const useAnalytics = () => {
const dailyMap = timeSeries.reduce((acc, curr) => { const dailyMap = timeSeries.reduce((acc, curr) => {
const day = curr.date || 'Unknown'; const day = curr.date || 'Unknown';
if (!acc[day]) { if (!acc[day]) {
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0 }; acc[day] = { date: day, views: 0, downloads: 0, earnings: 0, avgRating: 0, reviewCount: 0, conversionRate: 0 };
} }
acc[day].views += Number(curr.views || 0); acc[day].views += Number(curr.views || 0);
acc[day].downloads += Number(curr.downloads || 0); acc[day].downloads += Number(curr.downloads || 0);
acc[day].earnings += Number(curr.earnings || 0); acc[day].earnings += Number(curr.earnings || 0);
if (curr.avgRating > 0) {
acc[day].avgRating = curr.avgRating;
}
acc[day].reviewCount += Number(curr.reviewCount || 0);
acc[day].conversionRate = acc[day].views > 0
? Math.round((acc[day].downloads / acc[day].views) * 1000) / 10
: 0;
return acc; return acc;
}, {} as Record<string, any>); }, {} as Record<string, any>);
@ -58,16 +66,23 @@ export const useAnalytics = () => {
// Calculate global totals across all plugins // Calculate global totals across all plugins
const totals = useMemo(() => { const totals = useMemo(() => {
return timeSeries.reduce((acc, curr) => { const raw = timeSeries.reduce((acc, curr) => {
acc.views += Number(curr.views || 0); acc.views += Number(curr.views || 0);
acc.downloads += Number(curr.downloads || 0); acc.downloads += Number(curr.downloads || 0);
acc.earnings += Number(curr.earnings || 0); acc.earnings += Number(curr.earnings || 0);
return acc; return acc;
}, { views: 0, downloads: 0, earnings: 0 }); }, { views: 0, downloads: 0, earnings: 0 });
const conversionRate = raw.views > 0
? Math.round((raw.downloads / raw.views) * 1000) / 10
: 0;
return { ...raw, conversionRate };
}, [timeSeries]); }, [timeSeries]);
return { return {
timeSeries, timeSeries,
ratingSummary,
mapData, mapData,
breakdowns, breakdowns,
processedData, processedData,
@ -75,8 +90,8 @@ export const useAnalytics = () => {
loading, loading,
timeframe, timeframe,
setTimeframe, setTimeframe,
recentActivity: [], // Placeholder for activity feed recentActivity: [],
trends: { earnings: 0, downloads: 0, conversion: 0 }, // Placeholder for trend percentages trends: { earnings: 0, downloads: 0, conversion: 0 },
refresh: () => fetchData(timeframe) refresh: () => fetchData(timeframe)
}; };
}; };