@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,8 @@
1
+ import { supabaseHandlers } from './supabase';
1
2
  import { todosHandlers } from './todos';
2
3
 
3
4
  /**
4
5
  * All MSW request handlers.
5
6
  * Add new feature handlers here as the application grows.
6
7
  */
7
- export const handlers = [...todosHandlers];
8
+ export const handlers = [...todosHandlers, ...supabaseHandlers];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * MSW handlers for Supabase PostgREST API.
3
+ *
4
+ * Minimal handlers for testing. Extend as needed.
5
+ * @see https://postgrest.org/en/stable/references/api.html
6
+ */
7
+
8
+ import { http, HttpResponse } from 'msw';
9
+
10
+ import { MOCK_SUPABASE_URL, MOCK_USER } from '../constants';
11
+ import { createProfile, mockProfiles } from '../fixtures/profiles';
12
+
13
+ export const supabaseHandlers = [
14
+ // GET /rest/v1/profiles
15
+ http.get(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, ({ request }) => {
16
+ const url = new URL(request.url);
17
+ const idFilter = url.searchParams.get('id');
18
+ const isSingle = request.headers.get('Accept')?.includes('vnd.pgrst.object');
19
+
20
+ let profiles = [...mockProfiles];
21
+
22
+ // Filter by ID if specified (eq.user_123 format)
23
+ if (idFilter?.startsWith('eq.')) {
24
+ const id = idFilter.replace('eq.', '');
25
+ profiles = profiles.filter((p) => p.id === id);
26
+ }
27
+
28
+ // Simulate RLS: only return current user's data
29
+ profiles = profiles.filter((p) => p.id === MOCK_USER.id);
30
+
31
+ if (isSingle) {
32
+ return profiles.length > 0
33
+ ? HttpResponse.json(profiles[0])
34
+ : HttpResponse.json({ message: 'No rows found', code: 'PGRST116' }, { status: 406 });
35
+ }
36
+
37
+ return HttpResponse.json(profiles);
38
+ }),
39
+
40
+ // POST /rest/v1/profiles
41
+ http.post(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
42
+ const body = await request.json();
43
+ const profile = createProfile(body as Record<string, unknown>);
44
+
45
+ return request.headers.get('Prefer')?.includes('return=representation')
46
+ ? HttpResponse.json(profile, { status: 201 })
47
+ : new HttpResponse(null, { status: 201 });
48
+ }),
49
+
50
+ // PATCH /rest/v1/profiles
51
+ http.patch(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, async ({ request }) => {
52
+ const body = await request.json();
53
+ const profile = { ...mockProfiles[0], ...(body as Record<string, unknown>), updated_at: new Date().toISOString() };
54
+
55
+ return request.headers.get('Prefer')?.includes('return=representation')
56
+ ? HttpResponse.json(profile)
57
+ : new HttpResponse(null, { status: 204 });
58
+ }),
59
+
60
+ // DELETE /rest/v1/profiles
61
+ http.delete(`${MOCK_SUPABASE_URL}/rest/v1/profiles`, () => {
62
+ return new HttpResponse(null, { status: 204 });
63
+ }),
64
+ ];
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { delay, http, HttpResponse } from 'msw';
6
6
 
7
- import { API_CONFIG } from '@/lib/api';
7
+ import { API_CONFIG } from '@/lib/config';
8
8
  import type { Todo } from '@/types/api';
9
9
 
10
10
  import { mockTodos } from '../fixtures/todos';
@@ -1,3 +1,9 @@
1
+ // Shared test constants
2
+ export * from './constants';
3
+
4
+ // MSW handlers and server
1
5
  export { handlers } from './handlers';
2
6
  export { server } from './node';
7
+
8
+ // Fixtures
3
9
  export * from './fixtures';
@@ -0,0 +1,263 @@
1
+ import { screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+
5
+ import { render, resetClerkMocks, createProfile } from '@/test';
6
+
7
+ import { ProfilePage } from './Profile';
8
+
9
+ // Mock the hooks
10
+ const mockMutate = vi.fn();
11
+ const mockRefetch = vi.fn();
12
+
13
+ // Default mock profile
14
+ const defaultProfile = createProfile({
15
+ full_name: 'Test User',
16
+ email: 'test@example.com',
17
+ avatar_url: 'https://example.com/avatar.jpg',
18
+ });
19
+
20
+ // Hook mock state
21
+ let mockProfileState: {
22
+ profile: ReturnType<typeof createProfile> | null;
23
+ isLoading: boolean;
24
+ error: Error | null;
25
+ exists: boolean;
26
+ isFetching: boolean;
27
+ refetch: typeof mockRefetch;
28
+ } = {
29
+ profile: defaultProfile,
30
+ isLoading: false,
31
+ error: null,
32
+ exists: true,
33
+ isFetching: false,
34
+ refetch: mockRefetch,
35
+ };
36
+
37
+ let mockUpdateState = {
38
+ mutate: mockMutate,
39
+ isPending: false,
40
+ error: null as Error | null,
41
+ };
42
+
43
+ vi.mock('@/hooks', async () => {
44
+ const actual = await vi.importActual('@/hooks');
45
+ return {
46
+ ...actual,
47
+ useProfile: vi.fn(() => mockProfileState),
48
+ useUpdateProfile: vi.fn(() => mockUpdateState),
49
+ };
50
+ });
51
+
52
+ describe('ProfilePage', () => {
53
+ beforeEach(() => {
54
+ resetClerkMocks();
55
+ mockMutate.mockClear();
56
+ mockRefetch.mockClear();
57
+
58
+ // Reset mock state to defaults
59
+ mockProfileState = {
60
+ profile: defaultProfile,
61
+ isLoading: false,
62
+ error: null,
63
+ exists: true,
64
+ isFetching: false,
65
+ refetch: mockRefetch,
66
+ };
67
+
68
+ mockUpdateState = {
69
+ mutate: mockMutate,
70
+ isPending: false,
71
+ error: null,
72
+ };
73
+ });
74
+
75
+ it('renders profile page with title and description', () => {
76
+ render(<ProfilePage />);
77
+ // Verify both card title and description are present
78
+ expect(screen.getByText('Your Profile')).toBeInTheDocument();
79
+ expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument();
80
+ });
81
+
82
+ it('shows loading skeleton when profile is loading', () => {
83
+ mockProfileState.isLoading = true;
84
+ mockProfileState.profile = null;
85
+
86
+ const { container } = render(<ProfilePage />);
87
+
88
+ // Should show skeleton elements
89
+ const skeletons = container.querySelectorAll('[class*="animate-pulse"]');
90
+ expect(skeletons.length).toBeGreaterThan(0);
91
+ });
92
+
93
+ it('displays profile data when loaded', () => {
94
+ render(<ProfilePage />);
95
+
96
+ // Name appears in header and name field - use getAllByText
97
+ expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
98
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
99
+ expect(screen.getByAltText(/profile avatar/i)).toHaveAttribute('src', 'https://example.com/avatar.jpg');
100
+ });
101
+
102
+ it('shows avatar fallback when no avatar_url', () => {
103
+ mockProfileState.profile = createProfile({
104
+ full_name: 'John Doe',
105
+ avatar_url: null,
106
+ });
107
+
108
+ render(<ProfilePage />);
109
+
110
+ // Should show first letter of name as fallback
111
+ expect(screen.getByText('J')).toBeInTheDocument();
112
+ });
113
+
114
+ it('shows email initial when no name or avatar', () => {
115
+ mockProfileState.profile = createProfile({
116
+ full_name: null,
117
+ email: 'user@example.com',
118
+ avatar_url: null,
119
+ });
120
+
121
+ render(<ProfilePage />);
122
+
123
+ // Should show first letter of email as fallback
124
+ expect(screen.getByText('U')).toBeInTheDocument();
125
+ });
126
+
127
+ it('shows error state and retry button on fetch failure', async () => {
128
+ const user = userEvent.setup();
129
+ mockProfileState.error = new Error('Network error');
130
+ mockProfileState.profile = null;
131
+
132
+ render(<ProfilePage />);
133
+
134
+ expect(screen.getByText(/failed to load profile/i)).toBeInTheDocument();
135
+ expect(screen.getByText(/network error/i)).toBeInTheDocument();
136
+
137
+ const retryButton = screen.getByRole('button', { name: /try again/i });
138
+ await user.click(retryButton);
139
+
140
+ expect(mockRefetch).toHaveBeenCalled();
141
+ });
142
+
143
+ it('allows editing name field', async () => {
144
+ const user = userEvent.setup();
145
+ render(<ProfilePage />);
146
+
147
+ // Click edit button
148
+ const editButton = screen.getByRole('button', { name: /edit/i });
149
+ await user.click(editButton);
150
+
151
+ // Should show input field
152
+ const input = screen.getByRole('textbox', { name: /full name/i });
153
+ expect(input).toBeInTheDocument();
154
+ expect(input).toHaveValue('Test User');
155
+ });
156
+
157
+ it('saves updated name via useUpdateProfile', async () => {
158
+ const user = userEvent.setup();
159
+
160
+ // Mock successful mutation
161
+ mockMutate.mockImplementation((_data, options) => {
162
+ options?.onSuccess?.();
163
+ });
164
+
165
+ render(<ProfilePage />);
166
+
167
+ // Click edit
168
+ await user.click(screen.getByRole('button', { name: /edit/i }));
169
+
170
+ // Change name
171
+ const input = screen.getByRole('textbox', { name: /full name/i });
172
+ await user.clear(input);
173
+ await user.type(input, 'New Name');
174
+
175
+ // Click save
176
+ await user.click(screen.getByRole('button', { name: /save/i }));
177
+
178
+ await waitFor(() => {
179
+ expect(mockMutate).toHaveBeenCalledWith({ full_name: 'New Name' }, expect.any(Object));
180
+ });
181
+ });
182
+
183
+ it('submits form on Enter key', async () => {
184
+ const user = userEvent.setup();
185
+
186
+ // Mock successful mutation
187
+ mockMutate.mockImplementation((_data, options) => {
188
+ options?.onSuccess?.();
189
+ });
190
+
191
+ render(<ProfilePage />);
192
+
193
+ // Enter edit mode
194
+ await user.click(screen.getByRole('button', { name: /edit/i }));
195
+
196
+ // Change name and press Enter
197
+ const input = screen.getByRole('textbox', { name: /full name/i });
198
+ await user.clear(input);
199
+ await user.type(input, 'Entered Name{Enter}');
200
+
201
+ await waitFor(() => {
202
+ expect(mockMutate).toHaveBeenCalledWith({ full_name: 'Entered Name' }, expect.any(Object));
203
+ });
204
+ });
205
+
206
+ it('shows saving state on button while updating', async () => {
207
+ const user = userEvent.setup();
208
+ mockUpdateState.isPending = true;
209
+
210
+ render(<ProfilePage />);
211
+
212
+ // Enter edit mode
213
+ await user.click(screen.getByRole('button', { name: /edit/i }));
214
+
215
+ // Should show saving state
216
+ expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
217
+ });
218
+
219
+ it('cancel button reverts changes', async () => {
220
+ const user = userEvent.setup();
221
+ render(<ProfilePage />);
222
+
223
+ // Click edit
224
+ await user.click(screen.getByRole('button', { name: /edit/i }));
225
+
226
+ // Change name
227
+ const input = screen.getByRole('textbox', { name: /full name/i });
228
+ await user.clear(input);
229
+ await user.type(input, 'Changed Name');
230
+
231
+ // Click cancel
232
+ await user.click(screen.getByRole('button', { name: /cancel/i }));
233
+
234
+ // Should exit edit mode and show original name (appears multiple times)
235
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
236
+ expect(screen.getAllByText('Test User').length).toBeGreaterThan(0);
237
+ });
238
+
239
+ it('shows error message when update fails', async () => {
240
+ const user = userEvent.setup();
241
+ mockUpdateState.error = new Error('Update failed');
242
+
243
+ render(<ProfilePage />);
244
+
245
+ // Enter edit mode
246
+ await user.click(screen.getByRole('button', { name: /edit/i }));
247
+
248
+ // Should show error message
249
+ expect(screen.getByText(/failed to update/i)).toBeInTheDocument();
250
+ expect(screen.getByText(/update failed/i)).toBeInTheDocument();
251
+ });
252
+
253
+ it('shows "Not set" when name is empty', () => {
254
+ mockProfileState.profile = createProfile({
255
+ full_name: null,
256
+ email: 'test@example.com',
257
+ });
258
+
259
+ render(<ProfilePage />);
260
+
261
+ expect(screen.getByText(/not set/i)).toBeInTheDocument();
262
+ });
263
+ });
@@ -0,0 +1,171 @@
1
+ import { Trans, useLingui } from '@lingui/react/macro';
2
+ import { useState } from 'react';
3
+
4
+ import { SEO } from '@/components/shared';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Label } from '@/components/ui/label';
9
+ import { Skeleton } from '@/components/ui/skeleton';
10
+ import { useProfile, useSyncedState, useUpdateProfile } from '@/hooks';
11
+
12
+ /**
13
+ * Profile page demonstrating Supabase database integration.
14
+ * Shows current user's profile with ability to edit the name.
15
+ */
16
+ export function ProfilePage() {
17
+ const { t } = useLingui();
18
+ const { profile, isLoading, error, refetch } = useProfile();
19
+ const updateProfile = useUpdateProfile();
20
+
21
+ const [isEditing, setIsEditing] = useState(false);
22
+ // Sync name with profile, blocking sync while editing
23
+ const [name, setName] = useSyncedState(profile?.full_name ?? '', isEditing);
24
+
25
+ // Extract error messages for i18n (avoids object property access in Trans)
26
+ const fetchErrorMessage = error?.message;
27
+ const updateErrorMessage = updateProfile.error?.message;
28
+
29
+ const handleEdit = () => {
30
+ setIsEditing(true);
31
+ };
32
+
33
+ const handleCancel = () => {
34
+ setName(profile?.full_name ?? '');
35
+ setIsEditing(false);
36
+ };
37
+
38
+ const handleSave = () => {
39
+ updateProfile.mutate(
40
+ { full_name: name },
41
+ {
42
+ onSuccess: () => {
43
+ setIsEditing(false);
44
+ },
45
+ },
46
+ );
47
+ };
48
+
49
+ const handleSubmit = (e: React.FormEvent) => {
50
+ e.preventDefault();
51
+ handleSave();
52
+ };
53
+
54
+ return (
55
+ <div className="container mx-auto max-w-lg px-4 py-8">
56
+ <SEO
57
+ title={t({ message: 'Profile', comment: 'Profile page title for SEO' })}
58
+ description={t({
59
+ message: 'Manage your profile information',
60
+ comment: 'Profile page meta description for SEO',
61
+ })}
62
+ />
63
+
64
+ <Card>
65
+ <CardHeader>
66
+ <CardTitle>
67
+ <Trans comment="Profile page card title">Your Profile</Trans>
68
+ </CardTitle>
69
+ <CardDescription>
70
+ <Trans comment="Profile page card description">Manage your profile information stored in Supabase</Trans>
71
+ </CardDescription>
72
+ </CardHeader>
73
+ <CardContent>
74
+ {isLoading && (
75
+ <div className="space-y-4">
76
+ <div className="flex items-center gap-4">
77
+ <Skeleton className="size-16 rounded-full" />
78
+ <div className="space-y-2">
79
+ <Skeleton className="h-4 w-32" />
80
+ <Skeleton className="h-4 w-48" />
81
+ </div>
82
+ </div>
83
+ <Skeleton className="h-10 w-full" />
84
+ </div>
85
+ )}
86
+
87
+ {error && (
88
+ <div className="space-y-4">
89
+ <p className="text-destructive text-sm" role="alert">
90
+ <Trans comment="Error message when profile fails to load">
91
+ Failed to load profile: {fetchErrorMessage}
92
+ </Trans>
93
+ </p>
94
+ <Button variant="outline" onClick={() => refetch()}>
95
+ <Trans comment="Retry button label">Try Again</Trans>
96
+ </Button>
97
+ </div>
98
+ )}
99
+
100
+ {profile && (
101
+ <div className="space-y-6">
102
+ {/* Avatar and Email */}
103
+ <div className="flex items-center gap-4">
104
+ {profile.avatar_url ? (
105
+ <img
106
+ src={profile.avatar_url}
107
+ alt={t({ message: 'Profile avatar', comment: 'Alt text for profile avatar image' })}
108
+ className="size-16 rounded-full object-cover"
109
+ />
110
+ ) : (
111
+ <div className="bg-muted flex size-16 items-center justify-center rounded-full">
112
+ <span className="text-muted-foreground text-xl">
113
+ {profile.full_name?.[0]?.toUpperCase() ?? profile.email[0].toUpperCase()}
114
+ </span>
115
+ </div>
116
+ )}
117
+ <div>
118
+ <p className="font-medium">{profile.full_name ?? profile.email}</p>
119
+ <p className="text-muted-foreground text-sm">{profile.email}</p>
120
+ </div>
121
+ </div>
122
+
123
+ {/* Name Editor */}
124
+ <div className="space-y-2">
125
+ <Label htmlFor="full-name">
126
+ <Trans comment="Full name field label">Full Name</Trans>
127
+ </Label>
128
+ {isEditing ? (
129
+ <form onSubmit={handleSubmit} className="space-y-2">
130
+ <Input
131
+ id="full-name"
132
+ value={name}
133
+ onChange={(e) => setName(e.target.value)}
134
+ placeholder={t({ message: 'Enter your name', comment: 'Name input placeholder' })}
135
+ />
136
+ <div className="flex gap-2">
137
+ <Button type="submit" disabled={updateProfile.isPending}>
138
+ {updateProfile.isPending ? (
139
+ <Trans comment="Save button loading state">Saving...</Trans>
140
+ ) : (
141
+ <Trans comment="Save button label">Save</Trans>
142
+ )}
143
+ </Button>
144
+ <Button type="button" variant="outline" onClick={handleCancel} disabled={updateProfile.isPending}>
145
+ <Trans comment="Cancel button label">Cancel</Trans>
146
+ </Button>
147
+ </div>
148
+ {updateProfile.error && (
149
+ <p className="text-destructive text-sm" role="alert">
150
+ <Trans comment="Error message when update fails">Failed to update: {updateErrorMessage}</Trans>
151
+ </p>
152
+ )}
153
+ </form>
154
+ ) : (
155
+ <div className="flex items-center gap-2">
156
+ <p className="text-muted-foreground">
157
+ {profile.full_name || <Trans comment="Placeholder when no name is set">Not set</Trans>}
158
+ </p>
159
+ <Button variant="ghost" size="sm" onClick={handleEdit}>
160
+ <Trans comment="Edit button label">Edit</Trans>
161
+ </Button>
162
+ </div>
163
+ )}
164
+ </div>
165
+ </div>
166
+ )}
167
+ </CardContent>
168
+ </Card>
169
+ </div>
170
+ );
171
+ }
@@ -1,2 +1,3 @@
1
1
  export { HomePage } from './Home';
2
2
  export { NotFoundPage } from './NotFound';
3
+ export { ProfilePage } from './Profile';
@@ -1,6 +1,8 @@
1
1
  import { create } from 'zustand';
2
2
  import { devtools, persist } from 'zustand/middleware';
3
3
 
4
+ import { createSelectors } from '@/lib/createSelectors';
5
+ import { env } from '@/lib/env';
4
6
  import { STORAGE_KEYS } from '@/lib/storageKeys';
5
7
 
6
8
  export type Theme = 'light' | 'dark' | 'system';
@@ -31,18 +33,25 @@ function getSystemTheme(): 'light' | 'dark' {
31
33
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
32
34
  }
33
35
 
34
- export const usePreferencesStore = create<PreferencesState>()(
36
+ /** Current schema version for preferences store persistence */
37
+ const PREFERENCES_STORE_VERSION = 1;
38
+
39
+ const usePreferencesStoreBase = create<PreferencesState>()(
35
40
  devtools(
36
41
  persist(
37
42
  (set, get) => ({
38
43
  ...initialState,
39
- setTheme: (theme) => set({ theme }),
44
+ setTheme: (theme) => set({ theme }, undefined, 'preferences/setTheme'),
40
45
  toggleTheme: () =>
41
- set((state) => {
42
- const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
43
- return { theme: resolved === 'light' ? 'dark' : 'light' };
44
- }),
45
- reset: () => set(initialState),
46
+ set(
47
+ (state) => {
48
+ const resolved = state.theme === 'system' ? getSystemTheme() : state.theme;
49
+ return { theme: resolved === 'light' ? 'dark' : 'light' };
50
+ },
51
+ undefined,
52
+ 'preferences/toggleTheme',
53
+ ),
54
+ reset: () => set(initialState, undefined, 'preferences/reset'),
46
55
  getResolvedTheme: () => {
47
56
  const { theme } = get();
48
57
  return theme === 'system' ? getSystemTheme() : theme;
@@ -50,13 +59,30 @@ export const usePreferencesStore = create<PreferencesState>()(
50
59
  }),
51
60
  {
52
61
  name: STORAGE_KEYS.preferences,
53
- partialize: (state) => ({ theme: state.theme }),
62
+ version: PREFERENCES_STORE_VERSION,
63
+ partialize: (state): Preferences => ({ theme: state.theme }),
64
+ migrate: (persisted, version) => {
65
+ const state = persisted as Preferences;
66
+ if (version === 0) {
67
+ // v0 → v1: No changes needed, establishes baseline for future migrations
68
+ return state;
69
+ }
70
+ return state;
71
+ },
72
+ onRehydrateStorage: () => (_state, error) => {
73
+ if (error) {
74
+ console.error('Failed to hydrate preferences store:', error);
75
+ }
76
+ },
54
77
  },
55
78
  ),
56
- { name: 'preferences' },
79
+ { name: 'preferences', enabled: env.DEV },
57
80
  ),
58
81
  );
59
82
 
83
+ /** Preferences store with auto-generated selectors */
84
+ export const usePreferencesStore = createSelectors(usePreferencesStoreBase);
85
+
60
86
  /**
61
87
  * Initialize multi-tab sync for preferences.
62
88
  * Call this once at app startup.