@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.
- package/README.md +2 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -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/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- 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 +3 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +3 -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 +4 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +6 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +3 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +132 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.js +1 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +2 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +64 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +44 -10
- package/templates/.github/workflows/ci.yml +12 -4
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +251 -2
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +133 -11
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +24 -2
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +15 -3
- package/templates/playwright.config.ts +39 -4
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +13 -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/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- 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 +5 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +40 -2
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- 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/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- 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/env.ts +36 -14
- package/templates/src/lib/index.ts +5 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +58 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +19 -31
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/stores/preferencesStore.ts +35 -9
- package/templates/src/test/clerkMock.tsx +137 -0
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +51 -2
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +10 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +42 -2
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
- 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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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';
|