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.
- package/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- 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'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
|
+
}
|