@react-spa-scaffold/mcp 2.1.1 → 2.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/README.md +2 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.js.map +1 -1
- package/dist/features/definitions/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- package/dist/features/definitions/index.d.ts +1 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +1 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +2 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +4 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +7 -0
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +6 -0
- package/templates/.github/workflows/ci.yml +8 -3
- package/templates/CLAUDE.md +74 -1
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/E2E_TESTING.md +52 -7
- package/templates/e2e/fixtures/index.ts +13 -2
- package/templates/package.json +7 -3
- package/templates/playwright.config.ts +6 -1
- package/templates/src/components/layout/Header.tsx +2 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/hooks/index.ts +23 -2
- package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -0
- package/templates/src/lib/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/index.ts +1 -0
- package/templates/src/lib/sentry.ts +55 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/main.tsx +18 -8
- package/templates/src/stores/preferencesStore.ts +34 -9
- package/templates/src/test/clerkMock.tsx +97 -0
- package/templates/src/test/index.ts +3 -0
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test-setup.ts +16 -2
- package/templates/vitest.config.ts +9 -1
|
@@ -12,6 +12,7 @@ High-level architecture and key decisions. For API details, see [API Reference](
|
|
|
12
12
|
| Styling | Tailwind CSS 4 | No runtime cost, scales with team size |
|
|
13
13
|
| State | Zustand + TanStack Query | Minimal boilerplate, separation of concerns |
|
|
14
14
|
| Forms | React Hook Form + Zod | Minimal re-renders, type-safe validation |
|
|
15
|
+
| Authentication | Clerk | Modal-based auth, shadcn theme integration |
|
|
15
16
|
| i18n | Lingui | Smaller runtime, compile-time extraction |
|
|
16
17
|
| Testing | Vitest + Playwright | Fast, Vite-native, true cross-browser |
|
|
17
18
|
| Error Tracking | Sentry | Industry standard, lazy-loaded |
|
|
@@ -73,22 +74,21 @@ Providers wrap the app in this specific order:
|
|
|
73
74
|
```tsx
|
|
74
75
|
<StrictMode>
|
|
75
76
|
<QueryProvider>
|
|
76
|
-
{' '}
|
|
77
77
|
{/* TanStack Query - outermost for global cache */}
|
|
78
78
|
<I18nProvider>
|
|
79
|
-
{' '}
|
|
80
79
|
{/* Lingui - translations available everywhere */}
|
|
81
80
|
<BrowserRouter>
|
|
82
|
-
{' '}
|
|
83
81
|
{/* React Router - routing context */}
|
|
84
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
<ClerkThemeProvider>
|
|
83
|
+
{/* Clerk - auth inside Router for @clerk/react-router */}
|
|
84
|
+
<MobileProvider>
|
|
85
|
+
{/* Viewport - depends on router for SSR */}
|
|
86
|
+
<ErrorBoundary>
|
|
87
|
+
<App />
|
|
88
|
+
<Toaster />
|
|
89
|
+
</ErrorBoundary>
|
|
90
|
+
</MobileProvider>
|
|
91
|
+
</ClerkThemeProvider>
|
|
92
92
|
</BrowserRouter>
|
|
93
93
|
</I18nProvider>
|
|
94
94
|
</QueryProvider>
|
|
@@ -99,7 +99,8 @@ Providers wrap the app in this specific order:
|
|
|
99
99
|
|
|
100
100
|
- QueryProvider outermost so cache persists across route changes
|
|
101
101
|
- I18nProvider before Router so route components can use translations
|
|
102
|
-
-
|
|
102
|
+
- ClerkThemeProvider inside Router (required by @clerk/react-router declarative mode)
|
|
103
|
+
- MobileProvider inside Clerk for potential SSR viewport detection
|
|
103
104
|
- ErrorBoundary innermost to catch errors in App without breaking providers
|
|
104
105
|
|
|
105
106
|
### 2. State Management Separation
|
|
@@ -19,6 +19,71 @@ interface User {
|
|
|
19
19
|
|
|
20
20
|
See [Architecture Guide](./ARCHITECTURE.md#state-management) for when to use each solution.
|
|
21
21
|
|
|
22
|
+
### Zustand Best Practices
|
|
23
|
+
|
|
24
|
+
**Auto-generated selectors**: All stores use `createSelectors` for cleaner access:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
// Store definition
|
|
28
|
+
const useStoreBase = create<State>()(/* ... */);
|
|
29
|
+
export const useStore = createSelectors(useStoreBase);
|
|
30
|
+
|
|
31
|
+
// Component usage - auto-generated selectors
|
|
32
|
+
const count = useStore.use.count();
|
|
33
|
+
const increment = useStore.use.increment();
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Use `useShallow` for multiple values**: Prevents unnecessary re-renders:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
40
|
+
|
|
41
|
+
// Group state values with useShallow
|
|
42
|
+
const { searchQuery, sortBy } = useStore(
|
|
43
|
+
useShallow((s) => ({
|
|
44
|
+
searchQuery: s.searchQuery,
|
|
45
|
+
sortBy: s.sortBy,
|
|
46
|
+
})),
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Persist versioning**: Always include version and migrate for persisted stores:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
persist(
|
|
54
|
+
(set, get) => ({
|
|
55
|
+
/* ... */
|
|
56
|
+
}),
|
|
57
|
+
{
|
|
58
|
+
name: 'store-key',
|
|
59
|
+
version: 1, // Increment on breaking changes
|
|
60
|
+
migrate: (persisted, version) => {
|
|
61
|
+
if (version === 0) {
|
|
62
|
+
return { ...persisted, newField: 'default' };
|
|
63
|
+
}
|
|
64
|
+
return persisted;
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Middleware order**: Stack middlewares correctly:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// devtools → persist → subscribeWithSelector → store
|
|
74
|
+
create<State>()(
|
|
75
|
+
devtools(
|
|
76
|
+
persist(
|
|
77
|
+
subscribeWithSelector((set, get) => ({
|
|
78
|
+
/* ... */
|
|
79
|
+
})),
|
|
80
|
+
{ name: 'key' },
|
|
81
|
+
),
|
|
82
|
+
{ name: 'StoreName', enabled: process.env.NODE_ENV === 'development' },
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
22
87
|
### Query Hooks
|
|
23
88
|
|
|
24
89
|
Extract the fetcher function:
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
```
|
|
12
12
|
e2e/
|
|
13
13
|
├── fixtures/
|
|
14
|
-
│ └── index.ts # setupPage,
|
|
14
|
+
│ └── index.ts # setupPage, setupCleanPage, test, expect
|
|
15
15
|
├── tests/ # Functional E2E tests
|
|
16
16
|
│ ├── home.spec.ts # Page structure, accessibility
|
|
17
17
|
│ ├── theme.spec.ts # Theme toggle, persistence
|
|
@@ -26,9 +26,7 @@ e2e/
|
|
|
26
26
|
|
|
27
27
|
```typescript
|
|
28
28
|
import { expect, test } from '@playwright/test';
|
|
29
|
-
|
|
30
|
-
// For tests that need state clearing
|
|
31
|
-
import { setupPage } from '../fixtures';
|
|
29
|
+
import { setupPage, setupCleanPage } from '../fixtures';
|
|
32
30
|
```
|
|
33
31
|
|
|
34
32
|
## Core Patterns
|
|
@@ -107,13 +105,59 @@ await page.waitForTimeout(500);
|
|
|
107
105
|
## Running Tests
|
|
108
106
|
|
|
109
107
|
```bash
|
|
110
|
-
npm run e2e # Run
|
|
111
|
-
npm run e2e:
|
|
108
|
+
npm run e2e # Run desktop tests
|
|
109
|
+
npm run e2e:mobile # Run mobile tests (Pixel 5 emulation)
|
|
110
|
+
npm run e2e:all # Run both desktop and mobile
|
|
111
|
+
npm run e2e:ui # Interactive UI mode
|
|
112
112
|
npm run e2e:perf # Run performance tests
|
|
113
113
|
npm run e2e:perf:ui # Performance tests with interactive UI
|
|
114
|
-
npm run e2e:all # Run all tests (functional + performance)
|
|
115
114
|
```
|
|
116
115
|
|
|
116
|
+
## Mobile Testing
|
|
117
|
+
|
|
118
|
+
Tests run on both desktop (Chrome) and mobile (Pixel 5) viewports. Use the `isMobile` fixture for device-specific behavior.
|
|
119
|
+
|
|
120
|
+
### Using isMobile Fixture
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { expect, test } from '@playwright/test';
|
|
124
|
+
|
|
125
|
+
test('theme toggle works on all devices', async ({ page, isMobile }) => {
|
|
126
|
+
await page.goto('/');
|
|
127
|
+
|
|
128
|
+
// Same test logic works on both platforms
|
|
129
|
+
await page.getByRole('button', { name: /dark mode/i }).click();
|
|
130
|
+
await expect(page.locator('html')).toHaveClass(/dark/);
|
|
131
|
+
|
|
132
|
+
// Add mobile-specific assertions if needed
|
|
133
|
+
if (isMobile) {
|
|
134
|
+
// Verify touch-friendly button size, etc.
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Skip Tests by Platform
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
test('hover tooltip shows', async ({ page, isMobile }) => {
|
|
143
|
+
test.skip(isMobile, 'Hover not available on touch devices');
|
|
144
|
+
// Desktop-only test
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('touch gesture works', async ({ page, isMobile }) => {
|
|
148
|
+
test.skip(!isMobile, 'Touch gesture only on mobile');
|
|
149
|
+
// Mobile-only test
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Common Patterns
|
|
154
|
+
|
|
155
|
+
| Pattern | Desktop | Mobile |
|
|
156
|
+
| -------------- | --------- | --------------- |
|
|
157
|
+
| Viewport width | 1280px | 393px (Pixel 5) |
|
|
158
|
+
| Touch events | Click | Tap |
|
|
159
|
+
| Hover states | Supported | Not applicable |
|
|
160
|
+
|
|
117
161
|
## Performance Testing
|
|
118
162
|
|
|
119
163
|
Performance tests use [react-performance-tracking](https://github.com/mkaczkowski/react-performance-tracking) to measure:
|
|
@@ -129,3 +173,4 @@ Performance tests use [react-performance-tracking](https://github.com/mkaczkowsk
|
|
|
129
173
|
- [ ] No arbitrary timeouts
|
|
130
174
|
- [ ] Tests behavior, not implementation
|
|
131
175
|
- [ ] Uses `setupPage` when testing persistence
|
|
176
|
+
- [ ] Considers mobile viewport when relevant
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import type { Page } from '@playwright/test';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Navigate to page with clean state (clears localStorage)
|
|
4
|
+
* Navigate to page with clean state (clears localStorage).
|
|
5
5
|
*/
|
|
6
|
-
export async function setupPage(page: Page, path = '/') {
|
|
6
|
+
export async function setupPage(page: Page, path = '/'): Promise<void> {
|
|
7
7
|
await page.goto(path);
|
|
8
8
|
await page.evaluate(() => localStorage.clear());
|
|
9
9
|
await page.reload();
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Setup page with completely fresh state (cookies + localStorage).
|
|
14
|
+
* Use when tests need isolation from previous test state.
|
|
15
|
+
*/
|
|
16
|
+
export async function setupCleanPage(page: Page): Promise<void> {
|
|
17
|
+
await page.context().clearCookies();
|
|
18
|
+
await page.goto('/');
|
|
19
|
+
await page.evaluate(() => localStorage.clear());
|
|
20
|
+
await page.reload();
|
|
21
|
+
}
|
package/templates/package.json
CHANGED
|
@@ -39,11 +39,12 @@
|
|
|
39
39
|
"test": "vitest run",
|
|
40
40
|
"test:watch": "vitest",
|
|
41
41
|
"test:coverage": "vitest run --coverage",
|
|
42
|
-
"e2e": "playwright test --project=
|
|
43
|
-
"e2e:
|
|
42
|
+
"e2e": "playwright test --project=desktop",
|
|
43
|
+
"e2e:mobile": "playwright test --project=mobile",
|
|
44
|
+
"e2e:all": "playwright test --project=desktop --project=mobile",
|
|
45
|
+
"e2e:ui": "playwright test --ui",
|
|
44
46
|
"e2e:perf": "PERF_TEST=true playwright test --project=performance",
|
|
45
47
|
"e2e:perf:ui": "PERF_TEST=true playwright test --project=performance --ui",
|
|
46
|
-
"e2e:all": "PERF_TEST=true playwright test",
|
|
47
48
|
"i18n:extract": "lingui extract",
|
|
48
49
|
"prepare": "husky",
|
|
49
50
|
"mcp:build": "npm run build -w @react-spa-scaffold/mcp",
|
|
@@ -55,6 +56,8 @@
|
|
|
55
56
|
"release": "changeset publish"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|
|
59
|
+
"@clerk/react-router": "^2.3.7",
|
|
60
|
+
"@clerk/themes": "^2.4.46",
|
|
58
61
|
"@fontsource-variable/inter": "^5.2.5",
|
|
59
62
|
"@hookform/resolvers": "^5.0.1",
|
|
60
63
|
"@lingui/core": "^5.7.0",
|
|
@@ -69,6 +72,7 @@
|
|
|
69
72
|
"react": "^19.1.0",
|
|
70
73
|
"react-dom": "^19.1.0",
|
|
71
74
|
"react-hook-form": "^7.58.0",
|
|
75
|
+
"react-hotkeys-hook": "^5.2.1",
|
|
72
76
|
"react-performance-tracking": "^1.2.1",
|
|
73
77
|
"react-router": "^7.11.0",
|
|
74
78
|
"sonner": "^2.0.7",
|
|
@@ -17,10 +17,15 @@ export default defineConfig({
|
|
|
17
17
|
},
|
|
18
18
|
projects: [
|
|
19
19
|
{
|
|
20
|
-
name: '
|
|
20
|
+
name: 'desktop',
|
|
21
21
|
testDir: './e2e/tests',
|
|
22
22
|
use: { ...devices['Desktop Chrome'] },
|
|
23
23
|
},
|
|
24
|
+
{
|
|
25
|
+
name: 'mobile',
|
|
26
|
+
testDir: './e2e/tests',
|
|
27
|
+
use: { ...devices['Pixel 5'] },
|
|
28
|
+
},
|
|
24
29
|
{
|
|
25
30
|
name: 'performance',
|
|
26
31
|
testDir: './e2e/performance',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Trans } from '@lingui/react/macro';
|
|
2
2
|
|
|
3
|
-
import { LanguageSwitcher, ThemeToggle } from '@/components/shared';
|
|
3
|
+
import { AccountButton, LanguageSwitcher, ThemeToggle } from '@/components/shared';
|
|
4
4
|
|
|
5
5
|
export function Header() {
|
|
6
6
|
return (
|
|
@@ -12,6 +12,7 @@ export function Header() {
|
|
|
12
12
|
<div className="flex items-center gap-2">
|
|
13
13
|
<LanguageSwitcher />
|
|
14
14
|
<ThemeToggle />
|
|
15
|
+
<AccountButton />
|
|
15
16
|
</div>
|
|
16
17
|
</div>
|
|
17
18
|
</header>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { render, setMockSignedIn, setMockLoaded, resetClerkMocks } from '@/test';
|
|
5
|
+
|
|
6
|
+
import { AccountButton } from './AccountButton';
|
|
7
|
+
|
|
8
|
+
describe('AccountButton', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
resetClerkMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('shows skeleton when not loaded', () => {
|
|
14
|
+
setMockLoaded(false);
|
|
15
|
+
const { container } = render(<AccountButton />);
|
|
16
|
+
expect(container.querySelector('.rounded-full')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('shows user button when signed in', () => {
|
|
20
|
+
render(<AccountButton />);
|
|
21
|
+
expect(screen.getByTestId('user-button')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('shows sign in button when signed out', () => {
|
|
25
|
+
setMockSignedIn(false);
|
|
26
|
+
render(<AccountButton />);
|
|
27
|
+
expect(screen.getByTestId('sign-in-button')).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SignedIn, SignedOut, SignInButton, useAuth, UserButton } from '@clerk/react-router';
|
|
2
|
+
import { useLingui } from '@lingui/react/macro';
|
|
3
|
+
import { User } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
7
|
+
import { useTouchSizes } from '@/hooks';
|
|
8
|
+
|
|
9
|
+
export function AccountButton() {
|
|
10
|
+
const { t } = useLingui();
|
|
11
|
+
const { isLoaded } = useAuth();
|
|
12
|
+
const sizes = useTouchSizes();
|
|
13
|
+
|
|
14
|
+
// Show skeleton while Clerk loads to prevent UI flash
|
|
15
|
+
if (!isLoaded) {
|
|
16
|
+
return <Skeleton className="size-9 rounded-full" />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<SignedOut>
|
|
22
|
+
{/* eslint-disable-next-line lingui/no-unlocalized-strings */}
|
|
23
|
+
<SignInButton mode="modal">
|
|
24
|
+
<Button
|
|
25
|
+
variant="ghost"
|
|
26
|
+
size={sizes.iconButtonLg}
|
|
27
|
+
aria-label={t({ message: 'Sign in', comment: 'Sign in button aria label' })}
|
|
28
|
+
>
|
|
29
|
+
<User className="size-5" />
|
|
30
|
+
</Button>
|
|
31
|
+
</SignInButton>
|
|
32
|
+
</SignedOut>
|
|
33
|
+
<SignedIn>
|
|
34
|
+
<UserButton />
|
|
35
|
+
</SignedIn>
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AccountButton } from './AccountButton';
|
|
@@ -52,14 +52,14 @@ describe('ErrorBoundary', () => {
|
|
|
52
52
|
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('shows
|
|
55
|
+
it('shows Reload Page button', () => {
|
|
56
56
|
render(
|
|
57
57
|
<ErrorBoundary>
|
|
58
58
|
<ThrowingComponent />
|
|
59
59
|
</ErrorBoundary>,
|
|
60
60
|
);
|
|
61
61
|
|
|
62
|
-
expect(screen.getByRole('button', { name: /
|
|
62
|
+
expect(screen.getByRole('button', { name: /reload page/i })).toBeInTheDocument();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
it('renders custom fallback when provided', () => {
|
|
@@ -103,7 +103,7 @@ describe('ErrorBoundary', () => {
|
|
|
103
103
|
expect(onReset).toHaveBeenCalledTimes(1);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it('reloads page when
|
|
106
|
+
it('reloads page when Reload Page is clicked', () => {
|
|
107
107
|
const reloadMock = vi.fn();
|
|
108
108
|
const originalLocation = window.location;
|
|
109
109
|
|
|
@@ -119,7 +119,7 @@ describe('ErrorBoundary', () => {
|
|
|
119
119
|
</ErrorBoundary>,
|
|
120
120
|
);
|
|
121
121
|
|
|
122
|
-
fireEvent.click(screen.getByRole('button', { name: /
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: /reload page/i }));
|
|
123
123
|
|
|
124
124
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
|
125
125
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
1
|
import { Trans } from '@lingui/react/macro';
|
|
2
|
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|
3
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { captureException } from '@/lib/sentry';
|
|
5
8
|
|
|
6
9
|
interface Props {
|
|
7
10
|
children: ReactNode;
|
|
@@ -15,6 +18,48 @@ interface State {
|
|
|
15
18
|
error: Error | null;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
interface ErrorFallbackUIProps {
|
|
22
|
+
error: Error | null;
|
|
23
|
+
onRetry: () => void;
|
|
24
|
+
onReload: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ErrorFallbackUI({ error, onRetry, onReload }: ErrorFallbackUIProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex min-h-screen items-center justify-center p-4">
|
|
30
|
+
<Card className="max-w-md">
|
|
31
|
+
<CardHeader>
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<AlertTriangle className="text-destructive size-5" />
|
|
34
|
+
<CardTitle>
|
|
35
|
+
<Trans>Something went wrong</Trans>
|
|
36
|
+
</CardTitle>
|
|
37
|
+
</div>
|
|
38
|
+
<CardDescription>
|
|
39
|
+
<Trans>An unexpected error occurred. You can try again or reload the page.</Trans>
|
|
40
|
+
</CardDescription>
|
|
41
|
+
</CardHeader>
|
|
42
|
+
<CardContent className="space-y-4">
|
|
43
|
+
{error && (
|
|
44
|
+
<div className="bg-muted rounded-md p-3">
|
|
45
|
+
<p className="text-muted-foreground font-mono text-xs break-all">{error.message}</p>
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
<div className="flex gap-2">
|
|
49
|
+
<Button onClick={onRetry} variant="default">
|
|
50
|
+
<Trans>Try Again</Trans>
|
|
51
|
+
</Button>
|
|
52
|
+
<Button onClick={onReload} variant="outline">
|
|
53
|
+
<RefreshCw className="mr-2 size-4" />
|
|
54
|
+
<Trans>Reload Page</Trans>
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
18
63
|
export class ErrorBoundary extends Component<Props, State> {
|
|
19
64
|
constructor(props: Props) {
|
|
20
65
|
super(props);
|
|
@@ -28,22 +73,11 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
28
73
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
29
74
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
30
75
|
|
|
76
|
+
// Report error to Sentry with component stack
|
|
77
|
+
captureException(error, { componentStack: errorInfo.componentStack ?? undefined });
|
|
78
|
+
|
|
31
79
|
// Call custom error handler if provided
|
|
32
80
|
this.props.onError?.(error, errorInfo);
|
|
33
|
-
|
|
34
|
-
// Report to Sentry in production (if enabled and configured)
|
|
35
|
-
if (import.meta.env.PROD && SENTRY_CONFIG.enabled && SENTRY_CONFIG.dsn) {
|
|
36
|
-
// eslint-disable-next-line lingui/no-unlocalized-strings
|
|
37
|
-
import('@sentry/react')
|
|
38
|
-
.then((Sentry) => {
|
|
39
|
-
Sentry.captureException(error, {
|
|
40
|
-
extra: { componentStack: errorInfo.componentStack },
|
|
41
|
-
});
|
|
42
|
-
})
|
|
43
|
-
.catch(() => {
|
|
44
|
-
// Sentry failed to load, error already logged above
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
81
|
}
|
|
48
82
|
|
|
49
83
|
/**
|
|
@@ -54,49 +88,17 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
54
88
|
this.setState({ hasError: false, error: null });
|
|
55
89
|
};
|
|
56
90
|
|
|
91
|
+
handleReload = () => {
|
|
92
|
+
window.location.reload();
|
|
93
|
+
};
|
|
94
|
+
|
|
57
95
|
render() {
|
|
58
96
|
if (this.state.hasError) {
|
|
59
97
|
if (this.props.fallback) {
|
|
60
98
|
return this.props.fallback;
|
|
61
99
|
}
|
|
62
100
|
|
|
63
|
-
return
|
|
64
|
-
<div className="flex min-h-screen items-center justify-center p-4">
|
|
65
|
-
<div className="text-center">
|
|
66
|
-
<h1 className="text-destructive text-2xl font-bold">
|
|
67
|
-
<Trans comment="Error boundary - main error heading">Something went wrong</Trans>
|
|
68
|
-
</h1>
|
|
69
|
-
<p className="text-muted-foreground mt-2">
|
|
70
|
-
<Trans comment="Error boundary - error explanation">
|
|
71
|
-
We're sorry, but something unexpected happened.
|
|
72
|
-
</Trans>
|
|
73
|
-
</p>
|
|
74
|
-
{import.meta.env.DEV && this.state.error && (
|
|
75
|
-
<details className="bg-muted mt-4 rounded-md p-4 text-left">
|
|
76
|
-
<summary className="cursor-pointer font-medium">
|
|
77
|
-
<Trans comment="Error boundary - debug section heading">Error details</Trans>
|
|
78
|
-
</summary>
|
|
79
|
-
<pre className="mt-2 overflow-auto text-sm">{this.state.error.message}</pre>
|
|
80
|
-
<pre className="mt-1 overflow-auto text-xs opacity-75">{this.state.error.stack}</pre>
|
|
81
|
-
</details>
|
|
82
|
-
)}
|
|
83
|
-
<div className="mt-6 flex justify-center gap-3">
|
|
84
|
-
<button
|
|
85
|
-
onClick={this.reset}
|
|
86
|
-
className="bg-secondary text-secondary-foreground rounded px-4 py-2 transition-colors hover:opacity-90"
|
|
87
|
-
>
|
|
88
|
-
<Trans comment="Error boundary - try again button">Try Again</Trans>
|
|
89
|
-
</button>
|
|
90
|
-
<button
|
|
91
|
-
onClick={() => window.location.reload()}
|
|
92
|
-
className="bg-primary text-primary-foreground rounded px-4 py-2 transition-colors hover:opacity-90"
|
|
93
|
-
>
|
|
94
|
-
<Trans comment="Error boundary - refresh button">Refresh Page</Trans>
|
|
95
|
-
</button>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
101
|
+
return <ErrorFallbackUI error={this.state.error} onRetry={this.reset} onReload={this.handleReload} />;
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
return this.props.children;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { render, setMockSignedIn, setMockLoaded, resetClerkMocks } from '@/test';
|
|
5
|
+
|
|
6
|
+
import { ProtectedRoute } from './ProtectedRoute';
|
|
7
|
+
|
|
8
|
+
describe('ProtectedRoute', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
resetClerkMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('renders children when signed in', () => {
|
|
14
|
+
render(
|
|
15
|
+
<ProtectedRoute>
|
|
16
|
+
<div>Protected Content</div>
|
|
17
|
+
</ProtectedRoute>,
|
|
18
|
+
);
|
|
19
|
+
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('shows loading when auth is not loaded', () => {
|
|
23
|
+
setMockLoaded(false);
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<ProtectedRoute>
|
|
26
|
+
<div>Protected Content</div>
|
|
27
|
+
</ProtectedRoute>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
30
|
+
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('redirects when not signed in', () => {
|
|
34
|
+
setMockSignedIn(false);
|
|
35
|
+
render(
|
|
36
|
+
<ProtectedRoute>
|
|
37
|
+
<div>Protected Content</div>
|
|
38
|
+
</ProtectedRoute>,
|
|
39
|
+
);
|
|
40
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
|
41
|
+
expect(screen.getByTestId('redirect-to-sign-in')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RedirectToSignIn, useAuth } from '@clerk/react-router';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { PageLoading } from '@/components/ui/loading';
|
|
5
|
+
|
|
6
|
+
interface ProtectedRouteProps {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wraps routes that require authentication.
|
|
12
|
+
* Shows loading state while auth loads, redirects to sign-in if not authenticated.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Route path="/dashboard" element={
|
|
17
|
+
* <ProtectedRoute>
|
|
18
|
+
* <DashboardPage />
|
|
19
|
+
* </ProtectedRoute>
|
|
20
|
+
* } />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
24
|
+
const { isLoaded, isSignedIn } = useAuth();
|
|
25
|
+
|
|
26
|
+
if (!isLoaded) {
|
|
27
|
+
return <PageLoading />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!isSignedIn) {
|
|
31
|
+
return <RedirectToSignIn />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return <>{children}</>;
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ProtectedRoute } from './ProtectedRoute';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { AccountButton } from './AccountButton';
|
|
2
2
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
3
|
-
export { SEO } from './SEO';
|
|
4
3
|
export { LanguageSwitcher } from './LanguageSwitcher';
|
|
4
|
+
export { ProtectedRoute } from './ProtectedRoute';
|
|
5
5
|
export { RegisterForm } from './RegisterForm';
|
|
6
|
+
export { SEO } from './SEO';
|
|
7
|
+
export { ThemeToggle } from './ThemeToggle';
|