diff --git a/package-lock.json b/package-lock.json index d6725d2..0e175a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "d3-scale": "^4.0.2", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.26.0", + "lucide-react": "^1.27.0", "or": "^0.2.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", @@ -25,7 +25,7 @@ "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.1", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1" }, "devDependencies": { @@ -35,7 +35,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", @@ -385,9 +385,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2436,9 +2436,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -2448,7 +2448,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -2472,7 +2472,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3549,9 +3549,9 @@ } }, "node_modules/lucide-react": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", - "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -4843,9 +4843,9 @@ } }, "node_modules/recharts": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", - "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "license": "MIT", "workspaces": [ "www" diff --git a/package.json b/package.json index c7950d5..048b1f3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "d3-scale": "^4.0.2", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.26.0", + "lucide-react": "^1.27.0", "or": "^0.2.0", "qrcode.react": "^4.2.0", "react": "^19.2.8", @@ -28,7 +28,7 @@ "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.1", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1" }, "devDependencies": { @@ -38,7 +38,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", diff --git a/src/App.css b/src/App.css index bee433b..5009dfd 100644 --- a/src/App.css +++ b/src/App.css @@ -3130,4 +3130,442 @@ h3 { .lightbox-dot:hover:not(.active) { 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; } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 5d8e395..cb628ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,6 @@ import CheckEmailPage from './pages/auth/CheckEmailPage'; import ProfilePage from './pages/ProfilePage'; import AuthorProfilePage from './pages/AuthorProfilePage'; import SearchResultsPage from './pages/SearchResultsPage'; -import DashboardOverview from './pages/dashboard/DashboardOverview'; import DashboardAudience from './pages/dashboard/DashboardAudience'; import DashboardPlugins from './pages/dashboard/DashboardPlugins'; import ManagePlugin from './pages/dashboard/plugin/ManagePlugin'; @@ -141,7 +140,7 @@ const App = () => ( } /> } /> }> - } /> + } /> } /> } /> } /> @@ -169,12 +168,9 @@ const MainLayout = () => { import './pages/dashboard/Dashboard.css'; const DashboardLayout = () => { - const { t } = useTranslation(); return (
diff --git a/src/components/DeveloperOnboardingModal.css b/src/components/DeveloperOnboardingModal.css new file mode 100644 index 0000000..237a1da --- /dev/null +++ b/src/components/DeveloperOnboardingModal.css @@ -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; +} diff --git a/src/components/DeveloperOnboardingModal.tsx b/src/components/DeveloperOnboardingModal.tsx new file mode 100644 index 0000000..83a900e --- /dev/null +++ b/src/components/DeveloperOnboardingModal.tsx @@ -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 = ({ 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(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 ( +
+
+ + +
+
+ Developer Portal +
+

Become a Verified Developer

+

Set up your creator profile and legal seller identity to publish plugins on PumpkinMarket.

+
+ + {/* Step Indicator */} +
+
= 1 ? 'active' : ''}`}>1. Entity
+
+
= 2 ? 'active' : ''}`}>2. Legal
+
+
= 3 ? 'active' : ''}`}>3. Support
+
+
= 4 ? 'active' : ''}`}>4. Finish
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* STEP 1: Entity Type & Public Name */} + {step === 1 && ( +
+ +
+ + + +
+ +
+ + setDisplayName(e.target.value)} + placeholder={entityType === 'individual' ? 'e.g. John Doe or AlexDev' : 'e.g. Acme Studio'} + required + /> + This name will be publicly shown on all your plugin pages. +
+ +
+ + setPublishingIntent(e.target.value)} + placeholder="e.g. Economy utilities, Chat moderation tools, Mini-games" + /> +
+ + +
+ )} + + {/* STEP 2: Legal Identity & Physical Address */} + {step === 2 && ( +
+
+ + setLegalName(e.target.value)} + placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'} + required + /> + Used for verification, legal compliance, and tax records. +
+ +
+ + setStreetAddress(e.target.value)} + placeholder="123 Main St, Suite 400" + required + /> +
+ +
+
+ + setCity(e.target.value)} + placeholder="City" + required + /> +
+
+ + setPostalCode(e.target.value)} + placeholder="10001" + required + /> +
+
+ +
+
+ + +
+ +
+ + setVatId(e.target.value)} + placeholder="EU123456789 or Tax ID" + /> +
+
+ +
+ + +
+
+ )} + + {/* STEP 3: Support Contact & Portfolio Links */} + {step === 3 && ( +
+
+ + setSupportEmail(e.target.value)} + placeholder="support@yourdomain.com" + required + /> + Where users can reach you for plugin help and questions. +
+ +
+ + setWebsiteUrl(e.target.value)} + placeholder="https://yourwebsite.com" + /> +
+ +
+ + setGithubUrl(e.target.value)} + placeholder="https://github.com/yourname" + /> +
+ +
+ + +
+
+ )} + + {/* STEP 4: Monetization & Confirmation */} + {step === 4 && ( +
+
+
+ +
+

Publishing & Payouts

+

You can publish Free plugins immediately without linking a bank account.

+
+
+
    +
  • Publish unlimited free plugins ($0 upload fee)
  • +
  • Option to sell paid plugins via Stripe Connect
  • +
  • Access full analytics and reviews dashboard
  • +
+
+ +
+ + +
+ +
+ +
+
+ )} +
+
+ ); +}; + +export default DeveloperOnboardingModal; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 11cacbb..fec844a 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -4,6 +4,13 @@ import { QRCodeSVG } from 'qrcode.react'; import api from '../api'; import { useAuth } from '../App'; import { getCodeList } from 'country-list'; +import DeveloperOnboardingModal from '../components/DeveloperOnboardingModal'; +import { + User, Mail, Globe, Lock, Shield, CreditCard, + BookOpen, AlertTriangle, LogOut, CheckCircle, + AlertCircle, Eye, EyeOff, ChevronRight, Bell, + Smartphone, Key, Trash2, Code, Sparkles, Building2 +} from 'lucide-react'; interface LibraryEntry { plugin_id: number; @@ -15,47 +22,121 @@ interface LibraryEntry { purchased_at: string; } +type Tab = 'account' | 'security' | 'developer' | 'notifications' | 'library' | 'danger'; + +const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [ + { key: 'account', label: 'Account', icon: User }, + { key: 'security', label: 'Security', icon: Shield }, + { key: 'developer', label: 'Developer Profile', icon: Code }, + { key: 'notifications', label: 'Notifications', icon: Bell }, + { key: 'library', label: 'Library', icon: BookOpen }, + { key: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true }, +]; + const ProfilePage = () => { const { user, login, logout, refreshUser } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const [activeTab, setActiveTab] = useState('accountDetails'); + const [activeTab, setActiveTab] = useState('account'); useEffect(() => { const params = new URLSearchParams(location.search); - const tab = params.get('tab'); - if (tab === 'payouts') setActiveTab('payouts'); - else if (tab === 'security') setActiveTab('security'); - else if (tab === 'danger') setActiveTab('danger'); - else if (tab === 'library') setActiveTab('library'); - else setActiveTab('accountDetails'); + const tab = params.get('tab') as Tab | null; + if (tab && NAV.some(n => n.key === tab)) setActiveTab(tab); + else setActiveTab('account'); }, [location]); - const [formData, setFormData] = useState({ username: '', email: '', country: '', password: '', currentPassword: '', disable2faPassword: '' }); - const [message, setMessage] = useState({ text: '', type: '' }); - const [isStripeLoading, setIsStripeLoading] = useState(false); + const [formData, setFormData] = useState({ + username: '', email: '', country: '', + password: '', currentPassword: '', disable2faPassword: '', + newPassword: '', confirmPassword: '', + }); + const [showCurrentPw, setShowCurrentPw] = useState(false); + const [showNewPw, setShowNewPw] = useState(false); + const [showConfirmPw, setShowConfirmPw] = useState(false); + const [show2faPw, setShow2faPw] = useState(false); - // Library state + const [toast, setToast] = useState<{ text: string; type: 'success' | 'error' } | null>(null); + const [isStripeLoading, setIsStripeLoading] = useState(false); + const [saving, setSaving] = useState>({}); + + // Library const [library, setLibrary] = useState([]); const [libraryLoading, setLibraryLoading] = useState(false); const [libraryError, setLibraryError] = useState(null); - // 2FA state + // 2FA const [is2faEnabled, setIs2faEnabled] = useState(false); - const [setup2faData, setSetup2faData] = useState<{ uri: string, secret: string } | null>(null); + const [setup2faData, setSetup2faData] = useState<{ uri: string; secret: string } | null>(null); const [verify2faCode, setVerify2faCode] = useState(''); + // Notifications (UI-only for now, persisted locally) + const [notifPrefs, setNotifPrefs] = useState({ + purchaseEmail: true, + reviewEmail: true, + updateEmail: false, + marketingEmail: false, + }); + useEffect(() => { refreshUser?.(); }, []); useEffect(() => { if (user) { - setFormData(prev => ({ ...prev, username: user.username || '', email: user.email || '', country: user.country || '' })); + setFormData(prev => ({ + ...prev, + username: user.username || '', + email: user.email || '', + country: user.country || '', + })); setIs2faEnabled(user.totp_enabled || false); } }, [user]); - // Fetch library when tab becomes active + // Developer Profile State + const [isDevModalOpen, setIsDevModalOpen] = useState(false); + const [devProfile, setDevProfile] = useState(null); + const [devProfileForm, setDevProfileForm] = useState({ + displayName: '', legalName: '', streetAddress: '', city: '', postalCode: '', vatId: '', publishingIntent: '', supportEmail: '', websiteUrl: '', githubUrl: '', + }); + + useEffect(() => { + if (activeTab === 'developer' && user?.is_developer) { + api.get('/user/developer/profile') + .then(res => { + if (res.data.profile) { + setDevProfile(res.data.profile); + setDevProfileForm({ + displayName: res.data.profile.display_name || '', + legalName: res.data.profile.legal_name || '', + streetAddress: res.data.profile.street_address || '', + city: res.data.profile.city || '', + postalCode: res.data.profile.postal_code || '', + vatId: res.data.profile.vat_id || '', + publishingIntent: res.data.profile.publishing_intent || '', + supportEmail: res.data.profile.support_email || '', + websiteUrl: res.data.profile.website_url || '', + githubUrl: res.data.profile.github_url || '', + }); + } + }) + .catch(err => console.error('Failed to load dev profile', err)); + } + }, [activeTab, user?.is_developer]); + + const handleUpdateDevProfile = async (e: React.FormEvent) => { + e.preventDefault(); + await withSaving('devProfile', async () => { + try { + const res = await api.put('/user/developer/profile', devProfileForm); + setDevProfile(res.data.profile); + showToast('Developer profile updated!'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Failed to update developer profile.', 'error'); + } + }); + }; + useEffect(() => { if (activeTab !== 'library') return; setLibraryLoading(true); @@ -66,20 +147,27 @@ const ProfilePage = () => { .finally(() => setLibraryLoading(false)); }, [activeTab]); - const showMessage = (text: string, isError = false) => { - setMessage({ text, type: isError ? 'error' : 'success' }); - setTimeout(() => setMessage({ text: '', type: '' }), 4000); + const showToast = (text: string, type: 'success' | 'error' = 'success') => { + setToast({ text, type }); + setTimeout(() => setToast(null), 4000); }; - const handleUpdate = async (e: React.FormEvent, endpoint: string, payload: object) => { + const withSaving = async (key: string, fn: () => Promise) => { + setSaving(s => ({ ...s, [key]: true })); + try { await fn(); } finally { setSaving(s => ({ ...s, [key]: false })); } + }; + + const handleUpdate = async (e: React.FormEvent, endpoint: string, payload: object, key: string) => { e.preventDefault(); - try { - const res = await api.post(endpoint, payload); - if (res.data.token) login(res.data.token); - showMessage(res.data.message || 'Updated successfully!'); - } catch (err: any) { - showMessage(err.response?.data?.error || 'Update failed.', true); - } + await withSaving(key, async () => { + try { + const res = await api.post(endpoint, payload); + if (res.data.token) login(res.data.token); + showToast(res.data.message || 'Updated successfully!'); + } catch (err: any) { + showToast(err.response?.data?.error || 'Update failed.', 'error'); + } + }); }; const handleLogout = () => { logout(); navigate('/login'); }; @@ -90,7 +178,7 @@ const ProfilePage = () => { const res = await api.post('/stripe/onboard'); if (res.data.url) window.location.href = res.data.url; } catch (err: any) { - showMessage(err.response?.data?.error || 'Failed to start Stripe onboarding.', true); + showToast(err.response?.data?.error || 'Failed to start Stripe onboarding.', 'error'); setIsStripeLoading(false); } }; @@ -101,7 +189,7 @@ const ProfilePage = () => { const res = await api.get('/stripe/dashboard'); if (res.data.url) window.location.href = res.data.url; } catch (err: any) { - showMessage(err.response?.data?.error || 'Failed to open Stripe dashboard.', true); + showToast(err.response?.data?.error || 'Failed to open Stripe dashboard.', 'error'); setIsStripeLoading(false); } }; @@ -113,7 +201,7 @@ const ProfilePage = () => { logout(); navigate('/login'); } catch (err: any) { - showMessage(err.response?.data?.error || 'Failed to delete account.', true); + showToast(err.response?.data?.error || 'Failed to delete account.', 'error'); } }; @@ -122,7 +210,7 @@ const ProfilePage = () => { const res = await api.post('/user/2fa/setup'); setSetup2faData(res.data); } catch (err: any) { - showMessage(err.response?.data?.error || 'Failed to start 2FA setup.', true); + showToast(err.response?.data?.error || 'Failed to start 2FA setup.', 'error'); if (err.response?.status === 400 && err.response?.data?.error === '2FA is already enabled') { setIs2faEnabled(true); refreshUser?.(); @@ -137,10 +225,10 @@ const ProfilePage = () => { setIs2faEnabled(true); setSetup2faData(null); setVerify2faCode(''); - showMessage('2FA enabled successfully!'); + showToast('2FA enabled successfully!'); refreshUser?.(); } catch (err: any) { - showMessage(err.response?.data?.error || 'Invalid 2FA code.', true); + showToast(err.response?.data?.error || 'Invalid 2FA code.', 'error'); } }; @@ -150,132 +238,589 @@ const ProfilePage = () => { await api.post('/user/2fa/disable', { password: formData.disable2faPassword }); setIs2faEnabled(false); setFormData({ ...formData, disable2faPassword: '' }); - showMessage('2FA has been disabled.'); + showToast('2FA has been disabled.'); refreshUser?.(); } catch (err: any) { - showMessage(err.response?.data?.error || 'Failed to disable 2FA.', true); + showToast(err.response?.data?.error || 'Failed to disable 2FA.', 'error'); } }; + // ── helpers ────────────────────────────────────────────────────────────────── + + const SettingsCard = ({ title, description, icon: Icon, children }: { + title: string; description?: string; icon?: React.FC<{ size?: number }>; children: React.ReactNode; + }) => ( +
+ {(title || Icon) && ( +
+ {Icon &&
} +
+

{title}

+ {description &&

{description}

} +
+
+ )} +
{children}
+
+ ); + + const Field = ({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) => ( +
+ + {hint &&

{hint}

} + {children} +
+ ); + + const SaveBtn = ({ id, label = 'Save Changes' }: { id: string; label?: string }) => ( + + ); + + const Toggle = ({ checked, onChange, label, desc }: { checked: boolean; onChange: (v: boolean) => void; label: string; desc?: string }) => ( +
+
+

{label}

+ {desc &&

{desc}

} +
+ +
+ ); + + const PwInput = ({ placeholder, value, onChange, show, onToggle }: { + placeholder: string; value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void; + }) => ( +
+ onChange(e.target.value)} + required + /> + +
+ ); + + // ── render ──────────────────────────────────────────────────────────────────── + return (
-

User Profile

+

Account Settings

- {message.text && ( -

- {message.text} -

+ {/* Toast */} + {toast && ( +
+ {toast.type === 'success' + ? + : } + {toast.text} +
)}
+ {/* ── Sidebar nav ── */} + {/* ── Main content ── */}
- {/* ── Account Details ── */} - {activeTab === 'accountDetails' && ( + {/* ══ ACCOUNT ══════════════════════════════════════════════════ */} + {activeTab === 'account' && (
-

Change Account Details

+
+

+ Account Details +

+

Manage your public identity and contact information.

+
-
handleUpdate(e, '/user/change-username', { newUsername: formData.username })}> -
- - setFormData({ ...formData, username: e.target.value })} /> -
- -
+ +
handleUpdate(e, '/user/change-username', { newUsername: formData.username }, 'username')}> + + setFormData({ ...formData, username: e.target.value })} + placeholder="Your username" + /> + + + +
-
handleUpdate(e, '/user/change-email', { newEmail: formData.email })} style={{ marginTop: '2rem' }}> -
- - setFormData({ ...formData, email: e.target.value })} /> -
- -
+ +
handleUpdate(e, '/user/change-email', { newEmail: formData.email }, 'email')}> + + setFormData({ ...formData, email: e.target.value })} + placeholder="your@email.com" + /> + + + +
-
{ - if (!formData.country) { - e.preventDefault(); - showMessage('Please select a country', true); - return; - } - handleUpdate(e, '/user/change-country', { newCountry: formData.country }); - }} style={{ marginTop: '2rem' }}> -
- -

- Auto-detected from your IP address during registration. You can change it here. -

- -
- -
+ +
{ + if (!formData.country) { + e.preventDefault(); + showToast('Please select a country.', 'error'); + return; + } + handleUpdate(e, '/user/change-country', { newCountry: formData.country }, 'country'); + }}> + + + + + +
)} - {/* ── Library ── */} + {/* ══ SECURITY ══════════════════════════════════════════════════ */} + {activeTab === 'security' && ( +
+
+

+ Security Settings +

+

Protect your account with a strong password and two-factor authentication.

+
+ + {/* Password */} + +
{ + if (formData.newPassword !== formData.confirmPassword) { + e.preventDefault(); + showToast('Passwords do not match.', 'error'); + return; + } + handleUpdate(e, '/user/settings', { + currentPassword: formData.currentPassword, + newPassword: formData.newPassword, + }, 'password'); + }}> + + setFormData({ ...formData, currentPassword: v })} + show={showCurrentPw} + onToggle={() => setShowCurrentPw(s => !s)} + /> + + + setFormData({ ...formData, newPassword: v })} + show={showNewPw} + onToggle={() => setShowNewPw(s => !s)} + /> + + + setFormData({ ...formData, confirmPassword: v })} + show={showConfirmPw} + onToggle={() => setShowConfirmPw(s => !s)} + /> + + {formData.newPassword && formData.confirmPassword && formData.newPassword !== formData.confirmPassword && ( +

Passwords do not match

+ )} + + +
+ + {/* 2FA */} + + {is2faEnabled ? ( +
+
+ + 2FA is currently enabled on your account. +
+
+ + setFormData({ ...formData, disable2faPassword: v })} + show={show2faPw} + onToggle={() => setShow2faPw(s => !s)} + /> + + +
+
+ ) : !setup2faData ? ( +
+
+ + 2FA is not enabled. We recommend enabling it for account protection. +
+ +
+ ) : ( +
+

1 Scan this QR code with your authenticator app:

+
+ +
+

+ Or enter this code manually:
+ {setup2faData.secret} +

+

2 Enter the 6-digit code from your app:

+
+ setVerify2faCode(e.target.value.replace(/\D/g, ''))} + required + /> + +
+
+ )} +
+
+ )} + + {/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */} + {activeTab === 'developer' && ( +
+
+

+ Developer Account & Payouts +

+

Manage your creator identity, support information, and payout settings.

+
+ + {!user?.is_developer ? ( + +
+ +

You haven't set up a Developer Profile yet.

+

+ Individual creators and organizations can publish plugins for $0. Start your onboarding now! +

+ +
+
+ ) : ( + <> + {/* Developer Details */} + {devProfile && ( + +
+ + + + + + setDevProfileForm({ ...devProfileForm, displayName: e.target.value })} + required + /> + + + + setDevProfileForm({ ...devProfileForm, legalName: e.target.value })} + /> + + + + setDevProfileForm({ ...devProfileForm, streetAddress: e.target.value })} + placeholder="123 Main St" + /> + + +
+ + setDevProfileForm({ ...devProfileForm, city: e.target.value })} + /> + + + setDevProfileForm({ ...devProfileForm, postalCode: e.target.value })} + /> + +
+ + + setDevProfileForm({ ...devProfileForm, vatId: e.target.value })} + placeholder="EU123456789" + /> + + + + setDevProfileForm({ ...devProfileForm, supportEmail: e.target.value })} + placeholder="support@domain.com" + /> + + + + setDevProfileForm({ ...devProfileForm, websiteUrl: e.target.value })} + placeholder="https://website.com" + /> + + + + setDevProfileForm({ ...devProfileForm, githubUrl: e.target.value })} + placeholder="https://github.com/username" + /> + + + + +
+ )} + + {/* Payouts / Stripe */} + +
+ {user?.stripe_ready ? ( + <> +
+ +
+

Stripe account connected

+

Your payouts are active and ready to receive funds from paid plugin sales.

+
+
+ + + ) : user?.stripe_account_id ? ( + <> +
+ +
+

Stripe onboarding incomplete

+

Your account was created but hasn't been fully activated yet.

+
+
+ + + ) : ( + <> +
+ +

No Stripe account linked yet. Free plugin uploads are fully enabled.

+
+ + + )} +
+
+ + +
+ {[ + { q: 'Can I upload free plugins without Stripe?', a: 'Yes! Stripe Connect is only required if you want to sell paid plugins.' }, + { q: 'When do I get paid for sales?', a: 'Stripe transfers earnings automatically within 2–7 business days.' }, + { q: 'What platform fee applies?', a: 'PumpkinMarket takes a low platform commission per sale. Stripe processing fees apply.' }, + ].map(({ q, a }) => ( +
+

{q}

+

{a}

+
+ ))} +
+
+ + )} +
+ )} + + {/* ══ NOTIFICATIONS ═════════════════════════════════════════════ */} + {activeTab === 'notifications' && ( +
+
+

+ Notification Preferences +

+

Choose which emails and alerts you want to receive.

+
+ + +
+ setNotifPrefs(p => ({ ...p, purchaseEmail: v }))} + label="New purchase" + desc="Receive an email whenever someone buys your plugin." + /> + setNotifPrefs(p => ({ ...p, reviewEmail: v }))} + label="New review" + desc="Get notified when a user leaves a review on your plugin." + /> + setNotifPrefs(p => ({ ...p, updateEmail: v }))} + label="Marketplace updates" + desc="Important changes to marketplace policies or features." + /> + setNotifPrefs(p => ({ ...p, marketingEmail: v }))} + label="Tips & promotions" + desc="Occasional tips to grow your plugin sales." + /> +
+
+ +
+
+
+ )} + + {/* ══ LIBRARY ═══════════════════════════════════════════════════ */} {activeTab === 'library' && (
-
-

My Library

- {library.length > 0 && ( - - {library.length} plugin{library.length !== 1 ? 's' : ''} - - )} +
+

+ My Library + {library.length > 0 && ( + + {library.length} plugin{library.length !== 1 ? 's' : ''} + + )} +

+

Your purchased plugins, available to download at any time.

-

- Your purchased plugins. Click to visit the plugin page and download. -

{libraryLoading && (
@@ -313,42 +858,31 @@ const ProfilePage = () => { className="library-card" style={{ animationDelay: `${idx * 0.05}s` }} > - {/* Preview image */}
{entry.preview_path ? ( - {entry.name} + {entry.name} ) : (
{entry.name.charAt(0).toUpperCase()}
)} - {/* Owned badge */}
Owned
- {/* Category tag */} {entry.category && (
{entry.category}
)}
- - {/* Info */}
{entry.name}
by {entry.dev_name} -
- - €{(entry.amount_cents / 100).toFixed(2)} - + €{(entry.amount_cents / 100).toFixed(2)} {new Date(entry.purchased_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', @@ -356,8 +890,6 @@ const ProfilePage = () => {
- - {/* Hover arrow */}
→
))} @@ -366,128 +898,50 @@ const ProfilePage = () => {
)} - {/* ── Payouts ── */} - {activeTab === 'payouts' && ( -
-

Payout Settings

-

- Connect your Stripe account to receive payouts for your plugin sales. -

- -
- {user?.stripe_ready ? ( - /* ── Fully connected ── */ - <> -
- - - - Stripe account connected -
- - - ) : user?.stripe_account_id ? ( - /* ── Account created but onboarding not finished ── */ - <> -
- - - - Finish Stripe connection -
-

- Your Stripe account was created but hasn't been fully activated yet.
- Complete the onboarding to start receiving payouts. -

- - - ) : ( - /* ── No account at all ── */ - <> -
No Stripe account linked.
- - - )} -
-
- )} - - {/* ── Security ── */} - {activeTab === 'security' && ( -
-

Two-Factor Authentication

-
- {is2faEnabled ? ( -
-

✓ Two-Factor Authentication is currently enabled.

-
-

Enter your password to disable 2FA.

-
- setFormData({ ...formData, disable2faPassword: e.target.value })} required /> -
- -
-
- ) : ( -
-

Protect your account with Two-Factor Authentication using an app like Google Authenticator or Authy.

- {!setup2faData ? ( - - ) : ( -
-

1. Scan this QR code with your authenticator app:

-
- -
-

Or enter this code manually: {setup2faData.secret}

- -
-

2. Enter the 6-digit code from your app:

-
- setVerify2faCode(e.target.value.replace(/\D/g, ''))} required style={{ fontFamily: 'var(--font-mono)', fontSize: '1.2rem', letterSpacing: '0.2em', textAlign: 'center' }} /> - -
-
-
- )} -
- )} -
- -

Change Password

-
handleUpdate(e, '/user/settings', { currentPassword: formData.currentPassword, newPassword: formData.password })}> -
- - setFormData({ ...formData, currentPassword: e.target.value })} required /> -
-
- - setFormData({ ...formData, password: e.target.value })} required /> -
- -
-
- )} - - {/* ── Danger Zone ── */} + {/* ══ DANGER ZONE ═══════════════════════════════════════════════ */} {activeTab === 'danger' && (
-

Danger Zone

-

Proceeding will wipe your balance and all uploaded plugins from PumpkinMC.

- +
+

+ Danger Zone +

+

These actions are permanent and cannot be undone.

+
+ + +
+
+ + + Deleting your account will immediately remove all your plugins from the marketplace + and forfeit any pending payouts. + +
+ +
+
)}
+ + setIsDevModalOpen(false)} + onSuccess={() => { + refreshUser?.(); + setActiveTab('developer'); + }} + />
); }; diff --git a/src/pages/dashboard/DashboardOverview.tsx b/src/pages/dashboard/DashboardOverview.tsx index 5f7a163..5fce3c3 100644 --- a/src/pages/dashboard/DashboardOverview.tsx +++ b/src/pages/dashboard/DashboardOverview.tsx @@ -4,7 +4,7 @@ import { } from 'recharts'; import { TrendingUp, TrendingDown, Minus, DollarSign, - Download, MousePointer2, Clock, User + Download, MousePointer2, Clock, User, Star } from 'lucide-react'; import { useAnalytics } from './useAnalytics'; @@ -19,7 +19,11 @@ const CustomTooltip = ({ active, payload, label }: any) => { return (

{label}

-

${payload[0].value.toLocaleString()}

+ {payload.map((entry: any, index: number) => ( +

+ {entry.name}: {entry.dataKey === 'earnings' ? `$${entry.value.toLocaleString()}` : entry.value} +

+ ))}
); } @@ -44,7 +48,6 @@ const MiniChart = ({ data, color, dataKey }: any) => ( ); const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => { - // Logic Fix: Handle 0% as neutral const trendValue = parseFloat(comparison) || 0; const isPositive = trendValue > 0; const isNeutral = trendValue === 0; @@ -80,7 +83,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a }; 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']; return ( @@ -105,7 +108,7 @@ const DashboardOverview = () => {
-
+
{ chartData={processedData} dataKey="conversion" /> +
-
+

Revenue Growth

Earnings ($)
- + @@ -164,6 +175,7 @@ const DashboardOverview = () => { {
+ + {/* ── Rating Over Time Chart ── */} +
+
+

Rating Over Time

+
+ ★ Daily Rating (1-5) +
+
+ + + + + } /> + + + +