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
@@ -1,119 +1,120 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { MemoryRouter } from 'react-router-dom'
3
4
  import { AuthButton } from '../AuthButton'
4
5
 
5
- const mockSetToken = vi.fn()
6
- const mockSetAuth = vi.fn()
7
6
  const mockLogout = vi.fn()
8
7
 
9
- // Mock zustand
8
+ const unauthenticatedState = {
9
+ isAuthenticated: false,
10
+ token: null,
11
+ user: null,
12
+ loading: false,
13
+ error: null,
14
+ login: vi.fn(),
15
+ register: vi.fn(),
16
+ setToken: vi.fn(),
17
+ setAuth: vi.fn(),
18
+ logout: mockLogout,
19
+ clearError: vi.fn(),
20
+ }
21
+
22
+ const authenticatedState = {
23
+ isAuthenticated: true,
24
+ token: 'user-token',
25
+ user: { id: '1', username: 'testuser', role: 'user' },
26
+ loading: false,
27
+ error: null,
28
+ login: vi.fn(),
29
+ register: vi.fn(),
30
+ setToken: vi.fn(),
31
+ setAuth: vi.fn(),
32
+ logout: mockLogout,
33
+ clearError: vi.fn(),
34
+ }
35
+
10
36
  vi.mock('@client/stores/authStore', () => ({
11
- useAuthStore: vi.fn(selector => {
12
- const state = {
13
- isAuthenticated: false,
14
- token: null,
15
- user: null,
16
- logout: mockLogout,
17
- setToken: mockSetToken,
18
- setAuth: mockSetAuth,
19
- }
20
- if (selector) {
21
- return selector(state)
22
- }
23
- return state
24
- }),
37
+ useAuthStore: vi.fn(),
25
38
  }))
26
39
 
27
40
  import { useAuthStore } from '@client/stores/authStore'
28
41
 
42
+ const mockedUseAuthStore = vi.mocked(useAuthStore)
43
+
29
44
  describe('AuthButton', () => {
30
45
  beforeEach(() => {
31
46
  vi.clearAllMocks()
32
- // Mock window.location.reload
33
- Object.defineProperty(window, 'location', {
34
- writable: true,
35
- value: { reload: vi.fn() },
36
- })
37
- })
38
-
39
- it('should show login button when not authenticated', () => {
40
- vi.mocked(useAuthStore).mockImplementation(selector => {
41
- const state = {
42
- isAuthenticated: false,
43
- token: null,
44
- user: null,
45
- logout: mockLogout,
46
- setToken: mockSetToken,
47
- setAuth: mockSetAuth,
48
- }
49
- return selector ? selector(state) : state
50
- })
51
-
52
- render(<AuthButton />)
53
-
54
- expect(screen.getByText('登录')).toBeInTheDocument()
55
- expect(screen.queryByText('退出')).not.toBeInTheDocument()
56
47
  })
57
48
 
58
- it('should show logout button when authenticated', () => {
59
- vi.mocked(useAuthStore).mockImplementation(selector => {
49
+ it('should show sign in and sign up links when not authenticated', () => {
50
+ mockedUseAuthStore.mockImplementation(selector => {
60
51
  const state = {
61
- isAuthenticated: true,
62
- token: 'user-token',
63
- user: { id: '1', username: 'admin', role: 'admin' },
64
- logout: mockLogout,
65
- setToken: mockSetToken,
66
- setAuth: mockSetAuth,
52
+ ...unauthenticatedState,
53
+ login: vi.fn(),
54
+ register: vi.fn(),
55
+ setToken: vi.fn(),
56
+ setAuth: vi.fn(),
57
+ clearError: vi.fn(),
67
58
  }
68
59
  return selector ? selector(state) : state
69
60
  })
70
61
 
71
- render(<AuthButton />)
62
+ render(
63
+ <MemoryRouter>
64
+ <AuthButton />
65
+ </MemoryRouter>
66
+ )
72
67
 
73
- expect(screen.getByText('已登录')).toBeInTheDocument()
74
- expect(screen.getByText('退出')).toBeInTheDocument()
75
- expect(screen.queryByText('登录')).not.toBeInTheDocument()
68
+ expect(screen.getByTestId('auth-login')).toBeInTheDocument()
69
+ expect(screen.getByTestId('auth-register')).toBeInTheDocument()
70
+ expect(screen.queryByTestId('auth-logout')).not.toBeInTheDocument()
76
71
  })
77
72
 
78
- it('should call setToken and reload on login', () => {
79
- vi.mocked(useAuthStore).mockImplementation(selector => {
73
+ it('should show username and sign out button when authenticated', () => {
74
+ mockedUseAuthStore.mockImplementation(selector => {
80
75
  const state = {
81
- isAuthenticated: false,
82
- token: null,
83
- user: null,
84
- logout: mockLogout,
85
- setToken: mockSetToken,
86
- setAuth: mockSetAuth,
76
+ ...authenticatedState,
77
+ login: vi.fn(),
78
+ register: vi.fn(),
79
+ setToken: vi.fn(),
80
+ setAuth: vi.fn(),
81
+ clearError: vi.fn(),
87
82
  }
88
83
  return selector ? selector(state) : state
89
84
  })
90
85
 
91
- render(<AuthButton />)
92
-
93
- fireEvent.click(screen.getByText('登录'))
86
+ render(
87
+ <MemoryRouter>
88
+ <AuthButton />
89
+ </MemoryRouter>
90
+ )
94
91
 
95
- expect(mockSetToken).toHaveBeenCalledWith('user-token')
96
- expect(window.location.reload).toHaveBeenCalled()
92
+ expect(screen.getByTestId('auth-username')).toHaveTextContent('testuser')
93
+ expect(screen.getByTestId('auth-logout')).toBeInTheDocument()
94
+ expect(screen.queryByTestId('auth-login')).not.toBeInTheDocument()
97
95
  })
98
96
 
99
- it('should call logout and reload on logout', () => {
100
- vi.mocked(useAuthStore).mockImplementation(selector => {
97
+ it('should call logout on sign out click', () => {
98
+ mockedUseAuthStore.mockImplementation(selector => {
101
99
  const state = {
102
- isAuthenticated: true,
103
- token: 'user-token',
104
- user: { id: '1', username: 'admin', role: 'admin' },
105
- logout: mockLogout,
106
- setToken: mockSetToken,
107
- setAuth: mockSetAuth,
100
+ ...authenticatedState,
101
+ login: vi.fn(),
102
+ register: vi.fn(),
103
+ setToken: vi.fn(),
104
+ setAuth: vi.fn(),
105
+ clearError: vi.fn(),
108
106
  }
109
107
  return selector ? selector(state) : state
110
108
  })
111
109
 
112
- render(<AuthButton />)
110
+ render(
111
+ <MemoryRouter>
112
+ <AuthButton />
113
+ </MemoryRouter>
114
+ )
113
115
 
114
- fireEvent.click(screen.getByText('退出'))
116
+ fireEvent.click(screen.getByTestId('auth-logout'))
115
117
 
116
118
  expect(mockLogout).toHaveBeenCalled()
117
- expect(window.location.reload).toHaveBeenCalled()
118
119
  })
119
120
  })
@@ -1,40 +1,51 @@
1
- import { describe, it, expect, vi } from 'vitest'
1
+ import { describe, it, expect } from 'vitest'
2
2
  import { render, screen } from '@testing-library/react'
3
3
  import { BrowserRouter } from 'react-router-dom'
4
4
  import { Navigation } from '../Navigation'
5
-
6
- vi.mock('@client/stores/todoStore', () => ({
7
- useTodoStore: () => ({
8
- todos: [{ id: 1, title: 'Test Todo', status: 'pending' }],
9
- }),
10
- }))
5
+ import type { PresetTheme } from '../../preset-ui-config'
6
+
7
+ const MOCK_THEME: PresetTheme = {
8
+ primaryColor: '#6366f1',
9
+ primaryHover: '#4f46e5',
10
+ bgColor: '#ffffff',
11
+ textColor: '#111827',
12
+ secondaryBg: '#f9fafb',
13
+ borderColor: '#e5e7eb',
14
+ borderRadius: '12px',
15
+ logoText: 'Biomimic',
16
+ fontFamily: 'sans-serif',
17
+ }
11
18
 
12
19
  const renderWithRouter = (component: React.ReactNode) => {
13
20
  return render(<BrowserRouter>{component}</BrowserRouter>)
14
21
  }
15
22
 
16
23
  describe('Navigation', () => {
17
- it('should render navigation links', () => {
18
- renderWithRouter(<Navigation />)
24
+ it('should render navigation with items', () => {
25
+ renderWithRouter(
26
+ <Navigation
27
+ items={[
28
+ { label: 'Dashboard', icon: 'LayoutDashboard', path: '/dashboard' },
29
+ { label: 'Settings', icon: 'Settings', path: '/settings' },
30
+ ]}
31
+ theme={MOCK_THEME}
32
+ />
33
+ )
19
34
 
20
- expect(screen.getByText('Biomimic App')).toBeInTheDocument()
21
- expect(screen.getByText('Todo List')).toBeInTheDocument()
22
- expect(screen.getByText('Notifications')).toBeInTheDocument()
23
- expect(screen.getByText('WebSocket')).toBeInTheDocument()
35
+ expect(screen.getByTestId('app-nav')).toBeInTheDocument()
36
+ expect(screen.getByText('Dashboard')).toBeInTheDocument()
37
+ expect(screen.getByText('Settings')).toBeInTheDocument()
24
38
  })
25
39
 
26
- it('should display todo count badge', () => {
40
+ it('should render with no items when nothing provided', () => {
27
41
  renderWithRouter(<Navigation />)
28
42
 
29
- // 组件中没有显示待办数量的徽章
30
43
  expect(screen.getByTestId('app-nav')).toBeInTheDocument()
31
44
  })
32
45
 
33
- it('should have correct styling classes', () => {
34
- const { container } = renderWithRouter(<Navigation />)
46
+ it('should display logo text from theme', () => {
47
+ renderWithRouter(<Navigation theme={MOCK_THEME} />)
35
48
 
36
- const nav = container.querySelector('nav')
37
- expect(nav).toHaveClass('bg-white')
38
- expect(nav).toHaveClass('border-b')
49
+ expect(screen.getByText('Biomimic')).toBeInTheDocument()
39
50
  })
40
51
  })
@@ -4,3 +4,4 @@ export { EmptyState } from './EmptyState'
4
4
  export { ConnectionStatus } from './ConnectionStatus'
5
5
  export { MessageCard } from './MessageCard'
6
6
  export { AuthButton } from './AuthButton'
7
+ export { BottomTabBar } from './BottomTabBar'
@@ -0,0 +1,10 @@
1
+ /* eslint-disable react-refresh/only-export-components */
2
+ import { createContext, useContext } from 'react'
3
+
4
+ export const PresetContext = createContext<string>('todo')
5
+
6
+ export function usePreset(): string {
7
+ return useContext(PresetContext)
8
+ }
9
+
10
+ export const PresetProvider = PresetContext.Provider
@@ -1,18 +1,73 @@
1
- /**
2
- * Application entry point
3
- */
4
-
1
+ /* eslint-disable react-refresh/only-export-components */
5
2
  import React from 'react'
6
3
  import ReactDOM from 'react-dom/client'
7
4
  import { HelmetProvider } from 'react-helmet-async'
8
- import { App } from './App'
5
+ import { App as ClientApp } from './App'
9
6
  import './index.css'
10
7
 
11
- ReactDOM.createRoot(document.getElementById('root')!).render(
12
- <React.StrictMode>
8
+ const preset = import.meta.env.VITE_PRESET || 'todo'
9
+
10
+ if (preset !== 'saas') {
11
+ try {
12
+ const raw = localStorage.getItem('auth-token')
13
+ const parsed = raw ? JSON.parse(raw) : null
14
+ if (!parsed?.state?.token) {
15
+ localStorage.setItem(
16
+ 'auth-token',
17
+ JSON.stringify({
18
+ state: {
19
+ token: 'user-token',
20
+ isAuthenticated: true,
21
+ user: { id: 'user-1', username: 'Demo User', role: 'USER' },
22
+ loading: false,
23
+ error: null,
24
+ },
25
+ version: 0,
26
+ })
27
+ )
28
+ }
29
+ } catch {
30
+ localStorage.setItem(
31
+ 'auth-token',
32
+ JSON.stringify({
33
+ state: {
34
+ token: 'user-token',
35
+ isAuthenticated: true,
36
+ user: { id: 'user-1', username: 'Demo User', role: 'USER' },
37
+ loading: false,
38
+ error: null,
39
+ },
40
+ version: 0,
41
+ })
42
+ )
43
+ }
44
+ }
45
+
46
+ const AdminApp = React.lazy(() => import('@admin/App').then(m => ({ default: m.App })))
47
+
48
+ const RootApp = () => {
49
+ if (preset === 'saas') {
50
+ return (
51
+ <React.Suspense
52
+ fallback={
53
+ <div className="flex items-center justify-center h-screen text-gray-400">Loading...</div>
54
+ }
55
+ >
56
+ <AdminApp basePath="/" />
57
+ </React.Suspense>
58
+ )
59
+ }
60
+
61
+ return (
13
62
  <HelmetProvider>
14
- <App />
63
+ <ClientApp presetId={preset} />
15
64
  </HelmetProvider>
65
+ )
66
+ }
67
+
68
+ ReactDOM.createRoot(document.getElementById('root')!).render(
69
+ <React.StrictMode>
70
+ <RootApp />
16
71
  </React.StrictMode>
17
72
  )
18
73
 
@@ -0,0 +1,244 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { Helmet } from 'react-helmet-async'
3
+ import { Package, Minus, Plus, Trash2, ShoppingBag, ArrowLeft } from 'lucide-react'
4
+ import { apiClient } from '@client/services/apiClient'
5
+ import { LoadingSpinner } from '@client/components'
6
+ import type { CartItem } from '@shared/schemas'
7
+
8
+ const SHIPPING_THRESHOLD = 50
9
+ const TAX_RATE = 0.08
10
+
11
+ export const CartPage: React.FC = () => {
12
+ const [items, setItems] = useState<CartItem[]>([])
13
+ const [loading, setLoading] = useState(true)
14
+
15
+ useEffect(() => {
16
+ async function fetchCart() {
17
+ try {
18
+ const res = await apiClient.api.cart.$get()
19
+ const result = await res.json()
20
+ if (result.success) {
21
+ setItems(result.data.items)
22
+ }
23
+ } finally {
24
+ setLoading(false)
25
+ }
26
+ }
27
+ fetchCart()
28
+ }, [])
29
+
30
+ const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
31
+ const shipping = subtotal >= SHIPPING_THRESHOLD ? 0 : 5.99
32
+ const tax = subtotal * TAX_RATE
33
+ const total = subtotal + shipping + tax
34
+ const totalItems = items.reduce((sum, item) => sum + item.quantity, 0)
35
+
36
+ const updateQuantity = (id: number, delta: number) => {
37
+ setItems(prev =>
38
+ prev
39
+ .map(item =>
40
+ item.id === id ? { ...item, quantity: Math.max(0, item.quantity + delta) } : item
41
+ )
42
+ .filter(item => item.quantity > 0)
43
+ )
44
+ }
45
+
46
+ const removeItem = (id: number) => {
47
+ setItems(prev => prev.filter(item => item.id !== id))
48
+ }
49
+
50
+ if (loading) {
51
+ return (
52
+ <div className="flex items-center justify-center py-20" data-testid="cart-loading">
53
+ <LoadingSpinner size="lg" />
54
+ </div>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <div className="max-w-6xl mx-auto p-4 sm:p-6" data-testid="cart-page">
60
+ <Helmet>
61
+ <title>Shopping Cart - Biomimic App</title>
62
+ <meta name="description" content="Review your shopping cart" />
63
+ </Helmet>
64
+
65
+ <div className="mb-8">
66
+ <h1 className="text-3xl font-bold text-gray-900" data-testid="cart-title">
67
+ Shopping Cart
68
+ {totalItems > 0 && (
69
+ <span className="text-lg font-normal text-gray-500 ml-2">
70
+ ({totalItems} item{totalItems !== 1 ? 's' : ''})
71
+ </span>
72
+ )}
73
+ </h1>
74
+ </div>
75
+
76
+ {items.length === 0 ? (
77
+ <div className="flex flex-col items-center justify-center py-20" data-testid="cart-empty">
78
+ <div className="w-20 h-20 rounded-full bg-amber-50 flex items-center justify-center mb-6">
79
+ <ShoppingBag className="w-10 h-10 text-amber-500" />
80
+ </div>
81
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">Your cart is empty</h2>
82
+ <p className="text-gray-500 mb-6">Looks like you haven&apos;t added anything yet.</p>
83
+ <button
84
+ className="px-6 py-3 bg-amber-500 text-white font-medium rounded-xl hover:bg-amber-600 transition-colors"
85
+ data-testid="cart-browse-button"
86
+ >
87
+ Browse Products
88
+ </button>
89
+ </div>
90
+ ) : (
91
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
92
+ <div className="lg:col-span-2 space-y-4" data-testid="cart-items">
93
+ {items.map(item => (
94
+ <div
95
+ key={item.id}
96
+ className="flex gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-sm"
97
+ data-testid={`cart-item-${item.id}`}
98
+ >
99
+ <div
100
+ className="w-20 h-20 sm:w-24 sm:h-24 rounded-xl flex items-center justify-center flex-shrink-0"
101
+ style={{ backgroundColor: item.color }}
102
+ data-testid={`cart-item-image-${item.id}`}
103
+ >
104
+ <Package className="w-8 h-8 text-white/70" />
105
+ </div>
106
+
107
+ <div className="flex-1 min-w-0">
108
+ <div className="flex items-start justify-between gap-2">
109
+ <div>
110
+ <h3
111
+ className="font-medium text-gray-900 truncate"
112
+ data-testid={`cart-item-name-${item.id}`}
113
+ >
114
+ {item.name}
115
+ </h3>
116
+ <p
117
+ className="text-sm text-gray-500"
118
+ data-testid={`cart-item-variant-${item.id}`}
119
+ >
120
+ {item.variant}
121
+ </p>
122
+ </div>
123
+ <button
124
+ onClick={() => removeItem(item.id)}
125
+ className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors flex-shrink-0"
126
+ data-testid={`cart-item-remove-${item.id}`}
127
+ >
128
+ <Trash2 className="w-4 h-4" />
129
+ </button>
130
+ </div>
131
+
132
+ <div className="flex items-center justify-between mt-3">
133
+ <div
134
+ className="flex items-center gap-2"
135
+ data-testid={`cart-item-qty-${item.id}`}
136
+ >
137
+ <button
138
+ onClick={() => updateQuantity(item.id, -1)}
139
+ className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-50 transition-colors"
140
+ data-testid={`cart-item-decrease-${item.id}`}
141
+ >
142
+ <Minus className="w-3 h-3" />
143
+ </button>
144
+ <span
145
+ className="w-8 text-center text-sm font-medium text-gray-900"
146
+ data-testid={`cart-item-count-${item.id}`}
147
+ >
148
+ {item.quantity}
149
+ </span>
150
+ <button
151
+ onClick={() => updateQuantity(item.id, 1)}
152
+ className="w-8 h-8 rounded-lg border border-gray-300 flex items-center justify-center text-gray-600 hover:bg-gray-50 transition-colors"
153
+ data-testid={`cart-item-increase-${item.id}`}
154
+ >
155
+ <Plus className="w-3 h-3" />
156
+ </button>
157
+ </div>
158
+
159
+ <div className="text-right">
160
+ <p
161
+ className="text-sm text-gray-500"
162
+ data-testid={`cart-item-unit-price-${item.id}`}
163
+ >
164
+ ${item.price.toFixed(2)} each
165
+ </p>
166
+ <p
167
+ className="font-semibold text-gray-900"
168
+ data-testid={`cart-item-total-${item.id}`}
169
+ >
170
+ ${(item.price * item.quantity).toFixed(2)}
171
+ </p>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ ))}
177
+ </div>
178
+
179
+ <div className="lg:col-span-1" data-testid="cart-summary">
180
+ <div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 sticky top-6">
181
+ <h2 className="text-lg font-semibold text-gray-900 mb-4">Order Summary</h2>
182
+
183
+ <div className="space-y-3">
184
+ <div className="flex justify-between text-sm">
185
+ <span className="text-gray-500">Subtotal</span>
186
+ <span className="text-gray-900 font-medium" data-testid="cart-subtotal">
187
+ ${subtotal.toFixed(2)}
188
+ </span>
189
+ </div>
190
+ <div className="flex justify-between text-sm">
191
+ <span className="text-gray-500">Shipping</span>
192
+ <span className="text-gray-900 font-medium" data-testid="cart-shipping">
193
+ {shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`}
194
+ </span>
195
+ </div>
196
+ <div className="flex justify-between text-sm">
197
+ <span className="text-gray-500">Tax</span>
198
+ <span className="text-gray-900 font-medium" data-testid="cart-tax">
199
+ ${tax.toFixed(2)}
200
+ </span>
201
+ </div>
202
+
203
+ <div className="border-t border-gray-200 pt-3 mt-3">
204
+ <div className="flex justify-between">
205
+ <span className="font-semibold text-gray-900">Total</span>
206
+ <span className="font-bold text-gray-900 text-lg" data-testid="cart-total">
207
+ ${total.toFixed(2)}
208
+ </span>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <button
214
+ className="w-full mt-6 px-6 py-3 bg-amber-500 text-white font-semibold rounded-xl hover:bg-amber-600 transition-colors"
215
+ data-testid="cart-checkout-button"
216
+ >
217
+ Checkout
218
+ </button>
219
+
220
+ <div className="mt-4 text-center">
221
+ <button
222
+ className="inline-flex items-center gap-1.5 text-sm text-amber-600 hover:text-amber-700 font-medium transition-colors"
223
+ data-testid="cart-continue-shopping"
224
+ >
225
+ <ArrowLeft className="w-3.5 h-3.5" />
226
+ Continue Shopping
227
+ </button>
228
+ </div>
229
+
230
+ {subtotal < SHIPPING_THRESHOLD && subtotal > 0 && (
231
+ <p
232
+ className="mt-4 text-xs text-gray-500 text-center"
233
+ data-testid="cart-shipping-note"
234
+ >
235
+ Add ${(SHIPPING_THRESHOLD - subtotal).toFixed(2)} more for free shipping
236
+ </p>
237
+ )}
238
+ </div>
239
+ </div>
240
+ </div>
241
+ )}
242
+ </div>
243
+ )
244
+ }