new things
This commit is contained in:
parent
a363168a0b
commit
ef5092ee4b
34
package-lock.json
generated
34
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
438
src/App.css
438
src/App.css
@ -3131,3 +3131,441 @@ 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; }
|
||||
}
|
||||
@ -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 = () => (
|
||||
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
||||
<Route path="check-email" element={<CheckEmailPage />} />
|
||||
<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="plugins" element={<DashboardPlugins />} />
|
||||
<Route path="add-plugin" element={<AddPlugin />} />
|
||||
@ -169,12 +168,9 @@ const MainLayout = () => {
|
||||
import './pages/dashboard/Dashboard.css';
|
||||
|
||||
const DashboardLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<nav className="dashboard-nav">
|
||||
<NavLink to="/dashboard" end>{t('dashboard')}</NavLink>
|
||||
{/* <NavLink to="/dashboard/audience">Audience</NavLink> */}
|
||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||
</nav>
|
||||
<div className="dashboard-content">
|
||||
|
||||
304
src/components/DeveloperOnboardingModal.css
Normal file
304
src/components/DeveloperOnboardingModal.css
Normal 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;
|
||||
}
|
||||
369
src/components/DeveloperOnboardingModal.tsx
Normal file
369
src/components/DeveloperOnboardingModal.tsx
Normal 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
@ -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 (
|
||||
<div className="custom-tooltip-v2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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 = () => {
|
||||
|
||||
<div className="dashboard-grid-v2">
|
||||
<main className="main-stats">
|
||||
<div className="kpi-row">
|
||||
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<KpiCard
|
||||
title="Revenue"
|
||||
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||
@ -130,16 +133,24 @@ const DashboardOverview = () => {
|
||||
chartData={processedData}
|
||||
dataKey="conversion"
|
||||
/>
|
||||
<KpiCard
|
||||
title="Avg Rating"
|
||||
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'}
|
||||
comparison={0}
|
||||
icon={Star}
|
||||
chartData={processedData}
|
||||
dataKey="avgRating"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chart-container-v2">
|
||||
<div className="chart-container-v2" style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="chart-header">
|
||||
<h3>Revenue Growth</h3>
|
||||
<div className="chart-legend">
|
||||
<span className="legend-item earnings">Earnings ($)</span>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
@ -164,6 +175,7 @@ const DashboardOverview = () => {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="earnings"
|
||||
name="Earnings ($)"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={3}
|
||||
fillOpacity={1}
|
||||
@ -173,9 +185,79 @@ const DashboardOverview = () => {
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
<h3>Live Activity</h3>
|
||||
<span className="live-indicator"></span>
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings, Plus, ExternalLink } from 'lucide-react'; // Added some icons for a cleaner UI
|
||||
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { useAuth } from '../../App';
|
||||
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
||||
|
||||
// --- Main Component ---
|
||||
const DashboardPlugins = () => {
|
||||
const { user } = useAuth();
|
||||
const { timeSeries } = useAnalytics();
|
||||
const navigate = useNavigate();
|
||||
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||
|
||||
// Grouping analytics data by plugin ID
|
||||
const plugins = useMemo(() => {
|
||||
return Object.values(timeSeries.reduce((acc, curr) => {
|
||||
// Ensure we use curr.plugin_id or curr.id depending on your hook's structure
|
||||
const id = curr.plugin_id || curr.id;
|
||||
if (!acc[id]) {
|
||||
acc[id] = {
|
||||
@ -39,6 +42,40 @@ const DashboardPlugins = () => {
|
||||
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 (
|
||||
<div className="dashboard-grid-layout">
|
||||
<div className="data-card">
|
||||
|
||||
@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../../../api';
|
||||
import {
|
||||
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
||||
Circle
|
||||
Circle, BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
import StoreListing from './StoreListing';
|
||||
@ -11,6 +11,7 @@ import PublishUpdate from './PublishUpdate';
|
||||
import Pricing from './Pricing';
|
||||
import Coupons from './Coupons';
|
||||
import DangerZone from './DangerZone';
|
||||
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
||||
import './ManagePlugin.css';
|
||||
|
||||
export type PluginData = {
|
||||
@ -34,6 +35,7 @@ export type PluginData = {
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
|
||||
{ key: 'update', label: 'Publish Update', icon: Upload },
|
||||
{ key: 'analytics', label: 'Analytics', icon: BarChart3 },
|
||||
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||
{ key: 'coupons', label: 'Coupons', icon: Tag },
|
||||
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
|
||||
@ -130,6 +132,9 @@ const ManagePlugin = () => {
|
||||
{activeTab === 'update' && (
|
||||
<PublishUpdate plugin={plugin} onSaved={refreshPlugin} />
|
||||
)}
|
||||
{activeTab === 'analytics' && (
|
||||
<PluginAnalyticsTab pluginId={plugin.id} pluginName={plugin.name} />
|
||||
)}
|
||||
{activeTab === 'pricing' && (
|
||||
<Pricing plugin={plugin} onSaved={refreshPlugin} />
|
||||
)}
|
||||
|
||||
249
src/pages/dashboard/plugin/PluginAnalyticsTab.tsx
Normal file
249
src/pages/dashboard/plugin/PluginAnalyticsTab.tsx
Normal 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;
|
||||
@ -74,9 +74,7 @@ const Pricing = ({ plugin, onSaved }: Props) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
await api.put(`/plugins/${plugin.id}`, fd);
|
||||
onSaved();
|
||||
} catch {
|
||||
alert('Failed to save pricing.');
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
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 type { PluginData } from './ManagePlugin';
|
||||
|
||||
type ReleaseTrack = 'stable' | 'beta' | 'alpha';
|
||||
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 [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||
const [track] = useState<ReleaseTrack>('stable');
|
||||
const [version, setVersion] = useState('');
|
||||
const [track, setTrack] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||
const [releaseNotes, setReleaseNotes] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -28,14 +33,12 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append('wasm', wasmFile);
|
||||
fd.append('track', track);
|
||||
if (version) fd.append('version', version);
|
||||
fd.append('track', track);
|
||||
if (releaseNotes) fd.append('release_notes', releaseNotes);
|
||||
|
||||
try {
|
||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
await api.put(`/plugins/${plugin.id}`, fd);
|
||||
setPublished(true);
|
||||
onSaved();
|
||||
setTimeout(() => {
|
||||
@ -71,7 +74,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
<div>
|
||||
<p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p>
|
||||
<p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}>
|
||||
Your new version is being rolled out on the <strong>{track}</strong> track.
|
||||
Your new version has been published successfully to the <strong>{track}</strong> track.
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* ── Release Track ──
|
||||
{/* ── Release Track ── */}
|
||||
<div className="mp-card">
|
||||
<div className="mp-card-title">
|
||||
<Send size={14} />
|
||||
@ -110,18 +113,18 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
</div>
|
||||
|
||||
{track === 'stable' && (
|
||||
<div className="mp-banner warn">
|
||||
<div className="mp-banner warn" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
)}
|
||||
{track === 'alpha' && (
|
||||
<div className="mp-banner info">
|
||||
{track !== 'stable' && (
|
||||
<div className="mp-banner info" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
|
||||
{/* ── Version & Notes ── */}
|
||||
<div className="mp-card">
|
||||
@ -203,7 +206,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||
disabled={!wasmFile || uploading}
|
||||
>
|
||||
<Send size={15} />
|
||||
{uploading ? 'Publishing…' : `Publish to ${track.charAt(0).toUpperCase() + track.slice(1)}`}
|
||||
{uploading ? 'Publishing…' : 'Publish Update'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -186,9 +186,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||
fd.append('keywords', keywords);
|
||||
fd.append('is_early_access', String(isEarlyAccess));
|
||||
try {
|
||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
await api.put(`/plugins/${plugin.id}`, fd);
|
||||
onSaved();
|
||||
} catch {
|
||||
alert('Failed to save.');
|
||||
@ -213,9 +211,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
||||
const fd = new FormData();
|
||||
Array.from(files).forEach(f => fd.append('screenshots', f));
|
||||
try {
|
||||
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd);
|
||||
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
|
||||
} catch {
|
||||
alert('Screenshot upload failed.');
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import api from '../../api';
|
||||
|
||||
export const useAnalytics = () => {
|
||||
export const useAnalytics = (pluginId?: number) => {
|
||||
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
||||
const [ratingSummary, setRatingSummary] = useState<any>(null);
|
||||
const [mapData, setMapData] = useState<any[]>([]);
|
||||
const [breakdowns, setBreakdowns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -11,26 +12,26 @@ export const useAnalytics = () => {
|
||||
const fetchData = useCallback(async (selectedTimeframe: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// PRO TIP: Check if your 'api' instance already prepends '/api'
|
||||
// If it doesn't, use '/api/dev/analytics'
|
||||
const res = await api.get(`/user/analytics`, {
|
||||
params: { timeframe: selectedTimeframe }
|
||||
});
|
||||
const params: any = { timeframe: selectedTimeframe };
|
||||
if (pluginId) {
|
||||
params.plugin_id = pluginId;
|
||||
}
|
||||
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 : []);
|
||||
setRatingSummary(res.data.ratingSummary || null);
|
||||
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
||||
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
||||
} catch (error) {
|
||||
console.error("Analytics Fetch Error:", error);
|
||||
// Reset data on error so the UI doesn't show stale/broken stats
|
||||
setTimeSeries([]);
|
||||
setRatingSummary(null);
|
||||
setMapData([]);
|
||||
setBreakdowns([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [pluginId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(timeframe);
|
||||
@ -43,11 +44,18 @@ export const useAnalytics = () => {
|
||||
const dailyMap = timeSeries.reduce((acc, curr) => {
|
||||
const day = curr.date || 'Unknown';
|
||||
if (!acc[day]) {
|
||||
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0 };
|
||||
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0, avgRating: 0, reviewCount: 0, conversionRate: 0 };
|
||||
}
|
||||
acc[day].views += Number(curr.views || 0);
|
||||
acc[day].downloads += Number(curr.downloads || 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;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
@ -58,16 +66,23 @@ export const useAnalytics = () => {
|
||||
|
||||
// Calculate global totals across all plugins
|
||||
const totals = useMemo(() => {
|
||||
return timeSeries.reduce((acc, curr) => {
|
||||
const raw = timeSeries.reduce((acc, curr) => {
|
||||
acc.views += Number(curr.views || 0);
|
||||
acc.downloads += Number(curr.downloads || 0);
|
||||
acc.earnings += Number(curr.earnings || 0);
|
||||
return acc;
|
||||
}, { views: 0, downloads: 0, earnings: 0 });
|
||||
|
||||
const conversionRate = raw.views > 0
|
||||
? Math.round((raw.downloads / raw.views) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
return { ...raw, conversionRate };
|
||||
}, [timeSeries]);
|
||||
|
||||
return {
|
||||
timeSeries,
|
||||
ratingSummary,
|
||||
mapData,
|
||||
breakdowns,
|
||||
processedData,
|
||||
@ -75,8 +90,8 @@ export const useAnalytics = () => {
|
||||
loading,
|
||||
timeframe,
|
||||
setTimeframe,
|
||||
recentActivity: [], // Placeholder for activity feed
|
||||
trends: { earnings: 0, downloads: 0, conversion: 0 }, // Placeholder for trend percentages
|
||||
recentActivity: [],
|
||||
trends: { earnings: 0, downloads: 0, conversion: 0 },
|
||||
refresh: () => fetchData(timeframe)
|
||||
};
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user