@react-spa-scaffold/mcp 2.1.1 → 2.3.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 (168) hide show
  1. package/README.md +2 -1
  2. package/dist/constants.d.ts +4 -0
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +4 -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/database.d.ts +3 -0
  14. package/dist/features/definitions/database.d.ts.map +1 -0
  15. package/dist/features/definitions/database.js +45 -0
  16. package/dist/features/definitions/database.js.map +1 -0
  17. package/dist/features/definitions/deployment.d.ts +3 -0
  18. package/dist/features/definitions/deployment.d.ts.map +1 -0
  19. package/dist/features/definitions/deployment.js +14 -0
  20. package/dist/features/definitions/deployment.js.map +1 -0
  21. package/dist/features/definitions/forms.d.ts.map +1 -1
  22. package/dist/features/definitions/forms.js +4 -0
  23. package/dist/features/definitions/forms.js.map +1 -1
  24. package/dist/features/definitions/index.d.ts +3 -0
  25. package/dist/features/definitions/index.d.ts.map +1 -1
  26. package/dist/features/definitions/index.js +3 -0
  27. package/dist/features/definitions/index.js.map +1 -1
  28. package/dist/features/definitions/mobile.d.ts.map +1 -1
  29. package/dist/features/definitions/mobile.js +11 -2
  30. package/dist/features/definitions/mobile.js.map +1 -1
  31. package/dist/features/definitions/observability.js +1 -1
  32. package/dist/features/definitions/observability.js.map +1 -1
  33. package/dist/features/definitions/routing.d.ts.map +1 -1
  34. package/dist/features/definitions/routing.js +2 -1
  35. package/dist/features/definitions/routing.js.map +1 -1
  36. package/dist/features/definitions/state.d.ts.map +1 -1
  37. package/dist/features/definitions/state.js +9 -2
  38. package/dist/features/definitions/state.js.map +1 -1
  39. package/dist/features/definitions/testing.d.ts.map +1 -1
  40. package/dist/features/definitions/testing.js +4 -2
  41. package/dist/features/definitions/testing.js.map +1 -1
  42. package/dist/features/registry.d.ts.map +1 -1
  43. package/dist/features/registry.js +4 -1
  44. package/dist/features/registry.js.map +1 -1
  45. package/dist/features/types.test.js +6 -2
  46. package/dist/features/types.test.js.map +1 -1
  47. package/dist/resources/docs.d.ts.map +1 -1
  48. package/dist/resources/docs.js +5 -0
  49. package/dist/resources/docs.js.map +1 -1
  50. package/dist/tools/add-features.js +1 -1
  51. package/dist/tools/add-features.js.map +1 -1
  52. package/dist/utils/docs.d.ts.map +1 -1
  53. package/dist/utils/docs.js +2 -0
  54. package/dist/utils/docs.js.map +1 -1
  55. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  56. package/dist/utils/scaffold/claude-md/index.js +3 -1
  57. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  58. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  59. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  60. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  61. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  62. package/dist/utils/scaffold/compute.js +1 -1
  63. package/dist/utils/scaffold/compute.js.map +1 -1
  64. package/dist/utils/scaffold/generators.d.ts +2 -2
  65. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  66. package/dist/utils/scaffold/generators.js +64 -22
  67. package/dist/utils/scaffold/generators.js.map +1 -1
  68. package/package.json +1 -1
  69. package/templates/.env.example +44 -10
  70. package/templates/.github/workflows/ci.yml +12 -4
  71. package/templates/.github/workflows/deploy.yml +59 -0
  72. package/templates/CLAUDE.md +251 -2
  73. package/templates/docs/ARCHITECTURE.md +13 -12
  74. package/templates/docs/AUTHENTICATION.md +325 -0
  75. package/templates/docs/CODING_STANDARDS.md +65 -0
  76. package/templates/docs/DEPLOYMENT.md +268 -0
  77. package/templates/docs/E2E_TESTING.md +133 -11
  78. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  79. package/templates/docs/TESTING.md +195 -77
  80. package/templates/e2e/auth/auth.setup.ts +60 -0
  81. package/templates/e2e/fixtures/index.ts +24 -2
  82. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  83. package/templates/e2e/tests/profile.spec.ts +64 -0
  84. package/templates/e2e/tests/register-form.spec.ts +38 -0
  85. package/templates/gitignore +5 -0
  86. package/templates/package.json +15 -3
  87. package/templates/playwright.config.ts +39 -4
  88. package/templates/src/App.tsx +32 -19
  89. package/templates/src/components/layout/Header.test.tsx +17 -1
  90. package/templates/src/components/layout/Header.tsx +13 -1
  91. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
  92. package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
  93. package/templates/src/components/shared/AccountButton/index.ts +1 -0
  94. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
  95. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
  96. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  97. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  98. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  99. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
  100. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
  101. package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
  102. package/templates/src/components/shared/index.ts +5 -2
  103. package/templates/src/contexts/clerkContext.tsx +45 -0
  104. package/templates/src/contexts/performanceContext.tsx +3 -3
  105. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  106. package/templates/src/contexts/supabaseContext.tsx +87 -0
  107. package/templates/src/hooks/index.ts +40 -2
  108. package/templates/src/hooks/supabase/index.ts +12 -0
  109. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  110. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  111. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  112. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  113. package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
  114. package/templates/src/hooks/useCopyFeedback.ts +41 -0
  115. package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
  116. package/templates/src/hooks/useDebouncedCallback.ts +47 -0
  117. package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
  118. package/templates/src/hooks/useDocumentTitle.ts +31 -0
  119. package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
  120. package/templates/src/hooks/useIOSViewportReset.ts +18 -0
  121. package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
  122. package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
  123. package/templates/src/hooks/useLocalStorage.test.ts +111 -0
  124. package/templates/src/hooks/useLocalStorage.ts +77 -0
  125. package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
  126. package/templates/src/hooks/useSyncedFormData.ts +21 -0
  127. package/templates/src/hooks/useSyncedState.test.ts +119 -0
  128. package/templates/src/hooks/useSyncedState.ts +30 -0
  129. package/templates/src/index.css +1 -0
  130. package/templates/src/lib/api.test.ts +30 -38
  131. package/templates/src/lib/api.ts +1 -7
  132. package/templates/src/lib/config.ts +54 -4
  133. package/templates/src/lib/constants.ts +10 -0
  134. package/templates/src/lib/createSelectors.test.ts +136 -0
  135. package/templates/src/lib/createSelectors.ts +31 -0
  136. package/templates/src/lib/env.ts +36 -14
  137. package/templates/src/lib/index.ts +5 -2
  138. package/templates/src/lib/routes.ts +1 -0
  139. package/templates/src/lib/sentry.ts +58 -0
  140. package/templates/src/lib/storage.ts +6 -2
  141. package/templates/src/lib/supabase/client.ts +58 -0
  142. package/templates/src/lib/supabase/index.ts +5 -0
  143. package/templates/src/main.tsx +19 -31
  144. package/templates/src/mocks/constants.ts +31 -0
  145. package/templates/src/mocks/fixtures/index.ts +3 -1
  146. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  147. package/templates/src/mocks/fixtures/users.ts +91 -0
  148. package/templates/src/mocks/handlers/index.ts +2 -1
  149. package/templates/src/mocks/handlers/supabase.ts +64 -0
  150. package/templates/src/mocks/handlers/todos.ts +1 -1
  151. package/templates/src/mocks/index.ts +6 -0
  152. package/templates/src/pages/Profile.test.tsx +263 -0
  153. package/templates/src/pages/Profile.tsx +171 -0
  154. package/templates/src/pages/index.ts +1 -0
  155. package/templates/src/stores/preferencesStore.ts +35 -9
  156. package/templates/src/test/clerkMock.tsx +137 -0
  157. package/templates/src/test/fetchMock.ts +58 -0
  158. package/templates/src/test/index.ts +51 -2
  159. package/templates/src/test/mocks.ts +128 -1
  160. package/templates/src/test/providers.tsx +10 -4
  161. package/templates/src/test/supabaseMock.ts +112 -0
  162. package/templates/src/test-setup.ts +42 -2
  163. package/templates/src/types/database.ts +46 -0
  164. package/templates/src/types/index.ts +1 -0
  165. package/templates/src/types/supabase.ts +167 -0
  166. package/templates/src/vite-env.d.ts +6 -0
  167. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
  168. package/templates/vitest.config.ts +9 -1
@@ -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,44 @@
1
+ import { waitFor } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+
4
+ import { render, setMockClerkSignedIn, resetClerkMocks } from '@/test';
5
+
6
+ import { ProfileSync } from './ProfileSync';
7
+
8
+ // Mock the hooks
9
+ const mockMutate = vi.fn();
10
+ vi.mock('@/hooks', async () => {
11
+ const actual = await vi.importActual('@/hooks');
12
+ return {
13
+ ...actual,
14
+ useCurrentProfile: vi.fn(() => ({ data: [], isLoading: false })),
15
+ useUpsertProfile: vi.fn(() => ({ mutate: mockMutate, isPending: false })),
16
+ };
17
+ });
18
+
19
+ describe('ProfileSync', () => {
20
+ beforeEach(() => {
21
+ resetClerkMocks();
22
+ mockMutate.mockClear();
23
+ });
24
+
25
+ it('renders nothing (invisible)', () => {
26
+ setMockClerkSignedIn(true);
27
+ const { container } = render(<ProfileSync />);
28
+ expect(container).toBeEmptyDOMElement();
29
+ });
30
+
31
+ it('does not sync when signed out', async () => {
32
+ setMockClerkSignedIn(false);
33
+ render(<ProfileSync />);
34
+ await waitFor(() => expect(mockMutate).not.toHaveBeenCalled());
35
+ });
36
+
37
+ it('syncs profile when signed in', async () => {
38
+ setMockClerkSignedIn(true);
39
+ render(<ProfileSync />);
40
+ await waitFor(() => {
41
+ expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ id: 'user_123' }), expect.any(Object));
42
+ });
43
+ });
44
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ProfileSync - Invisible component that syncs Clerk user data to Supabase.
3
+ *
4
+ * This component ensures the authenticated user's profile exists in Supabase
5
+ * and stays in sync with their Clerk account data. It runs once per session
6
+ * and handles first-time profile creation automatically.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * // Add to your app layout or protected routes
11
+ * function App() {
12
+ * return (
13
+ * <>
14
+ * <ProfileSync />
15
+ * <Routes>...</Routes>
16
+ * </>
17
+ * );
18
+ * }
19
+ * ```
20
+ */
21
+
22
+ import { useEffect, useRef } from 'react';
23
+ import { useUser } from '@clerk/react-router';
24
+
25
+ import { useCurrentProfile, useUpsertProfile } from '@/hooks';
26
+
27
+ interface ProfileSyncProps {
28
+ /**
29
+ * Callback fired when profile sync completes successfully.
30
+ */
31
+ onSyncComplete?: () => void;
32
+ /**
33
+ * Callback fired when profile sync fails.
34
+ */
35
+ onSyncError?: (error: Error) => void;
36
+ }
37
+
38
+ /**
39
+ * Syncs the current Clerk user's data to Supabase profiles table.
40
+ *
41
+ * This component:
42
+ * 1. Waits for Clerk user to be loaded
43
+ * 2. Checks if a profile already exists
44
+ * 3. Creates or updates the profile if needed
45
+ * 4. Only syncs once per component mount to prevent loops
46
+ *
47
+ * The sync uses upsert, so it's safe to call multiple times - it will
48
+ * create the profile if it doesn't exist, or update if data changed.
49
+ */
50
+ export function ProfileSync({ onSyncComplete, onSyncError }: ProfileSyncProps) {
51
+ const { user, isLoaded: isUserLoaded } = useUser();
52
+ const { data: profiles, isLoading: isProfileLoading } = useCurrentProfile();
53
+ const upsertProfile = useUpsertProfile();
54
+
55
+ // Track if we've already synced this session to prevent infinite loops
56
+ const hasSynced = useRef(false);
57
+ const isSyncing = useRef(false);
58
+
59
+ useEffect(() => {
60
+ // Skip if already synced, currently syncing, or data not ready
61
+ if (hasSynced.current || isSyncing.current) return;
62
+ if (!isUserLoaded || !user) return;
63
+ if (isProfileLoading) return;
64
+
65
+ const existingProfile = profiles?.[0];
66
+
67
+ // Check if sync is needed:
68
+ // - No profile exists, OR
69
+ // - Email has changed (user updated in Clerk)
70
+ const needsSync = !existingProfile || existingProfile.email !== user.primaryEmailAddress?.emailAddress;
71
+
72
+ if (!needsSync) {
73
+ hasSynced.current = true;
74
+ return;
75
+ }
76
+
77
+ // Perform the sync
78
+ isSyncing.current = true;
79
+
80
+ upsertProfile.mutate(
81
+ {
82
+ id: user.id,
83
+ email: user.primaryEmailAddress?.emailAddress ?? '',
84
+ full_name: user.fullName ?? null,
85
+ avatar_url: user.imageUrl ?? null,
86
+ },
87
+ {
88
+ onSuccess: () => {
89
+ hasSynced.current = true;
90
+ isSyncing.current = false;
91
+ onSyncComplete?.();
92
+ },
93
+ onError: (error) => {
94
+ isSyncing.current = false;
95
+ // Don't set hasSynced so it can retry on next render
96
+ onSyncError?.(new Error(error.message));
97
+ },
98
+ },
99
+ );
100
+ }, [isUserLoaded, user, profiles, isProfileLoading, upsertProfile, onSyncComplete, onSyncError]);
101
+
102
+ // This component renders nothing - it's purely for side effects
103
+ return null;
104
+ }
@@ -0,0 +1 @@
1
+ export { ProfileSync } from './ProfileSync';
@@ -0,0 +1,43 @@
1
+ import { screen } from '@testing-library/react';
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+
4
+ import { render, setMockClerkSignedIn, setMockClerkLoaded, 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
+ setMockClerkLoaded(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
+ setMockClerkSignedIn(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,8 @@
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 { ProfileSync } from './ProfileSync';
5
+ export { ProtectedRoute } from './ProtectedRoute';
5
6
  export { RegisterForm } from './RegisterForm';
7
+ export { SEO } from './SEO';
8
+ export { ThemeToggle } from './ThemeToggle';
@@ -0,0 +1,45 @@
1
+ import { ClerkProvider } from '@clerk/react-router';
2
+ import { shadcn } from '@clerk/themes';
3
+ import type { Appearance } from '@clerk/types';
4
+ import type { ReactNode } from 'react';
5
+
6
+ interface ClerkThemeProviderProps {
7
+ children: ReactNode;
8
+ publishableKey: string;
9
+ }
10
+
11
+ /**
12
+ * Clerk appearance configuration.
13
+ * Uses shadcn theme which auto-adapts to light/dark mode.
14
+ * Requires @clerk/themes/shadcn.css to be imported in index.css.
15
+ */
16
+ const appearance: Appearance = {
17
+ baseTheme: shadcn,
18
+ variables: {
19
+ // Typography
20
+ fontFamily: '"Inter Variable", sans-serif',
21
+ // Match app's border radius (--radius: 0.45rem in index.css)
22
+ borderRadius: '0.45rem',
23
+ },
24
+ elements: {
25
+ // Make modal fullscreen on mobile devices
26
+ modalBackdrop: 'backdrop-blur-sm',
27
+ modalContent: 'sm:max-w-md max-sm:min-h-svh max-sm:min-w-full max-sm:rounded-none',
28
+ card: 'max-sm:rounded-none max-sm:shadow-none',
29
+ // Ensure consistent border radius on form elements
30
+ formFieldInput: 'rounded-md',
31
+ formButtonPrimary: 'rounded-md',
32
+ socialButtonsBlockButton: 'rounded-md',
33
+ },
34
+ };
35
+
36
+ /**
37
+ * ClerkProvider wrapper with consistent theming.
38
+ */
39
+ export function ClerkThemeProvider({ children, publishableKey }: ClerkThemeProviderProps) {
40
+ return (
41
+ <ClerkProvider publishableKey={publishableKey} afterSignOutUrl="/" appearance={appearance}>
42
+ {children}
43
+ </ClerkProvider>
44
+ );
45
+ }
@@ -2,6 +2,8 @@ import { lazy, Suspense, type ReactNode } from 'react';
2
2
 
3
3
  import { usePerformance as useLibPerformance } from 'react-performance-tracking/react';
4
4
 
5
+ import { PERFORMANCE_CONFIG } from '@/lib/config';
6
+
5
7
  /**
6
8
  * Lazy-load the PerformanceProvider to avoid bundling it in production.
7
9
  * This provider is only used during performance testing.
@@ -29,9 +31,7 @@ interface PerformanceProviderWrapperProps {
29
31
  * @see https://github.com/mkaczkowski/react-performance-tracking
30
32
  */
31
33
  export function PerformanceProviderWrapper({ children }: PerformanceProviderWrapperProps) {
32
- const isPerformanceEnabled = import.meta.env.DEV || import.meta.env.VITE_PERF_TEST === 'true';
33
-
34
- if (!isPerformanceEnabled) {
34
+ if (!PERFORMANCE_CONFIG.enabled) {
35
35
  return <>{children}</>;
36
36
  }
37
37
 
@@ -0,0 +1,59 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import type { ReactNode } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { SupabaseProvider, useSupabase } from './supabaseContext';
6
+
7
+ // Mock Clerk's useSession hook
8
+ vi.mock('@clerk/react-router', () => ({
9
+ useSession: () => ({
10
+ session: {
11
+ getToken: vi.fn().mockResolvedValue('mock-clerk-token'),
12
+ },
13
+ }),
14
+ }));
15
+
16
+ // Mock the Supabase client factory
17
+ vi.mock('@/lib/supabase', () => ({
18
+ createSupabaseClient: vi.fn().mockReturnValue({
19
+ from: vi.fn().mockReturnValue({
20
+ select: vi.fn().mockResolvedValue({ data: [], error: null }),
21
+ }),
22
+ }),
23
+ }));
24
+
25
+ describe('SupabaseContext', () => {
26
+ const wrapper = ({ children }: { children: ReactNode }) => <SupabaseProvider>{children}</SupabaseProvider>;
27
+
28
+ describe('SupabaseProvider', () => {
29
+ it('provides supabase client to children', () => {
30
+ const { result } = renderHook(() => useSupabase(), { wrapper });
31
+
32
+ expect(result.current).toBeDefined();
33
+ expect(result.current.from).toBeDefined();
34
+ });
35
+
36
+ it('provides a client with from method', () => {
37
+ const { result } = renderHook(() => useSupabase(), { wrapper });
38
+
39
+ expect(typeof result.current.from).toBe('function');
40
+ });
41
+ });
42
+
43
+ describe('useSupabase', () => {
44
+ // Note: The "throws outside provider" behavior is tested implicitly
45
+ // The global mock in test-setup.ts provides a mock client for all tests,
46
+ // so we verify the actual throw behavior through the real implementation.
47
+ // This test uses the mocked version which always returns a client.
48
+
49
+ it('returns the same client instance on re-renders', () => {
50
+ const { result, rerender } = renderHook(() => useSupabase(), { wrapper });
51
+
52
+ const firstClient = result.current;
53
+ rerender();
54
+ const secondClient = result.current;
55
+
56
+ expect(firstClient).toBe(secondClient);
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Supabase context provider with Clerk authentication integration.
3
+ *
4
+ * Provides a typed Supabase client that automatically uses Clerk session tokens
5
+ * for authentication. Must be placed inside ClerkProvider in the component tree.
6
+ *
7
+ * @see https://supabase.com/docs/guides/auth/third-party/clerk
8
+ */
9
+
10
+ import { useSession } from '@clerk/react-router';
11
+ import { createContext, useContext, useMemo, type ReactNode } from 'react';
12
+
13
+ import { createSupabaseClient, type TypedSupabaseClient } from '@/lib/supabase';
14
+
15
+ const SupabaseContext = createContext<TypedSupabaseClient | null>(null);
16
+
17
+ interface SupabaseProviderProps {
18
+ children: ReactNode;
19
+ }
20
+
21
+ /**
22
+ * Provides a Supabase client with Clerk authentication to the app.
23
+ *
24
+ * The client automatically injects Clerk session tokens into Supabase requests,
25
+ * enabling Row Level Security (RLS) policies based on `auth.uid()`.
26
+ *
27
+ * Must be placed INSIDE ClerkProvider in the provider hierarchy since it
28
+ * requires access to the Clerk session via useSession().
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * // In main.tsx
33
+ * <ClerkThemeProvider>
34
+ * <SupabaseProvider>
35
+ * <App />
36
+ * </SupabaseProvider>
37
+ * </ClerkThemeProvider>
38
+ * ```
39
+ */
40
+ export function SupabaseProvider({ children }: SupabaseProviderProps) {
41
+ const { session } = useSession();
42
+
43
+ // Create client with stable getToken reference
44
+ // Only recreate when session ID changes (sign in/out), not on every render
45
+ const supabase = useMemo(
46
+ () =>
47
+ createSupabaseClient(async () => {
48
+ if (!session) return null;
49
+ return session.getToken();
50
+ }),
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- session.id is stable, session object is not
52
+ [session?.id],
53
+ );
54
+
55
+ return <SupabaseContext.Provider value={supabase}>{children}</SupabaseContext.Provider>;
56
+ }
57
+
58
+ /**
59
+ * Hook to access the Supabase client.
60
+ *
61
+ * Returns a typed Supabase client that automatically handles Clerk authentication.
62
+ * All database operations will use RLS policies based on the current user.
63
+ *
64
+ * @throws Error if used outside SupabaseProvider
65
+ *
66
+ * @example
67
+ * ```tsx
68
+ * function MyComponent() {
69
+ * const supabase = useSupabase();
70
+ *
71
+ * // Fetch user's own profile (RLS enforced)
72
+ * const { data } = await supabase
73
+ * .from('profiles')
74
+ * .select('*')
75
+ * .single();
76
+ * }
77
+ * ```
78
+ */
79
+ export function useSupabase(): TypedSupabaseClient {
80
+ const context = useContext(SupabaseContext);
81
+
82
+ if (!context) {
83
+ throw new Error('useSupabase must be used within a SupabaseProvider');
84
+ }
85
+
86
+ return context;
87
+ }
@@ -1,7 +1,45 @@
1
+ // Media query and responsive hooks
1
2
  export { useMediaQuery, BREAKPOINTS } from './useMediaQuery';
2
- export { useThemeEffect } from './useThemeEffect';
3
+ export { useMobileContext } from '@/contexts/mobileContext';
3
4
  export { useTouchSizes } from './useTouchSizes';
5
+ export { useIOSViewportReset } from './useIOSViewportReset';
6
+
7
+ // Theme and UI hooks
8
+ export { useThemeEffect } from './useThemeEffect';
9
+ export { useDocumentTitle } from './useDocumentTitle';
10
+
11
+ // State and storage hooks
12
+ export { useLocalStorage } from './useLocalStorage';
13
+ export { useSyncedState } from './useSyncedState';
14
+ export { useSyncedFormData } from './useSyncedFormData';
15
+
16
+ // Utility hooks
17
+ export { useCopyFeedback } from './useCopyFeedback';
18
+ export { useDebouncedCallback } from './useDebouncedCallback';
19
+ export { useKeyboardShortcut } from './useKeyboardShortcuts';
20
+
21
+ // i18n hooks
4
22
  export { useLanguage } from './useLanguage';
23
+
24
+ // API hooks
5
25
  export { useExampleQuery } from './useExampleQuery';
26
+
27
+ // Form hooks
6
28
  export { useRegisterForm } from './useRegisterForm';
7
- export { useMobileContext } from '@/contexts/mobileContext';
29
+
30
+ // Supabase hooks
31
+ export {
32
+ // Generic query hook
33
+ useSupabaseQuery,
34
+ // Profile hooks (type-safe mutations)
35
+ useCurrentProfile,
36
+ useProfile,
37
+ useUpsertProfile,
38
+ useUpdateProfile,
39
+ useDeleteProfile,
40
+ // Types
41
+ type UseSupabaseQueryOptions,
42
+ } from './supabase';
43
+
44
+ // Supabase context
45
+ export { useSupabase } from '@/contexts/supabaseContext';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Supabase hooks exports.
3
+ *
4
+ * Note: Generic mutation hooks were removed in favor of table-specific hooks
5
+ * (like useProfiles) which provide better type safety with Supabase's complex types.
6
+ */
7
+
8
+ // Generic query hook
9
+ export { useSupabaseQuery, type UseSupabaseQueryOptions } from './useSupabaseQuery';
10
+
11
+ // Domain-specific hooks (type-safe mutations)
12
+ export { useCurrentProfile, useProfile, useUpsertProfile, useUpdateProfile, useDeleteProfile } from './useProfiles';