working on onboarding flow

This commit is contained in:
dimitar committed 2025-12-13 06:26:23 +01:00
1 parent 868eaa5e3d
commit d3a36b6103
19 files changed
+2062 -393

No files matched your search

+96 -5
View File
@@ -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}
+224 -48
View File
@@ -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,
+15 -14
View File
@@ -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`,
},
}
};