@croacroa/react-native-template 1.0.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/.env.example +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# ADR-004: Auth Adapter Pattern for Authentication
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Date
|
|
8
|
+
|
|
9
|
+
2024-01-15
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
The template needs to support multiple authentication providers (Supabase, Firebase, Auth0, custom backends) without requiring significant code changes. We need:
|
|
14
|
+
|
|
15
|
+
1. Easy switching between auth providers
|
|
16
|
+
2. Consistent API regardless of provider
|
|
17
|
+
3. Type safety
|
|
18
|
+
4. Testability with mock implementations
|
|
19
|
+
|
|
20
|
+
## Decision
|
|
21
|
+
|
|
22
|
+
Implement an **Adapter Pattern** for authentication that abstracts the auth provider behind a common interface.
|
|
23
|
+
|
|
24
|
+
## Rationale
|
|
25
|
+
|
|
26
|
+
1. **Flexibility**: Change auth providers without touching app code
|
|
27
|
+
2. **Testing**: Easy to mock for unit tests
|
|
28
|
+
3. **Consistency**: Same API for all providers
|
|
29
|
+
4. **Gradual Migration**: Can switch providers incrementally
|
|
30
|
+
|
|
31
|
+
## Implementation
|
|
32
|
+
|
|
33
|
+
### Interface Definition
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// services/authAdapter.ts
|
|
37
|
+
export interface AuthAdapter {
|
|
38
|
+
signIn(email: string, password: string): Promise<AuthResult>;
|
|
39
|
+
signUp(email: string, password: string, name: string): Promise<AuthResult>;
|
|
40
|
+
signOut(): Promise<void>;
|
|
41
|
+
refreshToken(refreshToken: string): Promise<AuthTokens>;
|
|
42
|
+
forgotPassword(email: string): Promise<void>;
|
|
43
|
+
resetPassword(token: string, newPassword: string): Promise<void>;
|
|
44
|
+
getSession(): Promise<AuthResult | null>;
|
|
45
|
+
onAuthStateChange?(callback: (user: User | null) => void): () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AuthResult {
|
|
49
|
+
user: User;
|
|
50
|
+
tokens: AuthTokens;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Mock Implementation
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
export const mockAuthAdapter: AuthAdapter = {
|
|
58
|
+
async signIn(email, password) {
|
|
59
|
+
await delay(1000); // Simulate network
|
|
60
|
+
return {
|
|
61
|
+
user: { id: '1', email, name: email.split('@')[0] },
|
|
62
|
+
tokens: { accessToken: 'mock', refreshToken: 'mock', expiresAt: ... },
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
// ... other methods
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Supabase Implementation
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
export const supabaseAuthAdapter: AuthAdapter = {
|
|
73
|
+
async signIn(email, password) {
|
|
74
|
+
const { data, error } = await supabase.auth.signInWithPassword({
|
|
75
|
+
email,
|
|
76
|
+
password,
|
|
77
|
+
});
|
|
78
|
+
if (error) throw error;
|
|
79
|
+
return transformSupabaseSession(data);
|
|
80
|
+
},
|
|
81
|
+
// ... other methods
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Usage
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// services/authAdapter.ts
|
|
89
|
+
// Change this line to switch providers
|
|
90
|
+
export const authAdapter: AuthAdapter = mockAuthAdapter;
|
|
91
|
+
// export const authAdapter: AuthAdapter = supabaseAuthAdapter;
|
|
92
|
+
// export const authAdapter: AuthAdapter = firebaseAuthAdapter;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Consequences
|
|
96
|
+
|
|
97
|
+
### Positive
|
|
98
|
+
|
|
99
|
+
- Provider-agnostic code
|
|
100
|
+
- Easy to test with mocks
|
|
101
|
+
- Clear contract for auth operations
|
|
102
|
+
- Can support multiple providers simultaneously
|
|
103
|
+
|
|
104
|
+
### Negative
|
|
105
|
+
|
|
106
|
+
- Some provider-specific features may not fit the interface
|
|
107
|
+
- Additional abstraction layer
|
|
108
|
+
- Need to maintain multiple implementations
|
|
109
|
+
|
|
110
|
+
### Mitigation
|
|
111
|
+
|
|
112
|
+
- Allow optional methods in interface
|
|
113
|
+
- Document provider-specific extensions
|
|
114
|
+
- Keep interface focused on common operations
|
|
115
|
+
|
|
116
|
+
## Testing
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// __tests__/auth.test.ts
|
|
120
|
+
import { mockAuthAdapter } from "@/services/authAdapter";
|
|
121
|
+
|
|
122
|
+
describe("Auth", () => {
|
|
123
|
+
it("signs in successfully", async () => {
|
|
124
|
+
const result = await mockAuthAdapter.signIn("test@example.com", "password");
|
|
125
|
+
expect(result.user.email).toBe("test@example.com");
|
|
126
|
+
expect(result.tokens.accessToken).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Migration Guide
|
|
132
|
+
|
|
133
|
+
To switch from mock to Supabase:
|
|
134
|
+
|
|
135
|
+
1. Install Supabase: `npx expo install @supabase/supabase-js`
|
|
136
|
+
2. Configure environment variables
|
|
137
|
+
3. Implement `supabaseAuthAdapter`
|
|
138
|
+
4. Update export in `authAdapter.ts`
|
|
139
|
+
|
|
140
|
+
## References
|
|
141
|
+
|
|
142
|
+
- [Adapter Pattern](https://refactoring.guru/design-patterns/adapter)
|
|
143
|
+
- [Supabase Auth](https://supabase.com/docs/guides/auth)
|
|
144
|
+
- [Firebase Auth](https://firebase.google.com/docs/auth)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Architecture Decision Records (ADRs)
|
|
2
|
+
|
|
3
|
+
This directory contains Architecture Decision Records (ADRs) documenting significant technical decisions made in this project.
|
|
4
|
+
|
|
5
|
+
## What is an ADR?
|
|
6
|
+
|
|
7
|
+
An ADR is a document that captures an important architectural decision made along with its context and consequences.
|
|
8
|
+
|
|
9
|
+
## ADR Template
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
# ADR-XXX: Title
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
[Proposed | Accepted | Deprecated | Superseded by ADR-XXX]
|
|
17
|
+
|
|
18
|
+
## Date
|
|
19
|
+
|
|
20
|
+
YYYY-MM-DD
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
[What is the issue that we're seeing that is motivating this decision?]
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
[What is the change that we're proposing and/or doing?]
|
|
29
|
+
|
|
30
|
+
## Consequences
|
|
31
|
+
|
|
32
|
+
### Positive
|
|
33
|
+
|
|
34
|
+
[What are the benefits?]
|
|
35
|
+
|
|
36
|
+
### Negative
|
|
37
|
+
|
|
38
|
+
[What are the drawbacks?]
|
|
39
|
+
|
|
40
|
+
### Mitigation
|
|
41
|
+
|
|
42
|
+
[How do we address the drawbacks?]
|
|
43
|
+
|
|
44
|
+
## References
|
|
45
|
+
|
|
46
|
+
[Links to relevant documentation, articles, or discussions]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Index
|
|
50
|
+
|
|
51
|
+
| ADR | Title | Status | Date |
|
|
52
|
+
| ------------------------------------ | --------------------------------- | -------- | ---------- |
|
|
53
|
+
| [001](./001-state-management.md) | Use Zustand for State Management | Accepted | 2024-01-01 |
|
|
54
|
+
| [002](./002-styling-approach.md) | Use NativeWind for Styling | Accepted | 2024-01-01 |
|
|
55
|
+
| [003](./003-data-fetching.md) | Use React Query for Data Fetching | Accepted | 2024-01-01 |
|
|
56
|
+
| [004](./004-auth-adapter-pattern.md) | Auth Adapter Pattern | Accepted | 2024-01-15 |
|
|
57
|
+
|
|
58
|
+
## When to Write an ADR
|
|
59
|
+
|
|
60
|
+
Write an ADR when:
|
|
61
|
+
|
|
62
|
+
1. Making a significant architectural decision
|
|
63
|
+
2. Choosing between multiple valid options
|
|
64
|
+
3. The decision will be hard to change later
|
|
65
|
+
4. Team members need to understand "why"
|
|
66
|
+
|
|
67
|
+
## How to Create a New ADR
|
|
68
|
+
|
|
69
|
+
1. Copy the template above
|
|
70
|
+
2. Number it sequentially (e.g., `005-your-decision.md`)
|
|
71
|
+
3. Fill in all sections
|
|
72
|
+
4. Add it to the index in this README
|
|
73
|
+
5. Submit for review with your PR
|
|
74
|
+
|
|
75
|
+
## Resources
|
|
76
|
+
|
|
77
|
+
- [ADR GitHub Organization](https://adr.github.io/)
|
|
78
|
+
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
|
package/eas.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cli": {
|
|
3
|
+
"version": ">= 10.0.0"
|
|
4
|
+
},
|
|
5
|
+
"build": {
|
|
6
|
+
"development": {
|
|
7
|
+
"developmentClient": true,
|
|
8
|
+
"distribution": "internal",
|
|
9
|
+
"env": {
|
|
10
|
+
"APP_VARIANT": "development"
|
|
11
|
+
},
|
|
12
|
+
"ios": {
|
|
13
|
+
"simulator": true
|
|
14
|
+
},
|
|
15
|
+
"android": {
|
|
16
|
+
"buildType": "apk"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"preview": {
|
|
20
|
+
"distribution": "internal",
|
|
21
|
+
"env": {
|
|
22
|
+
"APP_VARIANT": "preview"
|
|
23
|
+
},
|
|
24
|
+
"android": {
|
|
25
|
+
"buildType": "apk"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"production": {
|
|
29
|
+
"autoIncrement": true,
|
|
30
|
+
"env": {
|
|
31
|
+
"APP_VARIANT": "production"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"submit": {
|
|
36
|
+
"production": {
|
|
37
|
+
"ios": {
|
|
38
|
+
"appleId": "your-apple-id@email.com",
|
|
39
|
+
"ascAppId": "your-app-store-connect-app-id"
|
|
40
|
+
},
|
|
41
|
+
"android": {
|
|
42
|
+
"serviceAccountKeyPath": "./google-services.json",
|
|
43
|
+
"track": "internal"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/global.css
ADDED
package/hooks/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { useAuth, AuthProvider, getAuthToken } from "./useAuth";
|
|
2
|
+
export { useTheme, ThemeProvider } from "./useTheme";
|
|
3
|
+
export { useNotifications } from "./useNotifications";
|
|
4
|
+
export {
|
|
5
|
+
useCurrentUser,
|
|
6
|
+
useUser,
|
|
7
|
+
useUpdateUser,
|
|
8
|
+
queryKeys,
|
|
9
|
+
createCrudHooks,
|
|
10
|
+
postsApi,
|
|
11
|
+
} from "./useApi";
|
|
12
|
+
export {
|
|
13
|
+
useDeepLinking,
|
|
14
|
+
createDeepLink,
|
|
15
|
+
getDeepLinkPrefix,
|
|
16
|
+
} from "./useDeepLinking";
|
|
17
|
+
export { useBiometrics, getBiometricName } from "./useBiometrics";
|
|
18
|
+
export { useOffline, usePendingMutations } from "./useOffline";
|
|
19
|
+
export { useUpdates, getUpdateInfo, forceUpdate } from "./useUpdates";
|
|
20
|
+
export {
|
|
21
|
+
usePerformance,
|
|
22
|
+
measureAsync,
|
|
23
|
+
measureSync,
|
|
24
|
+
runAfterInteractions,
|
|
25
|
+
} from "./usePerformance";
|
package/hooks/useApi.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQueryClient,
|
|
5
|
+
UseQueryOptions,
|
|
6
|
+
UseMutationOptions,
|
|
7
|
+
} from "@tanstack/react-query";
|
|
8
|
+
import { api } from "@/services/api";
|
|
9
|
+
import { toast, handleApiError } from "@/utils/toast";
|
|
10
|
+
|
|
11
|
+
// Query keys factory for type-safe and organized cache management
|
|
12
|
+
export const queryKeys = {
|
|
13
|
+
all: ["api"] as const,
|
|
14
|
+
users: {
|
|
15
|
+
all: ["users"] as const,
|
|
16
|
+
detail: (id: string) => ["users", id] as const,
|
|
17
|
+
me: () => ["users", "me"] as const,
|
|
18
|
+
},
|
|
19
|
+
posts: {
|
|
20
|
+
all: ["posts"] as const,
|
|
21
|
+
list: (filters: Record<string, unknown>) => ["posts", "list", filters] as const,
|
|
22
|
+
detail: (id: string) => ["posts", id] as const,
|
|
23
|
+
},
|
|
24
|
+
// Add more query keys as needed
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
// Generic types for API responses
|
|
28
|
+
interface PaginatedResponse<T> {
|
|
29
|
+
data: T[];
|
|
30
|
+
page: number;
|
|
31
|
+
pageSize: number;
|
|
32
|
+
total: number;
|
|
33
|
+
totalPages: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ===========================================
|
|
37
|
+
// User Hooks
|
|
38
|
+
// ===========================================
|
|
39
|
+
|
|
40
|
+
interface User {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
email: string;
|
|
44
|
+
avatar?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch current user profile
|
|
49
|
+
*/
|
|
50
|
+
export function useCurrentUser(
|
|
51
|
+
options?: Omit<UseQueryOptions<User, Error>, "queryKey" | "queryFn">
|
|
52
|
+
) {
|
|
53
|
+
return useQuery({
|
|
54
|
+
queryKey: queryKeys.users.me(),
|
|
55
|
+
queryFn: () => api.get<User>("/users/me"),
|
|
56
|
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
57
|
+
...options,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch user by ID
|
|
63
|
+
*/
|
|
64
|
+
export function useUser(
|
|
65
|
+
userId: string,
|
|
66
|
+
options?: Omit<UseQueryOptions<User, Error>, "queryKey" | "queryFn">
|
|
67
|
+
) {
|
|
68
|
+
return useQuery({
|
|
69
|
+
queryKey: queryKeys.users.detail(userId),
|
|
70
|
+
queryFn: () => api.get<User>(`/users/${userId}`),
|
|
71
|
+
enabled: !!userId,
|
|
72
|
+
...options,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Update user profile
|
|
78
|
+
*/
|
|
79
|
+
export function useUpdateUser() {
|
|
80
|
+
const queryClient = useQueryClient();
|
|
81
|
+
|
|
82
|
+
return useMutation({
|
|
83
|
+
mutationFn: (data: Partial<User>) => api.patch<User>("/users/me", data),
|
|
84
|
+
onSuccess: (updatedUser) => {
|
|
85
|
+
// Update cache
|
|
86
|
+
queryClient.setQueryData(queryKeys.users.me(), updatedUser);
|
|
87
|
+
toast.success("Profile updated");
|
|
88
|
+
},
|
|
89
|
+
onError: (error) => {
|
|
90
|
+
handleApiError(error, "Failed to update profile");
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ===========================================
|
|
96
|
+
// Generic CRUD Hooks Factory
|
|
97
|
+
// ===========================================
|
|
98
|
+
|
|
99
|
+
interface CrudHooksConfig<T, CreateDTO, UpdateDTO> {
|
|
100
|
+
baseKey: readonly string[];
|
|
101
|
+
endpoint: string;
|
|
102
|
+
entityName: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Factory to create CRUD hooks for any resource
|
|
107
|
+
*/
|
|
108
|
+
export function createCrudHooks<
|
|
109
|
+
T extends { id: string },
|
|
110
|
+
CreateDTO = Omit<T, "id">,
|
|
111
|
+
UpdateDTO = Partial<T>
|
|
112
|
+
>(config: CrudHooksConfig<T, CreateDTO, UpdateDTO>) {
|
|
113
|
+
const { baseKey, endpoint, entityName } = config;
|
|
114
|
+
|
|
115
|
+
// Helper to build URL with query params
|
|
116
|
+
const buildUrl = (base: string, params?: Record<string, unknown>) => {
|
|
117
|
+
if (!params || Object.keys(params).length === 0) return base;
|
|
118
|
+
const searchParams = new URLSearchParams();
|
|
119
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
120
|
+
if (value !== undefined && value !== null) {
|
|
121
|
+
searchParams.append(key, String(value));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return `${base}?${searchParams.toString()}`;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
// List all
|
|
129
|
+
useList: (
|
|
130
|
+
filters?: Record<string, unknown>,
|
|
131
|
+
options?: Omit<UseQueryOptions<T[], Error>, "queryKey" | "queryFn">
|
|
132
|
+
) =>
|
|
133
|
+
useQuery({
|
|
134
|
+
queryKey: [...baseKey, "list", filters],
|
|
135
|
+
queryFn: () => api.get<T[]>(buildUrl(endpoint, filters)),
|
|
136
|
+
...options,
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
// Get by ID
|
|
140
|
+
useById: (
|
|
141
|
+
id: string,
|
|
142
|
+
options?: Omit<UseQueryOptions<T, Error>, "queryKey" | "queryFn">
|
|
143
|
+
) =>
|
|
144
|
+
useQuery({
|
|
145
|
+
queryKey: [...baseKey, id],
|
|
146
|
+
queryFn: () => api.get<T>(`${endpoint}/${id}`),
|
|
147
|
+
enabled: !!id,
|
|
148
|
+
...options,
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
// Create
|
|
152
|
+
useCreate: (
|
|
153
|
+
options?: Omit<UseMutationOptions<T, Error, CreateDTO>, "mutationFn">
|
|
154
|
+
) => {
|
|
155
|
+
const queryClient = useQueryClient();
|
|
156
|
+
return useMutation({
|
|
157
|
+
mutationFn: (data: CreateDTO) =>
|
|
158
|
+
api.post<T>(endpoint, data as Record<string, unknown>),
|
|
159
|
+
onSuccess: () => {
|
|
160
|
+
queryClient.invalidateQueries({ queryKey: baseKey });
|
|
161
|
+
toast.success(`${entityName} created`);
|
|
162
|
+
},
|
|
163
|
+
onError: (error) => {
|
|
164
|
+
handleApiError(error, `Failed to create ${entityName.toLowerCase()}`);
|
|
165
|
+
},
|
|
166
|
+
...options,
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// Update
|
|
171
|
+
useUpdate: (
|
|
172
|
+
options?: Omit<
|
|
173
|
+
UseMutationOptions<T, Error, { id: string; data: UpdateDTO }>,
|
|
174
|
+
"mutationFn"
|
|
175
|
+
>
|
|
176
|
+
) => {
|
|
177
|
+
const queryClient = useQueryClient();
|
|
178
|
+
return useMutation({
|
|
179
|
+
mutationFn: ({ id, data }: { id: string; data: UpdateDTO }) =>
|
|
180
|
+
api.patch<T>(`${endpoint}/${id}`, data as Record<string, unknown>),
|
|
181
|
+
onSuccess: (_, { id }) => {
|
|
182
|
+
queryClient.invalidateQueries({ queryKey: [...baseKey, id] });
|
|
183
|
+
queryClient.invalidateQueries({ queryKey: [...baseKey, "list"] });
|
|
184
|
+
toast.success(`${entityName} updated`);
|
|
185
|
+
},
|
|
186
|
+
onError: (error) => {
|
|
187
|
+
handleApiError(error, `Failed to update ${entityName.toLowerCase()}`);
|
|
188
|
+
},
|
|
189
|
+
...options,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// Delete
|
|
194
|
+
useDelete: (
|
|
195
|
+
options?: Omit<UseMutationOptions<void, Error, string>, "mutationFn">
|
|
196
|
+
) => {
|
|
197
|
+
const queryClient = useQueryClient();
|
|
198
|
+
return useMutation({
|
|
199
|
+
mutationFn: (id: string) => api.delete<void>(`${endpoint}/${id}`),
|
|
200
|
+
onSuccess: () => {
|
|
201
|
+
queryClient.invalidateQueries({ queryKey: baseKey });
|
|
202
|
+
toast.success(`${entityName} deleted`);
|
|
203
|
+
},
|
|
204
|
+
onError: (error) => {
|
|
205
|
+
handleApiError(error, `Failed to delete ${entityName.toLowerCase()}`);
|
|
206
|
+
},
|
|
207
|
+
...options,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ===========================================
|
|
214
|
+
// Example: Posts CRUD hooks
|
|
215
|
+
// ===========================================
|
|
216
|
+
|
|
217
|
+
interface Post {
|
|
218
|
+
id: string;
|
|
219
|
+
title: string;
|
|
220
|
+
content: string;
|
|
221
|
+
authorId: string;
|
|
222
|
+
createdAt: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const postsApi = createCrudHooks<Post>({
|
|
226
|
+
baseKey: queryKeys.posts.all,
|
|
227
|
+
endpoint: "/posts",
|
|
228
|
+
entityName: "Post",
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Usage:
|
|
232
|
+
// const { data: posts } = postsApi.useList();
|
|
233
|
+
// const { data: post } = postsApi.useById("123");
|
|
234
|
+
// const createPost = postsApi.useCreate();
|
|
235
|
+
// const updatePost = postsApi.useUpdate();
|
|
236
|
+
// const deletePost = postsApi.useDelete();
|