create-fullstack-scaffold 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. package/template/package-lock.json +0 -14554
@@ -0,0 +1,100 @@
1
+ import { useEffect } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+ import { Helmet } from 'react-helmet-async'
4
+ import { FolderOpen, Package } from 'lucide-react'
5
+ import { usePluginStore } from '@client/stores/pluginStore'
6
+ import { LoadingSpinner, EmptyState } from '@client/components'
7
+
8
+ const categoryGradients = [
9
+ 'from-violet-500 to-purple-600',
10
+ 'from-blue-500 to-cyan-500',
11
+ 'from-fuchsia-500 to-pink-500',
12
+ 'from-emerald-500 to-teal-500',
13
+ 'from-orange-500 to-amber-500',
14
+ 'from-rose-500 to-red-500',
15
+ 'from-indigo-500 to-blue-600',
16
+ 'from-teal-500 to-green-500',
17
+ 'from-cyan-500 to-sky-500',
18
+ ]
19
+
20
+ export const CategoriesPage: React.FC = () => {
21
+ const categories = usePluginStore(state => state.categories)
22
+ const loading = usePluginStore(state => state.loading)
23
+ const fetchCategories = usePluginStore(state => state.fetchCategories)
24
+ const setSelectedCategory = usePluginStore(state => state.setSelectedCategory)
25
+
26
+ useEffect(() => {
27
+ fetchCategories()
28
+ }, [fetchCategories])
29
+
30
+ return (
31
+ <div className="min-h-screen" data-testid="categories-page">
32
+ <Helmet>
33
+ <title>Categories - Plugin Marketplace</title>
34
+ <meta name="description" content="Browse plugins by category" />
35
+ </Helmet>
36
+
37
+ <div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-16 px-6">
38
+ <div className="max-w-6xl mx-auto">
39
+ <div className="flex items-center gap-3 mb-4">
40
+ <div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
41
+ <FolderOpen className="w-6 h-6 text-white" />
42
+ </div>
43
+ <h1 className="text-3xl font-bold text-white tracking-tight">Categories</h1>
44
+ </div>
45
+ <p className="text-purple-200/70 text-lg">
46
+ Browse plugins by category to find exactly what you need
47
+ </p>
48
+ </div>
49
+ </div>
50
+
51
+ <div className="max-w-6xl mx-auto px-6 -mt-8 pb-16">
52
+ {loading && categories.length === 0 ? (
53
+ <div className="flex items-center justify-center py-20">
54
+ <LoadingSpinner size="lg" />
55
+ </div>
56
+ ) : categories.length === 0 ? (
57
+ <EmptyState icon={FolderOpen} title="No categories yet" />
58
+ ) : (
59
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
60
+ {categories.map((category, index) => (
61
+ <Link
62
+ key={category.id}
63
+ to="/plugins"
64
+ onClick={() => setSelectedCategory(category.slug)}
65
+ className="group relative overflow-hidden bg-white rounded-2xl border border-gray-100 hover:border-transparent hover:shadow-2xl hover:shadow-violet-500/10 transition-all duration-300 block"
66
+ >
67
+ <div
68
+ className={`absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r ${
69
+ categoryGradients[index % categoryGradients.length]
70
+ } opacity-60 group-hover:opacity-100 transition-opacity`}
71
+ />
72
+ <div className="p-6">
73
+ <div
74
+ className={`w-14 h-14 rounded-2xl bg-gradient-to-br ${
75
+ categoryGradients[index % categoryGradients.length]
76
+ } flex items-center justify-center text-2xl shadow-lg mb-4`}
77
+ >
78
+ {category.icon || '📁'}
79
+ </div>
80
+ <h3 className="text-lg font-semibold text-gray-900 group-hover:text-violet-700 transition-colors">
81
+ {category.name}
82
+ </h3>
83
+ {category.description && (
84
+ <p className="text-sm text-gray-500 mt-1.5 leading-relaxed">
85
+ {category.description}
86
+ </p>
87
+ )}
88
+ <div className="mt-4 flex items-center gap-1.5 text-xs text-violet-500 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
89
+ <Package className="w-3.5 h-3.5" />
90
+ Browse plugins
91
+ </div>
92
+ </div>
93
+ </Link>
94
+ ))}
95
+ </div>
96
+ )}
97
+ </div>
98
+ </div>
99
+ )
100
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useCallback } from 'react'
2
2
  import { useParams, Link } from 'react-router-dom'
3
3
  import { Helmet } from 'react-helmet-async'
4
4
  import type { Content } from '@shared/modules/content'
@@ -10,11 +10,7 @@ export const ContentDetailPage: React.FC = () => {
10
10
  const [loading, setLoading] = useState(true)
11
11
  const [error, setError] = useState<string | null>(null)
12
12
 
13
- useEffect(() => {
14
- if (id) fetchContent(id)
15
- }, [id])
16
-
17
- const fetchContent = async (contentId: string) => {
13
+ const fetchContent = useCallback(async (contentId: string) => {
18
14
  setLoading(true)
19
15
  setError(null)
20
16
  try {
@@ -40,7 +36,11 @@ export const ContentDetailPage: React.FC = () => {
40
36
  } finally {
41
37
  setLoading(false)
42
38
  }
43
- }
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ if (id) fetchContent(id)
43
+ }, [id, fetchContent])
44
44
 
45
45
  const formatDate = (dateStr: string | null | undefined) => {
46
46
  if (!dateStr) return ''
@@ -63,9 +63,7 @@ export const ContentDetailPage: React.FC = () => {
63
63
  return (
64
64
  <div className="max-w-4xl mx-auto p-6">
65
65
  <div className="text-center py-12">
66
- <h1 className="text-2xl font-bold text-gray-900 mb-4">
67
- {error || '内容不存在'}
68
- </h1>
66
+ <h1 className="text-2xl font-bold text-gray-900 mb-4">{error || '内容不存在'}</h1>
69
67
  <Link to="/content" className="text-blue-600 hover:underline">
70
68
  ← 返回内容列表
71
69
  </Link>
@@ -107,10 +105,7 @@ export const ContentDetailPage: React.FC = () => {
107
105
  {content.category}
108
106
  </span>
109
107
  {content.tags?.map(tag => (
110
- <span
111
- key={tag}
112
- className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
113
- >
108
+ <span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
114
109
  {tag}
115
110
  </span>
116
111
  ))}
@@ -33,8 +33,7 @@ export const ContentListPage: React.FC = () => {
33
33
  })
34
34
  const result = await res.json()
35
35
  if (result.success) {
36
- const data = result.data
37
- setContents(Array.isArray(data) ? data : [])
36
+ setContents(result.data)
38
37
  } else {
39
38
  setError('Failed to fetch contents')
40
39
  }
@@ -122,9 +121,7 @@ export const ContentListPage: React.FC = () => {
122
121
  </div>
123
122
  )}
124
123
 
125
- {loading && (
126
- <div className="text-center py-12 text-gray-500">加载中...</div>
127
- )}
124
+ {loading && <div className="text-center py-12 text-gray-500">加载中...</div>}
128
125
 
129
126
  {!loading && !error && contents.length === 0 && (
130
127
  <div className="text-center py-12 text-gray-500">暂无内容</div>
@@ -146,12 +143,8 @@ export const ContentListPage: React.FC = () => {
146
143
  </span>
147
144
  <span className="text-sm text-gray-500">{content.author}</span>
148
145
  </div>
149
- <h2 className="text-xl font-semibold text-gray-900 mb-2">
150
- {content.title}
151
- </h2>
152
- <p className="text-gray-600 line-clamp-2">
153
- {content.content.slice(0, 150)}...
154
- </p>
146
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">{content.title}</h2>
147
+ <p className="text-gray-600 line-clamp-2">{content.content.slice(0, 150)}...</p>
155
148
  <div className="flex items-center gap-4 mt-3 text-sm text-gray-400">
156
149
  {content.publishedAt && <span>{formatDate(content.publishedAt)}</span>}
157
150
  <span>👁 {content.viewCount}</span>
@@ -0,0 +1,261 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Helmet } from 'react-helmet-async'
3
+ import { TrendingUp, TrendingDown, ArrowUpRight, ChevronDown } from 'lucide-react'
4
+ import { apiClient } from '@client/services/apiClient'
5
+ import { LoadingSpinner } from '@client/components'
6
+ import type { DashboardStat, RevenueData, ActivityStatus, Activity } from '@shared/schemas'
7
+
8
+ type StatCard = DashboardStat & { icon: React.FC<{ className?: string }> }
9
+
10
+ const statusStyles: Record<ActivityStatus, string> = {
11
+ Active: 'bg-green-100 text-green-700',
12
+ Pending: 'bg-yellow-100 text-yellow-700',
13
+ Inactive: 'bg-gray-100 text-gray-600',
14
+ }
15
+
16
+ const timeRanges = ['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last year']
17
+
18
+ export const DashboardPage: React.FC = () => {
19
+ const [timeRange, setTimeRange] = useState('Last 30 days')
20
+ const [dropdownOpen, setDropdownOpen] = useState(false)
21
+ const [loading, setLoading] = useState(true)
22
+ const [stats, setStats] = useState<StatCard[]>([])
23
+ const [revenueData, setRevenueData] = useState<RevenueData[]>([])
24
+ const [userGrowthData, setUserGrowthData] = useState<RevenueData[]>([])
25
+ const [recentActivity, setRecentActivity] = useState<Activity[]>([])
26
+
27
+ useEffect(() => {
28
+ async function fetchDashboard() {
29
+ try {
30
+ const api = apiClient.api as unknown as Record<
31
+ string,
32
+ Record<string, Record<string, () => Promise<Response>>>
33
+ >
34
+ const statsEndpoint = api?.admin?.dashboard?.stats
35
+ if (!statsEndpoint) {
36
+ setLoading(false)
37
+ return
38
+ }
39
+ const res = await statsEndpoint()
40
+ const result = (await res.json()) as Record<string, unknown>
41
+ const data = (result.data ?? {}) as Record<string, unknown>
42
+ if (result.success) {
43
+ setStats((data.stats as StatCard[]) || [])
44
+ setRevenueData((data.revenue as RevenueData[]) || [])
45
+ setUserGrowthData((data.userGrowth as RevenueData[]) || [])
46
+ setRecentActivity((data.activity as Activity[]) || [])
47
+ }
48
+ } finally {
49
+ setLoading(false)
50
+ }
51
+ }
52
+ fetchDashboard()
53
+ }, [])
54
+
55
+ if (loading) {
56
+ return (
57
+ <div
58
+ className="flex items-center justify-center min-h-screen"
59
+ data-testid="dashboard-loading"
60
+ >
61
+ <LoadingSpinner size="lg" />
62
+ </div>
63
+ )
64
+ }
65
+
66
+ return (
67
+ <div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8" data-testid="dashboard-page">
68
+ <Helmet>
69
+ <title>Dashboard - SaaS Admin</title>
70
+ <meta name="description" content="SaaS Admin Dashboard" />
71
+ </Helmet>
72
+
73
+ <div className="max-w-7xl mx-auto">
74
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
75
+ <h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Dashboard</h1>
76
+ <div className="relative">
77
+ <button
78
+ onClick={() => setDropdownOpen(!dropdownOpen)}
79
+ data-testid="time-range-dropdown"
80
+ className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
81
+ >
82
+ {timeRange}
83
+ <ChevronDown className="w-4 h-4" />
84
+ </button>
85
+ {dropdownOpen && (
86
+ <div className="absolute right-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
87
+ {timeRanges.map(range => (
88
+ <button
89
+ key={range}
90
+ onClick={() => {
91
+ setTimeRange(range)
92
+ setDropdownOpen(false)
93
+ }}
94
+ data-testid={`time-range-${range.toLowerCase().replace(/\s+/g, '-')}`}
95
+ className={`block w-full text-left px-4 py-2 text-sm transition-colors first:rounded-t-lg last:rounded-b-lg ${
96
+ range === timeRange
97
+ ? 'bg-gray-100 text-gray-900 font-medium'
98
+ : 'text-gray-600 hover:bg-gray-50'
99
+ }`}
100
+ >
101
+ {range}
102
+ </button>
103
+ ))}
104
+ </div>
105
+ )}
106
+ </div>
107
+ </div>
108
+
109
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-8">
110
+ {stats.map(stat => {
111
+ const Icon = stat.icon
112
+ const isPositive = stat.trend >= 0
113
+ return (
114
+ <div
115
+ key={stat.label}
116
+ data-testid={`stat-card-${stat.label.toLowerCase().replace(/\s+/g, '-')}`}
117
+ className="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-sm transition-shadow"
118
+ >
119
+ <div className="flex items-center justify-between mb-3">
120
+ <span className="text-sm font-medium text-gray-500">{stat.label}</span>
121
+ <div className="p-2 bg-gray-100 rounded-lg">
122
+ <Icon className="w-4 h-4 text-gray-600" />
123
+ </div>
124
+ </div>
125
+ <div className="text-2xl font-bold text-gray-900 mb-1">{stat.value}</div>
126
+ <div className="flex items-center gap-1">
127
+ {isPositive ? (
128
+ <TrendingUp className="w-3 h-3 text-green-500" />
129
+ ) : (
130
+ <TrendingDown className="w-3 h-3 text-red-500" />
131
+ )}
132
+ <span
133
+ className={`text-sm font-medium ${
134
+ isPositive ? 'text-green-600' : 'text-red-600'
135
+ }`}
136
+ >
137
+ {isPositive ? '+' : ''}
138
+ {stat.trend}%
139
+ </span>
140
+ <span className="text-xs text-gray-400 ml-1">vs last period</span>
141
+ </div>
142
+ </div>
143
+ )
144
+ })}
145
+ </div>
146
+
147
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 mb-8">
148
+ <div
149
+ className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
150
+ data-testid="revenue-chart"
151
+ >
152
+ <h2 className="text-base font-semibold text-gray-900 mb-6">Revenue Overview</h2>
153
+ <div className="flex items-end gap-3 h-48">
154
+ {revenueData.map(item => (
155
+ <div key={item.month} className="flex-1 flex flex-col items-center gap-2">
156
+ <div className="w-full flex flex-col items-center">
157
+ <span className="text-xs text-gray-500 mb-1">${item.value}k</span>
158
+ <div
159
+ className="w-full rounded-t-md bg-gray-800 transition-all hover:bg-gray-700"
160
+ style={{ height: `${(item.value / 100) * 160}px` }}
161
+ />
162
+ </div>
163
+ <span className="text-xs text-gray-500">{item.month}</span>
164
+ </div>
165
+ ))}
166
+ </div>
167
+ </div>
168
+
169
+ <div
170
+ className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
171
+ data-testid="user-growth-chart"
172
+ >
173
+ <h2 className="text-base font-semibold text-gray-900 mb-6">User Growth</h2>
174
+ <div className="flex items-end gap-3 h-48">
175
+ {userGrowthData.map(item => (
176
+ <div key={item.month} className="flex-1 flex flex-col items-center gap-2">
177
+ <div className="w-full flex flex-col items-center">
178
+ <span className="text-xs text-gray-500 mb-1">{item.value}%</span>
179
+ <div
180
+ className="w-full rounded-t-md transition-all"
181
+ style={{
182
+ height: `${(item.value / 100) * 160}px`,
183
+ background: `linear-gradient(to top, #1f2937 ${item.value}%, #e5e7eb ${item.value}%)`,
184
+ }}
185
+ />
186
+ </div>
187
+ <span className="text-xs text-gray-500">{item.month}</span>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ <div
195
+ className="bg-white rounded-lg border border-gray-200 overflow-hidden"
196
+ data-testid="recent-activity"
197
+ >
198
+ <div className="px-5 sm:px-6 py-4 border-b border-gray-100 flex items-center justify-between">
199
+ <h2 className="text-base font-semibold text-gray-900">Recent Activity</h2>
200
+ <button
201
+ data-testid="view-all-activity"
202
+ className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
203
+ >
204
+ View all
205
+ <ArrowUpRight className="w-3.5 h-3.5" />
206
+ </button>
207
+ </div>
208
+ <div className="overflow-x-auto">
209
+ <table className="w-full">
210
+ <thead>
211
+ <tr className="border-b border-gray-100">
212
+ <th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
213
+ User
214
+ </th>
215
+ <th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
216
+ Action
217
+ </th>
218
+ <th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider hidden sm:table-cell">
219
+ Date
220
+ </th>
221
+ <th className="text-left px-5 sm:px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider">
222
+ Status
223
+ </th>
224
+ </tr>
225
+ </thead>
226
+ <tbody>
227
+ {recentActivity.map(activity => (
228
+ <tr
229
+ key={activity.id}
230
+ data-testid={`activity-row-${activity.id}`}
231
+ className="border-b border-gray-50 last:border-0 hover:bg-gray-50 transition-colors"
232
+ >
233
+ <td className="px-5 sm:px-6 py-4">
234
+ <span className="text-sm font-medium text-gray-900">{activity.user}</span>
235
+ </td>
236
+ <td className="px-5 sm:px-6 py-4">
237
+ <span className="text-sm text-gray-600">{activity.action}</span>
238
+ </td>
239
+ <td className="px-5 sm:px-6 py-4 hidden sm:table-cell">
240
+ <span className="text-sm text-gray-500">{activity.date}</span>
241
+ </td>
242
+ <td className="px-5 sm:px-6 py-4">
243
+ <span
244
+ data-testid={`status-badge-${activity.id}`}
245
+ className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
246
+ statusStyles[activity.status]
247
+ }`}
248
+ >
249
+ {activity.status}
250
+ </span>
251
+ </td>
252
+ </tr>
253
+ ))}
254
+ </tbody>
255
+ </table>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ )
261
+ }
@@ -0,0 +1,211 @@
1
+ import { useEffect } from 'react'
2
+ import { Link } from 'react-router-dom'
3
+ import { Helmet } from 'react-helmet-async'
4
+ import {
5
+ Package,
6
+ Trash2,
7
+ ExternalLink,
8
+ Plus,
9
+ BarChart3,
10
+ CheckCircle2,
11
+ Clock,
12
+ Download,
13
+ } from 'lucide-react'
14
+ import { usePluginStore } from '@client/stores/pluginStore'
15
+ import { LoadingSpinner, EmptyState, StatusBadge } from '@client/components'
16
+
17
+ export const DeveloperDashboardPage: React.FC = () => {
18
+ const myPlugins = usePluginStore(state => state.myPlugins)
19
+ const loading = usePluginStore(state => state.loading)
20
+ const error = usePluginStore(state => state.error)
21
+ const fetchMyPlugins = usePluginStore(state => state.fetchMyPlugins)
22
+ const deletePlugin = usePluginStore(state => state.deletePlugin)
23
+ const fetchStats = usePluginStore(state => state.fetchStats)
24
+
25
+ useEffect(() => {
26
+ fetchMyPlugins()
27
+ fetchStats()
28
+ }, [fetchMyPlugins, fetchStats])
29
+
30
+ const statusColors: Record<string, 'green' | 'yellow' | 'red'> = {
31
+ approved: 'green',
32
+ pending: 'yellow',
33
+ rejected: 'red',
34
+ }
35
+
36
+ const safePlugins = myPlugins?.filter(Boolean) ?? []
37
+ const totalDownloads = safePlugins.reduce((sum, p) => sum + (p?.downloadCount ?? 0), 0)
38
+
39
+ return (
40
+ <div className="min-h-screen" data-testid="developer-dashboard-page">
41
+ <Helmet>
42
+ <title>Developer Dashboard - Plugin Marketplace</title>
43
+ <meta name="description" content="Manage your published plugins" />
44
+ </Helmet>
45
+
46
+ <div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-12 pb-16 px-6">
47
+ <div className="max-w-5xl mx-auto">
48
+ <div className="flex items-center justify-between">
49
+ <div className="flex items-center gap-3">
50
+ <div className="p-2.5 bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-xl shadow-lg shadow-violet-500/25">
51
+ <BarChart3 className="w-6 h-6 text-white" />
52
+ </div>
53
+ <div>
54
+ <h1 className="text-3xl font-bold text-white tracking-tight">
55
+ Developer Dashboard
56
+ </h1>
57
+ <p className="text-purple-200/60 mt-0.5">Manage your published plugins</p>
58
+ </div>
59
+ </div>
60
+ <Link
61
+ to="/publish"
62
+ className="group flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-xl hover:from-violet-400 hover:to-fuchsia-400 transition-all shadow-lg shadow-violet-500/25 hover:shadow-violet-500/40 text-sm font-semibold"
63
+ >
64
+ <Plus className="w-4 h-4 group-hover:rotate-90 transition-transform" />
65
+ New Plugin
66
+ </Link>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <div className="max-w-5xl mx-auto px-6 -mt-8 pb-16">
72
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
73
+ <div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
74
+ <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-violet-500 to-fuchsia-500" />
75
+ <div className="flex items-center gap-3">
76
+ <div className="p-2 bg-violet-50 rounded-lg">
77
+ <Package className="w-5 h-5 text-violet-500" />
78
+ </div>
79
+ <div>
80
+ <p className="text-xs text-gray-500 font-medium">My Plugins</p>
81
+ <p className="text-2xl font-bold text-gray-900">{safePlugins.length}</p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ <div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
86
+ <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
87
+ <div className="flex items-center gap-3">
88
+ <div className="p-2 bg-emerald-50 rounded-lg">
89
+ <CheckCircle2 className="w-5 h-5 text-emerald-500" />
90
+ </div>
91
+ <div>
92
+ <p className="text-xs text-gray-500 font-medium">Approved</p>
93
+ <p className="text-2xl font-bold text-emerald-600">
94
+ {safePlugins.filter(p => p.status === 'approved').length}
95
+ </p>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
100
+ <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 to-orange-500" />
101
+ <div className="flex items-center gap-3">
102
+ <div className="p-2 bg-amber-50 rounded-lg">
103
+ <Clock className="w-5 h-5 text-amber-500" />
104
+ </div>
105
+ <div>
106
+ <p className="text-xs text-gray-500 font-medium">Pending</p>
107
+ <p className="text-2xl font-bold text-amber-600">
108
+ {safePlugins.filter(p => p.status === 'pending').length}
109
+ </p>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ <div className="relative overflow-hidden bg-white rounded-2xl shadow-lg shadow-black/5 border border-gray-100 p-5">
114
+ <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-cyan-500" />
115
+ <div className="flex items-center gap-3">
116
+ <div className="p-2 bg-blue-50 rounded-lg">
117
+ <Download className="w-5 h-5 text-blue-500" />
118
+ </div>
119
+ <div>
120
+ <p className="text-xs text-gray-500 font-medium">Downloads</p>
121
+ <p className="text-2xl font-bold text-blue-600">{totalDownloads}</p>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ {error && (
128
+ <div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl">
129
+ {error}
130
+ </div>
131
+ )}
132
+
133
+ {loading && safePlugins.length === 0 ? (
134
+ <div className="flex items-center justify-center py-20">
135
+ <LoadingSpinner size="lg" />
136
+ </div>
137
+ ) : safePlugins.length === 0 ? (
138
+ <div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-12">
139
+ <EmptyState
140
+ icon={Package}
141
+ title="No plugins published yet"
142
+ description="Publish your first plugin to get started"
143
+ />
144
+ </div>
145
+ ) : (
146
+ <div className="space-y-3">
147
+ {safePlugins.map(plugin => (
148
+ <div
149
+ key={plugin.id}
150
+ className="group bg-white rounded-2xl border border-gray-100 hover:border-violet-200 hover:shadow-lg hover:shadow-violet-500/5 transition-all duration-300 p-5"
151
+ >
152
+ <div className="flex items-start gap-4">
153
+ <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-violet-100 to-fuchsia-100 flex items-center justify-center text-lg font-bold text-violet-600 shrink-0">
154
+ {plugin.name.charAt(0).toUpperCase()}
155
+ </div>
156
+ <div className="flex-1 min-w-0">
157
+ <div className="flex items-center gap-2">
158
+ <h3 className="text-base font-semibold text-gray-900 group-hover:text-violet-700 transition-colors">
159
+ {plugin.name}
160
+ </h3>
161
+ <StatusBadge
162
+ label={plugin.status}
163
+ colorScheme={statusColors[plugin.status] || 'gray'}
164
+ />
165
+ </div>
166
+ <p className="text-xs text-gray-400 mt-0.5 font-mono">
167
+ {plugin.slug} &middot; v{plugin.version}
168
+ </p>
169
+ {plugin.status === 'rejected' && plugin.rejectReason && (
170
+ <p className="text-sm text-red-500 mt-1">Rejected: {plugin.rejectReason}</p>
171
+ )}
172
+ <p className="text-sm text-gray-500 mt-2 line-clamp-2 leading-relaxed">
173
+ {plugin.description}
174
+ </p>
175
+ <div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
176
+ <span className="flex items-center gap-1">
177
+ <Download className="w-3.5 h-3.5" />
178
+ {plugin.downloadCount} downloads
179
+ </span>
180
+ <span>{new Date(plugin.createdAt).toLocaleDateString()}</span>
181
+ </div>
182
+ </div>
183
+ <div className="flex items-center gap-1.5 shrink-0">
184
+ <Link
185
+ to={`/plugins/${plugin.slug}`}
186
+ className="p-2.5 text-gray-400 hover:text-violet-500 hover:bg-violet-50 rounded-xl transition-all"
187
+ title="View"
188
+ >
189
+ <ExternalLink className="w-4.5 h-4.5" />
190
+ </Link>
191
+ <button
192
+ onClick={() => {
193
+ if (confirm('Delete this plugin?')) {
194
+ deletePlugin(plugin.slug)
195
+ }
196
+ }}
197
+ className="p-2.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
198
+ title="Delete"
199
+ >
200
+ <Trash2 className="w-4.5 h-4.5" />
201
+ </button>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ ))}
206
+ </div>
207
+ )}
208
+ </div>
209
+ </div>
210
+ )
211
+ }