@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.
Files changed (93) hide show
  1. package/README.md +2 -1
  2. package/dist/constants.d.ts +1 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +1 -0
  5. package/dist/constants.js.map +1 -1
  6. package/dist/features/definitions/auth.d.ts +3 -0
  7. package/dist/features/definitions/auth.d.ts.map +1 -0
  8. package/dist/features/definitions/auth.js +17 -0
  9. package/dist/features/definitions/auth.js.map +1 -0
  10. package/dist/features/definitions/core.d.ts.map +1 -1
  11. package/dist/features/definitions/core.js +16 -1
  12. package/dist/features/definitions/core.js.map +1 -1
  13. package/dist/features/definitions/forms.d.ts.map +1 -1
  14. package/dist/features/definitions/forms.js +4 -0
  15. package/dist/features/definitions/forms.js.map +1 -1
  16. package/dist/features/definitions/index.d.ts +1 -0
  17. package/dist/features/definitions/index.d.ts.map +1 -1
  18. package/dist/features/definitions/index.js +1 -0
  19. package/dist/features/definitions/index.js.map +1 -1
  20. package/dist/features/definitions/mobile.d.ts.map +1 -1
  21. package/dist/features/definitions/mobile.js +11 -2
  22. package/dist/features/definitions/mobile.js.map +1 -1
  23. package/dist/features/definitions/observability.js +1 -1
  24. package/dist/features/definitions/observability.js.map +1 -1
  25. package/dist/features/definitions/routing.d.ts.map +1 -1
  26. package/dist/features/definitions/routing.js +2 -1
  27. package/dist/features/definitions/routing.js.map +1 -1
  28. package/dist/features/definitions/state.d.ts.map +1 -1
  29. package/dist/features/definitions/state.js +9 -2
  30. package/dist/features/definitions/state.js.map +1 -1
  31. package/dist/features/definitions/testing.d.ts.map +1 -1
  32. package/dist/features/definitions/testing.js +4 -2
  33. package/dist/features/definitions/testing.js.map +1 -1
  34. package/dist/features/registry.d.ts.map +1 -1
  35. package/dist/features/registry.js +2 -1
  36. package/dist/features/registry.js.map +1 -1
  37. package/dist/features/types.test.js +4 -2
  38. package/dist/features/types.test.js.map +1 -1
  39. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  40. package/dist/utils/scaffold/generators.js +7 -0
  41. package/dist/utils/scaffold/generators.js.map +1 -1
  42. package/package.json +1 -1
  43. package/templates/.env.example +6 -0
  44. package/templates/.github/workflows/ci.yml +8 -3
  45. package/templates/CLAUDE.md +74 -1
  46. package/templates/docs/ARCHITECTURE.md +13 -12
  47. package/templates/docs/CODING_STANDARDS.md +65 -0
  48. package/templates/docs/E2E_TESTING.md +52 -7
  49. package/templates/e2e/fixtures/index.ts +13 -2
  50. package/templates/package.json +7 -3
  51. package/templates/playwright.config.ts +6 -1
  52. package/templates/src/components/layout/Header.tsx +2 -1
  53. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
  54. package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
  55. package/templates/src/components/shared/AccountButton/index.ts +1 -0
  56. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
  57. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
  58. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
  59. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
  60. package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
  61. package/templates/src/components/shared/index.ts +4 -2
  62. package/templates/src/contexts/clerkContext.tsx +45 -0
  63. package/templates/src/hooks/index.ts +23 -2
  64. package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
  65. package/templates/src/hooks/useCopyFeedback.ts +41 -0
  66. package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
  67. package/templates/src/hooks/useDebouncedCallback.ts +47 -0
  68. package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
  69. package/templates/src/hooks/useDocumentTitle.ts +31 -0
  70. package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
  71. package/templates/src/hooks/useIOSViewportReset.ts +18 -0
  72. package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
  73. package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
  74. package/templates/src/hooks/useLocalStorage.test.ts +111 -0
  75. package/templates/src/hooks/useLocalStorage.ts +77 -0
  76. package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
  77. package/templates/src/hooks/useSyncedFormData.ts +21 -0
  78. package/templates/src/hooks/useSyncedState.test.ts +119 -0
  79. package/templates/src/hooks/useSyncedState.ts +30 -0
  80. package/templates/src/index.css +1 -0
  81. package/templates/src/lib/constants.ts +10 -0
  82. package/templates/src/lib/createSelectors.test.ts +136 -0
  83. package/templates/src/lib/createSelectors.ts +31 -0
  84. package/templates/src/lib/index.ts +1 -0
  85. package/templates/src/lib/sentry.ts +55 -0
  86. package/templates/src/lib/storage.ts +6 -2
  87. package/templates/src/main.tsx +18 -8
  88. package/templates/src/stores/preferencesStore.ts +34 -9
  89. package/templates/src/test/clerkMock.tsx +97 -0
  90. package/templates/src/test/index.ts +3 -0
  91. package/templates/src/test/providers.tsx +7 -4
  92. package/templates/src/test-setup.ts +16 -2
  93. 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
- <MobileProvider>
85
- {' '}
86
- {/* Viewport - depends on router for SSR */}
87
- <ErrorBoundary>
88
- <App />
89
- <Toaster />
90
- </ErrorBoundary>
91
- </MobileProvider>
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
- - MobileProvider inside Router for potential SSR viewport detection
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, clearAppState
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 functional tests
111
- npm run e2e:ui # Functional tests with interactive UI
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
+ }
@@ -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=functional",
43
- "e2e:ui": "playwright test --project=functional --ui",
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: 'functional',
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 Refresh Page button', () => {
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: /refresh page/i })).toBeInTheDocument();
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 Refresh Page is clicked', () => {
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: /refresh page/i }));
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 { SENTRY_CONFIG } from '@/lib/config';
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 { ThemeToggle } from './ThemeToggle';
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';