Compare commits

..
Author SHA1 Message Date
dimitar 3ab9908588 ai rec works
as intended, needs refinments
2025-11-24 17:57:35 +01:00
dimitar 0183a8ea6a fitnees profile fix in user page 2025-11-23 18:15:43 +01:00
dimitar 5d1d881f16 db url fix in drizzle config 2025-11-23 17:32:01 +01:00
dimitar 1b50bfd2ee api calls simplified 2025-11-23 17:12:14 +01:00
dimitar 6cbed4e8d1 deps 2025-11-23 16:57:21 +01:00
dimitar 76dafe9cd5 fix deps 2025-11-23 16:39:02 +01:00
dimitar a96f1902c5 social login
implemented, we can add more providers later
2025-11-20 19:28:39 +01:00
37 changed files with 1935 additions and 842 deletions

No files matched your search

Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[Approve] Updating recommendation a2a4d10a-463d-4648-b2a3-3b97fd053078 with: {"status":"approved","approvedAt":"2025-11-24T16:18:30.211Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for a2a4d10a-463d-4648-b2a3-3b97fd053078: {"changes":0,"lastInsertRowid":0}
[SQLite] Fetch after update for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Result for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:21:37.787Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:22:27.297Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:28.104Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:29.552Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:42.561Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:43.627Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:27:02.858Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"rejected"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
+800 -582
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/database": "file:../../packages/database",
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-slot": "^1.2.4",
@@ -29,6 +30,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"postcss": "^8.4.31",
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { recommendationId, status, approvedBy } = await req.json();
if (!recommendationId || !status) {
return NextResponse.json(
{ error: "Recommendation ID and status are required" },
{ status: 400 }
);
}
const db = await getDatabase();
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
};
// Remove undefined keys
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
if (!updatedRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 }
);
}
// If approved, create a notification for the user
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
// or we need to add it to IDatabase/SQLiteDatabase
// For now, let's assume the notification is handled elsewhere or add it later
return NextResponse.json(updatedRecommendation);
} catch (error) {
console.error("Error approving recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { userId } = await req.json();
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 }
);
}
// Construct prompt for Ollama
const prompt = `
You are a professional fitness trainer and nutritionist.
Generate a detailed daily recommendation for a user with the following profile:
- Height: ${profile.height} cm
- Weight: ${profile.weight} kg
- Age: ${profile.age}
- Gender: ${profile.gender}
- Goal: ${profile.fitnessGoals.join(", ")}
- Activity Level: ${profile.activityLevel}
- Medical Conditions: ${profile.medicalConditions || "None"}
- Injuries: ${profile.injuries || "None"}
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "General advice and motivation for today.",
"activityPlan": "Detailed workout or activity plan for today.",
"dietPlan": "Detailed meal plan for today."
}
`;
// Call Ollama
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest", // Make sure this model is pulled
prompt: prompt,
stream: false,
format: "json",
}),
});
if (!ollamaResponse.ok) {
console.error("Ollama API error:", await ollamaResponse.text());
return NextResponse.json(
{ error: "Failed to generate recommendation from AI service" },
{ status: 500 }
);
}
const aiData = await ollamaResponse.json();
console.log("Raw AI Response:", aiData.response);
let parsedResponse;
try {
// Helper to clean up the response
let cleanResponse = aiData.response.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
// Fallback if model doesn't return perfect JSON despite instruction
console.error("Failed to parse AI response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from AI model" },
{ status: 500 }
);
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.userId, // Using userId as ID for now since it's 1:1
type: 'ai_plan',
content: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: 'pending'
});
return NextResponse.json(recommendation);
} catch (error) {
console.error("Error generating recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
+45 -21
View File
@@ -12,12 +12,21 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId')
if (!targetUserId) {
return new NextResponse('User ID is required', { status: 400 })
}
const db = await getDatabase()
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('User ID is required', { status: 400 })
}
const recommendations = await db.getAllRecommendations()
return NextResponse.json({ recommendations })
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) {
const currentUser = await db.getUserById(currentUserId)
@@ -52,21 +61,37 @@ export async function POST(request: Request) {
}
const body = await request.json()
const { userId, type, content, status } = body
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body
if (!userId || !type || !content) {
return new NextResponse('Missing required fields', { status: 400 })
// Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId,
type: 'ai_plan',
content: recommendationText,
activityPlan,
dietPlan,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
content,
status: status || 'pending'
})
// Handle User Goal (Generic)
if (type && content) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
content,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
return NextResponse.json('Missing required fields', { status: 400 })
return NextResponse.json(recommendation)
} catch (error) {
console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
@@ -81,7 +106,7 @@ export async function PUT(request: Request) {
}
const body = await request.json()
const { id, status, content } = body
const { id, status, recommendationText, activityPlan, dietPlan, content } = body
if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 })
@@ -89,13 +114,12 @@ export async function PUT(request: Request) {
const db = await getDatabase()
// Users can update status (e.g. mark as completed), Staff can update content too
// Ideally we'd check ownership for status update, but for now let's allow it if they have the ID
// A stricter check would be: fetch recommendation, check if userId matches currentUserId OR if currentUser is staff
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(content && { content })
...(recommendationText && { content: recommendationText }), // Map legacy field
...(content && { content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan })
})
return NextResponse.json(updated)
+1 -1
View File
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
}
// Enforce Hierarchy
const allowed = {
const allowed: Record<string, string[]> = {
superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"],
trainer: ["client"],
+2 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/input";
interface UserProfile {
@@ -55,7 +55,7 @@ export default function ProfilePage() {
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Profile</h1>
<Button
variant={isEditing ? "default" : "outline"}
variant={isEditing ? "primary" : "secondary"}
onClick={() => setIsEditing(!isEditing)}
>
{isEditing ? "Cancel" : "Edit Profile"}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
}
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan: string;
dietPlan: string;
status: string;
createdAt: Date;
}
export default function RecommendationsPage() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
// Fetch users
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
// Fetch pending recommendations
const recsRes = await fetch("/api/recommendations");
const recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
setPendingRecommendations(allRecs.filter((r: any) => r.status === 'pending'));
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleGenerate = async (userId: string) => {
setGenerating(userId);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setGenerating(null);
}
};
const handleApprove = async (recommendationId: string, status: "approved" | "rejected") => {
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId,
status,
approvedBy: user?.id || "admin",
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update status: ${errorData.error || 'Unknown error'}`);
} else {
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Error processing request");
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">AI Recommendations</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Generate Recommendations</h2>
<div className="bg-white shadow rounded-lg p-6">
<p className="mb-4 text-gray-600">
Select a user to generate a new daily recommendation.
</p>
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="flex items-center justify-between border-b pb-2">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => handleGenerate(user.id)}
disabled={generating === user.id}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating === user.id ? "Generating..." : "Generate"}
</button>
</li>
))}
{users.length === 0 && (
<p className="text-gray-500 italic">No users found.</p>
)}
</ul>
</div>
</div>
{/* Pending Approvals Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
<div className="bg-white shadow rounded-lg p-6">
{pendingRecommendations.length === 0 ? (
<p className="text-gray-500 italic">No pending recommendations.</p>
) : (
<ul className="space-y-6">
{pendingRecommendations.map((rec) => (
<li key={rec.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">For: User {rec.userId}</h3>
<span className="text-xs text-gray-500">
{new Date(rec.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span> {rec.content}
</div>
<div>
<span className="font-semibold">Activity:</span> {rec.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {rec.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleApprove(rec.id, "rejected")}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
>
Reject
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}
+18 -15
View File
@@ -3,10 +3,10 @@
import { type ReactElement } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Home, Users, BarChart3, User } from "lucide-react";
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
import { SignedIn, UserButton } from "@clerk/nextjs";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/Button";
interface NavItem {
href: string;
@@ -25,6 +25,11 @@ const navItems: NavItem[] = [
label: "Clients",
icon: Users,
},
{
href: "/recommendations",
label: "AI Recommendations",
icon: Brain,
},
{
href: "/analytics",
label: "Analytics",
@@ -59,22 +64,20 @@ export function Navigation(): ReactElement {
>
{navItems.map((item) => (
<li key={item.href}>
<Button
asChild
variant={pathname === item.href ? "default" : "ghost"}
size="sm"
className={cn(
"h-9 px-4 py-2",
pathname === item.href &&
<Link href={item.href} className="flex items-center gap-2">
<Button
variant={pathname === item.href ? "primary" : "secondary"}
className={cn(
"h-9 px-4 py-2",
pathname === item.href &&
"bg-primary text-primary-foreground",
)}
aria-current={pathname === item.href ? "page" : undefined}
>
<Link href={item.href} className="flex items-center gap-2">
)}
aria-current={pathname === item.href ? "page" : undefined}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
</Button>
</Button>
</Link>
</li>
))}
</ul>
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { UserGrowthChart } from '@/components/charts/UserGrowthChart'
import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart'
import { RevenueChart } from '@/components/charts/RevenueChart'
import { Card, CardHeader, CardContent } from '@/components/ui/Card'
import { Card, CardHeader, CardContent } from '@/components/ui/card'
interface ChartData {
label: string
@@ -86,7 +86,7 @@ export function AnalyticsDashboard() {
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
@@ -95,7 +95,7 @@ export function AnalyticsDashboard() {
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
export function GenerateButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
const handleGenerate = async () => {
setLoading(true);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully! Check Pending Approvals.");
// In a real app, we'd revalidate the path or update state
window.location.reload();
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleGenerate}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate
</button>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { Loader2, Check, X } from "lucide-react";
type Recommendation = {
recommendation: {
id: string;
recommendationText: string;
activityPlan: string;
dietPlan: string;
createdAt: Date;
};
user: {
firstName: string;
lastName: string;
};
};
export function RecommendationList({
initialRecommendations,
}: {
initialRecommendations: Recommendation[];
}) {
const [recommendations, setRecommendations] = useState(initialRecommendations);
const [processingId, setProcessingId] = useState<string | null>(null);
const handleAction = async (id: string, status: "approved" | "rejected") => {
setProcessingId(id);
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId: id,
status,
approvedBy: "admin_placeholder", // In real app, get from auth context
}),
});
if (!res.ok) {
alert("Failed to update status");
} else {
setRecommendations((prev) =>
prev.filter((item) => item.recommendation.id !== id)
);
}
} catch (error) {
console.error(error);
alert("Error processing request");
} finally {
setProcessingId(null);
}
};
if (recommendations.length === 0) {
return <p className="text-gray-500 italic">No pending recommendations.</p>;
}
return (
<ul className="space-y-6">
{recommendations.map(({ recommendation, user }) => (
<li key={recommendation.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">
For: {user.firstName} {user.lastName}
</h3>
<span className="text-xs text-gray-500">
{new Date(recommendation.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span>{" "}
{recommendation.recommendationText}
</div>
<div>
<span className="font-semibold">Activity:</span>{" "}
{recommendation.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {recommendation.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleAction(recommendation.id, "approved")}
disabled={processingId === recommendation.id}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" /> Approve
</>
)}
</button>
<button
onClick={() => handleAction(recommendation.id, "rejected")}
disabled={processingId === recommendation.id}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-2 h-4 w-4" /> Reject
</>
)}
</button>
</div>
</li>
))}
</ul>
);
}
+5 -3
View File
@@ -8,13 +8,15 @@ import {
CalendarCheck,
CreditCard,
Settings,
LogOut
LogOut,
Brain
} from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs";
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" },
{ icon: Brain, label: "AI Recommendations", href: "/recommendations" },
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
@@ -42,8 +44,8 @@ export function Sidebar() {
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "text-slate-400 hover:bg-slate-800 hover:text-white"
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
: "text-slate-400 hover:bg-slate-800 hover:text-white"
}`}
>
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
@@ -7,9 +7,11 @@ import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
status: "pending" | "completed";
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string;
}
@@ -68,15 +70,8 @@ export function Recommendations({ userId }: RecommendationsProps) {
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure?")) return;
// Note: Delete API not implemented in route.ts yet, but good to have UI ready or we can add it.
// For now, let's assume we might add it or just omit.
// Actually, I didn't add DELETE to route.ts. Let's skip for now.
alert("Delete functionality not available yet.");
};
const groupedRecs = {
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"),
@@ -84,7 +79,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term",
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
items: Recommendation[]
) => (
<div className="mb-6">
@@ -96,58 +91,66 @@ export function Recommendations({ userId }: RecommendationsProps) {
{items.map((rec) => (
<div
key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed"
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed" || rec.status === "approved"
? "bg-green-50 border-green-200"
: "bg-white border-gray-200"
}`}
>
<div>
<p className="text-sm">{rec.content}</p>
<p className="text-xs text-gray-400 mt-1">
<div className="w-full">
<p className="text-sm font-medium">{rec.content}</p>
{rec.type === 'ai_plan' && (
<div className="mt-2 text-xs text-gray-600 space-y-1">
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
{rec.dietPlan && <p><span className="font-semibold">Diet:</span> {rec.dietPlan}</p>}
</div>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span
className={
rec.status === "completed"
rec.status === "completed" || rec.status === "approved"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed" ? "Completed" : "Pending"}
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
{type !== 'ai_plan' && (
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type: type as any })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type: type as any, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
)}
</form>
)}
</form>
)}
</div>
);
@@ -159,7 +162,8 @@ export function Recommendations({ userId }: RecommendationsProps) {
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
+45 -16
View File
@@ -121,16 +121,24 @@ export class SQLiteDatabase implements IDatabase {
`)
// Recommendations table
// Removed DROP TABLE to persist data. Schema is now stable.
// this.db.exec(`DROP TABLE IF EXISTS recommendations`)
this.db.exec(`
CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
fitnessProfileId TEXT,
type TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
activityPlan TEXT,
dietPlan TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
approvedAt DATETIME,
approvedBy TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
)
`)
@@ -145,7 +153,9 @@ export class SQLiteDatabase implements IDatabase {
}
// User operations
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
async createUser(
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User> {
if (!this.db) throw new Error('Database not connected')
const id = userData.id || Math.random().toString(36).substr(2, 9)
@@ -509,25 +519,29 @@ export class SQLiteDatabase implements IDatabase {
}
// Recommendation operations
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'approvedAt' | 'approvedBy'>): Promise<Recommendation> {
if (!this.db) throw new Error('Database not connected')
const now = new Date()
const recommendation: Recommendation = {
...data,
createdAt: now,
updatedAt: now
status: data.status || 'pending'
}
const stmt = this.db.prepare(
`INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO recommendations (
id, userId, fitnessProfileId, type, content,
activityPlan, dietPlan, status, createdAt
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
recommendation.id, recommendation.userId, recommendation.type,
recommendation.content, recommendation.status,
recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
recommendation.id, recommendation.userId, recommendation.fitnessProfileId,
recommendation.type, recommendation.content, recommendation.activityPlan,
recommendation.dietPlan, recommendation.status,
recommendation.createdAt.toISOString()
)
return recommendation
@@ -542,6 +556,15 @@ export class SQLiteDatabase implements IDatabase {
return rows.map(row => this.mapRowToRecommendation(row))
}
async getAllRecommendations(): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations ORDER BY createdAt DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToRecommendation(row))
}
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
if (!this.db) throw new Error('Database not connected')
@@ -553,11 +576,13 @@ export class SQLiteDatabase implements IDatabase {
}
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => (updates as any)[field])
values.push(new Date().toISOString()) // updatedAt
const values = fields.map(field => {
const val = (updates as any)[field]
return val instanceof Date ? val.toISOString() : val
})
values.push(id)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause} WHERE id = ?`)
stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
@@ -577,11 +602,15 @@ export class SQLiteDatabase implements IDatabase {
return {
id: row.id,
userId: row.userId,
fitnessProfileId: row.fitnessProfileId,
type: row.type,
content: row.content,
activityPlan: row.activityPlan,
dietPlan: row.dietPlan,
status: row.status,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
approvedBy: row.approvedBy
}
}
+12 -5
View File
@@ -50,11 +50,15 @@ export interface Attendance {
export interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
fitnessProfileId?: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
status: "pending" | "completed";
activityPlan?: string;
dietPlan?: string;
status: "pending" | "approved" | "rejected" | "completed";
createdAt: Date;
updatedAt: Date;
approvedAt?: Date;
approvedBy?: string;
}
// Database Interface - allows us to swap implementations
@@ -64,7 +68,9 @@ export interface IDatabase {
disconnect(): Promise<void>;
// User operations
createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
createUser(
user: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User>;
getUserById(id: string): Promise<User | null>;
getUserByEmail(email: string): Promise<User | null>;
getAllUsers(): Promise<User[]>;
@@ -105,9 +111,10 @@ export interface IDatabase {
// Recommendation operations
createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "updatedAt">,
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>;
updateRecommendation(
id: string,
updates: Partial<Recommendation>,
+3 -2
View File
@@ -27,8 +27,9 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
"expo-router",
"expo-font"
],
"scheme": "fitai"
}
}
}
+68 -73
View File
@@ -11,7 +11,7 @@
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
"ajv-keywords": "^5.1.0",
@@ -21,6 +21,7 @@
"expo-camera": "~17.0.0",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
@@ -28,7 +29,7 @@
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "^19.2.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",
@@ -4029,9 +4030,9 @@
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz",
"integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz",
"integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==",
"license": "MIT",
"workspaces": [
"example"
@@ -6946,9 +6947,9 @@
}
},
"node_modules/expo": {
"version": "54.0.23",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.23.tgz",
"integrity": "sha512-b4uQoiRwQ6nwqsT2709RS15CWYNGF3eJtyr1KyLw9WuMAK7u4jjofkhRiO0+3o1C2NbV+WooyYTOZGubQQMBaQ==",
"version": "54.0.25",
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.25.tgz",
"integrity": "sha512-+iSeBJfHRHzNPnHMZceEXhSGw4t5bNqFyd/5xMUoGfM+39rO7F72wxiLRpBKj0M6+0GQtMaEs+eTbcCrO7XyJQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.0",
@@ -6962,13 +6963,13 @@
"@expo/vector-icons": "^15.0.3",
"@ungap/structured-clone": "^1.3.0",
"babel-preset-expo": "~54.0.7",
"expo-asset": "~12.0.9",
"expo-asset": "~12.0.10",
"expo-constants": "~18.0.10",
"expo-file-system": "~19.0.17",
"expo-file-system": "~19.0.19",
"expo-font": "~14.0.9",
"expo-keep-awake": "~15.0.7",
"expo-modules-autolinking": "3.0.21",
"expo-modules-core": "3.0.25",
"expo-modules-autolinking": "3.0.22",
"expo-modules-core": "3.0.26",
"pretty-format": "^29.7.0",
"react-refresh": "^0.14.2",
"whatwg-url-without-unicode": "8.0.0-3"
@@ -7006,17 +7007,32 @@
"expo": "*"
}
},
"node_modules/expo-asset": {
"version": "12.0.10",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.10.tgz",
"integrity": "sha512-pZyeJkoDsALh4gpCQDzTA/UCLaPH/1rjQNGubmLn/uDM27S4iYJb/YWw4+CNZOtd5bCUOhDPg5DtGQnydNFSXg==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.7",
"expo-constants": "~18.0.10"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-auth-session": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.8.tgz",
"integrity": "sha512-kpo2Jva+6uVjk6TmNqWAoqTnULXZaEVa9l4uf8JH32uDMt/iZQhM0fauy7Ww+y910Euhv5djCP7cPj8KWv6cmQ==",
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.9.tgz",
"integrity": "sha512-mPSwaRWOJYas160lXi5P/7BkLy0xbh+er+IMmAYHqf2+iz2WWs9W/4lMAklQVJG2mCyOZi24XrkffvB2izCa1g==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.7",
"expo-constants": "~18.0.8",
"expo-constants": "~18.0.10",
"expo-crypto": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-web-browser": "~15.0.7",
"expo-linking": "~8.0.9",
"expo-web-browser": "~15.0.9",
"invariant": "^2.2.4"
},
"peerDependencies": {
@@ -7070,6 +7086,16 @@
"expo": "*"
}
},
"node_modules/expo-file-system": {
"version": "19.0.19",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.19.tgz",
"integrity": "sha512-OrpOV4fEBFMFv+jy7PnENpPbsWoBmqWGidSwh1Ai52PLl6JIInYGfZTc6kqyPNGtFTwm7Y9mSWnE8g+dtLxu7g==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo-font": {
"version": "14.0.9",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.9.tgz",
@@ -7085,12 +7111,12 @@
}
},
"node_modules/expo-linking": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz",
"integrity": "sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz",
"integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==",
"license": "MIT",
"dependencies": {
"expo-constants": "~18.0.8",
"expo-constants": "~18.0.10",
"invariant": "^2.2.4"
},
"peerDependencies": {
@@ -7099,9 +7125,9 @@
}
},
"node_modules/expo-modules-autolinking": {
"version": "3.0.21",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.21.tgz",
"integrity": "sha512-pOtPDLln3Ju8DW1zRW4OwZ702YqZ8g+kM/tEY1sWfv22kWUtxkvK+ytRDRpRdnKEnC28okbhWqeMnmVkSFzP6Q==",
"version": "3.0.22",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.22.tgz",
"integrity": "sha512-Ej4SsZAnUUVFmbn6SoBso8K308mRKg8xgapdhP7v7IaSgfbexUoqxoiV31949HQQXuzmgvpkXCfp6Ex+mDW0EQ==",
"license": "MIT",
"dependencies": {
"@expo/spawn-async": "^1.7.2",
@@ -7115,9 +7141,9 @@
}
},
"node_modules/expo-modules-core": {
"version": "3.0.25",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.25.tgz",
"integrity": "sha512-0P8PT8UV6c5/+p8zeVM/FXvBgn/ErtGcMaasqUgbzzBUg94ktbkIrij9t9reGCrir03BYt/Bcpv+EQtYC8JOug==",
"version": "3.0.26",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.26.tgz",
"integrity": "sha512-WWjficXz32VmQ+xDoO+c0+jwDME0n/47wONrJkRvtm32H9W8n3MXkOMGemDl95HyPKYsaYKhjFGUOVOxIF3hcQ==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4"
@@ -7128,9 +7154,9 @@
}
},
"node_modules/expo-notifications": {
"version": "0.32.12",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.12.tgz",
"integrity": "sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw==",
"version": "0.32.13",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.13.tgz",
"integrity": "sha512-PL0R1ulLVUgAswlXtRDKxBlcipNM3YA6+P5nB5JIhXbsjLJ7y+EKVaEhHhbaGzuK1QVsRQSJNm/4oISX+vsmFQ==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.7",
@@ -7139,7 +7165,7 @@
"assert": "^2.0.0",
"badgin": "^1.1.5",
"expo-application": "~7.0.7",
"expo-constants": "~18.0.9"
"expo-constants": "~18.0.10"
},
"peerDependencies": {
"expo": "*",
@@ -7148,9 +7174,9 @@
}
},
"node_modules/expo-router": {
"version": "6.0.14",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.14.tgz",
"integrity": "sha512-vizLO4SgnMEL+PPs2dXr+etEOuksjue7yUQBCtfCEdqoDkQlB0r35zI7rS34Wt53sxKWSlM2p+038qQEpxtiFw==",
"version": "6.0.15",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.15.tgz",
"integrity": "sha512-PAettvLifQzb6hibCmBqxbR9UljlH61GvDRLyarGxs/tG9OpMXCoZHZo8gGCO24K1/6cchBKBcjvQ0PRrKwPew==",
"license": "MIT",
"dependencies": {
"@expo/metro-runtime": "^6.1.2",
@@ -7163,7 +7189,7 @@
"client-only": "^0.0.1",
"debug": "^4.3.4",
"escape-string-regexp": "^4.0.0",
"expo-server": "^1.0.3",
"expo-server": "^1.0.4",
"fast-deep-equal": "^3.1.3",
"invariant": "^2.2.4",
"nanoid": "^3.3.8",
@@ -7183,7 +7209,7 @@
"@testing-library/react-native": ">= 12.0.0",
"expo": "*",
"expo-constants": "^18.0.10",
"expo-linking": "^8.0.8",
"expo-linking": "^8.0.9",
"react": "*",
"react-dom": "*",
"react-native": "*",
@@ -7271,31 +7297,6 @@
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-asset": {
"version": "12.0.9",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.9.tgz",
"integrity": "sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.8.7",
"expo-constants": "~18.0.9"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-file-system": {
"version": "19.0.17",
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.17.tgz",
"integrity": "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/expo-keep-awake": {
"version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz",
@@ -11316,23 +11317,17 @@
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.2.0"
"react": "^19.1.0"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+3 -2
View File
@@ -17,7 +17,7 @@
"@clerk/clerk-expo": "^2.18.3",
"@expo/vector-icons": "^15.0.0",
"@hookform/resolvers": "^3.3.0",
"@react-native-picker/picker": "^2.11.4",
"@react-native-picker/picker": "2.11.1",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
"ajv-keywords": "^5.1.0",
@@ -27,6 +27,7 @@
"expo-camera": "~17.0.0",
"expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7",
"expo-font": "~14.0.9",
"expo-linking": "~8.0.0",
"expo-notifications": "~0.32.0",
"expo-router": "~6.0.14",
@@ -34,7 +35,7 @@
"expo-status-bar": "^3.0.8",
"expo-web-browser": "^15.0.9",
"react": "19.1.0",
"react-dom": "^19.2.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",
+47
View File
@@ -0,0 +1,47 @@
import { apiClient } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface Attendance {
id: string;
checkInTime: string;
checkOutTime?: string;
type: string;
notes?: string;
}
export const attendanceApi = {
getHistory: async (token: string): Promise<Attendance[]> => {
try {
const response = await apiClient.get(API_ENDPOINTS.ATTENDANCE.HISTORY, {
headers: { Authorization: `Bearer ${token}` },
});
return response.data;
} catch (error) {
throw error;
}
},
checkIn: async (type: string, token: string): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_IN,
{ type },
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw error;
}
},
checkOut: async (token: string): Promise<void> => {
try {
await apiClient.post(
API_ENDPOINTS.ATTENDANCE.CHECK_OUT,
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
} catch (error) {
throw error;
}
},
};
+18
View File
@@ -0,0 +1,18 @@
import axios from 'axios';
import { API_BASE_URL } from '../config/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Helper to set the auth token for a request
export const setAuthToken = (token: string) => {
if (token) {
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete apiClient.defaults.headers.common['Authorization'];
}
};
+9 -9
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import { API_BASE_URL } from "../config/api";
import { apiClient } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface FitnessProfile {
id?: string;
@@ -22,10 +22,10 @@ export const fitnessProfileApi = {
try {
console.log(
"Getting fitness profile with URL:",
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
);
const response = await axios.get(
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
const response = await apiClient.get(
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
{
headers: {
Authorization: `Bearer ${token}`,
@@ -49,8 +49,8 @@ export const fitnessProfileApi = {
token: string,
): Promise<FitnessProfile> => {
try {
const response = await axios.put(
`${API_BASE_URL}/api/users/${userId}/fitness-profile`,
const response = await apiClient.put(
`${API_ENDPOINTS.USERS}/${userId}/fitness-profile`,
data,
{
headers: {
@@ -74,8 +74,8 @@ export const fitnessProfileApi = {
token: string,
): Promise<FitnessProfile> => {
try {
const response = await axios.post(
`${API_BASE_URL}/api/users/${data.clientId}/fitness-profile`,
const response = await apiClient.post(
`${API_ENDPOINTS.USERS}/${data.clientId}/fitness-profile`,
data,
{
headers: {
+30
View File
@@ -0,0 +1,30 @@
import { apiClient } from "./client";
import { API_ENDPOINTS } from "../config/api";
export interface FitnessProfileData {
userId: 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 const profileApi = {
createFitnessProfile: async (
data: FitnessProfileData,
token: string,
): Promise<void> => {
try {
await apiClient.post(API_ENDPOINTS.PROFILE.FITNESS, data, {
headers: { Authorization: `Bearer ${token}` },
});
} catch (error) {
throw error;
}
},
};
+31 -7
View File
@@ -1,4 +1,4 @@
import React from "react";
import { useState, useEffect } from "react";
import { useSignIn, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
@@ -12,19 +12,20 @@ import {
Platform,
ScrollView,
} from "react-native";
import { OAuthButtons } from "../../components/auth/OAuthButtons";
export default function SignInScreen() {
const { signIn, setActive, isLoaded } = useSignIn();
const { isSignedIn } = useAuth();
const router = useRouter();
const [emailAddress, setEmailAddress] = React.useState("");
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [emailAddress, setEmailAddress] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Redirect if already signed in
React.useEffect(() => {
useEffect(() => {
if (isSignedIn) {
router.replace("/(tabs)");
}
@@ -64,7 +65,7 @@ export default function SignInScreen() {
setError(
err.errors?.[0]?.message ||
"Failed to sign in. Please check your credentials.",
"Failed to sign in. Please check your credentials.",
);
} finally {
setLoading(false);
@@ -100,6 +101,14 @@ export default function SignInScreen() {
</View>
) : null}
<OAuthButtons />
<View style={styles.dividerContainer}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>Email</Text>
@@ -256,4 +265,19 @@ const styles = StyleSheet.create({
color: "#2563eb",
fontWeight: "600",
},
dividerContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 24,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#ddd",
},
dividerText: {
marginHorizontal: 10,
color: "#666",
fontSize: 14,
},
});
+34 -10
View File
@@ -1,4 +1,4 @@
import React from "react";
import { useState, useEffect } from "react";
import { useSignUp, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import {
@@ -12,23 +12,24 @@ import {
Platform,
ScrollView,
} from "react-native";
import { OAuthButtons } from "../../components/auth/OAuthButtons";
export default function SignUpScreen() {
const { signUp, setActive, isLoaded } = useSignUp();
const { isSignedIn } = useAuth();
const router = useRouter();
const [emailAddress, setEmailAddress] = React.useState("");
const [password, setPassword] = React.useState("");
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");
const [pendingVerification, setPendingVerification] = React.useState(false);
const [code, setCode] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [emailAddress, setEmailAddress] = useState("");
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [pendingVerification, setPendingVerification] = useState(false);
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Redirect if already signed in
React.useEffect(() => {
useEffect(() => {
if (isSignedIn) {
router.replace("/(tabs)");
}
@@ -182,6 +183,14 @@ export default function SignUpScreen() {
</View>
) : null}
<OAuthButtons />
<View style={styles.dividerContainer}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={styles.label}>First Name</Text>
@@ -372,4 +381,19 @@ const styles = StyleSheet.create({
color: "#2563eb",
fontWeight: "600",
},
dividerContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 24,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: "#ddd",
},
dividerText: {
marginHorizontal: 10,
color: "#666",
fontSize: 14,
},
});
+11 -25
View File
@@ -1,16 +1,9 @@
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
import { useState, useEffect } from 'react'
import { useAuth } from '@clerk/clerk-expo'
import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '../../config/api'
import { attendanceApi, Attendance } from '../../api/attendance'
interface Attendance {
id: string
checkInTime: string
checkOutTime?: string
type: string
notes?: string
}
export default function AttendanceScreen() {
const { getToken, userId } = useAuth()
@@ -22,13 +15,10 @@ export default function AttendanceScreen() {
try {
setLoading(true)
const token = await getToken()
const url = `${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.HISTORY}`
console.log('Fetching attendance from:', url)
const response = await axios.get(url, {
headers: { Authorization: `Bearer ${token}` }
})
if (!token) return
const data: Attendance[] = response.data
console.log('Fetching attendance history')
const data = await attendanceApi.getHistory(token)
setHistory(data)
// Check if there's an active check-in (latest one has no checkOutTime)
@@ -52,11 +42,9 @@ export default function AttendanceScreen() {
const handleCheckIn = async () => {
try {
const token = await getToken()
await axios.post(
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_IN}`,
{ type: 'gym' },
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!token) return
await attendanceApi.checkIn('gym', token)
fetchAttendance()
Alert.alert('Success', 'Checked in successfully!')
} catch (error: any) {
@@ -68,11 +56,9 @@ export default function AttendanceScreen() {
const handleCheckOut = async () => {
try {
const token = await getToken()
await axios.post(
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_OUT}`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!token) return
await attendanceApi.checkOut(token)
fetchAttendance()
Alert.alert('Success', 'Checked out successfully!')
} catch (error: any) {
+21 -15
View File
@@ -1,9 +1,9 @@
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 { useAuth } from '@clerk/clerk-expo'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
import { profileApi } from '../api/profile'
interface FitnessProfile {
height: string
@@ -31,10 +31,11 @@ export default function WelcomeScreen() {
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const { getToken } = useAuth()
const fitnessGoalsOptions = [
'Weight Loss',
'Muscle Gain',
'Muscle Gain',
'Improve Endurance',
'Better Flexibility',
'General Fitness',
@@ -67,26 +68,31 @@ export default function WelcomeScreen() {
setLoading(true)
try {
const token = await getToken()
if (!token) {
throw new Error('Authentication required')
}
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}`,
await profileApi.createFitnessProfile(
{
userId: userData.id,
...profile
}
},
token
)
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.')
@@ -103,7 +109,7 @@ export default function WelcomeScreen() {
<View style={styles.section}>
<Text style={styles.sectionTitle}>Basic Information</Text>
<View style={styles.row}>
<View style={[styles.inputContainer, { flex: 1, marginRight: 8 }]}>
<Text style={styles.label}>Height (cm)</Text>
@@ -115,7 +121,7 @@ export default function WelcomeScreen() {
placeholder="170"
/>
</View>
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Weight (kg)</Text>
<TextInput
@@ -139,7 +145,7 @@ export default function WelcomeScreen() {
placeholder="25"
/>
</View>
<View style={[styles.inputContainer, { flex: 1, marginLeft: 8 }]}>
<Text style={styles.label}>Gender</Text>
<View style={styles.genderRow}>
@@ -0,0 +1,64 @@
import React from "react";
import * as WebBrowser from "expo-web-browser";
import { Button, View, StyleSheet, TouchableOpacity, Text } from "react-native";
import { useOAuth } from "@clerk/clerk-expo";
import { useWarmUpBrowser } from "../../hooks/useWarmUpBrowser";
import { Ionicons } from "@expo/vector-icons";
WebBrowser.maybeCompleteAuthSession();
export function OAuthButtons() {
useWarmUpBrowser();
const { startOAuthFlow } = useOAuth({ strategy: "oauth_google" });
const onPress = React.useCallback(async () => {
try {
const { createdSessionId, signIn, signUp, setActive } =
await startOAuthFlow();
if (createdSessionId) {
setActive!({ session: createdSessionId });
} else {
// Use signIn or signUp for next steps such as MFA
}
} catch (err) {
console.error("OAuth error", err);
}
}, []);
return (
<View style={styles.container}>
<TouchableOpacity style={styles.button} onPress={onPress}>
<Ionicons name="logo-google" size={20} color="#DB4437" style={styles.icon} />
<Text style={styles.text}>Continue with Google</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: "100%",
marginTop: 16,
},
button: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#ddd",
paddingVertical: 12,
borderRadius: 8,
marginBottom: 12,
},
icon: {
marginRight: 10,
},
text: {
fontSize: 16,
fontWeight: "500",
color: "#333",
},
});
+2 -2
View File
@@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__
? 'https://390dfd6ece05.ngrok-free.app'
? 'https://0ccbc9f6f846.ngrok-free.app'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
@@ -18,4 +18,4 @@ export const API_ENDPOINTS = {
HISTORY: '/api/attendance/history',
},
RECOMMENDATIONS: '/api/recommendations',
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import * as WebBrowser from "expo-web-browser";
export const useWarmUpBrowser = () => {
React.useEffect(() => {
// Warm up the android browser to improve UX
// https://docs.expo.dev/guides/authentication/#improving-user-experience
void WebBrowser.warmUpAsync();
return () => {
void WebBrowser.coolDownAsync();
};
}, []);
};
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./fitai.db",
// url: "./fitai.db",
url: "../../apps/admin/data/fitai.db",
},
});
+2 -1
View File
@@ -5,4 +5,5 @@ import * as schema from './schema'
const sqlite = new Database('./fitai.db')
export const db = drizzle(sqlite, { schema })
export * from './schema'
export * from './schema'
export { eq, and, or, desc, asc } from 'drizzle-orm'
+31
View File
@@ -145,6 +145,35 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
.$defaultFn(() => new Date()),
});
export const recommendations = sqliteTable("recommendations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
fitnessProfileId: text("fitness_profile_id")
.notNull()
.references(() => fitnessProfiles.id, { onDelete: "cascade" }),
recommendationText: text("recommendation_text").notNull(),
activityPlan: text("activity_plan").notNull(),
dietPlan: text("diet_plan").notNull(),
status: text("status", {
enum: ["pending", "approved", "rejected"],
})
.notNull()
.default("pending"),
generatedAt: integer("generated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
approvedAt: integer("approved_at", { mode: "timestamp" }),
approvedBy: text("approved_by"), // User ID of admin/trainer
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Client = typeof clients.$inferSelect;
@@ -157,3 +186,5 @@ export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert;
export type Recommendation = typeof recommendations.$inferSelect;
export type NewRecommendation = typeof recommendations.$inferInsert;
+1 -1
View File
@@ -1,5 +1,5 @@
## TODOS
- fix fitness-profile network error
<!-- - fix fitness-profile network error -->
- enhance mobile up ui and styling
- implement create admin/trainer/client flow