@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,307 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useMemo, ReactNode } from "react";
|
|
2
|
+
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
|
3
|
+
import GorhomBottomSheet, {
|
|
4
|
+
BottomSheetBackdrop,
|
|
5
|
+
BottomSheetView,
|
|
6
|
+
BottomSheetScrollView,
|
|
7
|
+
BottomSheetBackdropProps,
|
|
8
|
+
} from "@gorhom/bottom-sheet";
|
|
9
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
10
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
11
|
+
import { cn } from "@/utils/cn";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hook to control BottomSheet imperatively
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* function MyComponent() {
|
|
19
|
+
* const { ref, open, close, snapTo } = useBottomSheet();
|
|
20
|
+
*
|
|
21
|
+
* return (
|
|
22
|
+
* <>
|
|
23
|
+
* <Button onPress={() => open()}>Open Sheet</Button>
|
|
24
|
+
* <BottomSheet ref={ref}>
|
|
25
|
+
* <Text>Content</Text>
|
|
26
|
+
* </BottomSheet>
|
|
27
|
+
* </>
|
|
28
|
+
* );
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
import { useRef } from "react";
|
|
33
|
+
|
|
34
|
+
interface BottomSheetProps {
|
|
35
|
+
/**
|
|
36
|
+
* Content to render inside the bottom sheet
|
|
37
|
+
*/
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Snap points for the bottom sheet (e.g., ['25%', '50%', '90%'])
|
|
42
|
+
*/
|
|
43
|
+
snapPoints?: (string | number)[];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Initial snap point index
|
|
47
|
+
*/
|
|
48
|
+
index?: number;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Title displayed in the header
|
|
52
|
+
*/
|
|
53
|
+
title?: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Show close button in header
|
|
57
|
+
*/
|
|
58
|
+
showCloseButton?: boolean;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Called when the sheet is closed
|
|
62
|
+
*/
|
|
63
|
+
onClose?: () => void;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Called when the sheet index changes
|
|
67
|
+
*/
|
|
68
|
+
onChange?: (index: number) => void;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether to enable backdrop
|
|
72
|
+
*/
|
|
73
|
+
enableBackdrop?: boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether to close on backdrop press
|
|
77
|
+
*/
|
|
78
|
+
closeOnBackdropPress?: boolean;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether content is scrollable
|
|
82
|
+
*/
|
|
83
|
+
scrollable?: boolean;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether to enable handle
|
|
87
|
+
*/
|
|
88
|
+
enableHandle?: boolean;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Additional styles for the container
|
|
92
|
+
*/
|
|
93
|
+
containerClassName?: string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Additional styles for the content
|
|
97
|
+
*/
|
|
98
|
+
contentClassName?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type BottomSheetRef = GorhomBottomSheet;
|
|
102
|
+
|
|
103
|
+
export const BottomSheet = forwardRef<BottomSheetRef, BottomSheetProps>(
|
|
104
|
+
(
|
|
105
|
+
{
|
|
106
|
+
children,
|
|
107
|
+
snapPoints: customSnapPoints,
|
|
108
|
+
index = -1,
|
|
109
|
+
title,
|
|
110
|
+
showCloseButton = true,
|
|
111
|
+
onClose,
|
|
112
|
+
onChange,
|
|
113
|
+
enableBackdrop = true,
|
|
114
|
+
closeOnBackdropPress = true,
|
|
115
|
+
scrollable = false,
|
|
116
|
+
enableHandle = true,
|
|
117
|
+
containerClassName,
|
|
118
|
+
contentClassName,
|
|
119
|
+
},
|
|
120
|
+
ref
|
|
121
|
+
) => {
|
|
122
|
+
const { isDark } = useTheme();
|
|
123
|
+
|
|
124
|
+
// Default snap points
|
|
125
|
+
const snapPoints = useMemo(
|
|
126
|
+
() => customSnapPoints || ["50%", "90%"],
|
|
127
|
+
[customSnapPoints]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Handle sheet changes
|
|
131
|
+
const handleSheetChanges = useCallback(
|
|
132
|
+
(sheetIndex: number) => {
|
|
133
|
+
onChange?.(sheetIndex);
|
|
134
|
+
if (sheetIndex === -1) {
|
|
135
|
+
onClose?.();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[onChange, onClose]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Handle close button press
|
|
142
|
+
const handleClose = useCallback(() => {
|
|
143
|
+
if (ref && "current" in ref && ref.current) {
|
|
144
|
+
ref.current.close();
|
|
145
|
+
}
|
|
146
|
+
onClose?.();
|
|
147
|
+
}, [ref, onClose]);
|
|
148
|
+
|
|
149
|
+
// Backdrop component
|
|
150
|
+
const renderBackdrop = useCallback(
|
|
151
|
+
(props: BottomSheetBackdropProps) => (
|
|
152
|
+
<BottomSheetBackdrop
|
|
153
|
+
{...props}
|
|
154
|
+
disappearsOnIndex={-1}
|
|
155
|
+
appearsOnIndex={0}
|
|
156
|
+
pressBehavior={closeOnBackdropPress ? "close" : "none"}
|
|
157
|
+
opacity={0.5}
|
|
158
|
+
/>
|
|
159
|
+
),
|
|
160
|
+
[closeOnBackdropPress]
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Handle component
|
|
164
|
+
const renderHandle = useCallback(() => {
|
|
165
|
+
if (!enableHandle) return null;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<View className="items-center pt-2 pb-1">
|
|
169
|
+
<View
|
|
170
|
+
className={cn(
|
|
171
|
+
"w-10 h-1 rounded-full",
|
|
172
|
+
isDark ? "bg-gray-600" : "bg-gray-300"
|
|
173
|
+
)}
|
|
174
|
+
/>
|
|
175
|
+
</View>
|
|
176
|
+
);
|
|
177
|
+
}, [enableHandle, isDark]);
|
|
178
|
+
|
|
179
|
+
const ContentWrapper = scrollable ? BottomSheetScrollView : BottomSheetView;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<GorhomBottomSheet
|
|
183
|
+
ref={ref}
|
|
184
|
+
index={index}
|
|
185
|
+
snapPoints={snapPoints}
|
|
186
|
+
onChange={handleSheetChanges}
|
|
187
|
+
backdropComponent={enableBackdrop ? renderBackdrop : null}
|
|
188
|
+
handleComponent={renderHandle}
|
|
189
|
+
enablePanDownToClose
|
|
190
|
+
backgroundStyle={[
|
|
191
|
+
styles.background,
|
|
192
|
+
{ backgroundColor: isDark ? "#1e293b" : "#ffffff" },
|
|
193
|
+
]}
|
|
194
|
+
style={styles.sheet}
|
|
195
|
+
>
|
|
196
|
+
<View
|
|
197
|
+
className={cn(
|
|
198
|
+
"flex-1",
|
|
199
|
+
isDark ? "bg-surface-dark" : "bg-white",
|
|
200
|
+
containerClassName
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
{/* Header */}
|
|
204
|
+
{(title || showCloseButton) && (
|
|
205
|
+
<View
|
|
206
|
+
className={cn(
|
|
207
|
+
"flex-row items-center justify-between px-4 py-3 border-b",
|
|
208
|
+
isDark ? "border-gray-700" : "border-gray-100"
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
<Text
|
|
212
|
+
className={cn(
|
|
213
|
+
"text-lg font-semibold",
|
|
214
|
+
isDark ? "text-text-dark" : "text-text-light"
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{title || ""}
|
|
218
|
+
</Text>
|
|
219
|
+
{showCloseButton && (
|
|
220
|
+
<TouchableOpacity
|
|
221
|
+
onPress={handleClose}
|
|
222
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
223
|
+
className="p-1"
|
|
224
|
+
>
|
|
225
|
+
<Ionicons
|
|
226
|
+
name="close"
|
|
227
|
+
size={24}
|
|
228
|
+
color={isDark ? "#f8fafc" : "#0f172a"}
|
|
229
|
+
/>
|
|
230
|
+
</TouchableOpacity>
|
|
231
|
+
)}
|
|
232
|
+
</View>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Content */}
|
|
236
|
+
<ContentWrapper
|
|
237
|
+
style={styles.contentContainer}
|
|
238
|
+
contentContainerStyle={[
|
|
239
|
+
styles.content,
|
|
240
|
+
scrollable && styles.scrollContent,
|
|
241
|
+
]}
|
|
242
|
+
>
|
|
243
|
+
<View className={cn("flex-1", contentClassName)}>{children}</View>
|
|
244
|
+
</ContentWrapper>
|
|
245
|
+
</View>
|
|
246
|
+
</GorhomBottomSheet>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
BottomSheet.displayName = "BottomSheet";
|
|
252
|
+
|
|
253
|
+
const styles = StyleSheet.create({
|
|
254
|
+
sheet: {
|
|
255
|
+
shadowColor: "#000",
|
|
256
|
+
shadowOffset: { width: 0, height: -4 },
|
|
257
|
+
shadowOpacity: 0.1,
|
|
258
|
+
shadowRadius: 8,
|
|
259
|
+
elevation: 5,
|
|
260
|
+
},
|
|
261
|
+
background: {
|
|
262
|
+
borderTopLeftRadius: 24,
|
|
263
|
+
borderTopRightRadius: 24,
|
|
264
|
+
},
|
|
265
|
+
contentContainer: {
|
|
266
|
+
flex: 1,
|
|
267
|
+
},
|
|
268
|
+
content: {
|
|
269
|
+
flex: 1,
|
|
270
|
+
},
|
|
271
|
+
scrollContent: {
|
|
272
|
+
paddingBottom: 40,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
export function useBottomSheet() {
|
|
277
|
+
const ref = useRef<BottomSheetRef>(null);
|
|
278
|
+
|
|
279
|
+
const open = useCallback((snapIndex = 0) => {
|
|
280
|
+
ref.current?.snapToIndex(snapIndex);
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
const close = useCallback(() => {
|
|
284
|
+
ref.current?.close();
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
287
|
+
const snapTo = useCallback((index: number) => {
|
|
288
|
+
ref.current?.snapToIndex(index);
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
const expand = useCallback(() => {
|
|
292
|
+
ref.current?.expand();
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
const collapse = useCallback(() => {
|
|
296
|
+
ref.current?.collapse();
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
ref,
|
|
301
|
+
open,
|
|
302
|
+
close,
|
|
303
|
+
snapTo,
|
|
304
|
+
expand,
|
|
305
|
+
collapse,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof Button> = {
|
|
7
|
+
title: "UI/Button",
|
|
8
|
+
component: Button,
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: {
|
|
11
|
+
control: "select",
|
|
12
|
+
options: ["primary", "secondary", "outline", "ghost", "danger"],
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
control: "select",
|
|
16
|
+
options: ["sm", "md", "lg"],
|
|
17
|
+
},
|
|
18
|
+
isLoading: {
|
|
19
|
+
control: "boolean",
|
|
20
|
+
},
|
|
21
|
+
disabled: {
|
|
22
|
+
control: "boolean",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
children: "Button",
|
|
27
|
+
variant: "primary",
|
|
28
|
+
size: "md",
|
|
29
|
+
isLoading: false,
|
|
30
|
+
disabled: false,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
type Story = StoryObj<typeof Button>;
|
|
36
|
+
|
|
37
|
+
export const Primary: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
variant: "primary",
|
|
40
|
+
children: "Primary Button",
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Secondary: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
variant: "secondary",
|
|
47
|
+
children: "Secondary Button",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Outline: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
variant: "outline",
|
|
54
|
+
children: "Outline Button",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Ghost: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
variant: "ghost",
|
|
61
|
+
children: "Ghost Button",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Danger: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
variant: "danger",
|
|
68
|
+
children: "Danger Button",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const Loading: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
isLoading: true,
|
|
75
|
+
children: "Loading",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Disabled: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
disabled: true,
|
|
82
|
+
children: "Disabled",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const Sizes: Story = {
|
|
87
|
+
render: () => (
|
|
88
|
+
<View style={{ gap: 12 }}>
|
|
89
|
+
<Button size="sm">Small</Button>
|
|
90
|
+
<Button size="md">Medium</Button>
|
|
91
|
+
<Button size="lg">Large</Button>
|
|
92
|
+
</View>
|
|
93
|
+
),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const WithIcon: Story = {
|
|
97
|
+
render: () => (
|
|
98
|
+
<Button>
|
|
99
|
+
<Ionicons name="add" size={20} color="#fff" style={{ marginRight: 8 }} />
|
|
100
|
+
Add Item
|
|
101
|
+
</Button>
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const AllVariants: Story = {
|
|
106
|
+
render: () => (
|
|
107
|
+
<View style={{ gap: 12 }}>
|
|
108
|
+
<Button variant="primary">Primary</Button>
|
|
109
|
+
<Button variant="secondary">Secondary</Button>
|
|
110
|
+
<Button variant="outline">Outline</Button>
|
|
111
|
+
<Button variant="ghost">Ghost</Button>
|
|
112
|
+
<Button variant="danger">Danger</Button>
|
|
113
|
+
</View>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
Text,
|
|
5
|
+
ActivityIndicator,
|
|
6
|
+
PressableProps,
|
|
7
|
+
View,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import { cn } from "@/utils/cn";
|
|
10
|
+
|
|
11
|
+
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
|
12
|
+
type ButtonSize = "sm" | "md" | "lg";
|
|
13
|
+
|
|
14
|
+
interface ButtonProps extends PressableProps {
|
|
15
|
+
variant?: ButtonVariant;
|
|
16
|
+
size?: ButtonSize;
|
|
17
|
+
isLoading?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
textClassName?: string;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const variantStyles: Record<ButtonVariant, string> = {
|
|
24
|
+
primary: "bg-primary-600 active:bg-primary-700",
|
|
25
|
+
secondary: "bg-gray-200 dark:bg-gray-700 active:bg-gray-300 dark:active:bg-gray-600",
|
|
26
|
+
outline: "border-2 border-gray-300 dark:border-gray-600 bg-transparent active:bg-gray-100 dark:active:bg-gray-800",
|
|
27
|
+
ghost: "bg-transparent active:bg-gray-100 dark:active:bg-gray-800",
|
|
28
|
+
danger: "bg-red-600 active:bg-red-700",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const variantTextStyles: Record<ButtonVariant, string> = {
|
|
32
|
+
primary: "text-white",
|
|
33
|
+
secondary: "text-text-light dark:text-text-dark",
|
|
34
|
+
outline: "text-text-light dark:text-text-dark",
|
|
35
|
+
ghost: "text-primary-600 dark:text-primary-400",
|
|
36
|
+
danger: "text-white",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const sizeStyles: Record<ButtonSize, string> = {
|
|
40
|
+
sm: "px-3 py-2",
|
|
41
|
+
md: "px-4 py-3",
|
|
42
|
+
lg: "px-6 py-4",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const textSizeStyles: Record<ButtonSize, string> = {
|
|
46
|
+
sm: "text-sm",
|
|
47
|
+
md: "text-base",
|
|
48
|
+
lg: "text-lg",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const Button = forwardRef<View, ButtonProps>(
|
|
52
|
+
(
|
|
53
|
+
{
|
|
54
|
+
variant = "primary",
|
|
55
|
+
size = "md",
|
|
56
|
+
isLoading = false,
|
|
57
|
+
disabled,
|
|
58
|
+
className,
|
|
59
|
+
textClassName,
|
|
60
|
+
children,
|
|
61
|
+
...props
|
|
62
|
+
},
|
|
63
|
+
ref
|
|
64
|
+
) => {
|
|
65
|
+
const isDisabled = disabled || isLoading;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Pressable
|
|
69
|
+
ref={ref}
|
|
70
|
+
disabled={isDisabled}
|
|
71
|
+
className={cn(
|
|
72
|
+
"flex-row items-center justify-center rounded-xl",
|
|
73
|
+
variantStyles[variant],
|
|
74
|
+
sizeStyles[size],
|
|
75
|
+
isDisabled && "opacity-50",
|
|
76
|
+
className
|
|
77
|
+
)}
|
|
78
|
+
{...props}
|
|
79
|
+
>
|
|
80
|
+
{isLoading ? (
|
|
81
|
+
<ActivityIndicator
|
|
82
|
+
color={variant === "primary" || variant === "danger" ? "#ffffff" : "#3b82f6"}
|
|
83
|
+
size="small"
|
|
84
|
+
/>
|
|
85
|
+
) : typeof children === "string" ? (
|
|
86
|
+
<Text
|
|
87
|
+
className={cn(
|
|
88
|
+
"font-semibold",
|
|
89
|
+
variantTextStyles[variant],
|
|
90
|
+
textSizeStyles[size],
|
|
91
|
+
textClassName
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</Text>
|
|
96
|
+
) : (
|
|
97
|
+
children
|
|
98
|
+
)}
|
|
99
|
+
</Pressable>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
Button.displayName = "Button";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { View, Text } from "react-native";
|
|
3
|
+
import { Card } from "./Card";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Card> = {
|
|
6
|
+
title: "UI/Card",
|
|
7
|
+
component: Card,
|
|
8
|
+
argTypes: {
|
|
9
|
+
variant: {
|
|
10
|
+
control: "select",
|
|
11
|
+
options: ["default", "elevated", "outlined"],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
variant: "default",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof Card>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: (args) => (
|
|
24
|
+
<Card {...args} className="p-4">
|
|
25
|
+
<Text>Default Card Content</Text>
|
|
26
|
+
</Card>
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const Elevated: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<Card variant="elevated" className="p-4">
|
|
33
|
+
<Text>Elevated Card with Shadow</Text>
|
|
34
|
+
</Card>
|
|
35
|
+
),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const Outlined: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<Card variant="outlined" className="p-4">
|
|
41
|
+
<Text>Outlined Card with Border</Text>
|
|
42
|
+
</Card>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const AllVariants: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<View style={{ gap: 16 }}>
|
|
49
|
+
<Card variant="default" className="p-4">
|
|
50
|
+
<Text style={{ fontWeight: "bold" }}>Default</Text>
|
|
51
|
+
<Text>Background color, no border</Text>
|
|
52
|
+
</Card>
|
|
53
|
+
<Card variant="elevated" className="p-4">
|
|
54
|
+
<Text style={{ fontWeight: "bold" }}>Elevated</Text>
|
|
55
|
+
<Text>Background color with shadow</Text>
|
|
56
|
+
</Card>
|
|
57
|
+
<Card variant="outlined" className="p-4">
|
|
58
|
+
<Text style={{ fontWeight: "bold" }}>Outlined</Text>
|
|
59
|
+
<Text>Transparent with border</Text>
|
|
60
|
+
</Card>
|
|
61
|
+
</View>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const ComplexContent: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<Card className="p-4">
|
|
68
|
+
<View style={{ gap: 8 }}>
|
|
69
|
+
<Text style={{ fontSize: 18, fontWeight: "bold" }}>Card Title</Text>
|
|
70
|
+
<Text style={{ color: "#64748b" }}>
|
|
71
|
+
This is a more complex card with multiple elements inside.
|
|
72
|
+
</Text>
|
|
73
|
+
<View
|
|
74
|
+
style={{
|
|
75
|
+
height: 100,
|
|
76
|
+
backgroundColor: "#f1f5f9",
|
|
77
|
+
borderRadius: 8,
|
|
78
|
+
marginTop: 8,
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
</View>
|
|
82
|
+
</Card>
|
|
83
|
+
),
|
|
84
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { View, ViewProps } from "react-native";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
|
|
4
|
+
interface CardProps extends ViewProps {
|
|
5
|
+
variant?: "default" | "elevated" | "outlined";
|
|
6
|
+
className?: string;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Card({
|
|
11
|
+
variant = "default",
|
|
12
|
+
className,
|
|
13
|
+
children,
|
|
14
|
+
...props
|
|
15
|
+
}: CardProps) {
|
|
16
|
+
return (
|
|
17
|
+
<View
|
|
18
|
+
className={cn(
|
|
19
|
+
"rounded-xl",
|
|
20
|
+
variant === "default" && "bg-surface-light dark:bg-surface-dark",
|
|
21
|
+
variant === "elevated" &&
|
|
22
|
+
"bg-surface-light shadow-lg dark:bg-surface-dark",
|
|
23
|
+
variant === "outlined" &&
|
|
24
|
+
"border-2 border-gray-200 bg-transparent dark:border-gray-700",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
}
|