basic clerk auth

need polishing
This commit is contained in:
echo 2025-11-09 19:46:30 +01:00
parent ca790a7b97
commit 73907568ef
20 changed files with 2334 additions and 1123 deletions

View File

@ -43,3 +43,5 @@ android/app/build/generated/
# Bundle artifact
*.jsbundle
# clerk configuration (can include secrets)
/.clerk/

Binary file not shown.

12
apps/admin/middleware.ts Normal file
View File

@ -0,0 +1,12 @@
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware()
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
}

View File

@ -8,6 +8,7 @@
"name": "@fitai/admin",
"version": "1.0.0",
"dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.1.17",
@ -600,6 +601,103 @@
"dev": true,
"license": "MIT"
},
"node_modules/@clerk/backend": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-2.20.0.tgz",
"integrity": "sha512-RcZN7CAxGkkLydGtWpxCyq4C0pSo/1ch0LJMDQnckrt10Jx8mAjwce2nZQa2xRykxsOla4+boF9a5kDw3nUvVg==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.31.1",
"@clerk/types": "^4.97.2",
"cookie": "1.0.2",
"standardwebhooks": "^1.0.0",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@clerk/clerk-react": {
"version": "5.53.8",
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.53.8.tgz",
"integrity": "sha512-TOiYk31rQUL9JOKZr/fhajf+fQCHicy1J4Rxq7vqtjHseJsnIBjzTigjOap/w8PrDAF28O6dbPC5CA0Tp7Md8w==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.31.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
}
},
"node_modules/@clerk/nextjs": {
"version": "6.34.5",
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.34.5.tgz",
"integrity": "sha512-f1OyucHc5HHBZovzEtJrPR0MUePZxEH2mqu3dt24iGTWTmV2UPnHMB5uSi4XVSWcungnzHWKgTKnHKTVF3vxUA==",
"license": "MIT",
"dependencies": {
"@clerk/backend": "^2.20.0",
"@clerk/clerk-react": "^5.53.8",
"@clerk/shared": "^3.31.1",
"@clerk/types": "^4.97.2",
"server-only": "0.0.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16",
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
}
},
"node_modules/@clerk/shared": {
"version": "3.31.1",
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.31.1.tgz",
"integrity": "sha512-mqxZqlzLJYJxA+ryLzhwFR0eO73teAvRd+wvA8bLUZLYvCRFvaiHsB9dEvbo9Z5bMYdq3NPwnx2uljMuu/tiQw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"csstype": "3.1.3",
"dequal": "2.0.3",
"glob-to-regexp": "0.4.1",
"js-cookie": "3.0.5",
"std-env": "^3.9.0",
"swr": "2.3.4"
},
"engines": {
"node": ">=18.17.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-0",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@clerk/types": {
"version": "4.97.2",
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.97.2.tgz",
"integrity": "sha512-xnJq3xzpmuuDnNnWuUMKJLPPkaEaLDM0kiv2Hm0gKIcL1+1P3VaGf2vL9roIhmhLswB2PUwtVvZKBmGjT5yOVw==",
"license": "MIT",
"dependencies": {
"@clerk/shared": "^3.31.1"
},
"engines": {
"node": ">=18.17.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
@ -2203,6 +2301,12 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -4607,6 +4711,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4633,7 +4746,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@ -4954,9 +5066,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@ -5933,6 +6043,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -6414,6 +6530,12 @@
"node": ">=10.13.0"
}
},
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -8333,6 +8455,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -10652,6 +10783,12 @@
"node": ">=10"
}
},
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
@ -11091,6 +11228,22 @@
"node": ">=8"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@ -11433,6 +11586,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz",
"integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",

View File

@ -11,6 +11,7 @@
"test": "jest"
},
"dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.1.17",

View File

@ -1,22 +1,27 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ClerkProvider } from "@clerk/nextjs";
const inter = Inter({ subsets: ['latin'] })
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'FitAI Admin',
description: 'Fitness management admin dashboard',
}
title: "FitAI Admin",
description: "Fitness management admin dashboard",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
</ClerkProvider>
);
}

View File

@ -3,6 +3,7 @@
import Link from "next/link";
import { UserManagement } from "@/components/users/UserManagement";
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
export default function Home() {
return (
@ -12,22 +13,17 @@ export default function Home() {
<h1 className="text-4xl font-bold text-gray-900">
FitAI Admin Dashboard
</h1>
<nav className="flex gap-4">
<Link
href="/users"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
User Management
</Link>
<Link
href="/analytics"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
>
Analytics
</Link>
</nav>
<div className="flex items-center gap-4">
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</div>
</div>
<SignedIn>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Client Management</h2>
@ -37,11 +33,15 @@ export default function Home() {
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Payment Tracking</h2>
<p className="text-gray-600">Monitor payments and subscriptions</p>
<p className="text-gray-600">
Monitor payments and subscriptions
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Attendance</h2>
<p className="text-gray-600">Track client attendance and habits</p>
<p className="text-gray-600">
Track client attendance and habits
</p>
</div>
</div>
@ -62,6 +62,16 @@ export default function Home() {
</div>
</div>
</div>
</SignedIn>
<SignedOut>
<div className="text-center">
<p className="text-gray-600 mb-4">
Please sign in to access the admin dashboard.
</p>
<SignInButton />
</div>
</SignedOut>
</div>
</main>
);

View File

@ -5,7 +5,7 @@ import { AgCharts } from "ag-charts-react";
import { AgChartOptions } from "ag-charts-community";
interface BarData {
category: string;
label: string;
value: number;
color?: string;
}
@ -30,7 +30,7 @@ export function RevenueChart({
series: [
{
type: "bar",
xKey: "category",
xKey: "label",
yKey: "value",
fills: data.map((item) => item.color || "#10b981"),
strokes: ["#ffffff"],
@ -55,7 +55,7 @@ export function RevenueChart({
enabled: true,
renderer: (params: any) => {
return `<div class="bg-white p-2 rounded shadow-lg border">
<div class="font-bold">${params.datum.category}</div>
<div class="font-bold">${params.datum.label}</div>
<div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div>
</div>`;
},

View File

@ -163,14 +163,17 @@ export function UserGrid({
const gridRef = React.useRef<AgGridReact<User>>(null);
const gridOptions = {
theme: "legacy",
theme: "legacy" as const,
columnDefs,
defaultColDef,
rowData: users,
rowSelection: "multiple",
rowSelection: { mode: "multiRow" as const },
onSelectionChanged: () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes();
const selectedData = selectedNodes?.map((node) => node.data) || [];
const selectedData =
selectedNodes
?.map((node) => node.data)
.filter((data): data is User => data !== undefined) || [];
setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]);

View File

@ -191,14 +191,14 @@ export function UserManagement() {
</Button>
<Button
variant="secondary"
onClick={handleEditUser}
onClick={() => handleEditUser(selectedUser!)}
disabled={!selectedUser}
>
Edit User
</Button>
<Button
variant="secondary"
onClick={handleDeleteUser}
onClick={() => handleDeleteUser(selectedUser!)}
disabled={!selectedUser}
>
Delete User

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@
"test": "jest"
},
"dependencies": {
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0",
@ -21,12 +22,15 @@
"ajv-keywords": "^5.1.0",
"axios": "^1.6.0",
"expo": "~54.0.23",
"expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
@ -35,15 +39,15 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/react-native": "^12.4.0",
"@types/react": "~19.1.10",
"@types/react-native": "^0.73.0",
"typescript": "^5.1.3",
"eslint": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"babel-preset-expo": "~54.0.7",
"eslint": "^8.45.0",
"jest": "^29.2.1",
"@testing-library/react-native": "^12.4.0",
"react-test-renderer": "19.1.0",
"babel-preset-expo": "~54.0.7"
"typescript": "^5.1.3"
}
}

View File

@ -1,5 +1,5 @@
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() {
return (
@ -7,7 +7,7 @@ export default function TabLayout() {
<Tabs.Screen
name="index"
options={{
title: 'Home',
title: "Home",
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
@ -16,7 +16,7 @@ export default function TabLayout() {
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
title: "Profile",
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
@ -25,12 +25,12 @@ export default function TabLayout() {
<Tabs.Screen
name="attendance"
options={{
title: 'Attendance',
title: "Attendance",
tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} />
),
}}
/>
</Tabs>
)
);
}

View File

@ -1,33 +1,32 @@
import React from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
import React, { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useUser, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
export default function ProfileScreen() {
const { user, logout } = useAuth()
const router = useRouter()
const { user } = useUser();
const { signOut } = useAuth();
const router = useRouter();
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
useEffect(() => {
const checkProfile = async () => {
try {
await logout()
router.replace('/login')
const response = await axios.get(
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`,
);
if (response.status === 200 && response.data) {
router.replace("/activities");
}
} catch (error) {
Alert.alert('Error', 'Failed to logout')
// Profile not found, stay on profile
}
},
},
]
)
};
if (user) {
checkProfile();
}
}, [user, router]);
return (
<View style={styles.container}>
@ -36,35 +35,39 @@ export default function ProfileScreen() {
<Text style={styles.name}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.email}>{user?.email}</Text>
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
<Text style={styles.email}>
{user?.primaryEmailAddress?.emailAddress}
</Text>
{user?.phoneNumbers?.[0] && (
<Text style={styles.phone}>{user.phoneNumbers[0].phoneNumber}</Text>
)}
<View style={styles.roleBadge}>
<Text style={styles.roleText}>
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
</Text>
<Text style={styles.roleText}>User</Text>
</View>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<View style={styles.logoutButton}>
<TouchableOpacity onPress={() => signOut()}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
)
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
},
profileCard: {
backgroundColor: 'white',
backgroundColor: "white",
borderRadius: 12,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
@ -72,45 +75,45 @@ const styles = StyleSheet.create({
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontWeight: "bold",
marginBottom: 16,
},
name: {
fontSize: 20,
fontWeight: '600',
fontWeight: "600",
marginBottom: 4,
},
email: {
fontSize: 16,
color: '#666',
color: "#666",
marginBottom: 4,
},
phone: {
fontSize: 16,
color: '#666',
color: "#666",
marginBottom: 16,
},
roleBadge: {
backgroundColor: '#3b82f6',
backgroundColor: "#3b82f6",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
roleText: {
color: 'white',
color: "white",
fontSize: 14,
fontWeight: '600',
fontWeight: "600",
},
logoutButton: {
backgroundColor: '#ef4444',
backgroundColor: "#ef4444",
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
alignItems: "center",
marginTop: 24,
},
logoutText: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: '600',
fontWeight: "600",
},
})
});

View File

@ -1,15 +1,12 @@
import { AuthProvider } from '@/contexts/AuthContext'
import { Stack } from 'expo-router'
import { View, Text } from 'react-native'
import { ClerkProvider } from "@clerk/clerk-expo";
import { Slot } from "expo-router";
export default function RootLayout() {
return (
<AuthProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
)
<ClerkProvider
publishableKey={process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY}
>
<Slot />
</ClerkProvider>
);
}

View File

@ -1,161 +1,139 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
import { useSignIn, useOAuth, useUser } from "@clerk/clerk-expo";
import { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
Alert,
StyleSheet,
} from "react-native";
import { useRouter } from "expo-router";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
export default function LoginScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const { signIn, setActive } = useSignIn();
const { startOAuthFlow } = useOAuth({ strategy: "oauth_google" });
const { user } = useUser();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
const handleLogin = async () => {
if (!formData.email || !formData.password) {
Alert.alert('Error', 'Please fill in all fields')
return
const handleSignIn = async () => {
if (!email || !password) {
Alert.alert("Error", "Please fill in all fields");
return;
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
const result = await signIn.create({
identifier: email,
password,
});
if (response.data.user) {
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
// Check if user has completed fitness profile
try {
const profileResponse = await axios.get(
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}?userId=${response.data.user.id}`
)
if (profileResponse.data.profile) {
// User has profile, go to main app
Alert.alert('Success', 'Login successful!', [
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
])
if (result.status === "complete") {
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)/profile");
} else {
// New user, go to welcome page
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
Alert.alert("Error", "Sign in failed");
}
} catch (profileError) {
// Profile doesn't exist or server error, treat as new user
console.log('Profile check failed:', profileError)
Alert.alert('Welcome!', 'Let\'s set up your fitness profile', [
{ text: 'OK', onPress: () => router.replace('/welcome') }
])
} catch (err: any) {
Alert.alert("Error", err.message || "Sign in failed");
}
};
const handleGoogleSignIn = async () => {
try {
const result = await startOAuthFlow();
if (result.createdSessionId) {
router.replace("/(tabs)/profile");
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
} catch (err: any) {
Alert.alert("Error", err.message || "Google sign in failed");
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Login to your FitAI account</Text>
<View style={styles.form}>
<Text style={styles.title}>Sign In</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
value={email}
onChangeText={setEmail}
style={styles.input}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
value={password}
onChangeText={setPassword}
style={styles.input}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
<TouchableOpacity onPress={handleSignIn} style={styles.button}>
<Text style={styles.buttonText}>Sign In</Text>
</TouchableOpacity>
<Text style={styles.orText}>Or</Text>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/register')}
onPress={handleGoogleSignIn}
style={styles.googleButton}
>
<Text style={styles.linkText}>
Don't have an account? Register
</Text>
<Text style={styles.googleButtonText}>Sign In with Google</Text>
</TouchableOpacity>
</View>
</View>
)
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
justifyContent: "center",
padding: 20,
backgroundColor: "#f5f5f5",
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 20,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
padding: 15,
marginBottom: 15,
borderRadius: 8,
backgroundColor: "white",
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
backgroundColor: "#3b82f6",
padding: 15,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
alignItems: "center",
marginBottom: 10,
},
buttonText: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: '600',
fontWeight: "600",
},
linkButton: {
alignItems: 'center',
orText: {
textAlign: "center",
marginVertical: 10,
fontSize: 16,
color: "#666",
},
linkText: {
color: '#3b82f6',
fontSize: 14,
googleButton: {
backgroundColor: "#db4437",
padding: 15,
borderRadius: 8,
alignItems: "center",
},
})
googleButtonText: {
color: "white",
fontSize: 16,
fontWeight: "600",
},
});

View File

@ -1,164 +1,117 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
import { useSignUp } from "@clerk/clerk-expo";
import { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
Alert,
StyleSheet,
} from "react-native";
import { useRouter } from "expo-router";
export default function RegisterScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
phone: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const { signUp, setActive } = useSignUp();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const router = useRouter();
const handleRegister = async () => {
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
Alert.alert('Error', 'Please fill in all required fields')
return
const handleSignUp = async () => {
if (!email || !password || !firstName || !lastName) {
Alert.alert("Error", "Please fill in all fields");
return;
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData)
const result = await signUp.create({
emailAddress: email,
password,
firstName,
lastName,
});
if (response.status === 201) {
Alert.alert('Success', 'Registration successful! Please login.', [
{ text: 'OK', onPress: () => router.push('/login') }
])
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
} finally {
setLoading(false)
if (result.status === "complete") {
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)/profile");
} else {
Alert.alert("Error", "Sign up failed");
}
} catch (err: any) {
Alert.alert("Error", err.message || "Sign up failed");
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join FitAI today</Text>
<View style={styles.form}>
<Text style={styles.title}>Sign Up</Text>
<TextInput
style={styles.input}
placeholder="First Name"
value={formData.firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
value={firstName}
onChangeText={setFirstName}
style={styles.input}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Last Name"
value={formData.lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
value={lastName}
onChangeText={setLastName}
style={styles.input}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
value={email}
onChangeText={setEmail}
style={styles.input}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Phone (optional)"
value={formData.phone}
onChangeText={(text) => setFormData({ ...formData, phone: text })}
keyboardType="phone-pad"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
value={password}
onChangeText={setPassword}
style={styles.input}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Creating Account...' : 'Create Account'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/login')}
>
<Text style={styles.linkText}>
Already have an account? Login
</Text>
<TouchableOpacity onPress={handleSignUp} style={styles.button}>
<Text style={styles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</View>
</View>
)
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
justifyContent: "center",
padding: 20,
backgroundColor: "#f5f5f5",
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
fontSize: 24,
fontWeight: "bold",
textAlign: "center",
marginBottom: 20,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
padding: 15,
marginBottom: 15,
borderRadius: 8,
backgroundColor: "white",
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
backgroundColor: "#3b82f6",
padding: 15,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
alignItems: "center",
},
buttonText: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: '600',
fontWeight: "600",
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})
});

View File

@ -1,99 +1,106 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, ScrollView } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ScrollView,
} from "react-native";
import { useRouter } from "expo-router";
import { useUser } from "@clerk/clerk-expo";
import axios from "axios";
import { API_BASE_URL, API_ENDPOINTS } from "../config/api";
interface FitnessProfile {
height: string
weight: string
age: string
gender: 'male' | 'female' | 'other'
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active'
fitnessGoals: string[]
exerciseHabits: string
dietHabits: string
medicalConditions: string
height: string;
weight: string;
age: string;
gender: "male" | "female" | "other";
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
fitnessGoals: string[];
exerciseHabits: string;
dietHabits: string;
medicalConditions: string;
}
export default function WelcomeScreen() {
const [profile, setProfile] = useState<FitnessProfile>({
height: '',
weight: '',
age: '',
gender: 'male',
activityLevel: 'moderate',
height: "",
weight: "",
age: "",
gender: "male",
activityLevel: "moderate",
fitnessGoals: [],
exerciseHabits: '',
dietHabits: '',
medicalConditions: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
exerciseHabits: "",
dietHabits: "",
medicalConditions: "",
});
const [loading, setLoading] = useState(false);
const router = useRouter();
const fitnessGoalsOptions = [
'Weight Loss',
'Muscle Gain',
'Improve Endurance',
'Better Flexibility',
'General Fitness',
'Strength Training',
'Cardio Health'
]
"Weight Loss",
"Muscle Gain",
"Improve Endurance",
"Better Flexibility",
"General Fitness",
"Strength Training",
"Cardio Health",
];
const activityLevels = [
{ value: 'sedentary', label: 'Sedentary (little or no exercise)' },
{ value: 'light', label: 'Light (1-3 days/week)' },
{ value: 'moderate', label: 'Moderate (3-5 days/week)' },
{ value: 'active', label: 'Active (6-7 days/week)' },
{ value: 'very_active', label: 'Very Active (twice per day)' }
]
{ value: "sedentary", label: "Sedentary (little or no exercise)" },
{ value: "light", label: "Light (1-3 days/week)" },
{ value: "moderate", label: "Moderate (3-5 days/week)" },
{ value: "active", label: "Active (6-7 days/week)" },
{ value: "very_active", label: "Very Active (twice per day)" },
];
const toggleGoal = (goal: string) => {
setProfile(prev => ({
setProfile((prev) => ({
...prev,
fitnessGoals: prev.fitnessGoals.includes(goal)
? prev.fitnessGoals.filter(g => g !== goal)
: [...prev.fitnessGoals, goal]
}))
}
? prev.fitnessGoals.filter((g) => g !== goal)
: [...prev.fitnessGoals, goal],
}));
};
const { user } = useUser();
const handleSubmit = async () => {
if (!profile.height || !profile.weight || !profile.age) {
Alert.alert('Error', 'Please fill in all required fields')
return
Alert.alert("Error", "Please fill in all required fields");
return;
}
setLoading(true)
setLoading(true);
try {
const user = await SecureStore.getItemAsync('user')
if (!user) {
throw new Error('No user found')
}
const userData = JSON.parse(user)
const response = await axios.post(
`${API_BASE_URL}${API_ENDPOINTS.PROFILE.FITNESS}`,
{
userId: userData.id,
...profile
}
)
userId: user?.id,
...profile,
},
);
if (response.status === 201) {
Alert.alert('Success', 'Profile completed successfully!', [
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
])
Alert.alert("Success", "Profile completed successfully!", [
{ text: "OK", onPress: () => router.replace("/(tabs)") },
]);
}
} catch (error: any) {
console.log('Profile save error:', error)
Alert.alert('Error', error.response?.data?.error || 'Failed to save profile. Please try again.')
console.log("Profile save error:", error);
Alert.alert(
"Error",
error.response?.data?.error ||
"Failed to save profile. Please try again.",
);
} finally {
setLoading(false)
}
setLoading(false);
}
};
return (
<ScrollView style={styles.container}>
@ -110,7 +117,9 @@ export default function WelcomeScreen() {
<TextInput
style={styles.input}
value={profile.height}
onChangeText={(text) => setProfile({ ...profile, height: text })}
onChangeText={(text) =>
setProfile({ ...profile, height: text })
}
keyboardType="numeric"
placeholder="170"
/>
@ -121,7 +130,9 @@ export default function WelcomeScreen() {
<TextInput
style={styles.input}
value={profile.weight}
onChangeText={(text) => setProfile({ ...profile, weight: text })}
onChangeText={(text) =>
setProfile({ ...profile, weight: text })
}
keyboardType="numeric"
placeholder="70"
/>
@ -143,19 +154,22 @@ export default function WelcomeScreen() {
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Gender</Text>
<View style={styles.genderRow}>
{(['male', 'female', 'other'] as const).map((gender) => (
{(["male", "female", "other"] as const).map((gender) => (
<TouchableOpacity
key={gender}
style={[
styles.genderButton,
profile.gender === gender && styles.genderButtonSelected
profile.gender === gender && styles.genderButtonSelected,
]}
onPress={() => setProfile({ ...profile, gender })}
>
<Text style={[
<Text
style={[
styles.genderButtonText,
profile.gender === gender && styles.genderButtonTextSelected
]}>
profile.gender === gender &&
styles.genderButtonTextSelected,
]}
>
{gender.charAt(0).toUpperCase() + gender.slice(1)}
</Text>
</TouchableOpacity>
@ -172,14 +186,20 @@ export default function WelcomeScreen() {
key={level.value}
style={[
styles.activityOption,
profile.activityLevel === level.value && styles.activityOptionSelected
profile.activityLevel === level.value &&
styles.activityOptionSelected,
]}
onPress={() => setProfile({ ...profile, activityLevel: level.value as any })}
onPress={() =>
setProfile({ ...profile, activityLevel: level.value as any })
}
>
<Text style={[
<Text
style={[
styles.activityText,
profile.activityLevel === level.value && styles.activityTextSelected
]}>
profile.activityLevel === level.value &&
styles.activityTextSelected,
]}
>
{level.label}
</Text>
</TouchableOpacity>
@ -195,14 +215,18 @@ export default function WelcomeScreen() {
key={goal}
style={[
styles.goalButton,
profile.fitnessGoals.includes(goal) && styles.goalButtonSelected
profile.fitnessGoals.includes(goal) &&
styles.goalButtonSelected,
]}
onPress={() => toggleGoal(goal)}
>
<Text style={[
<Text
style={[
styles.goalButtonText,
profile.fitnessGoals.includes(goal) && styles.goalButtonTextSelected
]}>
profile.fitnessGoals.includes(goal) &&
styles.goalButtonTextSelected,
]}
>
{goal}
</Text>
</TouchableOpacity>
@ -215,7 +239,9 @@ export default function WelcomeScreen() {
<TextInput
style={[styles.input, styles.textArea]}
value={profile.exerciseHabits}
onChangeText={(text) => setProfile({ ...profile, exerciseHabits: text })}
onChangeText={(text) =>
setProfile({ ...profile, exerciseHabits: text })
}
placeholder="Describe your current exercise routine..."
multiline
numberOfLines={3}
@ -227,7 +253,9 @@ export default function WelcomeScreen() {
<TextInput
style={[styles.input, styles.textArea]}
value={profile.dietHabits}
onChangeText={(text) => setProfile({ ...profile, dietHabits: text })}
onChangeText={(text) =>
setProfile({ ...profile, dietHabits: text })
}
placeholder="Describe your current eating habits..."
multiline
numberOfLines={3}
@ -239,7 +267,9 @@ export default function WelcomeScreen() {
<TextInput
style={[styles.input, styles.textArea]}
value={profile.medicalConditions}
onChangeText={(text) => setProfile({ ...profile, medicalConditions: text })}
onChangeText={(text) =>
setProfile({ ...profile, medicalConditions: text })
}
placeholder="Any medical conditions we should know about..."
multiline
numberOfLines={3}
@ -252,51 +282,51 @@ export default function WelcomeScreen() {
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Saving...' : 'Complete Profile'}
{loading ? "Saving..." : "Complete Profile"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
)
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
backgroundColor: "#f5f5f5",
},
content: {
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
fontWeight: "bold",
marginBottom: 8,
color: '#333',
textAlign: 'center',
color: "#333",
textAlign: "center",
},
subtitle: {
fontSize: 16,
color: '#666',
color: "#666",
marginBottom: 32,
textAlign: 'center',
textAlign: "center",
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
fontWeight: "600",
marginBottom: 12,
color: '#333',
color: "#333",
},
sectionSubtitle: {
fontSize: 14,
color: '#666',
color: "#666",
marginBottom: 12,
},
row: {
flexDirection: 'row',
flexDirection: "row",
marginBottom: 16,
},
inputContainer: {
@ -304,105 +334,105 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 14,
fontWeight: '500',
fontWeight: "500",
marginBottom: 6,
color: '#333',
color: "#333",
},
input: {
backgroundColor: 'white',
backgroundColor: "white",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
fontSize: 16,
},
textArea: {
height: 80,
textAlignVertical: 'top',
textAlignVertical: "top",
},
genderRow: {
flexDirection: 'row',
flexDirection: "row",
},
genderButton: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 8,
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
borderRadius: 8,
alignItems: 'center',
alignItems: "center",
marginRight: 4,
},
genderButtonSelected: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
},
genderButtonText: {
fontSize: 12,
color: '#666',
color: "#666",
},
genderButtonTextSelected: {
color: 'white',
color: "white",
},
activityOption: {
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
borderRadius: 8,
marginBottom: 8,
backgroundColor: 'white',
backgroundColor: "white",
},
activityOptionSelected: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
},
activityText: {
fontSize: 14,
color: '#333',
color: "#333",
},
activityTextSelected: {
color: 'white',
color: "white",
},
goalsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
flexDirection: "row",
flexWrap: "wrap",
marginHorizontal: -4,
},
goalButton: {
backgroundColor: 'white',
backgroundColor: "white",
borderWidth: 1,
borderColor: '#ddd',
borderColor: "#ddd",
borderRadius: 20,
paddingHorizontal: 12,
paddingVertical: 8,
margin: 4,
},
goalButtonSelected: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
},
goalButtonText: {
fontSize: 12,
color: '#666',
color: "#666",
},
goalButtonTextSelected: {
color: 'white',
color: "white",
},
button: {
backgroundColor: '#3b82f6',
backgroundColor: "#3b82f6",
paddingVertical: 16,
borderRadius: 8,
alignItems: 'center',
alignItems: "center",
marginTop: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
backgroundColor: "#9ca3af",
},
buttonText: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: '600',
fontWeight: "600",
},
})
});

View File

@ -1,76 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import * as SecureStore from 'expo-secure-store'
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
phone?: string
}
interface AuthContextType {
user: User | null
login: (user: User) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadUser()
}, [])
const loadUser = async () => {
try {
const userData = await SecureStore.getItemAsync('user')
if (userData) {
setUser(JSON.parse(userData))
}
} catch (error) {
console.error('Failed to load user:', error)
} finally {
setIsLoading(false)
}
}
const login = async (userData: User) => {
try {
await SecureStore.setItemAsync('user', JSON.stringify(userData))
setUser(userData)
} catch (error) {
console.error('Failed to save user:', error)
throw error
}
}
const logout = async () => {
try {
await SecureStore.deleteItemAsync('user')
setUser(null)
} catch (error) {
console.error('Failed to logout:', error)
throw error
}
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@ -1,16 +1,16 @@
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { useUser } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { useEffect } from "react";
export function useRequireAuth() {
const { user, isLoading } = useAuth()
const router = useRouter()
const { user, isLoaded, isSignedIn } = useUser();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.replace('/login')
if (isLoaded && !isSignedIn) {
router.replace("/login");
}
}, [user, isLoading, router])
}, [isSignedIn, isLoaded, router]);
return { user, isLoading }
return { user, isLoading: !isLoaded };
}