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
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
59
|
-
|
|
49
|
+
it('should show sign in and sign up links when not authenticated', () => {
|
|
50
|
+
mockedUseAuthStore.mockImplementation(selector => {
|
|
60
51
|
const state = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
62
|
+
render(
|
|
63
|
+
<MemoryRouter>
|
|
64
|
+
<AuthButton />
|
|
65
|
+
</MemoryRouter>
|
|
66
|
+
)
|
|
72
67
|
|
|
73
|
-
expect(screen.
|
|
74
|
-
expect(screen.
|
|
75
|
-
expect(screen.
|
|
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
|
|
79
|
-
|
|
73
|
+
it('should show username and sign out button when authenticated', () => {
|
|
74
|
+
mockedUseAuthStore.mockImplementation(selector => {
|
|
80
75
|
const state = {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
render(
|
|
87
|
+
<MemoryRouter>
|
|
88
|
+
<AuthButton />
|
|
89
|
+
</MemoryRouter>
|
|
90
|
+
)
|
|
94
91
|
|
|
95
|
-
expect(
|
|
96
|
-
expect(
|
|
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
|
|
100
|
-
|
|
97
|
+
it('should call logout on sign out click', () => {
|
|
98
|
+
mockedUseAuthStore.mockImplementation(selector => {
|
|
101
99
|
const state = {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
110
|
+
render(
|
|
111
|
+
<MemoryRouter>
|
|
112
|
+
<AuthButton />
|
|
113
|
+
</MemoryRouter>
|
|
114
|
+
)
|
|
113
115
|
|
|
114
|
-
fireEvent.click(screen.
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
18
|
-
renderWithRouter(
|
|
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.
|
|
21
|
-
expect(screen.getByText('
|
|
22
|
-
expect(screen.getByText('
|
|
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
|
|
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
|
|
34
|
-
|
|
46
|
+
it('should display logo text from theme', () => {
|
|
47
|
+
renderWithRouter(<Navigation theme={MOCK_THEME} />)
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
expect(nav).toHaveClass('bg-white')
|
|
38
|
-
expect(nav).toHaveClass('border-b')
|
|
49
|
+
expect(screen.getByText('Biomimic')).toBeInTheDocument()
|
|
39
50
|
})
|
|
40
51
|
})
|
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
<
|
|
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'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
|
+
}
|