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",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.27.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"react-is": "^19.2.8",
|
"react-is": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "^3.10.0",
|
"recharts": "^3.10.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -35,7 +35,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
@ -385,9 +385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
|
||||||
"integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
|
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -2436,9 +2436,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "10.7.0",
|
"version": "10.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
|
||||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
"integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@ -2448,7 +2448,7 @@
|
|||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@eslint/config-array": "^0.23.5",
|
"@eslint/config-array": "^0.23.5",
|
||||||
"@eslint/config-helpers": "^0.6.0",
|
"@eslint/config-helpers": "^0.7.0",
|
||||||
"@eslint/core": "^1.2.1",
|
"@eslint/core": "^1.2.1",
|
||||||
"@eslint/plugin-kit": "^0.7.2",
|
"@eslint/plugin-kit": "^0.7.2",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
@ -2472,7 +2472,7 @@
|
|||||||
"imurmurhash": "^0.1.4",
|
"imurmurhash": "^0.1.4",
|
||||||
"is-glob": "^4.0.0",
|
"is-glob": "^4.0.0",
|
||||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||||
"minimatch": "^10.2.4",
|
"minimatch": "^10.2.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"optionator": "^0.9.3"
|
"optionator": "^0.9.3"
|
||||||
},
|
},
|
||||||
@ -3549,9 +3549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||||
"integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==",
|
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@ -4843,9 +4843,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||||
"integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==",
|
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"www"
|
"www"
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.27.0",
|
||||||
"or": "^0.2.0",
|
"or": "^0.2.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
"react-is": "^19.2.8",
|
"react-is": "^19.2.8",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"recharts": "^3.10.0",
|
"recharts": "^3.10.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -38,7 +38,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.3",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
|
|||||||
438
src/App.css
438
src/App.css
@ -3131,3 +3131,441 @@ h3 {
|
|||||||
.lightbox-dot:hover:not(.active) {
|
.lightbox-dot:hover:not(.active) {
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SETTINGS PAGE (ProfilePage.tsx)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Nav sidebar enhancements ── */
|
||||||
|
.settings-nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.settings-nav-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 60%, #fff));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-nav-meta { overflow: hidden; }
|
||||||
|
.settings-nav-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.settings-nav-email {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.settings-nav-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.profile-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.profile-nav-item.danger { color: #ef4444; }
|
||||||
|
.profile-nav-item.danger:hover { background: rgba(239, 68, 68, 0.1); }
|
||||||
|
.profile-nav-item.danger.active { background: rgba(239, 68, 68, 0.15); color: #ef4444; }
|
||||||
|
|
||||||
|
/* ── Section header ── */
|
||||||
|
.settings-section-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-section-sub {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings card ── */
|
||||||
|
.settings-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.settings-card:hover { border-color: color-mix(in srgb, var(--border) 60%, var(--primary)); }
|
||||||
|
.settings-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1.15rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.settings-card-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.settings-card-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.settings-card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Field ── */
|
||||||
|
.settings-field {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.settings-field:last-of-type { margin-bottom: 1.5rem; }
|
||||||
|
.settings-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.settings-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.settings-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.settings-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15);
|
||||||
|
}
|
||||||
|
.settings-select { cursor: pointer; appearance: auto; }
|
||||||
|
|
||||||
|
/* ── Password input with eye toggle ── */
|
||||||
|
.settings-pw-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.settings-pw-wrap .settings-input { padding-right: 44px; }
|
||||||
|
.settings-pw-eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.settings-pw-eye:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.settings-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.settings-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
.settings-btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.settings-btn-primary:hover:not(:disabled) {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
.settings-btn-secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.settings-btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.settings-btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
.settings-btn-danger-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
}
|
||||||
|
.settings-btn-danger-outline:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
.settings-btn-warn {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.settings-btn-warn:hover:not(:disabled) {
|
||||||
|
background: #d97706;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.spinner-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status rows (success / warn / error) ── */
|
||||||
|
.settings-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.settings-status-row.success {
|
||||||
|
background: rgba(74, 222, 128, 0.08);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.25);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.settings-status-row.warn {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.settings-status-row.error {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline error ── */
|
||||||
|
.settings-inline-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #ef4444;
|
||||||
|
margin: -0.75rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast ── */
|
||||||
|
.settings-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.8rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toastIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.settings-toast.success {
|
||||||
|
background: #18221a;
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
.settings-toast.error {
|
||||||
|
background: #1f1215;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle switch ── */
|
||||||
|
.settings-toggles { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.settings-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-toggle-row:last-child { border-bottom: none; }
|
||||||
|
.settings-toggle-label { font-size: 0.9rem; font-weight: 600; color: var(--text); margin: 0; }
|
||||||
|
.settings-toggle-desc { font-size: 0.8rem; color: var(--text-muted); margin: 0.2rem 0 0; }
|
||||||
|
.settings-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.22s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.settings-toggle.on { background: var(--primary); }
|
||||||
|
.settings-toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.settings-toggle.on .settings-toggle-thumb { transform: translateX(20px); }
|
||||||
|
|
||||||
|
/* ── 2FA setup ── */
|
||||||
|
.settings-2fa-setup { animation: fadeIn 0.2s ease; }
|
||||||
|
.settings-2fa-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.settings-2fa-step span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--primary-rgb), 0.18);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-qr-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.settings-2fa-manual {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.settings-secret {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--primary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.settings-code-input {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stripe section ── */
|
||||||
|
.settings-stripe-box { }
|
||||||
|
.settings-stripe-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAQ ── */
|
||||||
|
.settings-faq { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.settings-faq-item {
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-faq-item:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.settings-faq-q { font-size: 0.88rem; font-weight: 700; color: var(--text); margin: 0 0 0.3rem; }
|
||||||
|
.settings-faq-a { font-size: 0.82rem; color: var(--text-muted); margin: 0; line-height: 1.55; }
|
||||||
|
|
||||||
|
/* ── Danger box ── */
|
||||||
|
.settings-danger-box { }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-toast { right: 1rem; left: 1rem; max-width: none; }
|
||||||
|
}
|
||||||
@ -13,7 +13,6 @@ import CheckEmailPage from './pages/auth/CheckEmailPage';
|
|||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
import AuthorProfilePage from './pages/AuthorProfilePage';
|
import AuthorProfilePage from './pages/AuthorProfilePage';
|
||||||
import SearchResultsPage from './pages/SearchResultsPage';
|
import SearchResultsPage from './pages/SearchResultsPage';
|
||||||
import DashboardOverview from './pages/dashboard/DashboardOverview';
|
|
||||||
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
import DashboardAudience from './pages/dashboard/DashboardAudience';
|
||||||
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
import DashboardPlugins from './pages/dashboard/DashboardPlugins';
|
||||||
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
import ManagePlugin from './pages/dashboard/plugin/ManagePlugin';
|
||||||
@ -141,7 +140,7 @@ const App = () => (
|
|||||||
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
<Route path="confirm-email" element={<ConfirmEmailChangePage />} />
|
||||||
<Route path="check-email" element={<CheckEmailPage />} />
|
<Route path="check-email" element={<CheckEmailPage />} />
|
||||||
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
<Route path="dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<DashboardOverview />} />
|
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
||||||
<Route path="audience" element={<DashboardAudience />} />
|
<Route path="audience" element={<DashboardAudience />} />
|
||||||
<Route path="plugins" element={<DashboardPlugins />} />
|
<Route path="plugins" element={<DashboardPlugins />} />
|
||||||
<Route path="add-plugin" element={<AddPlugin />} />
|
<Route path="add-plugin" element={<AddPlugin />} />
|
||||||
@ -169,12 +168,9 @@ const MainLayout = () => {
|
|||||||
import './pages/dashboard/Dashboard.css';
|
import './pages/dashboard/Dashboard.css';
|
||||||
|
|
||||||
const DashboardLayout = () => {
|
const DashboardLayout = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-container">
|
<div className="dashboard-container">
|
||||||
<nav className="dashboard-nav">
|
<nav className="dashboard-nav">
|
||||||
<NavLink to="/dashboard" end>{t('dashboard')}</NavLink>
|
|
||||||
{/* <NavLink to="/dashboard/audience">Audience</NavLink> */}
|
|
||||||
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
<NavLink to="/dashboard/plugins">My Plugins</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
|
|||||||
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';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
TrendingUp, TrendingDown, Minus, DollarSign,
|
TrendingUp, TrendingDown, Minus, DollarSign,
|
||||||
Download, MousePointer2, Clock, User
|
Download, MousePointer2, Clock, User, Star
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
@ -19,7 +19,11 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
return (
|
return (
|
||||||
<div className="custom-tooltip-v2">
|
<div className="custom-tooltip-v2">
|
||||||
<p className="label">{label}</p>
|
<p className="label">{label}</p>
|
||||||
<p className="value">${payload[0].value.toLocaleString()}</p>
|
{payload.map((entry: any, index: number) => (
|
||||||
|
<p key={index} className="value" style={{ color: entry.color }}>
|
||||||
|
{entry.name}: {entry.dataKey === 'earnings' ? `$${entry.value.toLocaleString()}` : entry.value}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -44,7 +48,6 @@ const MiniChart = ({ data, color, dataKey }: any) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => {
|
const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: any) => {
|
||||||
// Logic Fix: Handle 0% as neutral
|
|
||||||
const trendValue = parseFloat(comparison) || 0;
|
const trendValue = parseFloat(comparison) || 0;
|
||||||
const isPositive = trendValue > 0;
|
const isPositive = trendValue > 0;
|
||||||
const isNeutral = trendValue === 0;
|
const isNeutral = trendValue === 0;
|
||||||
@ -80,7 +83,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DashboardOverview = () => {
|
const DashboardOverview = () => {
|
||||||
const { processedData, totals, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
|
const { processedData, totals, ratingSummary, timeframe, setTimeframe, recentActivity, trends } = useAnalytics();
|
||||||
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
|
const timeframes = ['Today', '7 Days', '1 Month', '1 Year'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -105,7 +108,7 @@ const DashboardOverview = () => {
|
|||||||
|
|
||||||
<div className="dashboard-grid-v2">
|
<div className="dashboard-grid-v2">
|
||||||
<main className="main-stats">
|
<main className="main-stats">
|
||||||
<div className="kpi-row">
|
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Revenue"
|
title="Revenue"
|
||||||
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
value={`$${totals.earnings.toLocaleString(undefined, { minimumFractionDigits: 2 })}`}
|
||||||
@ -130,16 +133,24 @@ const DashboardOverview = () => {
|
|||||||
chartData={processedData}
|
chartData={processedData}
|
||||||
dataKey="conversion"
|
dataKey="conversion"
|
||||||
/>
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Avg Rating"
|
||||||
|
value={ratingSummary?.averageRating ? `★ ${ratingSummary.averageRating.toFixed(1)}` : '★ --'}
|
||||||
|
comparison={0}
|
||||||
|
icon={Star}
|
||||||
|
chartData={processedData}
|
||||||
|
dataKey="avgRating"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chart-container-v2">
|
<div className="chart-container-v2" style={{ marginBottom: '1.5rem' }}>
|
||||||
<div className="chart-header">
|
<div className="chart-header">
|
||||||
<h3>Revenue Growth</h3>
|
<h3>Revenue Growth</h3>
|
||||||
<div className="chart-legend">
|
<div className="chart-legend">
|
||||||
<span className="legend-item earnings">Earnings ($)</span>
|
<span className="legend-item earnings">Earnings ($)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
|
<AreaChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
@ -164,6 +175,7 @@ const DashboardOverview = () => {
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="earnings"
|
dataKey="earnings"
|
||||||
|
name="Earnings ($)"
|
||||||
stroke="#6366f1"
|
stroke="#6366f1"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
@ -173,9 +185,79 @@ const DashboardOverview = () => {
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Rating Over Time Chart ── */}
|
||||||
|
<div className="chart-container-v2">
|
||||||
|
<div className="chart-header">
|
||||||
|
<h3>Rating Over Time</h3>
|
||||||
|
<div className="chart-legend">
|
||||||
|
<span className="legend-item" style={{ color: '#f59e0b' }}>★ Daily Rating (1-5)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<LineChart data={processedData} margin={{ top: 20, right: 20, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#94a3b8', fontSize: 12}}
|
||||||
|
dy={10}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[1, 5]}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{fill: '#94a3b8', fontSize: 12}}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="avgRating"
|
||||||
|
name="Rating"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 4, fill: '#f59e0b' }}
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="activity-sidebar-v2">
|
<aside className="activity-sidebar-v2">
|
||||||
|
{/* Rating Breakdown Widget */}
|
||||||
|
{ratingSummary && (
|
||||||
|
<div className="rating-breakdown-card" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '1.25rem', border: '1px solid var(--border)', marginBottom: '1.5rem' }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', margin: '0 0 1rem', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<Star size={16} fill="#f59e0b" color="#f59e0b" /> Rating Breakdown
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', marginBottom: '1.25rem' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '2.2rem', fontWeight: 800, color: 'var(--text)' }}>
|
||||||
|
{ratingSummary.averageRating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
{ratingSummary.totalReviews} total reviews
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{ratingSummary.breakdown?.map((b: any) => {
|
||||||
|
const pct = ratingSummary.totalReviews > 0 ? (b.count / ratingSummary.totalReviews) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={b.stars} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.78rem' }}>
|
||||||
|
<span style={{ width: '32px', color: 'var(--text-muted)' }}>{b.stars} ★</span>
|
||||||
|
<div style={{ flex: 1, height: '6px', background: 'rgba(255,255,255,0.06)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: '#f59e0b', borderRadius: '3px' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ width: '24px', textAlign: 'right', color: 'var(--text-muted)' }}>{b.count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<h3>Live Activity</h3>
|
<h3>Live Activity</h3>
|
||||||
<span className="live-indicator"></span>
|
<span className="live-indicator"></span>
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalytics } from './useAnalytics';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Settings, Plus, ExternalLink } from 'lucide-react'; // Added some icons for a cleaner UI
|
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../App';
|
||||||
|
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
const DashboardPlugins = () => {
|
const DashboardPlugins = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
const { timeSeries } = useAnalytics();
|
const { timeSeries } = useAnalytics();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
|
||||||
|
|
||||||
// Grouping analytics data by plugin ID
|
// Grouping analytics data by plugin ID
|
||||||
const plugins = useMemo(() => {
|
const plugins = useMemo(() => {
|
||||||
return Object.values(timeSeries.reduce((acc, curr) => {
|
return Object.values(timeSeries.reduce((acc, curr) => {
|
||||||
// Ensure we use curr.plugin_id or curr.id depending on your hook's structure
|
|
||||||
const id = curr.plugin_id || curr.id;
|
const id = curr.plugin_id || curr.id;
|
||||||
if (!acc[id]) {
|
if (!acc[id]) {
|
||||||
acc[id] = {
|
acc[id] = {
|
||||||
@ -39,6 +42,40 @@ const DashboardPlugins = () => {
|
|||||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!user?.is_developer) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-grid-layout">
|
||||||
|
<div className="data-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: '50%',
|
||||||
|
background: 'rgba(249, 115, 22, 0.12)', border: '1px solid rgba(249, 115, 22, 0.3)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 1.25rem'
|
||||||
|
}}>
|
||||||
|
<Sparkles size={32} color="#f97316" />
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '0 0 0.5rem' }}>Developer Onboarding Required</h2>
|
||||||
|
<p style={{ color: 'var(--dash-text-muted)', maxWidth: '520px', margin: '0 auto 1.75rem', lineHeight: 1.6 }}>
|
||||||
|
To manage existing plugins or publish new ones, please complete the free <strong>Developer Onboarding</strong> wizard to set up your creator & legal profile.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '0.75rem 1.75rem', fontSize: '1rem' }}
|
||||||
|
onClick={() => setIsDevModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Sparkles size={18} style={{ marginRight: '8px' }} />
|
||||||
|
Complete Free Developer Onboarding
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DeveloperOnboardingModal
|
||||||
|
isOpen={isDevModalOpen}
|
||||||
|
onClose={() => setIsDevModalOpen(false)}
|
||||||
|
onSuccess={() => setIsDevModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-grid-layout">
|
<div className="dashboard-grid-layout">
|
||||||
<div className="data-card">
|
<div className="data-card">
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import {
|
import {
|
||||||
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
LayoutGrid, Tag, Upload, DollarSign, Trash2,
|
||||||
Circle
|
Circle, BarChart3
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import StoreListing from './StoreListing';
|
import StoreListing from './StoreListing';
|
||||||
@ -11,6 +11,7 @@ import PublishUpdate from './PublishUpdate';
|
|||||||
import Pricing from './Pricing';
|
import Pricing from './Pricing';
|
||||||
import Coupons from './Coupons';
|
import Coupons from './Coupons';
|
||||||
import DangerZone from './DangerZone';
|
import DangerZone from './DangerZone';
|
||||||
|
import PluginAnalyticsTab from './PluginAnalyticsTab';
|
||||||
import './ManagePlugin.css';
|
import './ManagePlugin.css';
|
||||||
|
|
||||||
export type PluginData = {
|
export type PluginData = {
|
||||||
@ -32,11 +33,12 @@ export type PluginData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
|
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },
|
||||||
{ key: 'update', label: 'Publish Update', icon: Upload },
|
{ key: 'update', label: 'Publish Update', icon: Upload },
|
||||||
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
|
{ key: 'analytics', label: 'Analytics', icon: BarChart3 },
|
||||||
{ key: 'coupons', label: 'Coupons', icon: Tag },
|
{ key: 'pricing', label: 'Pricing', icon: DollarSign },
|
||||||
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
|
{ key: 'coupons', label: 'Coupons', icon: Tag },
|
||||||
|
{ key: 'danger', label: 'Danger Zone', icon: Trash2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ManagePlugin = () => {
|
const ManagePlugin = () => {
|
||||||
@ -130,6 +132,9 @@ const ManagePlugin = () => {
|
|||||||
{activeTab === 'update' && (
|
{activeTab === 'update' && (
|
||||||
<PublishUpdate plugin={plugin} onSaved={refreshPlugin} />
|
<PublishUpdate plugin={plugin} onSaved={refreshPlugin} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<PluginAnalyticsTab pluginId={plugin.id} pluginName={plugin.name} />
|
||||||
|
)}
|
||||||
{activeTab === 'pricing' && (
|
{activeTab === 'pricing' && (
|
||||||
<Pricing plugin={plugin} onSaved={refreshPlugin} />
|
<Pricing plugin={plugin} onSaved={refreshPlugin} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 {
|
try {
|
||||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
await api.put(`/plugins/${plugin.id}`, fd);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch {
|
} catch {
|
||||||
alert('Failed to save pricing.');
|
alert('Failed to save pricing.');
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Upload, FileCode, Send, CheckCircle} from 'lucide-react';
|
import { Upload, FileCode, Send, CheckCircle, AlertTriangle, Clock } from 'lucide-react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import type { PluginData } from './ManagePlugin';
|
import type { PluginData } from './ManagePlugin';
|
||||||
|
|
||||||
type ReleaseTrack = 'stable' | 'beta' | 'alpha';
|
|
||||||
type Props = { plugin: PluginData; onSaved: () => void };
|
type Props = { plugin: PluginData; onSaved: () => void };
|
||||||
|
|
||||||
|
const TRACKS = [
|
||||||
|
{ key: 'stable', label: 'Production (Stable)', desc: 'Available to all users across the marketplace.' },
|
||||||
|
{ key: 'beta', label: 'Beta', desc: 'Public beta release for community testing.' },
|
||||||
|
{ key: 'alpha', label: 'Alpha / Testing', desc: 'Early access build for testers.' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
||||||
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
const [wasmFile, setWasmFile] = useState<File | null>(null);
|
||||||
const [track] = useState<ReleaseTrack>('stable');
|
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
|
const [track, setTrack] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||||
const [releaseNotes, setReleaseNotes] = useState('');
|
const [releaseNotes, setReleaseNotes] = useState('');
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@ -28,14 +33,12 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
setUploading(true);
|
setUploading(true);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('wasm', wasmFile);
|
fd.append('wasm', wasmFile);
|
||||||
fd.append('track', track);
|
|
||||||
if (version) fd.append('version', version);
|
if (version) fd.append('version', version);
|
||||||
|
fd.append('track', track);
|
||||||
if (releaseNotes) fd.append('release_notes', releaseNotes);
|
if (releaseNotes) fd.append('release_notes', releaseNotes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
await api.put(`/plugins/${plugin.id}`, fd);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
setPublished(true);
|
setPublished(true);
|
||||||
onSaved();
|
onSaved();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -71,7 +74,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
<div>
|
<div>
|
||||||
<p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p>
|
<p style={{fontWeight:700, fontSize:'1rem', color:'var(--mp-text)'}}>Update Published!</p>
|
||||||
<p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}>
|
<p style={{fontSize:'0.83rem', color:'var(--mp-text-2)', marginTop:'0.3rem'}}>
|
||||||
Your new version is being rolled out on the <strong>{track}</strong> track.
|
Your new version has been published successfully to the <strong>{track}</strong> track.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -86,7 +89,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
<p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p>
|
<p>Ship a new version of your plugin. Choose your release track, upload the binary, and add release notes — like Google Play, but for your plugin marketplace.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Release Track ──
|
{/* ── Release Track ── */}
|
||||||
<div className="mp-card">
|
<div className="mp-card">
|
||||||
<div className="mp-card-title">
|
<div className="mp-card-title">
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
@ -110,18 +113,18 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{track === 'stable' && (
|
{track === 'stable' && (
|
||||||
<div className="mp-banner warn">
|
<div className="mp-banner warn" style={{ marginTop: '1rem' }}>
|
||||||
<AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} />
|
<AlertTriangle size={16} style={{flexShrink:0, marginTop:1}} />
|
||||||
<span>Stable releases are immediately visible to all users. Make sure your build is production-ready.</span>
|
<span>Production releases are immediately visible to all users. Make sure your build is tested.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{track === 'alpha' && (
|
{track !== 'stable' && (
|
||||||
<div className="mp-banner info">
|
<div className="mp-banner info" style={{ marginTop: '1rem' }}>
|
||||||
<Clock size={16} style={{flexShrink:0, marginTop:1}} />
|
<Clock size={16} style={{flexShrink:0, marginTop:1}} />
|
||||||
<span>Internal track is only visible to whitelisted user IDs. Configure access in your plugin settings.</span>
|
<span>Pre-release ({track}) tracks allow community testing before promoting to production.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div> */}
|
</div>
|
||||||
|
|
||||||
{/* ── Version & Notes ── */}
|
{/* ── Version & Notes ── */}
|
||||||
<div className="mp-card">
|
<div className="mp-card">
|
||||||
@ -203,7 +206,7 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
|
|||||||
disabled={!wasmFile || uploading}
|
disabled={!wasmFile || uploading}
|
||||||
>
|
>
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
{uploading ? 'Publishing…' : `Publish to ${track.charAt(0).toUpperCase() + track.slice(1)}`}
|
{uploading ? 'Publishing…' : 'Publish Update'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -186,9 +186,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
fd.append('keywords', keywords);
|
fd.append('keywords', keywords);
|
||||||
fd.append('is_early_access', String(isEarlyAccess));
|
fd.append('is_early_access', String(isEarlyAccess));
|
||||||
try {
|
try {
|
||||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
await api.put(`/plugins/${plugin.id}`, fd);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
onSaved();
|
onSaved();
|
||||||
} catch {
|
} catch {
|
||||||
alert('Failed to save.');
|
alert('Failed to save.');
|
||||||
@ -213,9 +211,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
Array.from(files).forEach(f => fd.append('screenshots', f));
|
Array.from(files).forEach(f => fd.append('screenshots', f));
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd, {
|
const res = await api.post(`/plugins/${plugin.id}/screenshots`, fd);
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
});
|
|
||||||
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
|
setScreenshots(prev => [...prev, ...(res.data.screenshots || [])]);
|
||||||
} catch {
|
} catch {
|
||||||
alert('Screenshot upload failed.');
|
alert('Screenshot upload failed.');
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
|
||||||
export const useAnalytics = () => {
|
export const useAnalytics = (pluginId?: number) => {
|
||||||
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
||||||
|
const [ratingSummary, setRatingSummary] = useState<any>(null);
|
||||||
const [mapData, setMapData] = useState<any[]>([]);
|
const [mapData, setMapData] = useState<any[]>([]);
|
||||||
const [breakdowns, setBreakdowns] = useState<any[]>([]);
|
const [breakdowns, setBreakdowns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -11,26 +12,26 @@ export const useAnalytics = () => {
|
|||||||
const fetchData = useCallback(async (selectedTimeframe: string) => {
|
const fetchData = useCallback(async (selectedTimeframe: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// PRO TIP: Check if your 'api' instance already prepends '/api'
|
const params: any = { timeframe: selectedTimeframe };
|
||||||
// If it doesn't, use '/api/dev/analytics'
|
if (pluginId) {
|
||||||
const res = await api.get(`/user/analytics`, {
|
params.plugin_id = pluginId;
|
||||||
params: { timeframe: selectedTimeframe }
|
}
|
||||||
});
|
const res = await api.get(`/user/analytics`, { params });
|
||||||
|
|
||||||
// Ensure we are accessing the correct keys from your Express res.json
|
|
||||||
setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []);
|
setTimeSeries(Array.isArray(res.data.timeSeries) ? res.data.timeSeries : []);
|
||||||
|
setRatingSummary(res.data.ratingSummary || null);
|
||||||
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
||||||
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Analytics Fetch Error:", error);
|
console.error("Analytics Fetch Error:", error);
|
||||||
// Reset data on error so the UI doesn't show stale/broken stats
|
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
|
setRatingSummary(null);
|
||||||
setMapData([]);
|
setMapData([]);
|
||||||
setBreakdowns([]);
|
setBreakdowns([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [pluginId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(timeframe);
|
fetchData(timeframe);
|
||||||
@ -43,11 +44,18 @@ export const useAnalytics = () => {
|
|||||||
const dailyMap = timeSeries.reduce((acc, curr) => {
|
const dailyMap = timeSeries.reduce((acc, curr) => {
|
||||||
const day = curr.date || 'Unknown';
|
const day = curr.date || 'Unknown';
|
||||||
if (!acc[day]) {
|
if (!acc[day]) {
|
||||||
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0 };
|
acc[day] = { date: day, views: 0, downloads: 0, earnings: 0, avgRating: 0, reviewCount: 0, conversionRate: 0 };
|
||||||
}
|
}
|
||||||
acc[day].views += Number(curr.views || 0);
|
acc[day].views += Number(curr.views || 0);
|
||||||
acc[day].downloads += Number(curr.downloads || 0);
|
acc[day].downloads += Number(curr.downloads || 0);
|
||||||
acc[day].earnings += Number(curr.earnings || 0);
|
acc[day].earnings += Number(curr.earnings || 0);
|
||||||
|
if (curr.avgRating > 0) {
|
||||||
|
acc[day].avgRating = curr.avgRating;
|
||||||
|
}
|
||||||
|
acc[day].reviewCount += Number(curr.reviewCount || 0);
|
||||||
|
acc[day].conversionRate = acc[day].views > 0
|
||||||
|
? Math.round((acc[day].downloads / acc[day].views) * 1000) / 10
|
||||||
|
: 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {} as Record<string, any>);
|
||||||
|
|
||||||
@ -58,16 +66,23 @@ export const useAnalytics = () => {
|
|||||||
|
|
||||||
// Calculate global totals across all plugins
|
// Calculate global totals across all plugins
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return timeSeries.reduce((acc, curr) => {
|
const raw = timeSeries.reduce((acc, curr) => {
|
||||||
acc.views += Number(curr.views || 0);
|
acc.views += Number(curr.views || 0);
|
||||||
acc.downloads += Number(curr.downloads || 0);
|
acc.downloads += Number(curr.downloads || 0);
|
||||||
acc.earnings += Number(curr.earnings || 0);
|
acc.earnings += Number(curr.earnings || 0);
|
||||||
return acc;
|
return acc;
|
||||||
}, { views: 0, downloads: 0, earnings: 0 });
|
}, { views: 0, downloads: 0, earnings: 0 });
|
||||||
|
|
||||||
|
const conversionRate = raw.views > 0
|
||||||
|
? Math.round((raw.downloads / raw.views) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { ...raw, conversionRate };
|
||||||
}, [timeSeries]);
|
}, [timeSeries]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeSeries,
|
timeSeries,
|
||||||
|
ratingSummary,
|
||||||
mapData,
|
mapData,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
processedData,
|
processedData,
|
||||||
@ -75,8 +90,8 @@ export const useAnalytics = () => {
|
|||||||
loading,
|
loading,
|
||||||
timeframe,
|
timeframe,
|
||||||
setTimeframe,
|
setTimeframe,
|
||||||
recentActivity: [], // Placeholder for activity feed
|
recentActivity: [],
|
||||||
trends: { earnings: 0, downloads: 0, conversion: 0 }, // Placeholder for trend percentages
|
trends: { earnings: 0, downloads: 0, conversion: 0 },
|
||||||
refresh: () => fetchData(timeframe)
|
refresh: () => fetchData(timeframe)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user