@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
@@ -0,0 +1,150 @@
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
+ createProfile,
9
+ setMockSupabaseData,
10
+ setMockSupabaseError,
11
+ resetSupabaseMocks,
12
+ } from '@/test';
13
+ import type { Profile } from '@/types/database';
14
+
15
+ import { useSupabaseQuery } from './useSupabaseQuery';
16
+
17
+ function createWrapper() {
18
+ const queryClient = createTestQueryClient();
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
21
+ };
22
+ }
23
+
24
+ describe('useSupabaseQuery', () => {
25
+ beforeEach(() => {
26
+ resetSupabaseMocks();
27
+ });
28
+
29
+ it('returns empty array when no data', async () => {
30
+ setMockSupabaseData([]);
31
+
32
+ const { result } = renderHook(
33
+ () =>
34
+ useSupabaseQuery<Profile>({
35
+ table: 'profiles',
36
+ queryKey: ['test'],
37
+ }),
38
+ { wrapper: createWrapper() },
39
+ );
40
+
41
+ await waitFor(() => {
42
+ expect(result.current.isSuccess).toBe(true);
43
+ });
44
+
45
+ expect(result.current.data).toEqual([]);
46
+ });
47
+
48
+ it('returns data when available', async () => {
49
+ const profile = createProfile({ id: 'user-1', full_name: 'Test User' });
50
+ setMockSupabaseData([profile]);
51
+
52
+ const { result } = renderHook(
53
+ () =>
54
+ useSupabaseQuery<Profile>({
55
+ table: 'profiles',
56
+ queryKey: ['test'],
57
+ }),
58
+ { wrapper: createWrapper() },
59
+ );
60
+
61
+ await waitFor(() => {
62
+ expect(result.current.isSuccess).toBe(true);
63
+ });
64
+
65
+ expect(result.current.data).toHaveLength(1);
66
+ expect(result.current.data?.[0].full_name).toBe('Test User');
67
+ });
68
+
69
+ it('returns error when query fails', async () => {
70
+ setMockSupabaseError({ message: 'Database error', code: 'DB_ERROR' });
71
+
72
+ const { result } = renderHook(
73
+ () =>
74
+ useSupabaseQuery<Profile>({
75
+ table: 'profiles',
76
+ queryKey: ['test-error'],
77
+ queryOptions: { retry: false },
78
+ }),
79
+ { wrapper: createWrapper() },
80
+ );
81
+
82
+ await waitFor(() => {
83
+ expect(result.current.isError).toBe(true);
84
+ });
85
+
86
+ expect(result.current.error?.message).toBe('Database error');
87
+ });
88
+
89
+ it('applies custom select', async () => {
90
+ const profile = createProfile({ id: 'user-1' });
91
+ setMockSupabaseData([profile]);
92
+
93
+ const { result } = renderHook(
94
+ () =>
95
+ useSupabaseQuery<Profile>({
96
+ table: 'profiles',
97
+ select: 'id, full_name',
98
+ queryKey: ['test-select'],
99
+ }),
100
+ { wrapper: createWrapper() },
101
+ );
102
+
103
+ await waitFor(() => {
104
+ expect(result.current.isSuccess).toBe(true);
105
+ });
106
+
107
+ expect(result.current.data).toBeDefined();
108
+ });
109
+
110
+ it('applies filter function', async () => {
111
+ const profile = createProfile({ id: 'user-1' });
112
+ setMockSupabaseData([profile]);
113
+
114
+ const { result } = renderHook(
115
+ () =>
116
+ useSupabaseQuery<Profile>({
117
+ table: 'profiles',
118
+ filter: (query) => query.eq('id', 'user-1'),
119
+ queryKey: ['test-filter'],
120
+ }),
121
+ { wrapper: createWrapper() },
122
+ );
123
+
124
+ await waitFor(() => {
125
+ expect(result.current.isSuccess).toBe(true);
126
+ });
127
+
128
+ expect(result.current.data).toHaveLength(1);
129
+ });
130
+
131
+ it('respects enabled option', async () => {
132
+ const profile = createProfile();
133
+ setMockSupabaseData([profile]);
134
+
135
+ const { result } = renderHook(
136
+ () =>
137
+ useSupabaseQuery<Profile>({
138
+ table: 'profiles',
139
+ queryKey: ['test-disabled'],
140
+ queryOptions: { enabled: false },
141
+ }),
142
+ { wrapper: createWrapper() },
143
+ );
144
+
145
+ // Query should not run when disabled
146
+ expect(result.current.isLoading).toBe(false);
147
+ expect(result.current.isFetching).toBe(false);
148
+ expect(result.current.data).toBeUndefined();
149
+ });
150
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Generic Supabase query hook with TanStack Query integration.
3
+ *
4
+ * Provides type-safe database queries with automatic Clerk authentication
5
+ * and RLS policy enforcement.
6
+ */
7
+
8
+ import { useQuery, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query';
9
+ import type { PostgrestError } from '@supabase/supabase-js';
10
+
11
+ import { useSupabase } from '@/contexts/supabaseContext';
12
+ import type { TableName } from '@/types/database';
13
+
14
+ /**
15
+ * Options for useSupabaseQuery hook.
16
+ */
17
+ export interface UseSupabaseQueryOptions<TData> {
18
+ /** The database table to query */
19
+ table: TableName;
20
+ /** Columns to select (defaults to '*') */
21
+ select?: string;
22
+ /** Optional filter function to apply conditions */
23
+ filter?: (query: ReturnType<ReturnType<typeof useSupabase>['from']>) => unknown;
24
+ /** Unique query key for caching (will be prefixed with ['supabase', table]) */
25
+ queryKey: string[];
26
+ /** Additional TanStack Query options */
27
+ queryOptions?: Omit<UseQueryOptions<TData[], PostgrestError>, 'queryKey' | 'queryFn'>;
28
+ }
29
+
30
+ /**
31
+ * Generic hook for Supabase SELECT queries with TanStack Query.
32
+ *
33
+ * Automatically applies Clerk authentication via the Supabase context,
34
+ * ensuring RLS policies are enforced based on the current user.
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * // Simple query - fetch all user's profiles
39
+ * const { data, isLoading } = useSupabaseQuery<Profile>({
40
+ * table: 'profiles',
41
+ * queryKey: ['all'],
42
+ * });
43
+ *
44
+ * // Query with filter
45
+ * const { data } = useSupabaseQuery<Profile>({
46
+ * table: 'profiles',
47
+ * select: 'id, full_name, avatar_url',
48
+ * filter: (query) => query.eq('id', userId),
49
+ * queryKey: ['single', userId],
50
+ * });
51
+ *
52
+ * // Query with custom options
53
+ * const { data } = useSupabaseQuery<Profile>({
54
+ * table: 'profiles',
55
+ * queryKey: ['current'],
56
+ * queryOptions: {
57
+ * staleTime: 1000 * 60 * 10, // 10 minutes
58
+ * enabled: !!userId,
59
+ * },
60
+ * });
61
+ * ```
62
+ */
63
+ export function useSupabaseQuery<TData>({
64
+ table,
65
+ select = '*',
66
+ filter,
67
+ queryKey,
68
+ queryOptions,
69
+ }: UseSupabaseQueryOptions<TData>): UseQueryResult<TData[], PostgrestError> {
70
+ const supabase = useSupabase();
71
+
72
+ return useQuery<TData[], PostgrestError>({
73
+ queryKey: ['supabase', table, ...queryKey],
74
+ queryFn: async () => {
75
+ let query = supabase.from(table).select(select);
76
+
77
+ if (filter) {
78
+ query = filter(query) as typeof query;
79
+ }
80
+
81
+ const { data, error } = await query;
82
+
83
+ if (error) {
84
+ throw error;
85
+ }
86
+
87
+ return (data ?? []) as TData[];
88
+ },
89
+ ...queryOptions,
90
+ });
91
+ }
@@ -1,18 +1,21 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { api, ApiClientError } from '@/lib/api';
4
+ import {
5
+ mockFetchError,
6
+ mockFetchNetworkError,
7
+ mockFetchNoContent,
8
+ mockFetchSuccess,
9
+ mockFetchUnknownError,
10
+ } from '@/test';
4
11
 
5
12
  describe('api client', () => {
6
- const mockFetch = vi.fn();
7
- const originalFetch = global.fetch;
8
-
9
13
  beforeEach(() => {
10
- global.fetch = mockFetch;
11
- vi.clearAllMocks();
14
+ vi.spyOn(global, 'fetch');
12
15
  });
13
16
 
14
17
  afterEach(() => {
15
- global.fetch = originalFetch;
18
+ vi.restoreAllMocks();
16
19
  });
17
20
 
18
21
  describe('HTTP methods', () => {
@@ -23,15 +26,11 @@ describe('api client', () => {
23
26
  { method: 'patch', httpMethod: 'PATCH' },
24
27
  { method: 'delete', httpMethod: 'DELETE' },
25
28
  ] as const)('$method makes $httpMethod request', async ({ method, httpMethod }) => {
26
- mockFetch.mockResolvedValueOnce({
27
- ok: true,
28
- status: 200,
29
- json: () => Promise.resolve({ id: 1 }),
30
- });
29
+ mockFetchSuccess({ id: 1 });
31
30
 
32
31
  await api[method]('/test');
33
32
 
34
- expect(mockFetch).toHaveBeenCalledWith(
33
+ expect(global.fetch).toHaveBeenCalledWith(
35
34
  expect.stringContaining('/test'),
36
35
  expect.objectContaining({ method: httpMethod }),
37
36
  );
@@ -39,30 +38,22 @@ describe('api client', () => {
39
38
 
40
39
  it('sends request body for POST/PUT/PATCH', async () => {
41
40
  const body = { name: 'Test' };
42
- mockFetch.mockResolvedValueOnce({
43
- ok: true,
44
- status: 200,
45
- json: () => Promise.resolve({ id: 1 }),
46
- });
41
+ mockFetchSuccess({ id: 1 });
47
42
 
48
43
  await api.post('/test', body);
49
44
 
50
- expect(mockFetch).toHaveBeenCalledWith(
45
+ expect(global.fetch).toHaveBeenCalledWith(
51
46
  expect.anything(),
52
47
  expect.objectContaining({ body: JSON.stringify(body) }),
53
48
  );
54
49
  });
55
50
 
56
51
  it('handles full URL without prepending base URL', async () => {
57
- mockFetch.mockResolvedValueOnce({
58
- ok: true,
59
- status: 200,
60
- json: () => Promise.resolve({}),
61
- });
52
+ mockFetchSuccess({});
62
53
 
63
54
  await api.get('https://external.api/data');
64
55
 
65
- expect(mockFetch).toHaveBeenCalledWith('https://external.api/data', expect.anything());
56
+ expect(global.fetch).toHaveBeenCalledWith('https://external.api/data', expect.anything());
66
57
  });
67
58
  });
68
59
 
@@ -71,12 +62,7 @@ describe('api client', () => {
71
62
  { status: 404, message: 'Resource not found', hasJson: true },
72
63
  { status: 500, message: 'Internal Server Error', hasJson: false },
73
64
  ])('handles $status error response', async ({ status, message, hasJson }) => {
74
- mockFetch.mockResolvedValueOnce({
75
- ok: false,
76
- status,
77
- statusText: message,
78
- json: hasJson ? () => Promise.resolve({ message }) : () => Promise.reject(new Error('No JSON')),
79
- });
65
+ mockFetchError(status, message, hasJson);
80
66
 
81
67
  const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
82
68
 
@@ -87,7 +73,7 @@ describe('api client', () => {
87
73
 
88
74
  it('handles timeout with TIMEOUT code', async () => {
89
75
  vi.useFakeTimers();
90
- mockFetch.mockImplementationOnce(
76
+ vi.mocked(global.fetch).mockImplementationOnce(
91
77
  () =>
92
78
  new Promise((_, reject) => {
93
79
  const error = new Error('Aborted');
@@ -106,20 +92,26 @@ describe('api client', () => {
106
92
  vi.useRealTimers();
107
93
  });
108
94
 
109
- it.each([
110
- { rejection: new Error('Network failure'), code: 'NETWORK_ERROR' },
111
- { rejection: 'string error', code: 'UNKNOWN' },
112
- ])('handles $code errors', async ({ rejection, code }) => {
113
- mockFetch.mockRejectedValueOnce(rejection);
95
+ it('handles NETWORK_ERROR', async () => {
96
+ mockFetchNetworkError();
97
+
98
+ const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
99
+
100
+ expect(error).toBeInstanceOf(ApiClientError);
101
+ expect(error.code).toBe('NETWORK_ERROR');
102
+ });
103
+
104
+ it('handles UNKNOWN errors', async () => {
105
+ mockFetchUnknownError('string error');
114
106
 
115
107
  const error = (await api.get('/error').catch((e) => e)) as ApiClientError;
116
108
 
117
109
  expect(error).toBeInstanceOf(ApiClientError);
118
- expect(error.code).toBe(code);
110
+ expect(error.code).toBe('UNKNOWN');
119
111
  });
120
112
 
121
113
  it('returns undefined for 204 No Content', async () => {
122
- mockFetch.mockResolvedValueOnce({ ok: true, status: 204 });
114
+ mockFetchNoContent();
123
115
 
124
116
  const result = await api.delete('/test/1');
125
117
 
@@ -5,13 +5,7 @@
5
5
 
6
6
  import type { ApiError } from '@/types/api';
7
7
 
8
- /**
9
- * API configuration.
10
- */
11
- export const API_CONFIG = {
12
- baseUrl: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
13
- timeout: 30000,
14
- } as const;
8
+ import { API_CONFIG } from './config';
15
9
 
16
10
  /**
17
11
  * Custom API error class
@@ -1,15 +1,65 @@
1
1
  /**
2
2
  * Application configuration.
3
3
  * Centralized config for feature flags, etc.
4
+ *
5
+ * All environment variables flow through the validated `env` object from env.ts.
4
6
  */
5
7
 
8
+ import { env } from './env';
9
+
10
+ // =============================================================================
11
+ // App Configuration
12
+ // =============================================================================
13
+
6
14
  export const APP_CONFIG = {
7
- name: import.meta.env.VITE_APP_NAME || 'My App',
8
- url: import.meta.env.VITE_APP_URL || 'http://localhost:5173',
15
+ name: env.VITE_APP_NAME,
16
+ url: env.VITE_APP_URL,
17
+ } as const;
18
+
19
+ // =============================================================================
20
+ // API Configuration
21
+ // =============================================================================
22
+
23
+ export const API_CONFIG = {
24
+ baseUrl: env.VITE_API_URL,
25
+ timeout: 30000,
9
26
  } as const;
10
27
 
28
+ // =============================================================================
29
+ // Sentry Configuration
30
+ // =============================================================================
31
+
11
32
  export const SENTRY_CONFIG = {
12
- enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false',
13
- dsn: import.meta.env.VITE_SENTRY_DSN,
33
+ enabled: env.VITE_SENTRY_ENABLED,
34
+ dsn: env.VITE_SENTRY_DSN,
35
+ environment: env.MODE,
14
36
  tracesSampleRate: 0.1,
15
37
  } as const;
38
+
39
+ // =============================================================================
40
+ // Clerk Configuration
41
+ // =============================================================================
42
+
43
+ export const CLERK_CONFIG = {
44
+ publishableKey: env.VITE_CLERK_PUBLISHABLE_KEY,
45
+ } as const;
46
+
47
+ // =============================================================================
48
+ // Supabase Configuration
49
+ // =============================================================================
50
+
51
+ export const SUPABASE_CONFIG = {
52
+ url: env.VITE_SUPABASE_DATABASE_URL,
53
+ anonKey: env.VITE_SUPABASE_ANON_KEY,
54
+ /** Whether both URL and anon key are configured */
55
+ isConfigured: Boolean(env.VITE_SUPABASE_DATABASE_URL && env.VITE_SUPABASE_ANON_KEY),
56
+ } as const;
57
+
58
+ // =============================================================================
59
+ // Performance Configuration
60
+ // =============================================================================
61
+
62
+ export const PERFORMANCE_CONFIG = {
63
+ /** Enable performance tracking in dev or when VITE_PERF_TEST is set */
64
+ enabled: env.DEV || env.VITE_PERF_TEST,
65
+ } as const;
@@ -1,25 +1,40 @@
1
1
  /**
2
2
  * Environment variable validation using Zod.
3
3
  * Validates at runtime to catch missing/invalid env vars early.
4
+ *
5
+ * All env vars are REQUIRED. The MCP scaffold tool strips unused vars
6
+ * when scaffolding builds without certain features.
4
7
  */
5
8
 
6
9
  import { z } from 'zod';
7
10
 
11
+ /**
12
+ * Transforms string env var to boolean.
13
+ * - 'true', '1' → true
14
+ * - 'false', '0' → false
15
+ */
16
+ const booleanEnv = z.enum(['true', 'false', '1', '0']).transform((val) => val === 'true' || val === '1');
17
+
8
18
  const envSchema = z.object({
9
- VITE_APP_NAME: z.string().min(1).optional(),
10
- VITE_APP_URL: z.string().url().optional(),
11
- VITE_API_URL: z.string().url().optional(),
12
- VITE_SENTRY_DSN: z.string().url().optional(),
13
- MODE: z.enum(['development', 'production', 'test']).default('development'),
14
- DEV: z.boolean().default(false),
15
- PROD: z.boolean().default(false),
19
+ VITE_APP_NAME: z.string().min(1),
20
+ VITE_APP_URL: z.string().url(),
21
+ VITE_API_URL: z.string().url(),
22
+ VITE_SENTRY_DSN: z.string().url(),
23
+ VITE_SENTRY_ENABLED: booleanEnv,
24
+ VITE_CLERK_PUBLISHABLE_KEY: z.string().min(1),
25
+ VITE_SUPABASE_DATABASE_URL: z.string().url(),
26
+ VITE_SUPABASE_ANON_KEY: z.string().min(1),
27
+ VITE_PERF_TEST: booleanEnv,
28
+ MODE: z.enum(['development', 'production', 'test']),
29
+ DEV: z.boolean(),
30
+ PROD: z.boolean(),
16
31
  });
17
32
 
18
33
  export type Env = z.infer<typeof envSchema>;
19
34
 
20
35
  /**
21
36
  * Validate environment variables and return typed env object.
22
- * Throws if validation fails in production.
37
+ * Throws if any required env var is missing or invalid.
23
38
  */
24
39
  export function validateEnv(): Env {
25
40
  const env = {
@@ -27,6 +42,11 @@ export function validateEnv(): Env {
27
42
  VITE_APP_URL: import.meta.env.VITE_APP_URL,
28
43
  VITE_API_URL: import.meta.env.VITE_API_URL,
29
44
  VITE_SENTRY_DSN: import.meta.env.VITE_SENTRY_DSN,
45
+ VITE_SENTRY_ENABLED: import.meta.env.VITE_SENTRY_ENABLED,
46
+ VITE_CLERK_PUBLISHABLE_KEY: import.meta.env.VITE_CLERK_PUBLISHABLE_KEY,
47
+ VITE_SUPABASE_DATABASE_URL: import.meta.env.VITE_SUPABASE_DATABASE_URL,
48
+ VITE_SUPABASE_ANON_KEY: import.meta.env.VITE_SUPABASE_ANON_KEY,
49
+ VITE_PERF_TEST: import.meta.env.VITE_PERF_TEST,
30
50
  MODE: import.meta.env.MODE,
31
51
  DEV: import.meta.env.DEV,
32
52
  PROD: import.meta.env.PROD,
@@ -35,15 +55,17 @@ export function validateEnv(): Env {
35
55
  const result = envSchema.safeParse(env);
36
56
 
37
57
  if (!result.success) {
38
- const errors = result.error.format();
39
- console.error('Environment validation failed:', errors);
58
+ const errors = result.error.flatten();
59
+ const fieldErrors = Object.entries(errors.fieldErrors)
60
+ .map(([key, msgs]) => `${key}: ${(msgs as string[]).join(', ')}`)
61
+ .join('; ');
62
+ const formErrors = errors.formErrors.join('; ');
63
+ const allErrors = [fieldErrors, formErrors].filter(Boolean).join('; ');
40
64
 
41
- if (import.meta.env.PROD) {
42
- throw new Error('Invalid environment configuration');
43
- }
65
+ throw new Error(`Environment validation failed: ${allErrors}`);
44
66
  }
45
67
 
46
- return result.success ? result.data : (env as Env);
68
+ return result.data;
47
69
  }
48
70
 
49
71
  /**
@@ -5,11 +5,13 @@
5
5
 
6
6
  export { cn } from './utils';
7
7
  export { STORAGE_KEYS, isAppKey } from './storageKeys';
8
- export { APP_CONFIG, SENTRY_CONFIG } from './config';
9
- export { API_CONFIG } from './api';
8
+ export { APP_CONFIG, API_CONFIG, SENTRY_CONFIG, CLERK_CONFIG, SUPABASE_CONFIG, PERFORMANCE_CONFIG } from './config';
10
9
  export { ROUTES, type AppRoute } from './routes';
11
10
  export { env, validateEnv, type Env } from './env';
12
11
  export { api, ApiClientError } from './api';
13
12
  export { getStorageItem, setStorageItem, removeStorageItem, clearAppStorage } from './storage';
14
13
  export { registerFormSchema, type RegisterFormData } from './validations';
15
14
  export { createSelectors } from './createSelectors';
15
+
16
+ // Supabase
17
+ export { createSupabaseClient, type TypedSupabaseClient, type GetTokenFn } from './supabase';
@@ -5,6 +5,7 @@
5
5
 
6
6
  export const ROUTES = {
7
7
  HOME: '/',
8
+ PROFILE: '/profile',
8
9
  NOT_FOUND: '*',
9
10
  } as const;
10
11
 
@@ -1,29 +1,32 @@
1
1
  import type * as SentryType from '@sentry/react';
2
2
 
3
+ import { SENTRY_CONFIG } from './config';
4
+ import { env } from './env';
5
+
3
6
  let sentryInstance: typeof SentryType | null = null;
4
7
 
5
8
  /**
6
9
  * Initialize Sentry error tracking.
7
- * Only runs in production when VITE_SENTRY_DSN is configured.
10
+ * Only runs in production when enabled and VITE_SENTRY_DSN is configured.
8
11
  */
9
- export async function initSentry(): Promise<void> {
10
- // Skip in development or if already initialized
11
- if (import.meta.env.DEV || sentryInstance) return;
12
-
13
- const dsn = import.meta.env.VITE_SENTRY_DSN;
14
- if (!dsn) return;
12
+ export async function initSentry(): Promise<typeof SentryType | null> {
13
+ // Skip if disabled, in development, already initialized, or no DSN
14
+ if (!SENTRY_CONFIG.enabled || env.DEV || sentryInstance || !SENTRY_CONFIG.dsn) {
15
+ return sentryInstance;
16
+ }
15
17
 
16
18
  const sentry = await import('@sentry/react');
17
19
 
18
20
  sentry.init({
19
- dsn,
20
- environment: import.meta.env.MODE,
21
+ dsn: SENTRY_CONFIG.dsn,
22
+ environment: SENTRY_CONFIG.environment,
21
23
  sendDefaultPii: true,
22
24
  integrations: [sentry.browserTracingIntegration()],
23
- tracesSampleRate: 0.1,
25
+ tracesSampleRate: SENTRY_CONFIG.tracesSampleRate,
24
26
  });
25
27
 
26
28
  sentryInstance = sentry;
29
+ return sentryInstance;
27
30
  }
28
31
 
29
32
  /**
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Supabase client factory with Clerk authentication integration.
3
+ *
4
+ * Uses the modern `accessToken` pattern for third-party auth providers.
5
+ * The Clerk session token is automatically injected into Supabase requests.
6
+ *
7
+ * @see https://supabase.com/docs/guides/auth/third-party/clerk
8
+ */
9
+
10
+ import { createClient, type SupabaseClient } from '@supabase/supabase-js';
11
+
12
+ import type { Database } from '@/types/database';
13
+
14
+ import { SUPABASE_CONFIG } from '../config';
15
+
16
+ /**
17
+ * Typed Supabase client with database schema.
18
+ */
19
+ export type TypedSupabaseClient = SupabaseClient<Database>;
20
+
21
+ /**
22
+ * Token getter function type.
23
+ * Returns the Clerk session token or null if not authenticated.
24
+ */
25
+ export type GetTokenFn = () => Promise<string | null>;
26
+
27
+ /**
28
+ * Creates a Supabase client configured with Clerk authentication.
29
+ *
30
+ * Uses the `accessToken` configuration option which is the recommended
31
+ * approach for third-party auth providers like Clerk.
32
+ *
33
+ * @param getToken - Async function that returns the Clerk session token
34
+ * @returns Typed Supabase client
35
+ * @throws Error if Supabase environment variables are not configured
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const { session } = useSession();
40
+ * const supabase = createSupabaseClient(() => session?.getToken() ?? null);
41
+ *
42
+ * // Now use supabase with Clerk auth
43
+ * const { data } = await supabase.from('profiles').select();
44
+ * ```
45
+ */
46
+ export function createSupabaseClient(getToken: GetTokenFn): TypedSupabaseClient {
47
+ if (!SUPABASE_CONFIG.isConfigured) {
48
+ throw new Error(
49
+ 'Missing Supabase environment variables. ' +
50
+ 'Set VITE_SUPABASE_DATABASE_URL and VITE_SUPABASE_ANON_KEY in your .env file.',
51
+ );
52
+ }
53
+
54
+ // Type assertion safe here - isConfigured guarantees both are defined
55
+ return createClient<Database>(SUPABASE_CONFIG.url!, SUPABASE_CONFIG.anonKey!, {
56
+ accessToken: getToken,
57
+ });
58
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Supabase client exports.
3
+ */
4
+
5
+ export { createSupabaseClient, type TypedSupabaseClient, type GetTokenFn } from './client';