@react-spa-scaffold/mcp 2.2.0 → 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 (112) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +3 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/features/definitions/database.d.ts +3 -0
  6. package/dist/features/definitions/database.d.ts.map +1 -0
  7. package/dist/features/definitions/database.js +45 -0
  8. package/dist/features/definitions/database.js.map +1 -0
  9. package/dist/features/definitions/deployment.d.ts +3 -0
  10. package/dist/features/definitions/deployment.d.ts.map +1 -0
  11. package/dist/features/definitions/deployment.js +14 -0
  12. package/dist/features/definitions/deployment.js.map +1 -0
  13. package/dist/features/definitions/index.d.ts +2 -0
  14. package/dist/features/definitions/index.d.ts.map +1 -1
  15. package/dist/features/definitions/index.js +2 -0
  16. package/dist/features/definitions/index.js.map +1 -1
  17. package/dist/features/registry.d.ts.map +1 -1
  18. package/dist/features/registry.js +3 -1
  19. package/dist/features/registry.js.map +1 -1
  20. package/dist/features/types.test.js +4 -2
  21. package/dist/features/types.test.js.map +1 -1
  22. package/dist/resources/docs.d.ts.map +1 -1
  23. package/dist/resources/docs.js +5 -0
  24. package/dist/resources/docs.js.map +1 -1
  25. package/dist/tools/add-features.js +1 -1
  26. package/dist/tools/add-features.js.map +1 -1
  27. package/dist/utils/docs.d.ts.map +1 -1
  28. package/dist/utils/docs.js +2 -0
  29. package/dist/utils/docs.js.map +1 -1
  30. package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
  31. package/dist/utils/scaffold/claude-md/index.js +3 -1
  32. package/dist/utils/scaffold/claude-md/index.js.map +1 -1
  33. package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
  34. package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
  35. package/dist/utils/scaffold/claude-md/sections.js +132 -2
  36. package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
  37. package/dist/utils/scaffold/compute.js +1 -1
  38. package/dist/utils/scaffold/compute.js.map +1 -1
  39. package/dist/utils/scaffold/generators.d.ts +2 -2
  40. package/dist/utils/scaffold/generators.d.ts.map +1 -1
  41. package/dist/utils/scaffold/generators.js +57 -22
  42. package/dist/utils/scaffold/generators.js.map +1 -1
  43. package/package.json +1 -1
  44. package/templates/.env.example +40 -12
  45. package/templates/.github/workflows/ci.yml +4 -1
  46. package/templates/.github/workflows/deploy.yml +59 -0
  47. package/templates/CLAUDE.md +177 -1
  48. package/templates/docs/AUTHENTICATION.md +325 -0
  49. package/templates/docs/DEPLOYMENT.md +268 -0
  50. package/templates/docs/E2E_TESTING.md +81 -4
  51. package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
  52. package/templates/docs/TESTING.md +195 -77
  53. package/templates/e2e/auth/auth.setup.ts +60 -0
  54. package/templates/e2e/fixtures/index.ts +11 -0
  55. package/templates/e2e/tests/profile.auth.spec.ts +103 -0
  56. package/templates/e2e/tests/profile.spec.ts +64 -0
  57. package/templates/e2e/tests/register-form.spec.ts +38 -0
  58. package/templates/gitignore +5 -0
  59. package/templates/package.json +8 -0
  60. package/templates/playwright.config.ts +33 -3
  61. package/templates/src/App.tsx +32 -19
  62. package/templates/src/components/layout/Header.test.tsx +17 -1
  63. package/templates/src/components/layout/Header.tsx +11 -0
  64. package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
  65. package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
  66. package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
  67. package/templates/src/components/shared/ProfileSync/index.ts +1 -0
  68. package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +3 -3
  69. package/templates/src/components/shared/index.ts +1 -0
  70. package/templates/src/contexts/performanceContext.tsx +3 -3
  71. package/templates/src/contexts/supabaseContext.test.tsx +59 -0
  72. package/templates/src/contexts/supabaseContext.tsx +87 -0
  73. package/templates/src/hooks/index.ts +17 -0
  74. package/templates/src/hooks/supabase/index.ts +12 -0
  75. package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
  76. package/templates/src/hooks/supabase/useProfiles.ts +213 -0
  77. package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
  78. package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
  79. package/templates/src/lib/api.test.ts +30 -38
  80. package/templates/src/lib/api.ts +1 -7
  81. package/templates/src/lib/config.ts +54 -4
  82. package/templates/src/lib/env.ts +36 -14
  83. package/templates/src/lib/index.ts +4 -2
  84. package/templates/src/lib/routes.ts +1 -0
  85. package/templates/src/lib/sentry.ts +13 -10
  86. package/templates/src/lib/supabase/client.ts +58 -0
  87. package/templates/src/lib/supabase/index.ts +5 -0
  88. package/templates/src/main.tsx +17 -39
  89. package/templates/src/mocks/constants.ts +31 -0
  90. package/templates/src/mocks/fixtures/index.ts +3 -1
  91. package/templates/src/mocks/fixtures/profiles.ts +55 -0
  92. package/templates/src/mocks/fixtures/users.ts +91 -0
  93. package/templates/src/mocks/handlers/index.ts +2 -1
  94. package/templates/src/mocks/handlers/supabase.ts +64 -0
  95. package/templates/src/mocks/handlers/todos.ts +1 -1
  96. package/templates/src/mocks/index.ts +6 -0
  97. package/templates/src/pages/Profile.test.tsx +263 -0
  98. package/templates/src/pages/Profile.tsx +171 -0
  99. package/templates/src/pages/index.ts +1 -0
  100. package/templates/src/stores/preferencesStore.ts +2 -1
  101. package/templates/src/test/clerkMock.tsx +49 -9
  102. package/templates/src/test/fetchMock.ts +58 -0
  103. package/templates/src/test/index.ts +49 -3
  104. package/templates/src/test/mocks.ts +128 -1
  105. package/templates/src/test/providers.tsx +7 -4
  106. package/templates/src/test/supabaseMock.ts +112 -0
  107. package/templates/src/test-setup.ts +26 -0
  108. package/templates/src/types/database.ts +46 -0
  109. package/templates/src/types/index.ts +1 -0
  110. package/templates/src/types/supabase.ts +167 -0
  111. package/templates/src/vite-env.d.ts +6 -0
  112. package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
@@ -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
+ }
@@ -26,3 +26,20 @@ export { useExampleQuery } from './useExampleQuery';
26
26
 
27
27
  // Form hooks
28
28
  export { useRegisterForm } from './useRegisterForm';
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';
@@ -0,0 +1,207 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+
6
+ import {
7
+ createTestQueryClient,
8
+ setMockClerkSignedIn,
9
+ setMockClerkUser,
10
+ resetClerkMocks,
11
+ createProfile,
12
+ setMockSupabaseData,
13
+ resetSupabaseMocks,
14
+ } from '@/test';
15
+
16
+ import { useCurrentProfile, useProfile, useUpsertProfile, useUpdateProfile, useDeleteProfile } from './useProfiles';
17
+
18
+ function createWrapper() {
19
+ const queryClient = createTestQueryClient();
20
+ return function Wrapper({ children }: { children: ReactNode }) {
21
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
22
+ };
23
+ }
24
+
25
+ describe('useCurrentProfile', () => {
26
+ beforeEach(() => {
27
+ resetClerkMocks();
28
+ resetSupabaseMocks();
29
+ });
30
+
31
+ it('fetches profile when user is signed in', async () => {
32
+ const profile = createProfile({ id: 'user-123', full_name: 'Test User' });
33
+ setMockSupabaseData([profile]);
34
+ setMockClerkUser({ id: 'user-123' });
35
+
36
+ const { result } = renderHook(() => useCurrentProfile(), {
37
+ wrapper: createWrapper(),
38
+ });
39
+
40
+ await waitFor(() => {
41
+ expect(result.current.isSuccess).toBe(true);
42
+ });
43
+
44
+ expect(result.current.data).toHaveLength(1);
45
+ expect(result.current.data?.[0].full_name).toBe('Test User');
46
+ });
47
+
48
+ it('does not fetch when user is not signed in', async () => {
49
+ setMockClerkSignedIn(false);
50
+
51
+ const { result } = renderHook(() => useCurrentProfile(), {
52
+ wrapper: createWrapper(),
53
+ });
54
+
55
+ // Query should be disabled
56
+ expect(result.current.isLoading).toBe(false);
57
+ expect(result.current.data).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe('useProfile', () => {
62
+ beforeEach(() => {
63
+ resetClerkMocks();
64
+ resetSupabaseMocks();
65
+ });
66
+
67
+ it('returns profile object and exists flag', async () => {
68
+ const profile = createProfile({ id: 'user-123', full_name: 'Test User' });
69
+ setMockSupabaseData([profile]);
70
+ setMockClerkUser({ id: 'user-123' });
71
+
72
+ const { result } = renderHook(() => useProfile(), {
73
+ wrapper: createWrapper(),
74
+ });
75
+
76
+ await waitFor(() => {
77
+ expect(result.current.isLoading).toBe(false);
78
+ });
79
+
80
+ expect(result.current.profile?.full_name).toBe('Test User');
81
+ expect(result.current.exists).toBe(true);
82
+ });
83
+
84
+ it('returns null profile when not found', async () => {
85
+ setMockSupabaseData([]);
86
+ setMockClerkUser({ id: 'user-123' });
87
+
88
+ const { result } = renderHook(() => useProfile(), {
89
+ wrapper: createWrapper(),
90
+ });
91
+
92
+ await waitFor(() => {
93
+ expect(result.current.isLoading).toBe(false);
94
+ });
95
+
96
+ expect(result.current.profile).toBeNull();
97
+ expect(result.current.exists).toBe(false);
98
+ });
99
+
100
+ it('exposes isFetching and refetch', async () => {
101
+ const profile = createProfile({ id: 'user-123' });
102
+ setMockSupabaseData([profile]);
103
+ setMockClerkUser({ id: 'user-123' });
104
+
105
+ const { result } = renderHook(() => useProfile(), {
106
+ wrapper: createWrapper(),
107
+ });
108
+
109
+ await waitFor(() => {
110
+ expect(result.current.isLoading).toBe(false);
111
+ });
112
+
113
+ expect(typeof result.current.isFetching).toBe('boolean');
114
+ expect(typeof result.current.refetch).toBe('function');
115
+ });
116
+ });
117
+
118
+ describe('useUpsertProfile', () => {
119
+ beforeEach(() => {
120
+ resetClerkMocks();
121
+ resetSupabaseMocks();
122
+ });
123
+
124
+ it('returns mutation function', () => {
125
+ const { result } = renderHook(() => useUpsertProfile(), {
126
+ wrapper: createWrapper(),
127
+ });
128
+
129
+ expect(typeof result.current.mutate).toBe('function');
130
+ expect(typeof result.current.mutateAsync).toBe('function');
131
+ expect(result.current.isIdle).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe('useUpdateProfile', () => {
136
+ beforeEach(() => {
137
+ resetClerkMocks();
138
+ resetSupabaseMocks();
139
+ });
140
+
141
+ it('returns mutation function', () => {
142
+ setMockClerkUser({ id: 'user-123' });
143
+
144
+ const { result } = renderHook(() => useUpdateProfile(), {
145
+ wrapper: createWrapper(),
146
+ });
147
+
148
+ expect(typeof result.current.mutate).toBe('function');
149
+ expect(typeof result.current.mutateAsync).toBe('function');
150
+ expect(result.current.isIdle).toBe(true);
151
+ });
152
+
153
+ it('throws when user is not authenticated', async () => {
154
+ setMockClerkSignedIn(false);
155
+
156
+ const { result } = renderHook(() => useUpdateProfile(), {
157
+ wrapper: createWrapper(),
158
+ });
159
+
160
+ let errorThrown = false;
161
+ try {
162
+ await result.current.mutateAsync({ full_name: 'Test' });
163
+ } catch (error) {
164
+ errorThrown = true;
165
+ expect((error as Error).message).toBe('No authenticated user');
166
+ }
167
+
168
+ expect(errorThrown).toBe(true);
169
+ });
170
+ });
171
+
172
+ describe('useDeleteProfile', () => {
173
+ beforeEach(() => {
174
+ resetClerkMocks();
175
+ resetSupabaseMocks();
176
+ });
177
+
178
+ it('returns mutation function', () => {
179
+ setMockClerkUser({ id: 'user-123' });
180
+
181
+ const { result } = renderHook(() => useDeleteProfile(), {
182
+ wrapper: createWrapper(),
183
+ });
184
+
185
+ expect(typeof result.current.mutate).toBe('function');
186
+ expect(typeof result.current.mutateAsync).toBe('function');
187
+ expect(result.current.isIdle).toBe(true);
188
+ });
189
+
190
+ it('throws when user is not authenticated', async () => {
191
+ setMockClerkSignedIn(false);
192
+
193
+ const { result } = renderHook(() => useDeleteProfile(), {
194
+ wrapper: createWrapper(),
195
+ });
196
+
197
+ let errorThrown = false;
198
+ try {
199
+ await result.current.mutateAsync();
200
+ } catch (error) {
201
+ errorThrown = true;
202
+ expect((error as Error).message).toBe('No authenticated user');
203
+ }
204
+
205
+ expect(errorThrown).toBe(true);
206
+ });
207
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * User profile hooks for Supabase with Clerk authentication.
3
+ *
4
+ * All operations are automatically scoped to the current user via RLS policies.
5
+ * The profile `id` field corresponds to the Clerk user_id (`auth.uid()`).
6
+ */
7
+
8
+ import { useUser } from '@clerk/react-router';
9
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
10
+ import type { PostgrestError } from '@supabase/supabase-js';
11
+
12
+ import { useSupabase } from '@/contexts/supabaseContext';
13
+ import type { Profile, ProfileInsert, ProfileUpdate } from '@/types/database';
14
+
15
+ import { useSupabaseQuery } from './useSupabaseQuery';
16
+
17
+ /**
18
+ * Hook to fetch the current user's profile (returns array).
19
+ *
20
+ * Uses Clerk's user ID to query the profile and automatically
21
+ * respects RLS policies. For convenience, consider using `useProfile()`
22
+ * which returns a single profile instead of an array.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * function ProfilePage() {
27
+ * const { data: profiles, isLoading, error } = useCurrentProfile();
28
+ * const profile = profiles?.[0];
29
+ *
30
+ * if (isLoading) return <Spinner />;
31
+ * if (error) return <Error message={error.message} />;
32
+ * if (!profile) return <CreateProfileForm />;
33
+ *
34
+ * return <ProfileDisplay profile={profile} />;
35
+ * }
36
+ * ```
37
+ */
38
+ export function useCurrentProfile() {
39
+ const { user, isLoaded } = useUser();
40
+ const userId = user?.id;
41
+
42
+ return useSupabaseQuery<Profile>({
43
+ table: 'profiles',
44
+ filter: (query) => query.eq('id', userId ?? ''),
45
+ queryKey: ['current', userId ?? ''],
46
+ queryOptions: {
47
+ enabled: isLoaded && !!userId,
48
+ },
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Convenience hook to fetch the current user's profile as a single object.
54
+ *
55
+ * This is a wrapper around `useCurrentProfile()` that extracts the first
56
+ * profile from the array and provides a cleaner API for common use cases.
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * function ProfilePage() {
61
+ * const { profile, isLoading, error, exists } = useProfile();
62
+ *
63
+ * if (isLoading) return <Spinner />;
64
+ * if (error) return <Error message={error.message} />;
65
+ * if (!exists) return <CreateProfileForm />;
66
+ *
67
+ * return <ProfileDisplay profile={profile} />;
68
+ * }
69
+ * ```
70
+ */
71
+ export function useProfile() {
72
+ const query = useCurrentProfile();
73
+ const profile = query.data?.[0] ?? null;
74
+
75
+ return {
76
+ /** The user's profile, or null if not found */
77
+ profile,
78
+ /** Whether a profile exists for the current user */
79
+ exists: profile !== null,
80
+ /** Whether the query is currently loading */
81
+ isLoading: query.isLoading,
82
+ /** Whether the query is fetching (includes background refetches) */
83
+ isFetching: query.isFetching,
84
+ /** Error from the query, if any */
85
+ error: query.error,
86
+ /** Refetch the profile data */
87
+ refetch: query.refetch,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Hook to create or update the current user's profile.
93
+ *
94
+ * Uses upsert to handle both creation and updates in a single operation.
95
+ * This is useful for syncing Clerk user data to Supabase on first login.
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * function ProfileSync() {
100
+ * const { user } = useUser();
101
+ * const upsertProfile = useUpsertProfile();
102
+ *
103
+ * useEffect(() => {
104
+ * if (user) {
105
+ * upsertProfile.mutate({
106
+ * id: user.id,
107
+ * email: user.primaryEmailAddress?.emailAddress ?? '',
108
+ * full_name: user.fullName,
109
+ * avatar_url: user.imageUrl,
110
+ * });
111
+ * }
112
+ * }, [user]);
113
+ * }
114
+ * ```
115
+ */
116
+ export function useUpsertProfile() {
117
+ const supabase = useSupabase();
118
+ const queryClient = useQueryClient();
119
+
120
+ return useMutation<Profile, PostgrestError, ProfileInsert>({
121
+ mutationFn: async (profile) => {
122
+ const { data, error } = await supabase.from('profiles').upsert(profile, { onConflict: 'id' }).select().single();
123
+
124
+ if (error) throw error;
125
+ return data as Profile;
126
+ },
127
+ onSuccess: () => {
128
+ // Invalidate profile queries to refetch fresh data
129
+ queryClient.invalidateQueries({ queryKey: ['supabase', 'profiles'] });
130
+ },
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Hook to update the current user's profile.
136
+ *
137
+ * Only updates the specified fields, leaving others unchanged.
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * function EditProfileForm() {
142
+ * const updateProfile = useUpdateProfile();
143
+ *
144
+ * const handleSubmit = (values: ProfileUpdate) => {
145
+ * updateProfile.mutate(values, {
146
+ * onSuccess: () => toast.success('Profile updated!'),
147
+ * onError: (error) => toast.error(error.message),
148
+ * });
149
+ * };
150
+ * }
151
+ * ```
152
+ */
153
+ export function useUpdateProfile() {
154
+ const { user } = useUser();
155
+ const supabase = useSupabase();
156
+ const queryClient = useQueryClient();
157
+
158
+ return useMutation<Profile, PostgrestError, ProfileUpdate>({
159
+ mutationFn: async (updates) => {
160
+ if (!user?.id) {
161
+ throw new Error('No authenticated user');
162
+ }
163
+
164
+ const { data, error } = await supabase.from('profiles').update(updates).eq('id', user.id).select().single();
165
+
166
+ if (error) throw error;
167
+ return data as Profile;
168
+ },
169
+ onSuccess: () => {
170
+ queryClient.invalidateQueries({ queryKey: ['supabase', 'profiles'] });
171
+ },
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Hook to delete the current user's profile.
177
+ *
178
+ * Use with caution - this permanently removes the user's profile data.
179
+ *
180
+ * @example
181
+ * ```tsx
182
+ * function DeleteAccountButton() {
183
+ * const deleteProfile = useDeleteProfile();
184
+ *
185
+ * const handleDelete = async () => {
186
+ * if (confirm('Are you sure? This cannot be undone.')) {
187
+ * await deleteProfile.mutateAsync();
188
+ * // User should be signed out after deletion
189
+ * }
190
+ * };
191
+ * }
192
+ * ```
193
+ */
194
+ export function useDeleteProfile() {
195
+ const { user } = useUser();
196
+ const supabase = useSupabase();
197
+ const queryClient = useQueryClient();
198
+
199
+ return useMutation<void, PostgrestError, void>({
200
+ mutationFn: async () => {
201
+ if (!user?.id) {
202
+ throw new Error('No authenticated user');
203
+ }
204
+
205
+ const { error } = await supabase.from('profiles').delete().eq('id', user.id);
206
+
207
+ if (error) throw error;
208
+ },
209
+ onSuccess: () => {
210
+ queryClient.removeQueries({ queryKey: ['supabase', 'profiles'] });
211
+ },
212
+ });
213
+ }