working on onboarding flow
This commit is contained in:
1 parent
868eaa5e3d
commit
d3a36b6103
19 files changed
+2062
-393
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -18,6 +18,32 @@ export default function OnboardingScreen() {
|
||||
const { getToken } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [gyms, setGyms] = useState<
|
||||
Array<{ id: string; name: string; location?: string }>
|
||||
>([]);
|
||||
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
|
||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGyms = async () => {
|
||||
try {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const res = await fetch("/api/gyms", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setGyms(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch gyms:", e);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
};
|
||||
loadGyms();
|
||||
}, []);
|
||||
const [fitnessProfile, setFitnessProfile] = useState({
|
||||
height: "",
|
||||
weight: "",
|
||||
@@ -41,6 +67,26 @@ export default function OnboardingScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not available");
|
||||
}
|
||||
|
||||
// If gym was selected or cleared, patch user's gym selection first
|
||||
// selectedGymId: string gym id, or null to proceed without gym
|
||||
try {
|
||||
await fetch("/api/users/gym", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to update gym selection:", e);
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
clientId: user.id,
|
||||
height: parseFloat(fitnessProfile.height),
|
||||
@@ -59,10 +105,6 @@ export default function OnboardingScreen() {
|
||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
||||
};
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not available");
|
||||
}
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
router.replace("/(tabs)");
|
||||
} catch (error) {
|
||||
@@ -213,6 +255,55 @@ export default function OnboardingScreen() {
|
||||
placeholder="Number of workouts per week"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Select a Gym</Text>
|
||||
{gymsLoading ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.levelButton,
|
||||
selectedGymId === null && styles.selectedButton,
|
||||
]}
|
||||
onPress={() => setSelectedGymId(null)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.levelButtonText,
|
||||
selectedGymId === null && styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
Proceed without gym
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{gyms.map((gym) => (
|
||||
<TouchableOpacity
|
||||
key={gym.id}
|
||||
style={[
|
||||
styles.levelButton,
|
||||
selectedGymId === gym.id && styles.selectedButton,
|
||||
]}
|
||||
onPress={() => setSelectedGymId(gym.id)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.levelButtonText,
|
||||
selectedGymId === gym.id && styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
{gym.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
onPress={handleSubmit}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
|
||||
import { useUser, useClerk } from "@clerk/clerk-expo";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useUser, useClerk, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -21,14 +30,55 @@ export default function ProfileScreen() {
|
||||
};
|
||||
|
||||
const confirmSignOut = () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
||||
]
|
||||
);
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
||||
]);
|
||||
};
|
||||
|
||||
// Gym selection state and handlers
|
||||
const { getToken } = useAuth();
|
||||
const [gyms, setGyms] = useState<
|
||||
Array<{ id: string; name: string; location?: string }>
|
||||
>([]);
|
||||
const [gymsLoading, setGymsLoading] = useState(false);
|
||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||
|
||||
const loadGyms = async () => {
|
||||
try {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const res = await fetch("/api/gyms", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
setGyms(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch gyms:", err);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGym = async () => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
await fetch("/api/users/gym", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
Alert.alert(
|
||||
"Success",
|
||||
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update gym selection:", err);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -48,7 +98,9 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.name}>{user?.fullName || "User"}</Text>
|
||||
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
|
||||
<Text style={styles.email}>
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
<View style={styles.memberBadge}>
|
||||
<Text style={styles.memberText}>Premium Member</Text>
|
||||
</View>
|
||||
@@ -59,39 +111,144 @@ export default function ProfileScreen() {
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/personal-details')}>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => router.push("/personal-details")}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
|
||||
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
|
||||
<Ionicons
|
||||
name="person-outline"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Personal Details</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => router.push("/fitness-profile")}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
|
||||
colors={["rgba(16, 185, 129, 0.1)", "rgba(16, 185, 129, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
|
||||
<Ionicons
|
||||
name="fitness-outline"
|
||||
size={20}
|
||||
color={theme.colors.success}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Fitness Profile</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
|
||||
colors={["rgba(245, 158, 11, 0.1)", "rgba(245, 158, 11, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
|
||||
<Ionicons
|
||||
name="notifications-outline"
|
||||
size={20}
|
||||
color={theme.colors.warning}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Notifications</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Gym Selection */}
|
||||
<View
|
||||
style={[styles.infoCard, theme.shadows.subtle, { marginTop: 12 }]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={styles.infoLabel}>Gym</Text>
|
||||
<TouchableOpacity onPress={loadGyms}>
|
||||
<Text style={{ color: theme.colors.primary }}>
|
||||
Refresh Gyms
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{gymsLoading ? (
|
||||
<ActivityIndicator style={{ padding: 12 }} />
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ paddingHorizontal: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.infoRow,
|
||||
selectedGymId === null && {
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedGymId(null)}
|
||||
>
|
||||
<Text style={styles.infoLabel}>Proceed without gym</Text>
|
||||
</TouchableOpacity>
|
||||
{gyms.map((gym) => (
|
||||
<TouchableOpacity
|
||||
key={gym.id}
|
||||
style={[
|
||||
styles.infoRow,
|
||||
selectedGymId === gym.id && {
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedGymId(gym.id)}
|
||||
>
|
||||
<Text style={styles.infoLabel}>{gym.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={{ padding: 12 }}>
|
||||
<AnimatedButton
|
||||
title="Apply Gym Selection"
|
||||
onPress={handleApplyGym}
|
||||
variant="primary"
|
||||
style={{ marginTop: 4 }}
|
||||
icon={
|
||||
<Ionicons
|
||||
name="checkmark-outline"
|
||||
size={20}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
@@ -99,24 +256,43 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
|
||||
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
|
||||
<Ionicons
|
||||
name="help-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.secondary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Help Center</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
|
||||
colors={[
|
||||
"rgba(107, 114, 128, 0.1)",
|
||||
"rgba(107, 114, 128, 0.05)",
|
||||
]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={20}
|
||||
color={theme.colors.gray600}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Privacy & Security</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -145,13 +321,13 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 30,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
profileCard: {
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
position: "relative",
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
@@ -159,28 +335,28 @@ const styles = StyleSheet.create({
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
placeholderAvatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
editBadge: {
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: theme.colors.white,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
@@ -188,23 +364,23 @@ const styles = StyleSheet.create({
|
||||
elevation: 3,
|
||||
},
|
||||
name: {
|
||||
fontSize: theme.typography.fontSize['2xl'],
|
||||
fontSize: theme.typography.fontSize["2xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
marginBottom: 12,
|
||||
},
|
||||
memberBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
memberText: {
|
||||
color: theme.colors.white,
|
||||
@@ -234,16 +410,16 @@ const styles = StyleSheet.create({
|
||||
borderColor: theme.colors.gray100,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
infoLabel: {
|
||||
@@ -261,7 +437,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 8,
|
||||
},
|
||||
version: {
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
marginTop: 24,
|
||||
color: theme.colors.gray400,
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'https://e0877d294c41.ngrok-free.app'
|
||||
: 'https://your-production-url.com'
|
||||
? "https://e0877d294c41.ngrok-free.app"
|
||||
: "https://your-production-url.com";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/api/auth/login',
|
||||
REGISTER: '/api/auth/register',
|
||||
LOGIN: "/api/auth/login",
|
||||
REGISTER: "/api/auth/register",
|
||||
},
|
||||
PROFILE: {
|
||||
FITNESS: '/api/profile/fitness',
|
||||
FITNESS: "/api/profile/fitness",
|
||||
},
|
||||
CLIENTS: '/api/clients',
|
||||
USERS: '/api/users',
|
||||
CLIENTS: "/api/clients",
|
||||
USERS: "/api/users",
|
||||
GYMS: "/api/gyms",
|
||||
ATTENDANCE: {
|
||||
CHECK_IN: '/api/attendance/check-in',
|
||||
CHECK_OUT: '/api/attendance/check-out',
|
||||
HISTORY: '/api/attendance/history',
|
||||
CHECK_IN: "/api/attendance/check-in",
|
||||
CHECK_OUT: "/api/attendance/check-out",
|
||||
HISTORY: "/api/attendance/history",
|
||||
},
|
||||
RECOMMENDATIONS: '/api/recommendations',
|
||||
RECOMMENDATIONS: "/api/recommendations",
|
||||
FITNESS_GOALS: {
|
||||
LIST: '/api/fitness-goals',
|
||||
CREATE: '/api/fitness-goals',
|
||||
LIST: "/api/fitness-goals",
|
||||
CREATE: "/api/fitness-goals",
|
||||
GET: (id: string) => `/api/fitness-goals/${id}`,
|
||||
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
||||
},
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user