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,127 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate, Link } from 'react-router-dom'
3
+ import { Helmet } from 'react-helmet-async'
4
+ import { LogIn } from 'lucide-react'
5
+ import { useAuthStore } from '@client/stores/authStore'
6
+ import { usePreset } from '../contexts/PresetContext'
7
+
8
+ const DEMO_CREDENTIALS: Record<string, { email: string; password: string; name: string }> = {
9
+ todo: { email: 'demo@biomimic.app', password: 'demo123', name: 'Demo User' },
10
+ plugin: { email: 'developer@pluginhub.io', password: 'dev123', name: 'Plugin Developer' },
11
+ ecommerce: { email: 'shopper@shopmart.com', password: 'shop123', name: 'Shopper' },
12
+ community: { email: 'member@community.dev', password: 'member123', name: 'Community Member' },
13
+ saas: { email: 'admin@biomimic.app', password: 'admin123', name: 'Admin' },
14
+ }
15
+
16
+ export const LoginPage: React.FC = () => {
17
+ const navigate = useNavigate()
18
+ const login = useAuthStore(state => state.login)
19
+ const loading = useAuthStore(state => state.loading)
20
+ const error = useAuthStore(state => state.error)
21
+ const clearError = useAuthStore(state => state.clearError)
22
+
23
+ const preset = usePreset()
24
+ const creds = DEMO_CREDENTIALS[preset] ?? DEMO_CREDENTIALS.todo
25
+
26
+ const [username, setUsername] = useState(creds.email)
27
+ const [password, setPassword] = useState(creds.password)
28
+
29
+ const handleSubmit = async (e: React.FormEvent) => {
30
+ e.preventDefault()
31
+ await login(username, password)
32
+ const state = useAuthStore.getState()
33
+ if (state.isAuthenticated) {
34
+ navigate('/todos')
35
+ }
36
+ }
37
+
38
+ return (
39
+ <>
40
+ <Helmet>
41
+ <title>Login - Biomimic App</title>
42
+ </Helmet>
43
+ <div className="min-h-[calc(100vh-200px)] flex items-center justify-center">
44
+ <div className="w-full max-w-md" data-testid="login-page">
45
+ <div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
46
+ <div className="text-center mb-6">
47
+ <div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-3">
48
+ <LogIn className="w-6 h-6 text-blue-600" />
49
+ </div>
50
+ <h1 className="text-2xl font-bold text-gray-900">Welcome Back</h1>
51
+ <p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
52
+ </div>
53
+
54
+ <form onSubmit={handleSubmit} className="space-y-4">
55
+ <div>
56
+ <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
57
+ Username
58
+ </label>
59
+ <input
60
+ id="username"
61
+ type="text"
62
+ value={username}
63
+ onChange={e => {
64
+ setUsername(e.target.value)
65
+ if (error) clearError()
66
+ }}
67
+ placeholder="Enter your username"
68
+ required
69
+ data-testid="login-username"
70
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
71
+ />
72
+ </div>
73
+
74
+ <div>
75
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
76
+ Password
77
+ </label>
78
+ <input
79
+ id="password"
80
+ type="password"
81
+ value={password}
82
+ onChange={e => {
83
+ setPassword(e.target.value)
84
+ if (error) clearError()
85
+ }}
86
+ placeholder="Enter your password"
87
+ required
88
+ data-testid="login-password"
89
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
90
+ />
91
+ </div>
92
+
93
+ {error && (
94
+ <div
95
+ className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
96
+ data-testid="login-error"
97
+ >
98
+ {error}
99
+ </div>
100
+ )}
101
+
102
+ <button
103
+ type="submit"
104
+ disabled={loading}
105
+ data-testid="login-submit"
106
+ className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
107
+ >
108
+ {loading ? 'Signing in...' : 'Sign In'}
109
+ </button>
110
+ </form>
111
+
112
+ <p className="mt-4 text-center text-sm text-gray-500">
113
+ Don&apos;t have an account?{' '}
114
+ <Link
115
+ to="/register"
116
+ className="text-blue-600 hover:text-blue-700 font-medium"
117
+ data-testid="login-register-link"
118
+ >
119
+ Sign up
120
+ </Link>
121
+ </p>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </>
126
+ )
127
+ }
@@ -0,0 +1,196 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Helmet } from 'react-helmet-async'
3
+ import { Package, Truck, Clock, CheckCircle, XCircle, RotateCcw, Search } from 'lucide-react'
4
+ import { apiClient } from '@client/services/apiClient'
5
+ import { LoadingSpinner } from '@client/components'
6
+
7
+ import type { ECommerceOrder } from '@shared/schemas'
8
+
9
+ type ECommerceOrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled'
10
+
11
+ type FilterStatus = 'all' | ECommerceOrderStatus
12
+
13
+ const STATUS_CONFIG: Record<
14
+ ECommerceOrderStatus,
15
+ { label: string; icon: React.FC<{ className?: string }>; bg: string; text: string }
16
+ > = {
17
+ processing: { label: 'Processing', icon: Clock, bg: 'bg-amber-100', text: 'text-amber-700' },
18
+ shipped: { label: 'Shipped', icon: Truck, bg: 'bg-blue-100', text: 'text-blue-700' },
19
+ delivered: { label: 'Delivered', icon: CheckCircle, bg: 'bg-green-100', text: 'text-green-700' },
20
+ cancelled: { label: 'Cancelled', icon: XCircle, bg: 'bg-red-100', text: 'text-red-700' },
21
+ }
22
+
23
+ const FILTER_OPTIONS: { value: FilterStatus; label: string }[] = [
24
+ { value: 'all', label: 'All' },
25
+ { value: 'processing', label: 'Processing' },
26
+ { value: 'shipped', label: 'Shipped' },
27
+ { value: 'delivered', label: 'Delivered' },
28
+ { value: 'cancelled', label: 'Cancelled' },
29
+ ]
30
+
31
+ export const OrdersPage: React.FC = () => {
32
+ const [activeFilter, setActiveFilter] = useState<FilterStatus>('all')
33
+ const [orders, setOrders] = useState<ECommerceOrder[]>([])
34
+ const [loading, setLoading] = useState(true)
35
+
36
+ useEffect(() => {
37
+ async function fetchOrders() {
38
+ try {
39
+ const res = await apiClient.api['orders-mock'].$get()
40
+ const result = await res.json()
41
+ if (result.success) {
42
+ setOrders(result.data)
43
+ }
44
+ } finally {
45
+ setLoading(false)
46
+ }
47
+ }
48
+ fetchOrders()
49
+ }, [])
50
+
51
+ const filteredOrders =
52
+ activeFilter === 'all' ? orders : orders.filter(order => order.status === activeFilter)
53
+
54
+ if (loading) {
55
+ return (
56
+ <div className="flex items-center justify-center py-20" data-testid="orders-loading">
57
+ <LoadingSpinner size="lg" />
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className="max-w-4xl mx-auto p-4 sm:p-6" data-testid="orders-page">
64
+ <Helmet>
65
+ <title>Order History - Biomimic App</title>
66
+ <meta name="description" content="View your order history and track shipments" />
67
+ </Helmet>
68
+
69
+ <div className="mb-8">
70
+ <h1 className="text-3xl font-bold text-gray-900" data-testid="orders-title">
71
+ Order History
72
+ </h1>
73
+ <p className="text-gray-500 mt-2">{orders.length} orders placed</p>
74
+ </div>
75
+
76
+ <div className="flex flex-wrap gap-2 mb-6" data-testid="orders-filters">
77
+ {FILTER_OPTIONS.map(option => (
78
+ <button
79
+ key={option.value}
80
+ onClick={() => setActiveFilter(option.value)}
81
+ className={`px-4 py-2 text-sm font-medium rounded-xl transition-colors ${
82
+ activeFilter === option.value
83
+ ? 'bg-amber-500 text-white'
84
+ : 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
85
+ }`}
86
+ data-testid={`orders-filter-${option.value}`}
87
+ >
88
+ {option.label}
89
+ </button>
90
+ ))}
91
+ </div>
92
+
93
+ {filteredOrders.length === 0 ? (
94
+ <div className="flex flex-col items-center justify-center py-20" data-testid="orders-empty">
95
+ <div className="w-20 h-20 rounded-full bg-gray-100 flex items-center justify-center mb-6">
96
+ <Search className="w-10 h-10 text-gray-400" />
97
+ </div>
98
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">No orders found</h2>
99
+ <p className="text-gray-500">No orders match the selected filter.</p>
100
+ </div>
101
+ ) : (
102
+ <div className="space-y-4" data-testid="orders-list">
103
+ {filteredOrders.map(order => {
104
+ const statusCfg = STATUS_CONFIG[order.status]
105
+ const StatusIcon = statusCfg.icon
106
+ return (
107
+ <div
108
+ key={order.id}
109
+ className="bg-white rounded-xl border border-gray-200 shadow-sm p-5"
110
+ data-testid={`order-card-${order.id}`}
111
+ >
112
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
113
+ <div>
114
+ <div className="flex items-center gap-3">
115
+ <h3
116
+ className="font-semibold text-gray-900"
117
+ data-testid={`order-id-${order.id}`}
118
+ >
119
+ {order.id}
120
+ </h3>
121
+ <span
122
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${statusCfg.bg} ${statusCfg.text}`}
123
+ data-testid={`order-status-${order.id}`}
124
+ >
125
+ <StatusIcon className="w-3.5 h-3.5" />
126
+ {statusCfg.label}
127
+ </span>
128
+ </div>
129
+ <p
130
+ className="text-sm text-gray-500 mt-0.5"
131
+ data-testid={`order-date-${order.id}`}
132
+ >
133
+ {new Date(order.date).toLocaleDateString('en-US', {
134
+ year: 'numeric',
135
+ month: 'long',
136
+ day: 'numeric',
137
+ })}
138
+ </p>
139
+ </div>
140
+ <p
141
+ className="text-lg font-bold text-gray-900"
142
+ data-testid={`order-total-${order.id}`}
143
+ >
144
+ ${order.total.toFixed(2)}
145
+ </p>
146
+ </div>
147
+
148
+ <div
149
+ className="flex items-center gap-2 mb-4"
150
+ data-testid={`order-products-${order.id}`}
151
+ >
152
+ {order.products.map((product, idx) => (
153
+ <div
154
+ key={idx}
155
+ className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 rounded-lg"
156
+ data-testid={`order-product-${order.id}-${idx}`}
157
+ >
158
+ <div
159
+ className="w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0"
160
+ style={{ backgroundColor: product.color }}
161
+ >
162
+ <Package className="w-3 h-3 text-white/70" />
163
+ </div>
164
+ <span className="text-sm text-gray-700 truncate max-w-[120px] sm:max-w-[200px]">
165
+ {product.name}
166
+ </span>
167
+ </div>
168
+ ))}
169
+ </div>
170
+
171
+ <div className="flex items-center gap-3 pt-3 border-t border-gray-100">
172
+ {(order.status === 'shipped' || order.status === 'processing') && (
173
+ <button
174
+ className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-amber-600 bg-amber-50 rounded-xl hover:bg-amber-100 transition-colors"
175
+ data-testid={`order-track-${order.id}`}
176
+ >
177
+ <Truck className="w-4 h-4" />
178
+ Track Order
179
+ </button>
180
+ )}
181
+ <button
182
+ className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors"
183
+ data-testid={`order-reorder-${order.id}`}
184
+ >
185
+ <RotateCcw className="w-4 h-4" />
186
+ Reorder
187
+ </button>
188
+ </div>
189
+ </div>
190
+ )
191
+ })}
192
+ </div>
193
+ )}
194
+ </div>
195
+ )
196
+ }
@@ -0,0 +1,345 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { useParams, Link } from 'react-router-dom'
3
+ import { Helmet } from 'react-helmet-async'
4
+ import {
5
+ ArrowLeft,
6
+ Download,
7
+ Star,
8
+ ExternalLink,
9
+ Tag,
10
+ Terminal,
11
+ User,
12
+ Calendar,
13
+ Package,
14
+ Shield,
15
+ Globe,
16
+ } from 'lucide-react'
17
+ import { usePluginStore } from '@client/stores/pluginStore'
18
+ import { LoadingSpinner, StatusBadge } from '@client/components'
19
+ import type { CreateReviewInput } from '@shared/schemas'
20
+
21
+ export const PluginDetailPage: React.FC = () => {
22
+ const { slug } = useParams<{ slug: string }>()
23
+ const currentPlugin = usePluginStore(state => state.currentPlugin)
24
+ const reviews = usePluginStore(state => state.reviews)
25
+ const loading = usePluginStore(state => state.loading)
26
+ const error = usePluginStore(state => state.error)
27
+ const fetchPlugin = usePluginStore(state => state.fetchPlugin)
28
+ const fetchReviews = usePluginStore(state => state.fetchReviews)
29
+ const trackInstall = usePluginStore(state => state.trackInstall)
30
+ const submitReview = usePluginStore(state => state.submitReview)
31
+ const clearCurrentPlugin = usePluginStore(state => state.clearCurrentPlugin)
32
+
33
+ const [reviewForm, setReviewForm] = useState<CreateReviewInput>({
34
+ rating: 5,
35
+ title: '',
36
+ content: '',
37
+ })
38
+ const [submittingReview, setSubmittingReview] = useState(false)
39
+
40
+ useEffect(() => {
41
+ if (slug) {
42
+ fetchPlugin(slug)
43
+ fetchReviews(slug)
44
+ }
45
+ return () => clearCurrentPlugin()
46
+ }, [slug, fetchPlugin, fetchReviews, clearCurrentPlugin])
47
+
48
+ const handleInstall = async () => {
49
+ if (slug) {
50
+ await trackInstall(slug)
51
+ }
52
+ }
53
+
54
+ const handleSubmitReview = async (e: React.FormEvent) => {
55
+ e.preventDefault()
56
+ if (!slug) return
57
+ setSubmittingReview(true)
58
+ await submitReview(slug, reviewForm)
59
+ setSubmittingReview(false)
60
+ setReviewForm({ rating: 5, title: '', content: '' })
61
+ }
62
+
63
+ if (loading && !currentPlugin) {
64
+ return (
65
+ <div className="min-h-screen bg-gradient-to-b from-slate-900 via-purple-900 to-slate-50">
66
+ <div className="flex items-center justify-center py-32">
67
+ <LoadingSpinner size="lg" color="text-white" />
68
+ </div>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ if (error && !currentPlugin) {
74
+ return (
75
+ <div className="min-h-screen bg-gradient-to-b from-slate-900 via-purple-900 to-slate-50 px-6 pt-12">
76
+ <div className="max-w-4xl mx-auto">
77
+ <div className="p-5 bg-red-500/10 border border-red-500/20 text-red-300 rounded-2xl backdrop-blur-sm">
78
+ {error}
79
+ </div>
80
+ <Link
81
+ to="/plugins"
82
+ className="mt-4 inline-flex items-center gap-2 text-purple-300 hover:text-white transition-colors"
83
+ >
84
+ <ArrowLeft className="w-4 h-4" />
85
+ Back to Plugins
86
+ </Link>
87
+ </div>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ if (!currentPlugin) return null
93
+
94
+ const statusColors: Record<string, 'green' | 'yellow' | 'red'> = {
95
+ approved: 'green',
96
+ pending: 'yellow',
97
+ rejected: 'red',
98
+ }
99
+
100
+ const avgRating =
101
+ reviews.length > 0 ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length : 0
102
+
103
+ return (
104
+ <div className="min-h-screen" data-testid="plugin-detail-page">
105
+ <Helmet>
106
+ <title>{currentPlugin.name} - Plugin Marketplace</title>
107
+ <meta name="description" content={currentPlugin.description} />
108
+ </Helmet>
109
+
110
+ <div className="bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 pt-8 pb-20 px-6">
111
+ <div className="max-w-5xl mx-auto">
112
+ <Link
113
+ to="/plugins"
114
+ className="inline-flex items-center gap-2 text-purple-300 hover:text-white transition-colors mb-8 text-sm"
115
+ >
116
+ <ArrowLeft className="w-4 h-4" />
117
+ Back to Marketplace
118
+ </Link>
119
+
120
+ <div className="flex items-start gap-6">
121
+ <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-violet-500/30 shrink-0">
122
+ {currentPlugin.name.charAt(0).toUpperCase()}
123
+ </div>
124
+ <div className="flex-1 min-w-0">
125
+ <div className="flex items-center gap-3 flex-wrap">
126
+ <h1 className="text-3xl font-bold text-white tracking-tight">
127
+ {currentPlugin.name}
128
+ </h1>
129
+ <StatusBadge
130
+ label={currentPlugin.status}
131
+ colorScheme={statusColors[currentPlugin.status] || 'gray'}
132
+ />
133
+ {currentPlugin.featured && <StatusBadge label="Featured" colorScheme="purple" />}
134
+ </div>
135
+ <div className="flex items-center gap-4 mt-3 text-sm text-purple-200/60 flex-wrap">
136
+ <span className="flex items-center gap-1.5">
137
+ <User className="w-4 h-4" />
138
+ {currentPlugin.authorName}
139
+ </span>
140
+ <span className="flex items-center gap-1.5">
141
+ <Package className="w-4 h-4" />v{currentPlugin.version}
142
+ </span>
143
+ <span className="flex items-center gap-1.5">
144
+ <Calendar className="w-4 h-4" />
145
+ {new Date(currentPlugin.createdAt).toLocaleDateString()}
146
+ </span>
147
+ {currentPlugin.license && (
148
+ <span className="flex items-center gap-1.5">
149
+ <Shield className="w-4 h-4" />
150
+ {currentPlugin.license}
151
+ </span>
152
+ )}
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <div className="mt-8 flex items-center gap-3 flex-wrap">
158
+ <button
159
+ onClick={handleInstall}
160
+ className="flex items-center gap-2 px-6 py-3 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 font-semibold"
161
+ >
162
+ <Download className="w-5 h-5" />
163
+ Install Plugin
164
+ </button>
165
+ {currentPlugin.repositoryUrl && (
166
+ <a
167
+ href={currentPlugin.repositoryUrl}
168
+ target="_blank"
169
+ rel="noopener noreferrer"
170
+ className="flex items-center gap-2 px-5 py-3 bg-white/10 text-white rounded-xl hover:bg-white/20 transition-all text-sm backdrop-blur-sm border border-white/10"
171
+ >
172
+ <Globe className="w-4 h-4" />
173
+ Repository
174
+ </a>
175
+ )}
176
+ {currentPlugin.homepageUrl && (
177
+ <a
178
+ href={currentPlugin.homepageUrl}
179
+ target="_blank"
180
+ rel="noopener noreferrer"
181
+ className="flex items-center gap-2 px-5 py-3 bg-white/10 text-white rounded-xl hover:bg-white/20 transition-all text-sm backdrop-blur-sm border border-white/10"
182
+ >
183
+ <ExternalLink className="w-4 h-4" />
184
+ Homepage
185
+ </a>
186
+ )}
187
+ </div>
188
+
189
+ <div className="mt-6 flex items-center gap-6 text-sm text-purple-200/50">
190
+ <span className="flex items-center gap-1.5">
191
+ <Download className="w-4 h-4" />
192
+ <span className="text-white font-semibold">{currentPlugin.downloadCount}</span>{' '}
193
+ downloads
194
+ </span>
195
+ <span className="flex items-center gap-1.5">
196
+ <Star className="w-4 h-4 text-yellow-400" />
197
+ <span className="text-white font-semibold">{avgRating.toFixed(1)}</span>
198
+ <span>({reviews.length} reviews)</span>
199
+ </span>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="max-w-5xl mx-auto px-6 -mt-8 pb-16">
205
+ <div className="bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-8">
206
+ <p className="text-gray-600 leading-relaxed text-base">{currentPlugin.description}</p>
207
+
208
+ {currentPlugin.tags && currentPlugin.tags.length > 0 && (
209
+ <div className="mt-6 flex items-center gap-2 flex-wrap">
210
+ {currentPlugin.tags.map(tag => (
211
+ <span
212
+ key={tag}
213
+ className="inline-flex items-center gap-1 px-3 py-1.5 bg-gradient-to-r from-violet-50 to-fuchsia-50 text-violet-700 rounded-lg text-sm font-medium"
214
+ >
215
+ <Tag className="w-3.5 h-3.5 opacity-60" />
216
+ {tag}
217
+ </span>
218
+ ))}
219
+ </div>
220
+ )}
221
+
222
+ {currentPlugin.commands && currentPlugin.commands.length > 0 && (
223
+ <div className="mt-8">
224
+ <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-4">
225
+ <Terminal className="w-5 h-5 text-violet-500" />
226
+ Commands
227
+ </h2>
228
+ <div className="space-y-2">
229
+ {currentPlugin.commands.map((cmd, i) => (
230
+ <div
231
+ key={i}
232
+ className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl border border-gray-100"
233
+ >
234
+ <code className="text-sm font-mono text-violet-600 font-semibold bg-violet-50 px-2 py-0.5 rounded">
235
+ {cmd.name}
236
+ </code>
237
+ {cmd.description && (
238
+ <span className="text-sm text-gray-500">{cmd.description}</span>
239
+ )}
240
+ </div>
241
+ ))}
242
+ </div>
243
+ </div>
244
+ )}
245
+ </div>
246
+
247
+ <div className="mt-6 bg-white rounded-2xl shadow-xl shadow-black/5 border border-gray-100 p-8">
248
+ <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2 mb-6">
249
+ <Star className="w-5 h-5 text-yellow-400" />
250
+ Reviews ({reviews.length})
251
+ </h2>
252
+
253
+ {reviews.length > 0 ? (
254
+ <div className="space-y-4">
255
+ {reviews.map(review => (
256
+ <div key={review.id} className="p-5 bg-gray-50 rounded-xl border border-gray-100">
257
+ <div className="flex items-center justify-between">
258
+ <div className="flex items-center gap-3">
259
+ <div className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-400 to-fuchsia-400 flex items-center justify-center text-white text-sm font-bold">
260
+ {review.userName.charAt(0).toUpperCase()}
261
+ </div>
262
+ <span className="font-semibold text-gray-900">{review.userName}</span>
263
+ <div className="flex items-center">
264
+ {Array.from({ length: 5 }).map((_, i) => (
265
+ <Star
266
+ key={i}
267
+ className={`w-4 h-4 ${
268
+ i < review.rating
269
+ ? 'text-yellow-400 fill-yellow-400'
270
+ : 'text-gray-200'
271
+ }`}
272
+ />
273
+ ))}
274
+ </div>
275
+ </div>
276
+ <span className="text-xs text-gray-400">
277
+ {new Date(review.createdAt).toLocaleDateString()}
278
+ </span>
279
+ </div>
280
+ {review.title && (
281
+ <h4 className="mt-3 font-semibold text-gray-800">{review.title}</h4>
282
+ )}
283
+ {review.content && (
284
+ <p className="mt-1.5 text-sm text-gray-600 leading-relaxed">{review.content}</p>
285
+ )}
286
+ </div>
287
+ ))}
288
+ </div>
289
+ ) : (
290
+ <p className="text-gray-400 text-sm">No reviews yet. Be the first to review!</p>
291
+ )}
292
+
293
+ <form onSubmit={handleSubmitReview} className="mt-8 pt-8 border-t border-gray-100">
294
+ <h3 className="text-base font-semibold text-gray-900 mb-5">Write a Review</h3>
295
+ <div className="mb-5">
296
+ <label className="block text-sm font-medium text-gray-600 mb-2">Rating</label>
297
+ <div className="flex items-center gap-1.5">
298
+ {Array.from({ length: 5 }).map((_, i) => (
299
+ <button
300
+ key={i}
301
+ type="button"
302
+ onClick={() => setReviewForm(prev => ({ ...prev, rating: i + 1 }))}
303
+ >
304
+ <Star
305
+ className={`w-7 h-7 cursor-pointer transition-all ${
306
+ i < reviewForm.rating
307
+ ? 'text-yellow-400 fill-yellow-400 scale-110'
308
+ : 'text-gray-200 hover:text-yellow-300 hover:scale-105'
309
+ }`}
310
+ />
311
+ </button>
312
+ ))}
313
+ </div>
314
+ </div>
315
+ <div className="mb-4">
316
+ <input
317
+ type="text"
318
+ value={reviewForm.title ?? ''}
319
+ onChange={e => setReviewForm(prev => ({ ...prev, title: e.target.value }))}
320
+ placeholder="Review title (optional)"
321
+ className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm transition-all"
322
+ />
323
+ </div>
324
+ <div className="mb-5">
325
+ <textarea
326
+ value={reviewForm.content ?? ''}
327
+ onChange={e => setReviewForm(prev => ({ ...prev, content: e.target.value }))}
328
+ placeholder="Write your review... (optional)"
329
+ rows={3}
330
+ className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-500 focus:border-transparent outline-none text-sm resize-none transition-all"
331
+ />
332
+ </div>
333
+ <button
334
+ type="submit"
335
+ disabled={submittingReview}
336
+ className="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 disabled:from-gray-300 disabled:to-gray-300 disabled:cursor-not-allowed transition-all text-sm font-semibold shadow-md shadow-violet-500/15"
337
+ >
338
+ {submittingReview ? <LoadingSpinner size="sm" color="text-white" /> : 'Submit Review'}
339
+ </button>
340
+ </form>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ )
345
+ }