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,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&apos;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
+ })