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,220 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Helmet } from 'react-helmet-async'
|
|
3
|
+
import { Settings as SettingsIcon, Users, CreditCard, Key, Bell, Save } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
type TabKey = 'general' | 'team' | 'billing' | 'api-keys' | 'notifications'
|
|
6
|
+
|
|
7
|
+
interface Tab {
|
|
8
|
+
id: TabKey
|
|
9
|
+
label: string
|
|
10
|
+
icon: React.FC<{ className?: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const tabs: Tab[] = [
|
|
14
|
+
{ id: 'general', label: 'General', icon: SettingsIcon },
|
|
15
|
+
{ id: 'team', label: 'Team', icon: Users },
|
|
16
|
+
{ id: 'billing', label: 'Billing', icon: CreditCard },
|
|
17
|
+
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
|
18
|
+
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const timezones = [
|
|
22
|
+
'UTC',
|
|
23
|
+
'America/New_York',
|
|
24
|
+
'America/Chicago',
|
|
25
|
+
'America/Denver',
|
|
26
|
+
'America/Los_Angeles',
|
|
27
|
+
'Europe/London',
|
|
28
|
+
'Europe/Paris',
|
|
29
|
+
'Asia/Tokyo',
|
|
30
|
+
'Asia/Shanghai',
|
|
31
|
+
'Australia/Sydney',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const languages = [
|
|
35
|
+
'English',
|
|
36
|
+
'Chinese (Simplified)',
|
|
37
|
+
'Chinese (Traditional)',
|
|
38
|
+
'Japanese',
|
|
39
|
+
'Korean',
|
|
40
|
+
'Spanish',
|
|
41
|
+
'French',
|
|
42
|
+
'German',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export const SettingsPage: React.FC = () => {
|
|
46
|
+
const [activeTab, setActiveTab] = useState<TabKey>('general')
|
|
47
|
+
const [orgName, setOrgName] = useState('Acme Corp')
|
|
48
|
+
const [orgUrl, setOrgUrl] = useState('acme-corp')
|
|
49
|
+
const [timezone, setTimezone] = useState('America/New_York')
|
|
50
|
+
const [language, setLanguage] = useState('English')
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8" data-testid="settings-page">
|
|
54
|
+
<Helmet>
|
|
55
|
+
<title>Settings - SaaS Admin</title>
|
|
56
|
+
<meta name="description" content="Manage organization settings" />
|
|
57
|
+
</Helmet>
|
|
58
|
+
|
|
59
|
+
<div className="max-w-7xl mx-auto">
|
|
60
|
+
<div className="mb-8">
|
|
61
|
+
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Settings</h1>
|
|
62
|
+
<p className="mt-1 text-sm text-gray-500">Manage your organization settings</p>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="flex flex-col lg:flex-row gap-6">
|
|
66
|
+
<nav
|
|
67
|
+
className="flex lg:flex-col gap-1 lg:w-56 shrink-0 overflow-x-auto lg:overflow-visible"
|
|
68
|
+
data-testid="settings-tabs"
|
|
69
|
+
>
|
|
70
|
+
{tabs.map(tab => {
|
|
71
|
+
const Icon = tab.icon
|
|
72
|
+
const isActive = activeTab === tab.id
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
key={tab.id}
|
|
76
|
+
onClick={() => setActiveTab(tab.id)}
|
|
77
|
+
data-testid={`tab-${tab.id}`}
|
|
78
|
+
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
|
|
79
|
+
isActive
|
|
80
|
+
? 'bg-gray-800 text-white'
|
|
81
|
+
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
|
82
|
+
}`}
|
|
83
|
+
>
|
|
84
|
+
<Icon className="w-4 h-4" />
|
|
85
|
+
{tab.label}
|
|
86
|
+
</button>
|
|
87
|
+
)
|
|
88
|
+
})}
|
|
89
|
+
</nav>
|
|
90
|
+
|
|
91
|
+
<div className="flex-1 min-w-0">
|
|
92
|
+
{activeTab === 'general' && (
|
|
93
|
+
<div
|
|
94
|
+
className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
|
|
95
|
+
data-testid="general-settings-content"
|
|
96
|
+
>
|
|
97
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-6">General Settings</h2>
|
|
98
|
+
|
|
99
|
+
<div className="space-y-5 max-w-lg">
|
|
100
|
+
<div>
|
|
101
|
+
<label
|
|
102
|
+
htmlFor="org-name"
|
|
103
|
+
className="block text-sm font-medium text-gray-700 mb-1.5"
|
|
104
|
+
>
|
|
105
|
+
Organization Name
|
|
106
|
+
</label>
|
|
107
|
+
<input
|
|
108
|
+
id="org-name"
|
|
109
|
+
type="text"
|
|
110
|
+
value={orgName}
|
|
111
|
+
onChange={e => setOrgName(e.target.value)}
|
|
112
|
+
data-testid="input-org-name"
|
|
113
|
+
className="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-800 focus:border-transparent outline-none transition-all"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div>
|
|
118
|
+
<label
|
|
119
|
+
htmlFor="org-url"
|
|
120
|
+
className="block text-sm font-medium text-gray-700 mb-1.5"
|
|
121
|
+
>
|
|
122
|
+
Organization URL
|
|
123
|
+
</label>
|
|
124
|
+
<div className="flex">
|
|
125
|
+
<span className="inline-flex items-center px-3.5 rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 text-sm text-gray-500">
|
|
126
|
+
https://
|
|
127
|
+
</span>
|
|
128
|
+
<input
|
|
129
|
+
id="org-url"
|
|
130
|
+
type="text"
|
|
131
|
+
value={orgUrl}
|
|
132
|
+
onChange={e => setOrgUrl(e.target.value)}
|
|
133
|
+
data-testid="input-org-url"
|
|
134
|
+
className="flex-1 px-3.5 py-2.5 text-sm border border-gray-300 rounded-r-lg focus:ring-2 focus:ring-gray-800 focus:border-transparent outline-none transition-all"
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<p className="mt-1 text-xs text-gray-400">
|
|
138
|
+
This will be your organization's public URL
|
|
139
|
+
</p>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div>
|
|
143
|
+
<label
|
|
144
|
+
htmlFor="timezone"
|
|
145
|
+
className="block text-sm font-medium text-gray-700 mb-1.5"
|
|
146
|
+
>
|
|
147
|
+
Timezone
|
|
148
|
+
</label>
|
|
149
|
+
<select
|
|
150
|
+
id="timezone"
|
|
151
|
+
value={timezone}
|
|
152
|
+
onChange={e => setTimezone(e.target.value)}
|
|
153
|
+
data-testid="select-timezone"
|
|
154
|
+
className="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-800 focus:border-transparent outline-none transition-all bg-white"
|
|
155
|
+
>
|
|
156
|
+
{timezones.map(tz => (
|
|
157
|
+
<option key={tz} value={tz}>
|
|
158
|
+
{tz}
|
|
159
|
+
</option>
|
|
160
|
+
))}
|
|
161
|
+
</select>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div>
|
|
165
|
+
<label
|
|
166
|
+
htmlFor="language"
|
|
167
|
+
className="block text-sm font-medium text-gray-700 mb-1.5"
|
|
168
|
+
>
|
|
169
|
+
Language
|
|
170
|
+
</label>
|
|
171
|
+
<select
|
|
172
|
+
id="language"
|
|
173
|
+
value={language}
|
|
174
|
+
onChange={e => setLanguage(e.target.value)}
|
|
175
|
+
data-testid="select-language"
|
|
176
|
+
className="w-full px-3.5 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-800 focus:border-transparent outline-none transition-all bg-white"
|
|
177
|
+
>
|
|
178
|
+
{languages.map(lang => (
|
|
179
|
+
<option key={lang} value={lang}>
|
|
180
|
+
{lang}
|
|
181
|
+
</option>
|
|
182
|
+
))}
|
|
183
|
+
</select>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div className="pt-4">
|
|
187
|
+
<button
|
|
188
|
+
data-testid="save-settings"
|
|
189
|
+
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-gray-800 rounded-lg hover:bg-gray-700 transition-colors"
|
|
190
|
+
>
|
|
191
|
+
<Save className="w-4 h-4" />
|
|
192
|
+
Save Changes
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{activeTab !== 'general' && (
|
|
200
|
+
<div
|
|
201
|
+
className="bg-white rounded-lg border border-gray-200 p-5 sm:p-6"
|
|
202
|
+
data-testid={`${activeTab}-settings-content`}
|
|
203
|
+
>
|
|
204
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-2">
|
|
205
|
+
{tabs.find(t => t.id === activeTab)?.label} Settings
|
|
206
|
+
</h2>
|
|
207
|
+
<p className="text-sm text-gray-500">
|
|
208
|
+
{activeTab === 'team' && 'Manage team members and permissions.'}
|
|
209
|
+
{activeTab === 'billing' && 'Manage billing and subscription details.'}
|
|
210
|
+
{activeTab === 'api-keys' && 'Manage API keys for integrations.'}
|
|
211
|
+
{activeTab === 'notifications' && 'Configure notification preferences.'}
|
|
212
|
+
</p>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { NavLink } from 'react-router-dom'
|
|
3
|
+
import { Helmet } from 'react-helmet-async'
|
|
4
|
+
import { ChevronUp, ChevronDown, MessageSquare, Eye, Search, Plus } from 'lucide-react'
|
|
5
|
+
import { apiClient } from '@client/services/apiClient'
|
|
6
|
+
import { LoadingSpinner } from '@client/components'
|
|
7
|
+
import type { TopicStatus, Topic } from '@shared/schemas'
|
|
8
|
+
|
|
9
|
+
type FilterType = 'all' | TopicStatus
|
|
10
|
+
|
|
11
|
+
const FILTERS: { value: FilterType; label: string }[] = [
|
|
12
|
+
{ value: 'all', label: 'All' },
|
|
13
|
+
{ value: 'hot', label: 'Hot' },
|
|
14
|
+
{ value: 'unanswered', label: 'Unanswered' },
|
|
15
|
+
{ value: 'solved', label: 'Solved' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export const TopicsPage: React.FC = () => {
|
|
19
|
+
const [search, setSearch] = useState('')
|
|
20
|
+
const [filter, setFilter] = useState<FilterType>('all')
|
|
21
|
+
const [topics, setTopics] = useState<Topic[]>([])
|
|
22
|
+
const [loading, setLoading] = useState(true)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
async function fetchTopics() {
|
|
26
|
+
try {
|
|
27
|
+
const endpoint = filter === 'hot' ? apiClient.api.topics.popular : apiClient.api.topics
|
|
28
|
+
const res = await endpoint.$get()
|
|
29
|
+
const result = await res.json()
|
|
30
|
+
if (result.success) {
|
|
31
|
+
const d = result.data as unknown
|
|
32
|
+
setTopics(Array.isArray(d) ? d : ((d as Record<string, unknown>).topics as Topic[]) ?? [])
|
|
33
|
+
}
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
fetchTopics()
|
|
39
|
+
}, [filter])
|
|
40
|
+
|
|
41
|
+
const filteredTopics = useMemo(() => {
|
|
42
|
+
if (!search.trim()) return topics
|
|
43
|
+
const q = search.toLowerCase()
|
|
44
|
+
return topics.filter(
|
|
45
|
+
t =>
|
|
46
|
+
t.title.toLowerCase().includes(q) ||
|
|
47
|
+
t.excerpt.toLowerCase().includes(q) ||
|
|
48
|
+
t.tags.some(tag => tag.label.toLowerCase().includes(q))
|
|
49
|
+
)
|
|
50
|
+
}, [search, topics])
|
|
51
|
+
|
|
52
|
+
const voteColor = (votes: number) =>
|
|
53
|
+
votes > 0 ? 'text-emerald-600' : votes < 0 ? 'text-red-500' : 'text-gray-400'
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="min-h-screen bg-white" data-testid="topics-page">
|
|
57
|
+
<Helmet>
|
|
58
|
+
<title>Community Forum</title>
|
|
59
|
+
<meta name="description" content="Browse and discuss community topics" />
|
|
60
|
+
</Helmet>
|
|
61
|
+
|
|
62
|
+
<div className="bg-gradient-to-b from-emerald-50 to-white pt-12 pb-10 px-4 sm:px-6">
|
|
63
|
+
<div className="max-w-4xl mx-auto">
|
|
64
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
65
|
+
<div>
|
|
66
|
+
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">Community Forum</h1>
|
|
67
|
+
<p className="mt-1 text-gray-500">
|
|
68
|
+
Ask questions, share knowledge, connect with others
|
|
69
|
+
</p>
|
|
70
|
+
</div>
|
|
71
|
+
<button className="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-medium rounded-xl shadow-sm shadow-emerald-500/25 transition-colors self-start sm:self-auto">
|
|
72
|
+
<Plus className="w-4 h-4" />
|
|
73
|
+
Ask Question
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div className="max-w-4xl mx-auto px-4 sm:px-6 -mt-4 pb-16">
|
|
80
|
+
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
|
81
|
+
<div className="relative flex-1">
|
|
82
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
83
|
+
<input
|
|
84
|
+
type="text"
|
|
85
|
+
value={search}
|
|
86
|
+
onChange={e => setSearch(e.target.value)}
|
|
87
|
+
placeholder="Search topics..."
|
|
88
|
+
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl focus:ring-2 focus:ring-emerald-500 focus:border-transparent outline-none transition-shadow text-sm"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex gap-2 flex-wrap">
|
|
92
|
+
{FILTERS.map(f => (
|
|
93
|
+
<button
|
|
94
|
+
key={f.value}
|
|
95
|
+
onClick={() => setFilter(f.value)}
|
|
96
|
+
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
|
|
97
|
+
filter === f.value
|
|
98
|
+
? 'bg-emerald-500 text-white shadow-sm shadow-emerald-500/25'
|
|
99
|
+
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
{f.label}
|
|
103
|
+
</button>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{loading ? (
|
|
109
|
+
<div className="flex items-center justify-center py-20" data-testid="topics-loading">
|
|
110
|
+
<LoadingSpinner size="lg" />
|
|
111
|
+
</div>
|
|
112
|
+
) : filteredTopics.length === 0 ? (
|
|
113
|
+
<div className="text-center py-20">
|
|
114
|
+
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
|
|
115
|
+
<MessageSquare className="w-7 h-7 text-gray-400" />
|
|
116
|
+
</div>
|
|
117
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-1">No topics found</h3>
|
|
118
|
+
<p className="text-gray-500 text-sm">Try adjusting your search or filter</p>
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="space-y-3">
|
|
122
|
+
{filteredTopics.map(topic => (
|
|
123
|
+
<NavLink
|
|
124
|
+
key={topic.id}
|
|
125
|
+
to={`/topics/${topic.id}`}
|
|
126
|
+
className="flex gap-4 p-4 bg-white rounded-2xl border border-gray-100 hover:border-emerald-200 hover:shadow-md hover:shadow-emerald-500/5 transition-all group"
|
|
127
|
+
>
|
|
128
|
+
<div className="flex flex-col items-center gap-0.5 pt-0.5 min-w-[48px]">
|
|
129
|
+
<ChevronUp className="w-4 h-4 text-gray-400 hover:text-emerald-500 cursor-pointer" />
|
|
130
|
+
<span className={`text-sm font-bold ${voteColor(topic.votes)}`}>
|
|
131
|
+
{topic.votes}
|
|
132
|
+
</span>
|
|
133
|
+
<ChevronDown className="w-4 h-4 text-gray-400 hover:text-red-400 cursor-pointer" />
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="flex-1 min-w-0">
|
|
137
|
+
<h3 className="font-semibold text-gray-900 group-hover:underline decoration-emerald-400 underline-offset-2 leading-snug">
|
|
138
|
+
{topic.title}
|
|
139
|
+
</h3>
|
|
140
|
+
<p className="mt-1 text-sm text-gray-500 line-clamp-2 leading-relaxed">
|
|
141
|
+
{topic.excerpt}
|
|
142
|
+
</p>
|
|
143
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
144
|
+
{topic.tags.map(tag => (
|
|
145
|
+
<span
|
|
146
|
+
key={tag.label}
|
|
147
|
+
className={`px-2.5 py-0.5 rounded-lg text-xs font-medium ${tag.color}`}
|
|
148
|
+
>
|
|
149
|
+
{tag.label}
|
|
150
|
+
</span>
|
|
151
|
+
))}
|
|
152
|
+
<span className="text-xs text-gray-400 mx-1">·</span>
|
|
153
|
+
<div className="flex items-center gap-1.5">
|
|
154
|
+
<div className="w-5 h-5 rounded-full bg-emerald-100 text-emerald-700 text-[10px] font-bold flex items-center justify-center">
|
|
155
|
+
{topic.author.initials}
|
|
156
|
+
</div>
|
|
157
|
+
<span className="text-xs text-gray-600">{topic.author.name}</span>
|
|
158
|
+
<span className="text-xs text-gray-400">{topic.createdAt}</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="hidden sm:flex flex-col items-end justify-center gap-1 text-xs text-gray-400 min-w-[80px]">
|
|
164
|
+
<div className="flex items-center gap-1">
|
|
165
|
+
<MessageSquare className="w-3.5 h-3.5" />
|
|
166
|
+
<span>{topic.replyCount}</span>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="flex items-center gap-1">
|
|
169
|
+
<Eye className="w-3.5 h-3.5" />
|
|
170
|
+
<span>{topic.viewCount}</span>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</NavLink>
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
3
|
+
import '@testing-library/jest-dom'
|
|
4
|
+
import { HelmetProvider } from 'react-helmet-async'
|
|
5
|
+
import { LoginPage } from '../LoginPage'
|
|
6
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
7
|
+
|
|
8
|
+
interface MockAuthStore {
|
|
9
|
+
login: ReturnType<typeof vi.fn>
|
|
10
|
+
loading: boolean
|
|
11
|
+
error: string | null
|
|
12
|
+
clearError: ReturnType<typeof vi.fn>
|
|
13
|
+
isAuthenticated: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const mockStore: MockAuthStore = {
|
|
17
|
+
login: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
loading: false,
|
|
19
|
+
error: null,
|
|
20
|
+
clearError: vi.fn(),
|
|
21
|
+
isAuthenticated: false,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
vi.mock('@client/stores/authStore', () => ({
|
|
25
|
+
useAuthStore: Object.assign(
|
|
26
|
+
vi.fn((selector?: (state: MockAuthStore) => unknown) => {
|
|
27
|
+
if (selector) {
|
|
28
|
+
return selector(mockStore)
|
|
29
|
+
}
|
|
30
|
+
return mockStore
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
getState: () => mockStore,
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const renderLoginPage = () =>
|
|
39
|
+
render(
|
|
40
|
+
<HelmetProvider>
|
|
41
|
+
<MemoryRouter>
|
|
42
|
+
<LoginPage />
|
|
43
|
+
</MemoryRouter>
|
|
44
|
+
</HelmetProvider>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
describe('LoginPage', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks()
|
|
50
|
+
mockStore.loading = false
|
|
51
|
+
mockStore.error = null
|
|
52
|
+
mockStore.isAuthenticated = false
|
|
53
|
+
mockStore.login.mockResolvedValue(undefined)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('Initial Render', () => {
|
|
57
|
+
it('should render login page container', () => {
|
|
58
|
+
renderLoginPage()
|
|
59
|
+
expect(screen.getByTestId('login-page')).toBeInTheDocument()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should render page title', () => {
|
|
63
|
+
renderLoginPage()
|
|
64
|
+
expect(screen.getByText('Welcome Back')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should render subtitle', () => {
|
|
68
|
+
renderLoginPage()
|
|
69
|
+
expect(screen.getByText('Sign in to your account')).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('Form Inputs', () => {
|
|
74
|
+
it('should render username input', () => {
|
|
75
|
+
renderLoginPage()
|
|
76
|
+
expect(screen.getByTestId('login-username')).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should render password input', () => {
|
|
80
|
+
renderLoginPage()
|
|
81
|
+
expect(screen.getByTestId('login-password')).toBeInTheDocument()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should render submit button', () => {
|
|
85
|
+
renderLoginPage()
|
|
86
|
+
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should show Sign In text when not loading', () => {
|
|
90
|
+
renderLoginPage()
|
|
91
|
+
expect(screen.getByTestId('login-submit')).toHaveTextContent('Sign In')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should show Signing in... text when loading', () => {
|
|
95
|
+
mockStore.loading = true
|
|
96
|
+
renderLoginPage()
|
|
97
|
+
expect(screen.getByText('Signing in...')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should disable submit button when loading', () => {
|
|
101
|
+
mockStore.loading = true
|
|
102
|
+
renderLoginPage()
|
|
103
|
+
expect(screen.getByTestId('login-submit')).toBeDisabled()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('Form Submission', () => {
|
|
108
|
+
it('should call login with username and password', async () => {
|
|
109
|
+
renderLoginPage()
|
|
110
|
+
fireEvent.change(screen.getByTestId('login-username'), { target: { value: 'testuser' } })
|
|
111
|
+
fireEvent.change(screen.getByTestId('login-password'), { target: { value: 'pass123' } })
|
|
112
|
+
fireEvent.click(screen.getByTestId('login-submit'))
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(mockStore.login).toHaveBeenCalledWith('testuser', 'pass123')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('Error Display', () => {
|
|
121
|
+
it('should display error message when error exists', () => {
|
|
122
|
+
mockStore.error = 'Invalid credentials'
|
|
123
|
+
renderLoginPage()
|
|
124
|
+
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
|
125
|
+
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should not display error message when error is null', () => {
|
|
129
|
+
renderLoginPage()
|
|
130
|
+
expect(screen.queryByTestId('login-error')).not.toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should call clearError when typing in username field with existing error', () => {
|
|
134
|
+
mockStore.error = 'Some error'
|
|
135
|
+
renderLoginPage()
|
|
136
|
+
fireEvent.change(screen.getByTestId('login-username'), { target: { value: 'a' } })
|
|
137
|
+
expect(mockStore.clearError).toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should call clearError when typing in password field with existing error', () => {
|
|
141
|
+
mockStore.error = 'Some error'
|
|
142
|
+
renderLoginPage()
|
|
143
|
+
fireEvent.change(screen.getByTestId('login-password'), { target: { value: 'a' } })
|
|
144
|
+
expect(mockStore.clearError).toHaveBeenCalled()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('Quick Demo Login', () => {
|
|
149
|
+
it('should pre-fill username and password from preset credentials', () => {
|
|
150
|
+
renderLoginPage()
|
|
151
|
+
const usernameInput = screen.getByTestId('login-username') as HTMLInputElement
|
|
152
|
+
const passwordInput = screen.getByTestId('login-password') as HTMLInputElement
|
|
153
|
+
expect(usernameInput.value).toBe('demo@biomimic.app')
|
|
154
|
+
expect(passwordInput.value).toBe('demo123')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('Navigation Links', () => {
|
|
159
|
+
it('should render register link', () => {
|
|
160
|
+
renderLoginPage()
|
|
161
|
+
expect(screen.getByTestId('login-register-link')).toBeInTheDocument()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should link to register page', () => {
|
|
165
|
+
renderLoginPage()
|
|
166
|
+
const link = screen.getByTestId('login-register-link')
|
|
167
|
+
expect(link).toHaveAttribute('href', '/register')
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|