@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.
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -0
- package/dist/constants.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/index.d.ts +2 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +2 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +3 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +4 -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 +57 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +40 -12
- package/templates/.github/workflows/ci.yml +4 -1
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +177 -1
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +81 -4
- 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 +11 -0
- 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 +8 -0
- package/templates/playwright.config.ts +33 -3
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +11 -0
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +3 -3
- 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 +3 -3
- package/templates/src/components/shared/index.ts +1 -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 +17 -0
- 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/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/env.ts +36 -14
- package/templates/src/lib/index.ts +4 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +13 -10
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +17 -39
- 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 +2 -1
- package/templates/src/test/clerkMock.tsx +49 -9
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +49 -3
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +7 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +26 -0
- 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
|
@@ -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
|
|
11
|
-
vi.clearAllMocks();
|
|
14
|
+
vi.spyOn(global, 'fetch');
|
|
12
15
|
});
|
|
13
16
|
|
|
14
17
|
afterEach(() => {
|
|
15
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
110
|
+
expect(error.code).toBe('UNKNOWN');
|
|
119
111
|
});
|
|
120
112
|
|
|
121
113
|
it('returns undefined for 204 No Content', async () => {
|
|
122
|
-
|
|
114
|
+
mockFetchNoContent();
|
|
123
115
|
|
|
124
116
|
const result = await api.delete('/test/1');
|
|
125
117
|
|
package/templates/src/lib/api.ts
CHANGED
|
@@ -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:
|
|
8
|
-
url:
|
|
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:
|
|
13
|
-
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;
|
package/templates/src/lib/env.ts
CHANGED
|
@@ -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)
|
|
10
|
-
VITE_APP_URL: z.string().url()
|
|
11
|
-
VITE_API_URL: z.string().url()
|
|
12
|
-
VITE_SENTRY_DSN: z.string().url()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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.
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
throw new Error('Invalid environment configuration');
|
|
43
|
-
}
|
|
65
|
+
throw new Error(`Environment validation failed: ${allErrors}`);
|
|
44
66
|
}
|
|
45
67
|
|
|
46
|
-
return result.
|
|
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';
|
|
@@ -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<
|
|
10
|
-
// Skip in development or
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
21
|
+
dsn: SENTRY_CONFIG.dsn,
|
|
22
|
+
environment: SENTRY_CONFIG.environment,
|
|
21
23
|
sendDefaultPii: true,
|
|
22
24
|
integrations: [sentry.browserTracingIntegration()],
|
|
23
|
-
tracesSampleRate:
|
|
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
|
+
}
|