@squad-sports/react-native 1.3.1
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/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +46 -0
- package/src/SquadExperience.tsx +200 -0
- package/src/SquadProvider.tsx +232 -0
- package/src/SquadSportsSDK.ts +286 -0
- package/src/__tests__/DeepLinkHandler.test.ts +101 -0
- package/src/__tests__/ErrorBoundary.test.tsx +161 -0
- package/src/__tests__/EventProcessor.test.ts +241 -0
- package/src/__tests__/PushNotificationHandler.test.ts +91 -0
- package/src/__tests__/SecureStorage.test.ts +62 -0
- package/src/__tests__/SquadSportsSDK.test.ts +278 -0
- package/src/__tests__/VerificationCooldown.test.ts +153 -0
- package/src/components/ErrorBoundary.tsx +129 -0
- package/src/components/SOTD/SOTDComponents.tsx +101 -0
- package/src/components/audio/AudioPlayerRow.tsx +189 -0
- package/src/components/audio/recording/AudioRecording.tsx +232 -0
- package/src/components/communities/CommunityComponents.tsx +78 -0
- package/src/components/dialogs/AllDialogs.tsx +123 -0
- package/src/components/dialogs/ConfirmDialog.tsx +77 -0
- package/src/components/dialogs/PermissionDialog.tsx +132 -0
- package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
- package/src/components/events/EventComponents.tsx +93 -0
- package/src/components/feed/ChatBannerCard.tsx +94 -0
- package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
- package/src/components/feed/FreestyleCard.tsx +119 -0
- package/src/components/feed/InterstitialOverlay.tsx +190 -0
- package/src/components/feed/PollCard.tsx +158 -0
- package/src/components/feed/SponsoredContentCard.tsx +118 -0
- package/src/components/freestyle/FreestyleComponents.tsx +148 -0
- package/src/components/index.ts +42 -0
- package/src/components/message/MessageCard.tsx +166 -0
- package/src/components/message/MessageComponents.tsx +143 -0
- package/src/components/poll/PollComponents.tsx +226 -0
- package/src/components/sentinels/Sentinels.tsx +175 -0
- package/src/components/squad/FeedSquad.tsx +54 -0
- package/src/components/toasts/Toasts.tsx +88 -0
- package/src/components/ux/RootUXComponents.tsx +157 -0
- package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
- package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
- package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
- package/src/components/ux/buttons/Button.tsx +37 -0
- package/src/components/ux/buttons/InfoButton.tsx +24 -0
- package/src/components/ux/buttons/XButton.tsx +27 -0
- package/src/components/ux/carousel/Carousel.tsx +134 -0
- package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
- package/src/components/ux/errors/ErrorHint.tsx +46 -0
- package/src/components/ux/inputs/CodeInput.tsx +121 -0
- package/src/components/ux/inputs/DatePicker.tsx +76 -0
- package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
- package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
- package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
- package/src/components/ux/inputs/TextInput.tsx +58 -0
- package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
- package/src/components/ux/layout/BlurOverlay.tsx +26 -0
- package/src/components/ux/layout/CrossFade.tsx +30 -0
- package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
- package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
- package/src/components/ux/layout/NetworkBanner.tsx +64 -0
- package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
- package/src/components/ux/layout/RefreshControl.tsx +7 -0
- package/src/components/ux/layout/Screen.tsx +31 -0
- package/src/components/ux/layout/ScreenHeader.tsx +89 -0
- package/src/components/ux/layout/TabBar.tsx +39 -0
- package/src/components/ux/layout/Toast.tsx +116 -0
- package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
- package/src/components/ux/navigation/BackButton.tsx +29 -0
- package/src/components/ux/navigation/LinkButton.tsx +21 -0
- package/src/components/ux/navigation/UrlButton.tsx +25 -0
- package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
- package/src/components/ux/shapes/Shapes.tsx +23 -0
- package/src/components/ux/text/Typography.tsx +28 -0
- package/src/components/ux/user-image/UserImage.tsx +75 -0
- package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
- package/src/components/wallet/WalletComponents.tsx +116 -0
- package/src/contexts/AuthContext.tsx +45 -0
- package/src/contexts/EventProcessorContext.tsx +41 -0
- package/src/contexts/PlayerQueueContext.tsx +95 -0
- package/src/hooks/useAuth.ts +23 -0
- package/src/hooks/useDataRefresh.ts +30 -0
- package/src/hooks/useEventProcessor.ts +6 -0
- package/src/hooks/useImageOptimization.ts +59 -0
- package/src/hooks/useOnboardingStepGuard.ts +36 -0
- package/src/hooks/usePendingNavigation.ts +26 -0
- package/src/hooks/useSquadData.ts +84 -0
- package/src/hooks/useUserCreated.ts +25 -0
- package/src/hooks/useUserUpdate.ts +25 -0
- package/src/hooks/useViewabilityTracker.ts +40 -0
- package/src/index.ts +109 -0
- package/src/navigation/SquadNavigator.tsx +262 -0
- package/src/realtime/DeepLinkHandler.ts +113 -0
- package/src/realtime/EventProcessor.ts +313 -0
- package/src/realtime/NetworkMonitor.ts +84 -0
- package/src/realtime/OfflineQueue.ts +133 -0
- package/src/realtime/PushNotificationHandler.ts +125 -0
- package/src/realtime/useRealtimeSync.ts +84 -0
- package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
- package/src/screens/auth/EnterCodeScreen.tsx +253 -0
- package/src/screens/auth/EnterEmailScreen.tsx +234 -0
- package/src/screens/auth/LandingScreen.tsx +90 -0
- package/src/screens/auth/LoginScreen.tsx +126 -0
- package/src/screens/events/EventScreen.tsx +163 -0
- package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
- package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
- package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
- package/src/screens/home/HomeScreen.tsx +365 -0
- package/src/screens/home/slivers/SquadCircle.tsx +77 -0
- package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
- package/src/screens/invite/InviteScreen.tsx +175 -0
- package/src/screens/messaging/MessagingScreen.tsx +360 -0
- package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
- package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
- package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
- package/src/screens/polls/PollResponseScreen.tsx +229 -0
- package/src/screens/polls/PollSummationScreen.tsx +78 -0
- package/src/screens/profile/ProfileScreen.tsx +234 -0
- package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
- package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
- package/src/screens/settings/EditProfileScreen.tsx +154 -0
- package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
- package/src/screens/settings/SettingsScreen.tsx +194 -0
- package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
- package/src/screens/wallet/WalletScreen.tsx +174 -0
- package/src/services/AuthStateManager.ts +93 -0
- package/src/services/NavigationService.ts +40 -0
- package/src/services/UserDataManager.ts +59 -0
- package/src/services/UserUpdateService.ts +31 -0
- package/src/services/VerificationStateManager.ts +41 -0
- package/src/squad-line/CallScreen.tsx +158 -0
- package/src/squad-line/IncomingCallOverlay.tsx +113 -0
- package/src/squad-line/SquadLineClient.ts +327 -0
- package/src/squad-line/useSquadLine.ts +80 -0
- package/src/state/audio.ts +38 -0
- package/src/state/client.ts +26 -0
- package/src/state/communities.ts +45 -0
- package/src/state/contacts.ts +28 -0
- package/src/state/device-info.ts +22 -0
- package/src/state/events.ts +16 -0
- package/src/state/features.ts +57 -0
- package/src/state/index.ts +121 -0
- package/src/state/invitations.ts +16 -0
- package/src/state/modal-keys.ts +63 -0
- package/src/state/modal-queue.ts +104 -0
- package/src/state/navigation.ts +34 -0
- package/src/state/permissions.ts +43 -0
- package/src/state/session.ts +223 -0
- package/src/state/squaddie-of-the-day.ts +21 -0
- package/src/state/sync/crdt.ts +70 -0
- package/src/state/sync/dependable.ts +213 -0
- package/src/state/sync/feed-v2.ts +42 -0
- package/src/state/sync/messages.ts +44 -0
- package/src/state/sync/offline-support.ts +42 -0
- package/src/state/sync/polls.ts +37 -0
- package/src/state/sync/refresh.ts +24 -0
- package/src/state/sync/squad-v2.ts +25 -0
- package/src/state/ui.ts +36 -0
- package/src/state/user.ts +46 -0
- package/src/state/wallet.ts +26 -0
- package/src/storage/SecureStorage.ts +77 -0
- package/src/theme/ThemeContext.tsx +159 -0
- package/src/types/modules.d.ts +165 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TextInput,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Animated,
|
|
8
|
+
useWindowDimensions,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useRoute, useNavigation } from '@react-navigation/native';
|
|
11
|
+
import type { RouteProp } from '@react-navigation/native';
|
|
12
|
+
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
13
|
+
|
|
14
|
+
import type { RootStackParamList } from '../../navigation/SquadNavigator';
|
|
15
|
+
import { useApiClient } from '../../SquadProvider';
|
|
16
|
+
import { useSquadLine } from '../../squad-line/useSquadLine';
|
|
17
|
+
import { useTheme, Colors } from '../../theme/ThemeContext';
|
|
18
|
+
import ScreenHeader from '../../components/ux/layout/ScreenHeader';
|
|
19
|
+
import Button from '../../components/ux/buttons/Button';
|
|
20
|
+
import UserImage from '../../components/ux/user-image/UserImage';
|
|
21
|
+
import { BodyRegular, BodySmall } from '../../components/ux/text/Typography';
|
|
22
|
+
import type { User } from '@squad-sports/core';
|
|
23
|
+
|
|
24
|
+
type Route = RouteProp<RootStackParamList, 'AddCallTitle'>;
|
|
25
|
+
type Nav = NativeStackNavigationProp<RootStackParamList, 'AddCallTitle'>;
|
|
26
|
+
|
|
27
|
+
const MAX_TITLE_LENGTH = 30;
|
|
28
|
+
|
|
29
|
+
export function AddCallTitleScreen() {
|
|
30
|
+
const route = useRoute<Route>();
|
|
31
|
+
const navigation = useNavigation<Nav>();
|
|
32
|
+
const apiClient = useApiClient();
|
|
33
|
+
const { makeCall } = useSquadLine();
|
|
34
|
+
const { theme } = useTheme();
|
|
35
|
+
|
|
36
|
+
const { connectionId } = route.params;
|
|
37
|
+
const [title, setTitle] = useState('');
|
|
38
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const [otherUser, setOtherUser] = useState<User | null>(null);
|
|
41
|
+
const inputRef = useRef<TextInput>(null);
|
|
42
|
+
const progressAnim = useRef(new Animated.Value(0)).current;
|
|
43
|
+
|
|
44
|
+
// Load the other user's info
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const load = async () => {
|
|
47
|
+
try {
|
|
48
|
+
const user = await apiClient.getUser(connectionId);
|
|
49
|
+
setOtherUser(user);
|
|
50
|
+
} catch {}
|
|
51
|
+
};
|
|
52
|
+
load();
|
|
53
|
+
setTimeout(() => inputRef.current?.focus(), 300);
|
|
54
|
+
}, [connectionId, apiClient]);
|
|
55
|
+
|
|
56
|
+
// Animate progress ring
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
Animated.timing(progressAnim, {
|
|
59
|
+
toValue: title.length / MAX_TITLE_LENGTH,
|
|
60
|
+
duration: 150,
|
|
61
|
+
useNativeDriver: false,
|
|
62
|
+
}).start();
|
|
63
|
+
}, [title.length, progressAnim]);
|
|
64
|
+
|
|
65
|
+
const hasEmoji = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(title);
|
|
66
|
+
const isValid = title.trim().length > 0 && title.length <= MAX_TITLE_LENGTH && !hasEmoji;
|
|
67
|
+
|
|
68
|
+
const handleTitleChange = useCallback((text: string) => {
|
|
69
|
+
if (text.length <= MAX_TITLE_LENGTH) {
|
|
70
|
+
setTitle(text);
|
|
71
|
+
setError(null);
|
|
72
|
+
}
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const handleStartCall = useCallback(async () => {
|
|
76
|
+
if (!isValid || isLoading) return;
|
|
77
|
+
|
|
78
|
+
setIsLoading(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const success = await makeCall(connectionId, title.trim());
|
|
83
|
+
if (success) {
|
|
84
|
+
navigation.navigate('ActiveCall', {
|
|
85
|
+
connectionId,
|
|
86
|
+
title: title.trim(),
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
setError('Failed to start call. Please try again.');
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setError('Call failed. Check your connection and try again.');
|
|
93
|
+
} finally {
|
|
94
|
+
setIsLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}, [isValid, isLoading, connectionId, title, makeCall, navigation]);
|
|
97
|
+
|
|
98
|
+
const titleColor = title.length >= 25 ? Colors.orange1 : Colors.gray6;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
102
|
+
<ScreenHeader title="Squad Line" />
|
|
103
|
+
|
|
104
|
+
<View style={styles.content}>
|
|
105
|
+
<View style={styles.avatarSection}>
|
|
106
|
+
<View style={styles.avatarRing}>
|
|
107
|
+
<UserImage
|
|
108
|
+
imageUrl={otherUser?.imageUrl}
|
|
109
|
+
displayName={otherUser?.displayName}
|
|
110
|
+
size={72}
|
|
111
|
+
/>
|
|
112
|
+
{/* Progress ring overlay */}
|
|
113
|
+
<View style={styles.progressRing}>
|
|
114
|
+
<Animated.View
|
|
115
|
+
style={[
|
|
116
|
+
styles.progressArc,
|
|
117
|
+
{
|
|
118
|
+
borderColor: theme.buttonColor,
|
|
119
|
+
transform: [{
|
|
120
|
+
rotate: progressAnim.interpolate({
|
|
121
|
+
inputRange: [0, 1],
|
|
122
|
+
outputRange: ['0deg', '360deg'],
|
|
123
|
+
}),
|
|
124
|
+
}],
|
|
125
|
+
},
|
|
126
|
+
]}
|
|
127
|
+
/>
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
|
|
131
|
+
{otherUser?.displayName && (
|
|
132
|
+
<BodyRegular style={styles.userName}>
|
|
133
|
+
{otherUser.displayName}
|
|
134
|
+
</BodyRegular>
|
|
135
|
+
)}
|
|
136
|
+
</View>
|
|
137
|
+
|
|
138
|
+
<View style={styles.titleSection}>
|
|
139
|
+
<BodyRegular style={styles.prompt}>
|
|
140
|
+
Tell {otherUser?.displayName ?? 'them'} why you're calling
|
|
141
|
+
</BodyRegular>
|
|
142
|
+
|
|
143
|
+
<TextInput
|
|
144
|
+
ref={inputRef}
|
|
145
|
+
value={title}
|
|
146
|
+
onChangeText={handleTitleChange}
|
|
147
|
+
placeholder="Enter a title..."
|
|
148
|
+
placeholderTextColor={Colors.gray6}
|
|
149
|
+
style={[styles.titleInput, { borderColor: title ? Colors.white : Colors.gray5 }]}
|
|
150
|
+
maxLength={MAX_TITLE_LENGTH}
|
|
151
|
+
returnKeyType="go"
|
|
152
|
+
onSubmitEditing={handleStartCall}
|
|
153
|
+
autoCorrect={false}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<View style={styles.titleMeta}>
|
|
157
|
+
<Text style={[styles.charCount, { color: titleColor }]}>
|
|
158
|
+
{title.length}/{MAX_TITLE_LENGTH}
|
|
159
|
+
</Text>
|
|
160
|
+
{hasEmoji && (
|
|
161
|
+
<BodySmall style={styles.warning}>No emojis allowed</BodySmall>
|
|
162
|
+
)}
|
|
163
|
+
</View>
|
|
164
|
+
|
|
165
|
+
{error && (
|
|
166
|
+
<View style={styles.errorContainer}>
|
|
167
|
+
<BodySmall style={styles.errorText}>{error}</BodySmall>
|
|
168
|
+
</View>
|
|
169
|
+
)}
|
|
170
|
+
</View>
|
|
171
|
+
</View>
|
|
172
|
+
|
|
173
|
+
<View style={styles.footer}>
|
|
174
|
+
<Button
|
|
175
|
+
style={[
|
|
176
|
+
styles.callButton,
|
|
177
|
+
{ backgroundColor: isValid && !isLoading ? theme.buttonColor : Colors.gray2 },
|
|
178
|
+
]}
|
|
179
|
+
onPress={handleStartCall}
|
|
180
|
+
disabled={!isValid || isLoading}
|
|
181
|
+
>
|
|
182
|
+
<Text
|
|
183
|
+
style={[
|
|
184
|
+
styles.callButtonText,
|
|
185
|
+
{ color: isValid && !isLoading ? theme.buttonText : Colors.gray6 },
|
|
186
|
+
]}
|
|
187
|
+
>
|
|
188
|
+
{isLoading ? 'Connecting...' : 'Start Call'}
|
|
189
|
+
</Text>
|
|
190
|
+
</Button>
|
|
191
|
+
</View>
|
|
192
|
+
</View>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const styles = StyleSheet.create({
|
|
197
|
+
container: {
|
|
198
|
+
flex: 1,
|
|
199
|
+
},
|
|
200
|
+
content: {
|
|
201
|
+
flex: 1,
|
|
202
|
+
alignItems: 'center',
|
|
203
|
+
paddingHorizontal: 24,
|
|
204
|
+
paddingTop: 32,
|
|
205
|
+
},
|
|
206
|
+
avatarSection: {
|
|
207
|
+
alignItems: 'center',
|
|
208
|
+
marginBottom: 32,
|
|
209
|
+
},
|
|
210
|
+
avatarRing: {
|
|
211
|
+
position: 'relative',
|
|
212
|
+
width: 88,
|
|
213
|
+
height: 88,
|
|
214
|
+
justifyContent: 'center',
|
|
215
|
+
alignItems: 'center',
|
|
216
|
+
},
|
|
217
|
+
progressRing: {
|
|
218
|
+
...StyleSheet.absoluteFillObject,
|
|
219
|
+
borderRadius: 44,
|
|
220
|
+
borderWidth: 3,
|
|
221
|
+
borderColor: 'rgba(255,255,255,0.1)',
|
|
222
|
+
},
|
|
223
|
+
progressArc: {
|
|
224
|
+
...StyleSheet.absoluteFillObject,
|
|
225
|
+
borderRadius: 44,
|
|
226
|
+
borderWidth: 3,
|
|
227
|
+
borderTopColor: 'transparent',
|
|
228
|
+
borderRightColor: 'transparent',
|
|
229
|
+
borderBottomColor: 'transparent',
|
|
230
|
+
},
|
|
231
|
+
userName: {
|
|
232
|
+
color: Colors.white,
|
|
233
|
+
marginTop: 12,
|
|
234
|
+
fontWeight: '600',
|
|
235
|
+
},
|
|
236
|
+
titleSection: {
|
|
237
|
+
width: '100%',
|
|
238
|
+
maxWidth: 400,
|
|
239
|
+
},
|
|
240
|
+
prompt: {
|
|
241
|
+
color: Colors.gray6,
|
|
242
|
+
textAlign: 'center',
|
|
243
|
+
marginBottom: 16,
|
|
244
|
+
},
|
|
245
|
+
titleInput: {
|
|
246
|
+
color: Colors.white,
|
|
247
|
+
fontSize: 18,
|
|
248
|
+
fontWeight: '500',
|
|
249
|
+
textAlign: 'center',
|
|
250
|
+
paddingVertical: 16,
|
|
251
|
+
paddingHorizontal: 16,
|
|
252
|
+
borderWidth: 1,
|
|
253
|
+
borderRadius: 12,
|
|
254
|
+
backgroundColor: Colors.gray2,
|
|
255
|
+
},
|
|
256
|
+
titleMeta: {
|
|
257
|
+
flexDirection: 'row',
|
|
258
|
+
justifyContent: 'space-between',
|
|
259
|
+
alignItems: 'center',
|
|
260
|
+
marginTop: 8,
|
|
261
|
+
paddingHorizontal: 4,
|
|
262
|
+
},
|
|
263
|
+
charCount: {
|
|
264
|
+
fontSize: 12,
|
|
265
|
+
},
|
|
266
|
+
warning: {
|
|
267
|
+
color: Colors.orange1,
|
|
268
|
+
},
|
|
269
|
+
errorContainer: {
|
|
270
|
+
marginTop: 12,
|
|
271
|
+
paddingVertical: 8,
|
|
272
|
+
paddingHorizontal: 12,
|
|
273
|
+
backgroundColor: 'rgba(233, 120, 92, 0.14)',
|
|
274
|
+
borderRadius: 8,
|
|
275
|
+
},
|
|
276
|
+
errorText: {
|
|
277
|
+
color: Colors.orange1,
|
|
278
|
+
},
|
|
279
|
+
footer: {
|
|
280
|
+
paddingHorizontal: 24,
|
|
281
|
+
paddingBottom: 32,
|
|
282
|
+
},
|
|
283
|
+
callButton: {
|
|
284
|
+
height: 56,
|
|
285
|
+
borderRadius: 28,
|
|
286
|
+
justifyContent: 'center',
|
|
287
|
+
alignItems: 'center',
|
|
288
|
+
},
|
|
289
|
+
callButtonText: {
|
|
290
|
+
fontSize: 16,
|
|
291
|
+
fontWeight: '600',
|
|
292
|
+
},
|
|
293
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
FlatList,
|
|
7
|
+
Pressable,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { useApiClient } from '../../SquadProvider';
|
|
11
|
+
import { useTheme, Colors } from '../../theme/ThemeContext';
|
|
12
|
+
import ScreenHeader from '../../components/ux/layout/ScreenHeader';
|
|
13
|
+
import Button from '../../components/ux/buttons/Button';
|
|
14
|
+
import { TitleSmall, TitleMedium, TitleLarge, BodyRegular, BodyMedium, BodySmall } from '../../components/ux/text/Typography';
|
|
15
|
+
|
|
16
|
+
interface CouponItem {
|
|
17
|
+
id: string;
|
|
18
|
+
code: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
brandName?: string;
|
|
22
|
+
brandImageUrl?: string;
|
|
23
|
+
discount?: string;
|
|
24
|
+
expiresAt?: string;
|
|
25
|
+
status: string;
|
|
26
|
+
isRedeemed: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function WalletScreen() {
|
|
30
|
+
const apiClient = useApiClient();
|
|
31
|
+
const { theme } = useTheme();
|
|
32
|
+
|
|
33
|
+
const [balance, setBalance] = useState<number>(0);
|
|
34
|
+
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
|
35
|
+
const [loading, setLoading] = useState(true);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const load = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const [wallet, couponsData] = await Promise.all([
|
|
41
|
+
apiClient.getWallet().catch(() => null),
|
|
42
|
+
apiClient.getCoupons().catch(() => null),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
if ((wallet as any)?.balance !== undefined) {
|
|
46
|
+
setBalance((wallet as any).balance);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (couponsData?.coupons) {
|
|
50
|
+
setCoupons(couponsData.coupons.map((c: any) => ({
|
|
51
|
+
id: c.id,
|
|
52
|
+
code: c.code ?? '',
|
|
53
|
+
title: c.title ?? c.name ?? '',
|
|
54
|
+
description: c.description,
|
|
55
|
+
brandName: c.brand?.name,
|
|
56
|
+
brandImageUrl: c.brand?.imageUrl,
|
|
57
|
+
discount: c.discount ?? c.value,
|
|
58
|
+
expiresAt: c.expiresAt,
|
|
59
|
+
status: c.status ?? 'active',
|
|
60
|
+
isRedeemed: c.status === 'redeemed',
|
|
61
|
+
})));
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
setLoading(false);
|
|
65
|
+
};
|
|
66
|
+
load();
|
|
67
|
+
}, [apiClient]);
|
|
68
|
+
|
|
69
|
+
const handleRedeem = useCallback(async (code: string) => {
|
|
70
|
+
try {
|
|
71
|
+
await apiClient.redeemCoupon(code, {});
|
|
72
|
+
setCoupons(prev => prev.map(c =>
|
|
73
|
+
c.code === code ? { ...c, isRedeemed: true, status: 'redeemed' } : c
|
|
74
|
+
));
|
|
75
|
+
} catch {}
|
|
76
|
+
}, [apiClient]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<View style={[styles.container, { backgroundColor: theme.screenBackground }]}>
|
|
80
|
+
<ScreenHeader title="Wallet" />
|
|
81
|
+
|
|
82
|
+
{/* Balance card */}
|
|
83
|
+
<View style={[styles.balanceCard, { borderColor: theme.buttonColor }]}>
|
|
84
|
+
<BodySmall style={styles.balanceLabel}>Your Balance</BodySmall>
|
|
85
|
+
<TitleLarge style={[styles.balanceAmount, { color: theme.buttonColor }]}>
|
|
86
|
+
{balance}
|
|
87
|
+
</TitleLarge>
|
|
88
|
+
<BodySmall style={styles.balanceUnit}>points</BodySmall>
|
|
89
|
+
</View>
|
|
90
|
+
|
|
91
|
+
{/* Coupons */}
|
|
92
|
+
<TitleSmall style={styles.sectionTitle}>Your Coupons</TitleSmall>
|
|
93
|
+
|
|
94
|
+
<FlatList
|
|
95
|
+
data={coupons}
|
|
96
|
+
keyExtractor={item => item.id}
|
|
97
|
+
contentContainerStyle={styles.couponList}
|
|
98
|
+
renderItem={({ item }) => (
|
|
99
|
+
<View style={[styles.couponCard, item.isRedeemed && styles.couponRedeemed]}>
|
|
100
|
+
<View style={styles.couponLeft}>
|
|
101
|
+
{item.brandName && (
|
|
102
|
+
<BodySmall style={styles.brandName}>{item.brandName}</BodySmall>
|
|
103
|
+
)}
|
|
104
|
+
<TitleSmall style={styles.couponTitle}>{item.title}</TitleSmall>
|
|
105
|
+
{item.description && (
|
|
106
|
+
<BodySmall style={styles.couponDesc} numberOfLines={2}>{item.description}</BodySmall>
|
|
107
|
+
)}
|
|
108
|
+
{item.discount && (
|
|
109
|
+
<BodyMedium style={[styles.couponDiscount, { color: theme.buttonColor }]}>
|
|
110
|
+
{item.discount}
|
|
111
|
+
</BodyMedium>
|
|
112
|
+
)}
|
|
113
|
+
</View>
|
|
114
|
+
<View style={styles.couponRight}>
|
|
115
|
+
{item.isRedeemed ? (
|
|
116
|
+
<View style={styles.redeemedBadge}>
|
|
117
|
+
<BodySmall style={styles.redeemedText}>Redeemed</BodySmall>
|
|
118
|
+
</View>
|
|
119
|
+
) : (
|
|
120
|
+
<Button
|
|
121
|
+
style={[styles.redeemButton, { backgroundColor: theme.buttonColor }]}
|
|
122
|
+
onPress={() => handleRedeem(item.code)}
|
|
123
|
+
>
|
|
124
|
+
<Text style={[styles.redeemText, { color: theme.buttonText }]}>Redeem</Text>
|
|
125
|
+
</Button>
|
|
126
|
+
)}
|
|
127
|
+
</View>
|
|
128
|
+
</View>
|
|
129
|
+
)}
|
|
130
|
+
ListEmptyComponent={
|
|
131
|
+
!loading ? (
|
|
132
|
+
<View style={styles.empty}>
|
|
133
|
+
<BodyRegular style={styles.emptyText}>No coupons available</BodyRegular>
|
|
134
|
+
</View>
|
|
135
|
+
) : null
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const styles = StyleSheet.create({
|
|
143
|
+
container: { flex: 1 },
|
|
144
|
+
balanceCard: {
|
|
145
|
+
marginHorizontal: 24, marginTop: 8, marginBottom: 24,
|
|
146
|
+
padding: 24, borderRadius: 16, borderWidth: 1.5,
|
|
147
|
+
backgroundColor: Colors.gray2, alignItems: 'center',
|
|
148
|
+
},
|
|
149
|
+
balanceLabel: { color: Colors.gray6, marginBottom: 4 },
|
|
150
|
+
balanceAmount: { fontSize: 48, lineHeight: 56, fontWeight: '700' },
|
|
151
|
+
balanceUnit: { color: Colors.gray6 },
|
|
152
|
+
sectionTitle: { color: Colors.white, paddingHorizontal: 24, marginBottom: 12 },
|
|
153
|
+
couponList: { paddingHorizontal: 24, gap: 12, paddingBottom: 48 },
|
|
154
|
+
couponCard: {
|
|
155
|
+
flexDirection: 'row', backgroundColor: Colors.gray2,
|
|
156
|
+
borderRadius: 12, padding: 16, gap: 12,
|
|
157
|
+
},
|
|
158
|
+
couponRedeemed: { opacity: 0.6 },
|
|
159
|
+
couponLeft: { flex: 1 },
|
|
160
|
+
couponRight: { justifyContent: 'center' },
|
|
161
|
+
brandName: { color: Colors.gray6, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4 },
|
|
162
|
+
couponTitle: { color: Colors.white, marginBottom: 4 },
|
|
163
|
+
couponDesc: { color: Colors.gray6, marginBottom: 4 },
|
|
164
|
+
couponDiscount: { fontWeight: '600' },
|
|
165
|
+
redeemButton: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 16 },
|
|
166
|
+
redeemText: { fontSize: 13, fontWeight: '600' },
|
|
167
|
+
redeemedBadge: {
|
|
168
|
+
paddingVertical: 6, paddingHorizontal: 12,
|
|
169
|
+
borderRadius: 12, backgroundColor: Colors.gray3,
|
|
170
|
+
},
|
|
171
|
+
redeemedText: { color: Colors.gray6, fontSize: 12 },
|
|
172
|
+
empty: { alignItems: 'center', paddingTop: 32 },
|
|
173
|
+
emptyText: { color: Colors.gray6 },
|
|
174
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthStateManager — manages email/phone session creation and verification.
|
|
3
|
+
* Ported from squad-demo/src/services/AuthStateManager.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { SquadApiClient, CreateSessionStatus } from '@squad-sports/core';
|
|
6
|
+
|
|
7
|
+
export interface AuthState {
|
|
8
|
+
isAuthenticated: boolean;
|
|
9
|
+
isEmailVerified: boolean;
|
|
10
|
+
hasDisplayName: boolean;
|
|
11
|
+
currentAccessToken: string | null;
|
|
12
|
+
currentUserId: string | null;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new email session — sends magic link or OTP code.
|
|
18
|
+
*/
|
|
19
|
+
export async function createEmailSession(
|
|
20
|
+
email: string,
|
|
21
|
+
client: SquadApiClient,
|
|
22
|
+
isFromMagicLink: boolean = false,
|
|
23
|
+
firstName?: string,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
const { CreateSessionRequest } = await import('@squad-sports/core');
|
|
27
|
+
const request = new CreateSessionRequest({ email });
|
|
28
|
+
|
|
29
|
+
const headers: Record<string, string> = {};
|
|
30
|
+
if (firstName) headers['X-First-Name'] = firstName;
|
|
31
|
+
|
|
32
|
+
const response = await client.createSessionV2(request, headers);
|
|
33
|
+
return !!response;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[AuthStateManager] createEmailSession error:', error);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a new phone session — sends SMS OTP code.
|
|
42
|
+
*/
|
|
43
|
+
export async function createPhoneSession(
|
|
44
|
+
phone: string,
|
|
45
|
+
client: SquadApiClient,
|
|
46
|
+
): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
const { CreateSessionRequest } = await import('@squad-sports/core');
|
|
49
|
+
const formattedPhone = phone.startsWith('+') ? phone : `+${phone}`;
|
|
50
|
+
const request = new CreateSessionRequest({ phone: formattedPhone });
|
|
51
|
+
const response = await client.createSessionV2(request);
|
|
52
|
+
return !!response;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('[AuthStateManager] createPhoneSession error:', error);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Verify a session with an OTP code.
|
|
61
|
+
*/
|
|
62
|
+
export async function fulfillSession(
|
|
63
|
+
params: { phone?: string; email?: string; code: string },
|
|
64
|
+
client: SquadApiClient,
|
|
65
|
+
): Promise<{ success: boolean; accessToken?: string; userId?: string }> {
|
|
66
|
+
try {
|
|
67
|
+
const { CreateSessionRequest, CreateSessionStatus } = await import('@squad-sports/core');
|
|
68
|
+
const formattedPhone = params.phone
|
|
69
|
+
? params.phone.startsWith('+') ? params.phone : `+${params.phone}`
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
const request = new CreateSessionRequest({
|
|
73
|
+
phone: formattedPhone,
|
|
74
|
+
email: params.email,
|
|
75
|
+
code: params.code.trim(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const response = await client.fulfillSessionV2(request);
|
|
79
|
+
|
|
80
|
+
if (response?.status === 1 && response?.accessToken) { // ACTIVE = 1
|
|
81
|
+
return {
|
|
82
|
+
success: true,
|
|
83
|
+
accessToken: response.accessToken,
|
|
84
|
+
userId: (response as any).userId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { success: false };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error('[AuthStateManager] fulfillSession error:', error);
|
|
91
|
+
return { success: false };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationService — imperative navigation for use outside React components.
|
|
3
|
+
* Ported from squad-demo/src/services/NavigationService.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { NavigationContainerRef } from '@react-navigation/native';
|
|
6
|
+
|
|
7
|
+
class NavigationService {
|
|
8
|
+
private navRef: NavigationContainerRef<any> | null = null;
|
|
9
|
+
|
|
10
|
+
setNavigationRef(ref: NavigationContainerRef<any>): void {
|
|
11
|
+
this.navRef = ref;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
navigate(name: string, params?: Record<string, unknown>): void {
|
|
15
|
+
if (!this.navRef?.isReady()) {
|
|
16
|
+
console.warn('[NavigationService] Navigation not ready');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
(this.navRef as any).navigate(name, params);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
goBack(): void {
|
|
23
|
+
if (this.navRef?.canGoBack()) {
|
|
24
|
+
this.navRef.goBack();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
reset(routes: Array<{ name: string; params?: Record<string, unknown> }>): void {
|
|
29
|
+
this.navRef?.reset({
|
|
30
|
+
index: 0,
|
|
31
|
+
routes: routes as any,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getCurrentRoute(): string | undefined {
|
|
36
|
+
return this.navRef?.getCurrentRoute()?.name;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const navigationService = new NavigationService();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserDataManager — manages loading/caching user data with storage sync.
|
|
3
|
+
* Ported from squad-demo/src/services/UserDataManager.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { SquadApiClient, User } from '@squad-sports/core';
|
|
6
|
+
|
|
7
|
+
class UserDataManager {
|
|
8
|
+
private cachedUser: User | null = null;
|
|
9
|
+
|
|
10
|
+
async loadUserData(client: SquadApiClient): Promise<User | null> {
|
|
11
|
+
try {
|
|
12
|
+
const user = await client.getLoggedInUser();
|
|
13
|
+
if (user) {
|
|
14
|
+
this.cachedUser = user;
|
|
15
|
+
await this.persistUser(user);
|
|
16
|
+
}
|
|
17
|
+
return user;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error('[UserDataManager] Error loading user:', error);
|
|
20
|
+
return this.cachedUser;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getCachedUser(): User | null {
|
|
25
|
+
return this.cachedUser;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setCachedUser(user: User): void {
|
|
29
|
+
this.cachedUser = user;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
clearCache(): void {
|
|
33
|
+
this.cachedUser = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async persistUser(user: User): Promise<void> {
|
|
37
|
+
try {
|
|
38
|
+
const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default;
|
|
39
|
+
await AsyncStorage.setItem('SQUAD_SDK_CACHED_USER', JSON.stringify(user));
|
|
40
|
+
if (user.id) {
|
|
41
|
+
await AsyncStorage.setItem('SQUAD_SDK_AUTH_USER_ID', user.id);
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async loadCachedUser(): Promise<User | null> {
|
|
47
|
+
try {
|
|
48
|
+
const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default;
|
|
49
|
+
const raw = await AsyncStorage.getItem('SQUAD_SDK_CACHED_USER');
|
|
50
|
+
if (raw) {
|
|
51
|
+
this.cachedUser = JSON.parse(raw);
|
|
52
|
+
return this.cachedUser;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const userDataManager = new UserDataManager();
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserUpdateService — handles user profile updates with retry logic.
|
|
3
|
+
* Ported from squad-demo/src/services/UserUpdateService.ts.
|
|
4
|
+
*/
|
|
5
|
+
import type { SquadApiClient, User } from '@squad-sports/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Update user profile with automatic retry on failure.
|
|
9
|
+
*/
|
|
10
|
+
export async function updateUser(
|
|
11
|
+
client: SquadApiClient,
|
|
12
|
+
user: User,
|
|
13
|
+
maxRetries: number = 2,
|
|
14
|
+
): Promise<User | null> {
|
|
15
|
+
let lastError: Error | null = null;
|
|
16
|
+
|
|
17
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await client.updateLoggedInUser(user);
|
|
20
|
+
return result;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
23
|
+
if (attempt < maxRetries) {
|
|
24
|
+
await new Promise<void>(r => setTimeout(() => r(), 1000 * (attempt + 1)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.error('[UserUpdateService] All retry attempts failed:', lastError);
|
|
30
|
+
return null;
|
|
31
|
+
}
|