Compare commits

..
18 Commits
Author SHA1 Message Date
dimitar bf532ba35d deepseek integrated succesfully 2025-11-24 19:25:34 +01:00
dimitar 0896dd46d1 ai rec refinement 2025-11-24 19:08:58 +01:00
dimitar e9ba9e2700 recommendatio edit added 2025-11-24 18:36:04 +01:00
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
dimitar 8b4cef33dc recommendation flow implemented
POC phase compleated
2025-11-20 19:10:16 +01:00
dimitar 118efad70f invite and saving fitness profile
added, need polishing
2025-11-20 18:36:40 +01:00
dimitar daaf5aa2dd attendace view in admin up fixed 2025-11-19 06:10:53 +01:00
dimitar 67636120d6 Merge branch 'superAdmin' 2025-11-19 05:14:36 +01:00
dimitar 892cf1a040 organize docs 2025-11-19 05:12:19 +01:00
dimitar 4909815807 minir fix 2025-11-19 05:07:14 +01:00
dimitar a87b94219d super admin added 2025-11-19 04:00:03 +01:00
dimitar b1bb5d8166 redesign
admin dashboard
2025-11-19 03:34:18 +01:00
87 changed files with 4397 additions and 1455 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# DeepSeek AI API Key
# Get your API key from https://platform.deepseek.com/
DEEPSEEK_API_KEY=your_deepseek_api_key_here
+2 -3
View File
@@ -12,7 +12,6 @@
},
"aliases": {
"components": "@/components",
"utils": "@/lib",
"ui": "@/components/ui"
"utils": "@/lib/utils"
}
}
}
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
+1153 -1085
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -12,9 +12,10 @@
},
"dependencies": {
"@clerk/nextjs": "^6.34.5",
"@fitai/database": "file:../../packages/database",
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2",
"@tailwindcss/postcss": "^4.1.17",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.7",
"@types/bcryptjs": "^3.0.0",
"@types/sqlite3": "^5.1.0",
@@ -22,13 +23,17 @@
"ag-charts-react": "^12.3.1",
"ag-grid-community": "^34.3.1",
"ag-grid-react": "^34.3.1",
"autoprefixer": "^10.4.21",
"autoprefixer": "^10.4.16",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.10.0",
"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.5.6",
"postcss": "^8.4.31",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
@@ -36,7 +41,9 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"svix": "^1.81.0",
"tailwindcss": "^4.1.17",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
tailwindcss: {},
autoprefixer: {},
},
};
@@ -0,0 +1,33 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '../../../data/fitai.db');
const db = new Database(dbPath);
console.log('Migrating fitness_profiles table...');
try {
// Check if columns exist
const tableInfo = db.prepare('PRAGMA table_info(fitness_profiles)').all();
const columns = tableInfo.map(c => c.name);
if (!columns.includes('allergies')) {
console.log('Adding allergies column...');
db.prepare('ALTER TABLE fitness_profiles ADD COLUMN allergies TEXT').run();
} else {
console.log('allergies column already exists.');
}
if (!columns.includes('injuries')) {
console.log('Adding injuries column...');
db.prepare('ALTER TABLE fitness_profiles ADD COLUMN injuries TEXT').run();
} else {
console.log('injuries column already exists.');
}
console.log('Migration completed successfully.');
} catch (error) {
console.error('Migration failed:', error);
} finally {
db.close();
}
+19
View File
@@ -0,0 +1,19 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(process.cwd(), 'data', 'fitai.db');
const db = new Database(dbPath);
try {
console.log('Attempting to add lastVisit column to clients table...');
db.exec('ALTER TABLE clients ADD COLUMN lastVisit DATETIME');
console.log('Successfully added lastVisit column.');
} catch (error) {
if (error.message.includes('duplicate column name')) {
console.log('Column lastVisit already exists.');
} else {
console.error('Error adding column:', error);
}
}
db.close();
+62
View File
@@ -0,0 +1,62 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath);
function migrateRoles() {
try {
console.log('Starting migration to update role constraints...');
// 1. Disable foreign keys
db.pragma('foreign_keys = OFF');
// 2. Start transaction
db.transaction(() => {
// 3. Create new table with updated check constraint
console.log('Creating new users table...');
db.prepare(`
CREATE TABLE IF NOT EXISTS users_new (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
firstName TEXT NOT NULL,
lastName TEXT NOT NULL,
password TEXT NOT NULL,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
)
`).run();
// 4. Copy data
console.log('Copying data...');
db.prepare(`
INSERT INTO users_new (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
SELECT id, email, firstName, lastName, password, phone, role, createdAt, updatedAt FROM users
`).run();
// 5. Drop old table
console.log('Dropping old table...');
db.prepare('DROP TABLE users').run();
// 6. Rename new table
console.log('Renaming new table...');
db.prepare('ALTER TABLE users_new RENAME TO users').run();
// 7. Re-enable foreign keys (in a separate step usually, but good to be safe)
})();
db.pragma('foreign_keys = ON');
console.log('Migration completed successfully.');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
db.close();
}
}
migrateRoles();
+51
View File
@@ -0,0 +1,51 @@
const Database = require('better-sqlite3');
const path = require('path');
const bcrypt = require('bcryptjs');
const dbPath = path.join(__dirname, '../data/fitai.db');
const db = new Database(dbPath);
async function seedSuperAdmin() {
const email = 'taratur@gmail.com';
const password = 'password123';
const firstName = 'Super';
const lastName = 'Admin';
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
const id = 'user_superadmin_' + Math.random().toString(36).substr(2, 9);
const now = new Date().toISOString();
try {
console.log('Creating Super Admin...');
// Check if exists
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (existing) {
console.log('Super Admin already exists. Updating role...');
db.prepare('UPDATE users SET role = "superAdmin" WHERE email = ?').run(email);
console.log('Role updated.');
return;
}
db.prepare(`
INSERT INTO users (id, email, firstName, lastName, password, role, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, email, firstName, lastName, hashedPassword, 'superAdmin', now, now);
console.log(`Super Admin created successfully.`);
console.log(`Email: ${email}`);
console.log(`Password: ${password}`);
console.log(`ID: ${id}`);
console.log('\nIMPORTANT: You must also create this user in Clerk manually or sign up with this email to link the accounts if you want to log in as this user.');
} catch (error) {
console.error('Error creating Super Admin:', error);
} finally {
db.close();
}
}
seedSuperAdmin();
@@ -1,6 +1,7 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
export async function GET(req: Request) {
try {
@@ -8,9 +9,12 @@ export async function GET(req: Request) {
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
const user = await db.getUserById(userId)
if (!user || user.role !== 'admin') {
// Ensure user is synced (handles seed script ID mismatch)
// We need to import ensureUserSynced
const user = await ensureUserSynced(userId, db)
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
return new NextResponse('Forbidden', { status: 403 })
}
@@ -0,0 +1,25 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
export async function GET() {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase()
const user = await ensureUserSynced(userId, db)
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
return new NextResponse('Forbidden', { status: 403 })
}
const stats = await db.getDashboardStats()
return NextResponse.json(stats)
} catch (error) {
console.error('Dashboard stats error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
+36 -20
View File
@@ -16,10 +16,20 @@ export async function GET(request: NextRequest) {
const profile = db
.prepare(
`SELECT * FROM fitness_profiles WHERE user_id = ?`
`SELECT * FROM fitness_profiles WHERE userId = ?`
)
.get(userId);
if (profile) {
const p = profile as any;
// Parse JSON fields
try {
p.fitnessGoals = JSON.parse(p.fitnessGoals || '[]');
} catch (e) {
p.fitnessGoals = [];
}
}
return NextResponse.json({ profile: profile || null });
} catch (error) {
console.error("Error fetching fitness profile:", error);
@@ -45,19 +55,22 @@ export async function POST(request: NextRequest) {
weight,
age,
gender,
fitnessGoal,
fitnessGoals, // Changed from fitnessGoal
activityLevel,
medicalConditions,
allergies,
injuries,
exerciseHabits,
dietHabits
} = body;
// Check if profile exists
const existingProfile = db
.prepare(`SELECT id FROM fitness_profiles WHERE user_id = ?`)
.get(userId) as { id: string } | undefined;
.prepare(`SELECT userId FROM fitness_profiles WHERE userId = ?`)
.get(userId) as { userId: string } | undefined;
const now = Date.now();
const now = new Date().toISOString();
const fitnessGoalsJson = JSON.stringify(fitnessGoals || []);
if (existingProfile) {
// Update existing profile
@@ -67,52 +80,55 @@ export async function POST(request: NextRequest) {
weight = ?,
age = ?,
gender = ?,
fitness_goal = ?,
activity_level = ?,
medical_conditions = ?,
fitnessGoals = ?,
activityLevel = ?,
medicalConditions = ?,
allergies = ?,
injuries = ?,
updated_at = ?
WHERE user_id = ?`
exerciseHabits = ?,
dietHabits = ?,
updatedAt = ?
WHERE userId = ?`
).run(
height || null,
weight || null,
age || null,
gender || null,
fitnessGoal || null,
fitnessGoalsJson,
activityLevel || null,
medicalConditions || null,
allergies || null,
injuries || null,
exerciseHabits || null,
dietHabits || null,
now,
userId
);
return NextResponse.json({
message: "Fitness profile updated successfully",
profileId: existingProfile.id,
userId: userId,
});
} else {
// Create new profile
const profileId = `fp_${randomBytes(16).toString("hex")}`;
db.prepare(
`INSERT INTO fitness_profiles
(id, user_id, height, weight, age, gender, fitness_goal, activity_level,
medical_conditions, allergies, injuries, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
profileId,
userId,
height || null,
weight || null,
age || null,
gender || null,
fitnessGoal || null,
fitnessGoalsJson,
activityLevel || null,
medicalConditions || null,
allergies || null,
injuries || null,
exerciseHabits || null,
dietHabits || null,
now,
now
);
@@ -120,7 +136,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
message: "Fitness profile created successfully",
profileId,
userId,
},
{ status: 201 }
);
@@ -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,198 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { userId, useExternalModel } = 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
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."
}
`;
let parsedResponse;
if (useExternalModel) {
// Use DeepSeek AI
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
if (!deepseekApiKey) {
return NextResponse.json(
{ error: "DeepSeek API key not configured" },
{ status: 500 }
);
}
console.log("Using DeepSeek AI model...");
const deepseekResponse = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${deepseekApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7,
max_tokens: 1000,
}),
});
if (!deepseekResponse.ok) {
const errorText = await deepseekResponse.text();
console.error("DeepSeek API error:", errorText);
return NextResponse.json(
{ error: "Failed to generate recommendation from DeepSeek AI" },
{ status: 500 }
);
}
const deepseekData = await deepseekResponse.json();
console.log("Raw DeepSeek Response:", deepseekData);
try {
const content = deepseekData.choices[0].message.content;
let cleanResponse = content.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) {
console.error("Failed to parse DeepSeek response:", deepseekData);
return NextResponse.json(
{ error: "Invalid response format from DeepSeek AI" },
{ status: 500 }
);
}
} else {
// Use local Ollama
console.log("Using local Ollama model...");
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest",
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 Ollama" },
{ status: 500 }
);
}
const aiData = await ollamaResponse.json();
console.log("Raw Ollama Response:", aiData.response);
try {
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) {
console.error("Failed to parse Ollama response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from Ollama" },
{ status: 500 }
);
}
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.userId,
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 }
);
}
}
@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { getDatabase } from '@/lib/database'
export async function GET(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId')
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
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (currentUserId !== targetUserId) {
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
}
let recommendations = await db.getRecommendationsByUserId(targetUserId)
// Non-staff users should only see approved recommendations
if (!isStaff) {
recommendations = recommendations.filter((rec: any) => rec.status === 'approved')
}
return NextResponse.json(recommendations)
} catch (error) {
console.error('Error fetching recommendations:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
export async function POST(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const db = await getDatabase()
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
const body = await request.json()
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body
// 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)
}
// 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 })
} catch (error) {
console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
export async function PUT(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const body = await request.json()
const { id, status, recommendationText, activityPlan, dietPlan, content } = body
if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 })
}
const db = await getDatabase()
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(recommendationText && { content: recommendationText }), // Map legacy field
...(content && { content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan })
})
return NextResponse.json(updated)
} catch (error) {
console.error('Error updating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}
+90 -11
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "../../../lib/database/index";
import bcrypt from "bcryptjs";
import { auth, clerkClient } from "@clerk/nextjs/server";
export async function GET(request: NextRequest) {
try {
@@ -34,18 +35,61 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const db = await getDatabase();
const body = await request.json();
const { email, password, firstName, lastName, role, phone } = body;
const { userId: clerkUserId } = await auth();
if (!clerkUserId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!email || !password || !firstName || !lastName || !role) {
const db = await getDatabase();
// Get current user to check role
// Note: In a real app, we'd map Clerk ID to our DB ID.
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
// we might need to rely on the user being synced.
// Let's assume the user calling this API is already in our DB.
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
// OR we'll assume the first user is Super Admin if no users exist?
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
// For this step, let's assume we can get the user.
// WAIT: The current `users` table has `id` as a string. Is it the Clerk ID?
// In `sync-user.ts`, we use `evt.data.id` as the `id` when creating the user.
// So yes, `users.id` IS the Clerk ID.
const currentUser = await db.getUserById(clerkUserId);
if (!currentUser) {
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
}
const body = await request.json();
const { email, firstName, lastName, role, phone } = body;
if (!email || !firstName || !lastName || !role) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 },
);
}
// Check if user already exists
// Enforce Hierarchy
const allowed: Record<string, string[]> = {
superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"],
trainer: ["client"],
client: []
};
const userRole = currentUser.role as keyof typeof allowed;
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
return NextResponse.json(
{ error: `You are not authorized to create a ${role}` },
{ status: 403 }
);
}
// Check if user already exists locally
const existingUser = await db.getUserByEmail(email);
if (existingUser) {
return NextResponse.json(
@@ -54,20 +98,55 @@ export async function POST(request: NextRequest) {
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 12);
// Create Clerk Invitation
// Note: We pass the role in publicMetadata so it persists when they sign up
try {
const client = await clerkClient();
await client.invitations.createInvitation({
emailAddress: email,
publicMetadata: {
role,
},
ignoreExisting: true // Don't fail if invite exists
});
} catch (clerkError: any) {
console.error("Clerk invitation error:", clerkError);
// If user already exists in Clerk, we might want to handle it.
// But for now, let's proceed to create local record if invite sent or if they exist.
if (clerkError.errors?.[0]?.code === 'form_identifier_exists') {
return NextResponse.json(
{ error: "User already exists in Clerk system" },
{ status: 409 },
);
}
return NextResponse.json(
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
{ status: 500 },
);
}
// Create user
const userId = await db.createUser({
// Create user in local DB with temporary ID (will be migrated on first login)
// We set a placeholder password since it's required by schema but won't be used
const newUserId = await db.createUser({
email,
password: hashedPassword,
password: "INVITED_USER_PENDING",
firstName,
lastName,
role,
phone,
});
return NextResponse.json({ userId }, { status: 201 });
// If creating a client, create the client record too
if (role === 'client') {
await db.createClient({
userId: newUserId.id,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
});
}
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
} catch (error) {
console.error("Create user error:", error);
return NextResponse.json(
+74 -1
View File
@@ -1,3 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+7 -20
View File
@@ -8,6 +8,7 @@ import {
SignInButton,
UserButton,
} from "@clerk/nextjs";
import { Sidebar } from "@/components/ui/Sidebar";
const inter = Inter({ subsets: ["latin"] });
@@ -25,26 +26,12 @@ export default function RootLayout({
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
<header className="bg-white shadow-sm border-b">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-gray-900">FitAI Admin</h1>
</div>
<div className="flex items-center gap-4">
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
<SignedOut>
<SignInButton mode="modal">
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
Sign In
</button>
</SignInButton>
</SignedOut>
</div>
</div>
</header>
{children}
<div className="flex min-h-screen bg-slate-50">
<Sidebar />
<main className="flex-1 ml-64 p-8">
{children}
</main>
</div>
</body>
</html>
</ClerkProvider>
+90 -55
View File
@@ -1,68 +1,103 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Users, CreditCard, CalendarCheck, TrendingUp } from "lucide-react";
import { StatsCard } from "@/components/ui/StatsCard";
import { UserManagement } from "@/components/users/UserManagement";
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import axios from "axios";
interface DashboardStats {
totalUsers: number;
activeClients: number;
totalRevenue: number;
revenueGrowth: number;
}
export default function Home() {
const [stats, setStats] = useState<DashboardStats>({
totalUsers: 0,
activeClients: 0,
totalRevenue: 0,
revenueGrowth: 0,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
const response = await axios.get("/api/admin/stats");
setStats(response.data);
} catch (error) {
console.error("Failed to fetch dashboard stats:", error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
};
return (
<main className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<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="space-y-8">
<div>
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2>
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total Users"
value={loading ? "..." : stats.totalUsers}
change="+12%" // Placeholder for now as we don't track historical growth yet
trend="up"
icon={Users}
color="blue"
/>
<StatsCard
title="Active Clients"
value={loading ? "..." : stats.activeClients}
change="+5%"
trend="up"
icon={CalendarCheck}
color="green"
/>
<StatsCard
title="Revenue"
value={loading ? "..." : formatCurrency(stats.totalRevenue)}
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`}
trend={stats.revenueGrowth >= 0 ? "up" : "down"}
icon={CreditCard}
color="purple"
/>
<StatsCard
title="Growth"
value="24%" // Placeholder
change="-2%"
trend="down"
icon={TrendingUp}
color="orange"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3>
<UserManagement />
</div>
<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>
<p className="text-gray-600">
Manage fitness clients and their profiles
</p>
</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>
</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>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6">
Recent User Activity
</h2>
<div>
<UserManagement />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6">Quick Analytics</h2>
<div>
<AnalyticsDashboard />
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
<AnalyticsDashboard />
</div>
</div>
</main>
</div>
);
}
+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"}
+260
View File
@@ -0,0 +1,260 @@
"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);
const [useExternalModel, setUseExternalModel] = useState(false);
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, useExternalModel }),
});
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");
}
};
const handleEdit = async (rec: Recommendation) => {
const newContent = prompt("Edit Advice:", rec.content);
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
// User cancelled one of the prompts
return;
}
try {
const res = await fetch("/api/recommendations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rec.id,
content: newContent,
activityPlan: newActivityPlan,
dietPlan: newDietPlan,
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
} else {
alert("Recommendation updated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error("Error updating recommendation:", error);
alert("Failed to update recommendation.");
}
};
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">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">AI Recommendations</h1>
{/* Model Selection Toggle */}
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
<span className="text-sm font-medium text-gray-700">
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
</span>
<button
onClick={() => setUseExternalModel(!useExternalModel)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-xs text-gray-500">
{useExternalModel ? "External" : "Local"}
</span>
</div>
</div>
<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={() => handleEdit(rec)}
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Edit
</button>
<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>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { getDatabase } from "@/lib/database";
import { Recommendations } from "@/components/users/Recommendations";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{
id: string;
}>;
}
export default async function UserProfilePage({ params }: PageProps) {
const { id } = await params;
const db = await getDatabase();
const user = await db.getUserById(id);
if (!user) {
notFound();
}
const client = await db.getClientByUserId(user.id);
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">
{user.firstName} {user.lastName}
</h1>
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
{user.role}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
<div className="space-y-2">
<p><span className="font-medium">Email:</span> {user.email}</p>
<p><span className="font-medium">Phone:</span> {user.phone || "N/A"}</p>
<p><span className="font-medium">Joined:</span> {user.createdAt.toLocaleDateString()}</p>
</div>
</div>
{/* Client Info */}
{client && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
<div className="space-y-2">
<p><span className="font-medium">Type:</span> {client.membershipType}</p>
<p><span className="font-medium">Status:</span> {client.membershipStatus}</p>
<p><span className="font-medium">Member Since:</span> {client.joinDate.toLocaleDateString()}</p>
<p><span className="font-medium">Last Visit:</span> {client.lastVisit?.toLocaleDateString() || "Never"}</p>
</div>
</div>
)}
</div>
{/* Fitness Profile */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
{fitnessProfile ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
<p>Height: {fitnessProfile.height} cm</p>
<p>Weight: {fitnessProfile.weight} kg</p>
<p>Age: {fitnessProfile.age}</p>
<p>Gender: {fitnessProfile.gender}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Health & Habits</h3>
<p>Activity Level: {fitnessProfile.activityLevel.replace('_', ' ')}</p>
<p>Diet: {fitnessProfile.dietHabits || "N/A"}</p>
<p>Exercise: {fitnessProfile.exerciseHabits || "N/A"}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
</div>
<div className="col-span-full mt-4">
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
<div className="flex flex-wrap gap-2">
{fitnessProfile.fitnessGoals.map((goal, i) => (
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm">
{goal.replace('_', ' ')}
</span>
))}
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic">No fitness profile created yet.</p>
)}
</div>
{/* Recommendations Component */}
<Recommendations userId={user.id} />
</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>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
LayoutDashboard,
Users,
CalendarCheck,
CreditCard,
Settings,
LogOut,
Brain,
} from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs";
interface Recommendation {
id: string;
status: string;
}
export function Sidebar() {
const pathname = usePathname();
const { user } = useUser();
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const fetchPending = async () => {
try {
const res = await fetch("/api/recommendations");
if (res.ok) {
const data = await res.json();
const pending = (data.recommendations || []).filter(
(r: Recommendation) => r.status === "pending"
);
setPendingCount(pending.length);
}
} catch (e) {
console.error("Failed to fetch pending recommendations", e);
}
};
fetchPending();
}, []);
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" },
{
icon: Brain,
label: (
<span className="flex items-center">
AI Recommendations
{pendingCount > 0 && (
<span className="ml-2 inline-block bg-red-600 text-xs rounded-full px-2 py-0.5">
{pendingCount}
</span>
)}
</span>
),
href: "/recommendations",
},
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" },
];
return (
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
<div className="p-6 border-b border-slate-800">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
FitAI Admin
</h1>
</div>
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
const label = typeof item.label === "string" ? item.label : item.label;
return (
<Link
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"}`}
>
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
<span className="font-medium">{label}</span>
</Link>
);
})}
</nav>
<div className="p-4 border-t border-slate-800">
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50">
<UserButton afterSignOutUrl="/" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.fullName || "Admin User"}
</p>
<p className="text-xs text-slate-400 truncate">
{user?.primaryEmailAddress?.emailAddress}
</p>
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,51 @@
import { LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface StatsCardProps {
title: string;
value: string | number;
change?: string;
trend?: "up" | "down" | "neutral";
icon: LucideIcon;
color?: "blue" | "green" | "purple" | "orange";
}
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
const colorStyles = {
blue: "bg-blue-50 text-blue-600",
green: "bg-green-50 text-green-600",
purple: "bg-purple-50 text-purple-600",
orange: "bg-orange-50 text-orange-600",
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className={`p-2 rounded-lg ${colorStyles[color]}`}>
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change && (
<p className="text-xs text-muted-foreground mt-1">
<span
className={`font-medium ${trend === "up"
? "text-green-600"
: trend === "down"
? "text-red-600"
: "text-slate-600"
}`}
>
{change}
</span>{" "}
vs last month
</p>
)}
</CardContent>
</Card>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,174 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string;
}
interface RecommendationsProps {
userId: string;
}
export function Recommendations({ userId }: RecommendationsProps) {
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [newRec, setNewRec] = useState<{
type: "short_term" | "medium_term" | "long_term";
content: string;
}>({ type: "short_term", content: "" });
useEffect(() => {
fetchRecommendations();
}, [userId]);
const fetchRecommendations = async () => {
setLoading(true);
try {
const response = await fetch(`/api/recommendations?userId=${userId}`);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
}
} catch (error) {
console.error("Failed to fetch recommendations:", error);
} finally {
setLoading(false);
}
};
const handleAddRecommendation = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
type: newRec.type,
content: newRec.content,
}),
});
if (response.ok) {
setNewRec({ ...newRec, content: "" });
fetchRecommendations();
} else {
alert("Failed to add recommendation");
}
} catch (error) {
console.error(error);
}
};
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"),
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
items: Recommendation[]
) => (
<div className="mb-6">
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
<div className="space-y-2">
{items.length === 0 && (
<p className="text-gray-500 text-sm italic">No recommendations yet.</p>
)}
{items.map((rec) => (
<div
key={rec.id}
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 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 === "approved"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
{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>
)}
</form>
)}
</div>
);
if (loading) return <div>Loading recommendations...</div>;
return (
<Card>
<CardHeader>
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<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)}
</div>
</CardContent>
</Card>
);
}
+33 -9
View File
@@ -70,6 +70,7 @@ export function UserGrid({
sortable: true,
cellRenderer: (params: any) => {
const roleColors = {
superAdmin: "bg-red-100 text-red-800",
admin: "bg-purple-100 text-purple-800",
trainer: "bg-blue-100 text-blue-800",
client: "bg-green-100 text-green-800",
@@ -77,7 +78,14 @@ export function UserGrid({
const colorClass =
roleColors[params.value as keyof typeof roleColors] ||
"bg-gray-100 text-gray-800";
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
},
minWidth: 120,
},
@@ -87,6 +95,7 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
minWidth: 130,
valueFormatter: (params: any) => params.value || "N/A",
},
{
headerName: "Membership",
@@ -94,17 +103,24 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return "N/A";
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
const membershipColors = {
vip: "bg-yellow-100 text-yellow-800",
premium: "bg-blue-100 text-blue-800",
basic: "bg-gray-100 text-gray-800",
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
premium: "bg-blue-100 text-blue-800 border-blue-200",
basic: "bg-slate-100 text-slate-800 border-slate-200",
};
const colorClass =
membershipColors[params.value as keyof typeof membershipColors] ||
"bg-gray-100 text-gray-800";
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
{label}
</span>
);
},
minWidth: 120,
},
@@ -114,17 +130,25 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return "N/A";
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
const statusColors = {
active: "bg-green-100 text-green-800",
inactive: "bg-red-100 text-red-800",
suspended: "bg-yellow-100 text-yellow-800",
suspended: "bg-orange-100 text-orange-800",
expired: "bg-gray-100 text-gray-800",
};
const colorClass =
statusColors[params.value as keyof typeof statusColors] ||
"bg-gray-100 text-gray-800";
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
{label}
</span>
);
},
minWidth: 120,
},
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { UserGrid } from "@/components/users/UserGrid";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface User {
id: string;
@@ -139,23 +139,44 @@ export function UserManagement() {
};
const handleSaveEdit = async () => {
if (!editForm || !selectedUser) return;
if (!editForm) return;
try {
const response = await fetch("/api/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
});
if (response.ok) {
setIsEditing(false);
setEditForm(null);
fetchUsers();
if (selectedUser) {
// Update existing user
const response = await fetch("/api/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
});
if (response.ok) {
setIsEditing(false);
setEditForm(null);
fetchUsers();
} else {
alert("Error updating user");
}
} else {
alert("Error updating user");
// Create (Invite) new user
const response = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
});
if (response.ok) {
setIsEditing(false);
setEditForm(null);
fetchUsers();
alert("Invitation sent successfully!");
} else {
const errorData = await response.json();
alert(`Error sending invitation: ${errorData.error}`);
}
}
} catch (error) {
console.error(error);
alert("An unexpected error occurred");
}
};
@@ -191,14 +212,30 @@ export function UserManagement() {
</Button>
<Button
variant="secondary"
onClick={handleEditUser}
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser}
>
Edit User
</Button>
<Button
variant="secondary"
onClick={handleDeleteUser}
onClick={() => {
setEditForm({
firstName: "",
lastName: "",
email: "",
role: "client",
phone: "",
});
setSelectedUser(null);
setIsEditing(true);
}}
>
Invite User
</Button>
<Button
variant="secondary"
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser}
>
Delete User
@@ -221,6 +258,12 @@ export function UserManagement() {
>
Admins
</Button>
<Button
variant={filter === "superAdmin" ? "primary" : "secondary"}
onClick={() => setFilter("superAdmin")}
>
Super Admins
</Button>
</div>
</div>
@@ -259,7 +302,7 @@ export function UserManagement() {
{isEditing && editForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">Edit User</h3>
<h3 className="text-lg font-semibold mb-4">{selectedUser ? 'Edit User' : 'Invite New User'}</h3>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -304,6 +347,7 @@ export function UserManagement() {
}
className="w-full border border-gray-300 rounded px-3 py-2"
required
disabled={!!selectedUser} // Disable email edit for existing users if desired, or keep enabled
/>
</div>
<div className="mb-4">
@@ -316,10 +360,17 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2"
required
>
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
We can add a visual indicator or fetch "me" to filter. */}
<option value="client">Client</option>
<option value="trainer">Trainer</option>
<option value="admin">Admin</option>
<option value="superAdmin">Super Admin</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Note: You can only assign roles lower than your own.
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Phone</label>
@@ -347,7 +398,7 @@ export function UserManagement() {
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
{selectedUser ? 'Save Changes' : 'Send Invitation'}
</button>
</div>
</form>
@@ -383,8 +434,14 @@ export function UserManagement() {
{selectedUser && (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">User Details</h3>
<a
href={`/users/${selectedUser.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
>
View Full Profile & Recommendations
</a>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
@@ -436,8 +493,8 @@ export function UserManagement() {
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
? new Date(
selectedUser.client.lastVisit,
).toLocaleDateString()
selectedUser.client.lastVisit,
).toLocaleDateString()
: "Never"}
</p>
</div>
+207 -19
View File
@@ -1,7 +1,7 @@
import Database from 'better-sqlite3'
import path from 'path'
import fs from 'fs'
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types'
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
export class SQLiteDatabase implements IDatabase {
private db: Database.Database | null = null
@@ -60,7 +60,7 @@ export class SQLiteDatabase implements IDatabase {
lastName TEXT NOT NULL,
password TEXT NOT NULL,
phone TEXT,
role TEXT NOT NULL CHECK (role IN ('admin', 'client')),
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
)
@@ -91,6 +91,8 @@ export class SQLiteDatabase implements IDatabase {
exerciseHabits TEXT,
dietHabits TEXT,
medicalConditions TEXT,
allergies TEXT,
injuries TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
@@ -118,6 +120,32 @@ 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,
fitnessProfileId TEXT,
type TEXT NOT NULL,
content TEXT NOT NULL,
activityPlan TEXT,
dietPlan TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
createdAt DATETIME NOT NULL,
approvedAt DATETIME,
approvedBy TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
)
`)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
`)
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
@@ -125,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)
@@ -139,8 +169,8 @@ export class SQLiteDatabase implements IDatabase {
}
const stmt = this.db.prepare(
`INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
@@ -189,7 +219,7 @@ export class SQLiteDatabase implements IDatabase {
values.push(new Date().toISOString()) // updatedAt
values.push(id)
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ?`)
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
stmt.run(values)
return this.getUserById(id)
@@ -203,6 +233,20 @@ export class SQLiteDatabase implements IDatabase {
return (result.changes || 0) > 0
}
async migrateUserId(oldId: string, newId: string): Promise<void> {
if (!this.db) throw new Error('Database not connected')
// We need to disable foreign keys temporarily if we want to update ID without cascade (if cascade isn't set)
// But we should try to update and let cascade handle it if possible.
// Since we didn't set ON UPDATE CASCADE, we might need to manually update references or use PRAGMA.
// Simplest way: Update the ID. If it fails due to FK, we have to handle it.
// For the Super Admin seed case, there are no dependencies.
const stmt = this.db.prepare('UPDATE users SET id = ? WHERE id = ?')
stmt.run(newId, oldId)
}
// Client operations
async createClient(clientData: Omit<Client, 'id'>): Promise<Client> {
if (!this.db) throw new Error('Database not connected')
@@ -211,8 +255,8 @@ export class SQLiteDatabase implements IDatabase {
const client: Client = { id, ...clientData }
const stmt = this.db.prepare(
`INSERT INTO clients (id, userId, membershipType, membershipStatus, joinDate)
VALUES (?, ?, ?, ?, ?)`
`INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
VALUES(?, ?, ?, ?, ?)`
)
stmt.run(
@@ -257,7 +301,7 @@ export class SQLiteDatabase implements IDatabase {
const values = fields.map(field => (updates as any)[field])
values.push(id)
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ?`)
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
stmt.run(values)
return this.getClientById(id)
@@ -283,17 +327,17 @@ export class SQLiteDatabase implements IDatabase {
}
const stmt = this.db.prepare(
`INSERT INTO fitness_profiles
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
exerciseHabits, dietHabits, medicalConditions, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO fitness_profiles
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
profile.dietHabits, profile.medicalConditions, profile.createdAt.toISOString(),
profile.updatedAt.toISOString()
profile.dietHabits, profile.medicalConditions, profile.allergies, profile.injuries,
profile.createdAt.toISOString(), profile.updatedAt.toISOString()
)
return profile
@@ -330,7 +374,7 @@ export class SQLiteDatabase implements IDatabase {
values.push(new Date().toISOString()) // updatedAt
values.push(userId)
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ?`)
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
stmt.run(values)
return this.getFitnessProfileByUserId(userId)
@@ -361,8 +405,8 @@ export class SQLiteDatabase implements IDatabase {
}
const stmt = this.db.prepare(
`INSERT INTO attendance (id, clientId, checkInTime, type, notes, createdAt)
VALUES (?, ?, ?, ?, ?, ?)`
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
VALUES(?, ?, ?, ?, ?, ?)`
)
stmt.run(
@@ -370,6 +414,12 @@ export class SQLiteDatabase implements IDatabase {
attendance.type, attendance.notes, attendance.createdAt.toISOString()
)
// Update client last visit
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
now.toISOString(),
clientId
)
return attendance
}
@@ -432,7 +482,8 @@ export class SQLiteDatabase implements IDatabase {
userId: row.userId,
membershipType: row.membershipType,
membershipStatus: row.membershipStatus,
joinDate: new Date(row.joinDate)
joinDate: new Date(row.joinDate),
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
}
}
@@ -448,6 +499,8 @@ export class SQLiteDatabase implements IDatabase {
exerciseHabits: row.exerciseHabits,
dietHabits: row.dietHabits,
medicalConditions: row.medicalConditions,
allergies: row.allergies,
injuries: row.injuries,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt)
}
@@ -464,4 +517,139 @@ export class SQLiteDatabase implements IDatabase {
createdAt: new Date(row.createdAt)
}
}
// Recommendation operations
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,
status: data.status || 'pending'
}
const stmt = this.db.prepare(
`INSERT INTO recommendations (
id, userId, fitnessProfileId, type, content,
activityPlan, dietPlan, status, createdAt
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
stmt.run(
recommendation.id, recommendation.userId, recommendation.fitnessProfileId,
recommendation.type, recommendation.content, recommendation.activityPlan,
recommendation.dietPlan, recommendation.status,
recommendation.createdAt.toISOString()
)
return recommendation
}
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
const rows = stmt.all(userId)
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')
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
if (fields.length === 0) {
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = stmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
const setClause = fields.map(field => `${field} = ?`).join(', ')
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} WHERE id = ?`)
stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
const row = getStmt.get(id)
return row ? this.mapRowToRecommendation(row) : null
}
async deleteRecommendation(id: string): Promise<boolean> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
const result = stmt.run(id)
return (result.changes || 0) > 0
}
private mapRowToRecommendation(row: any): Recommendation {
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),
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
approvedBy: row.approvedBy
}
}
async getDashboardStats(): Promise<{
totalUsers: number;
activeClients: number;
totalRevenue: number;
revenueGrowth: number;
}> {
if (!this.db) throw new Error('Database not connected')
// Total Users
const userCountStmt = this.db.prepare('SELECT COUNT(*) as count FROM users')
const userCount = (userCountStmt.get() as any).count
// Active Clients
const activeClientCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM clients WHERE membershipStatus = 'active'")
const activeClientCount = (activeClientCountStmt.get() as any).count
// Total Revenue (assuming payments table exists, handling if it's empty)
// Note: We need to create the payments table first if it doesn't exist in createTables
// For now, returning 0 if table doesn't exist or is empty
let totalRevenue = 0
let revenueGrowth = 0
try {
const revenueStmt = this.db.prepare('SELECT SUM(amount) as total FROM payments WHERE status = "completed"')
const revenueResult = revenueStmt.get() as any
totalRevenue = revenueResult?.total || 0
} catch (e) {
// Table might not exist yet
console.warn('Payments table query failed, returning 0 revenue')
}
return {
totalUsers: userCount,
activeClients: activeClientCount,
totalRevenue,
revenueGrowth
}
}
}
+42 -2
View File
@@ -6,7 +6,7 @@ export interface User {
lastName: string;
password: string;
phone?: string;
role: "admin" | "trainer" | "client";
role: "superAdmin" | "admin" | "trainer" | "client";
createdAt: Date;
updatedAt: Date;
}
@@ -17,6 +17,7 @@ export interface Client {
membershipType: "basic" | "premium" | "vip";
membershipStatus: "active" | "inactive" | "expired";
joinDate: Date;
lastVisit?: Date;
}
export interface FitnessProfile {
@@ -30,6 +31,8 @@ export interface FitnessProfile {
exerciseHabits: string;
dietHabits: string;
medicalConditions: string;
allergies?: string;
injuries?: string;
createdAt: Date;
updatedAt: Date;
}
@@ -44,6 +47,20 @@ export interface Attendance {
createdAt: Date;
}
export interface Recommendation {
id: string;
userId: string;
fitnessProfileId?: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "approved" | "rejected" | "completed";
createdAt: Date;
approvedAt?: Date;
approvedBy?: string;
}
// Database Interface - allows us to swap implementations
export interface IDatabase {
// Connection management
@@ -51,12 +68,15 @@ 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[]>;
updateUser(id: string, updates: Partial<User>): Promise<User | null>;
deleteUser(id: string): Promise<boolean>;
migrateUserId(oldId: string, newId: string): Promise<void>;
// Client operations
createClient(client: Omit<Client, "id">): Promise<Client>;
@@ -88,6 +108,26 @@ export interface IDatabase {
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
getAllAttendance(): Promise<Attendance[]>;
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
// Recommendation operations
createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>;
updateRecommendation(
id: string,
updates: Partial<Recommendation>,
): Promise<Recommendation | null>;
deleteRecommendation(id: string): Promise<boolean>;
// Dashboard operations
getDashboardStats(): Promise<{
totalUsers: number;
activeClients: number;
totalRevenue: number;
revenueGrowth: number; // Percentage vs last month
}>;
}
// Database configuration
+35 -2
View File
@@ -5,16 +5,49 @@ export async function ensureUserSynced(userId: string, db: IDatabase) {
const existingUser = await db.getUserById(userId)
if (existingUser) return existingUser
console.log('User not found in DB, syncing from Clerk:', userId)
console.log('User not found in DB by ID, checking Clerk:', userId)
const clerkUser = await currentUser()
if (!clerkUser || clerkUser.id !== userId) {
// If we can't get the user from Clerk (e.g. running locally without full auth sync),
// we might want to fail gracefully or throw.
// For now, throw to be safe.
throw new Error('Could not fetch Clerk user details')
}
const email = clerkUser.emailAddresses[0]?.emailAddress
if (!email) throw new Error('User has no email')
// Check if user exists by email (e.g. seeded user)
const existingByEmail = await db.getUserByEmail(email)
if (existingByEmail) {
console.log('User found by email but ID mismatch. Migrating ID...', {
oldId: existingByEmail.id,
newId: userId
})
// Update the ID to match Clerk ID
// We need to do this manually via SQL because IDatabase interface might not expose a direct ID update method easily
// But we can use a raw query if we had access, or add a method.
// Since we don't have direct access to `db.db` here (it's hidden behind IDatabase),
// we should add a method to IDatabase or use a workaround.
// Actually, `SQLiteDatabase` is what we have at runtime.
// Let's assume we can cast it or add `updateUserId` to the interface.
// For now, let's try to update it using `updateUser` but `updateUser` usually updates fields based on ID.
// We can't update the ID itself using `updateUser(id, { id: newId })` because `updateUser` implementation filters out `id` from updates.
// We need to add a method to migrate user ID in IDatabase.
// Or, strictly for this prototype, we can delete the old user and create a new one (DATA LOSS RISK).
// But since it's a seeded super admin with no data, it's fine.
// However, if they had clients, we'd lose the link.
// Let's add `migrateUserId(oldId, newId)` to IDatabase.
await db.migrateUserId(existingByEmail.id, userId)
return db.getUserById(userId)
}
console.log('Creating new user from Clerk data:', userId)
const user = await db.createUser({
id: userId,
email,
@@ -22,7 +55,7 @@ export async function ensureUserSynced(userId: string, db: IDatabase) {
lastName: clerkUser.lastName || '',
password: '', // Managed by Clerk
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
role: (clerkUser.publicMetadata.role as 'admin' | 'client') || 'client'
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
})
return user
+11 -23
View File
@@ -1,26 +1,14 @@
export const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export const formatCurrency = (
amount: number,
currency: string = 'USD'
): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount)
}
export const calculateDaysBetween = (startDate: Date, endDate: Date): number => {
const timeDiff = endDate.getTime() - startDate.getTime()
return Math.ceil(timeDiff / (1000 * 3600 * 24))
}
export const generateId = (): string => {
return Math.random().toString(36).substr(2, 9)
export function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
+63 -8
View File
@@ -1,21 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
}
+3
View File
@@ -0,0 +1,3 @@
## user flow
superAdmin -> localAdmin/Gym -> localAdmin -> creates trainers -> can add users
+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,
},
});
+10
View File
@@ -67,6 +67,16 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="goals"
options={{
title: "Goals",
headerTitle: "Fitness Goals",
tabBarIcon: ({ color, size }) => (
<Ionicons name="trophy" size={size} color={color} />
),
}}
/>
</Tabs>
);
}
+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) {
+272
View File
@@ -0,0 +1,272 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Alert,
} from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term";
content: string;
status: "pending" | "completed";
createdAt: string;
}
export default function GoalsScreen() {
const { userId, getToken } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = async () => {
if (!userId) return;
try {
const token = await getToken();
const response = await fetch(
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
} else {
console.error("Failed to fetch recommendations");
}
} catch (error) {
console.error("Error fetching recommendations:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRecommendations();
}, [userId]);
const onRefresh = () => {
setRefreshing(true);
fetchRecommendations();
};
const toggleStatus = async (id: string, currentStatus: string) => {
const newStatus = currentStatus === "pending" ? "completed" : "pending";
// Optimistic update
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
);
try {
const token = await getToken();
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id,
status: newStatus,
}),
});
if (!response.ok) {
// Revert on failure
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
} catch (error) {
console.error(error);
// Revert on error
setRecommendations(prev =>
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
);
Alert.alert("Error", "Failed to update status");
}
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term"
) => {
const items = recommendations.filter((r) => r.type === type);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{items.length === 0 ? (
<Text style={styles.emptyText}>No goals set yet.</Text>
) : (
items.map((rec) => (
<TouchableOpacity
key={rec.id}
style={[
styles.card,
rec.status === "completed" && styles.cardCompleted,
]}
onPress={() => toggleStatus(rec.id, rec.status)}
>
<View style={styles.checkbox}>
{rec.status === "completed" && (
<Ionicons name="checkmark" size={16} color="#fff" />
)}
</View>
<View style={styles.cardContent}>
<Text
style={[
styles.cardText,
rec.status === "completed" && styles.cardTextCompleted,
]}
>
{rec.content}
</Text>
<Text style={styles.dateText}>
{new Date(rec.createdAt).toLocaleDateString()}
</Text>
</View>
</TouchableOpacity>
))
)}
</View>
);
};
if (loading && !refreshing) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#2563eb" />
</View>
);
}
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
>
<View style={styles.header}>
<Text style={styles.headerTitle}>My Goals</Text>
<Text style={styles.headerSubtitle}>
Track your fitness journey progress
</Text>
</View>
{renderSection("Short Term Goals", "short_term")}
{renderSection("Medium Term Goals", "medium_term")}
{renderSection("Long Term Goals", "long_term")}
<View style={styles.footer} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
header: {
padding: 20,
backgroundColor: "#fff",
marginBottom: 10,
},
headerTitle: {
fontSize: 28,
fontWeight: "bold",
color: "#111827",
},
headerSubtitle: {
fontSize: 16,
color: "#6b7280",
marginTop: 4,
},
section: {
padding: 20,
paddingTop: 10,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#374151",
marginBottom: 12,
},
emptyText: {
fontStyle: "italic",
color: "#9ca3af",
},
card: {
backgroundColor: "#fff",
padding: 16,
borderRadius: 12,
marginBottom: 12,
flexDirection: "row",
alignItems: "flex-start",
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
cardCompleted: {
backgroundColor: "#f0fdf4", // light green
borderColor: "#bbf7d0",
borderWidth: 1,
},
checkbox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: "#d1d5db",
marginRight: 12,
marginTop: 2,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#fff",
},
cardContent: {
flex: 1,
},
cardText: {
fontSize: 16,
color: "#1f2937",
lineHeight: 24,
},
cardTextCompleted: {
textDecorationLine: "line-through",
color: "#9ca3af",
},
dateText: {
fontSize: 12,
color: "#9ca3af",
marginTop: 4,
},
footer: {
height: 40,
},
});
+3
View File
@@ -75,6 +75,8 @@ export default function HomeScreen() {
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</TouchableOpacity>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
@@ -88,6 +90,7 @@ export default function HomeScreen() {
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
</View>
<View style={styles.actionButton}>
<View style={styles.actionIcon}>
<Ionicons name="calendar-outline" size={24} color="#10b981" />
@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
import { useAuth } from "@clerk/clerk-expo";
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan?: string;
dietPlan?: string;
status: string;
createdAt: string;
}
export default function RecommendationsScreen() {
const { getToken, userId } = useAuth();
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchRecommendations = async () => {
try {
if (!userId) {
console.error('No userId available');
return;
}
const token = await getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
console.log('Fetching recommendations from:', url);
const res = await fetch(url, { headers });
if (!res.ok) {
const errorText = await res.text();
console.error('API Error:', res.status, errorText);
throw new Error(`Network response was not ok: ${res.status}`);
}
const data = await res.json();
console.log('Recommendations data:', data);
setRecommendations(data.recommendations || data || []);
} catch (e) {
console.error('Failed to load recommendations', e);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchRecommendations();
}, []);
const onRefresh = () => {
setRefreshing(true);
fetchRecommendations();
};
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#000" />
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.header}>AI Recommendations</Text>
<FlatList
data={recommendations}
keyExtractor={(item) => item.id}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.empty}>No recommendations available yet.</Text>
<Text style={styles.emptySub}>Pull down to refresh</Text>
</View>
}
renderItem={({ item }) => (
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text style={styles.status}>{item.status.toUpperCase()}</Text>
<Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
</View>
<Text style={styles.sectionTitle}>Daily Advice</Text>
<Text style={styles.content}>{item.content}</Text>
{item.activityPlan && (
<>
<Text style={styles.sectionTitle}>Activity Plan</Text>
<Text style={styles.content}>{item.activityPlan}</Text>
</>
)}
{item.dietPlan && (
<>
<Text style={styles.sectionTitle}>Diet Plan</Text>
<Text style={styles.content}>{item.dietPlan}</Text>
</>
)}
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
header: {
fontSize: 28,
fontWeight: 'bold',
padding: 20,
paddingBottom: 12,
color: '#1a1a1a',
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
card: {
backgroundColor: '#fff',
padding: 16,
marginHorizontal: 16,
marginBottom: 12,
borderRadius: 12,
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
status: {
fontSize: 12,
fontWeight: '600',
color: '#2e7d32',
backgroundColor: '#e8f5e9',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
},
date: {
fontSize: 12,
color: '#666',
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#1a1a1a',
marginTop: 12,
marginBottom: 6,
},
content: {
fontSize: 14,
color: '#333',
lineHeight: 20,
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
empty: {
textAlign: 'center',
fontSize: 16,
color: '#666',
fontWeight: '500',
},
emptySub: {
textAlign: 'center',
fontSize: 14,
color: '#999',
marginTop: 8,
},
});
+4 -3
View File
@@ -13,6 +13,7 @@ import { useAuth } from "@clerk/clerk-expo";
import { Ionicons } from "@expo/vector-icons";
import { Input } from "../components/Input";
import { Picker } from "../components/Picker";
import { API_BASE_URL } from "../config/api";
interface FitnessProfileData {
height?: number;
@@ -64,7 +65,7 @@ export default function FitnessProfileScreen() {
try {
setFetchingProfile(true);
const token = await getToken();
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = `${API_BASE_URL}` || "http://localhost:3000";
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
headers: {
Authorization: `Bearer ${token}`,
@@ -98,9 +99,9 @@ export default function FitnessProfileScreen() {
try {
setLoading(true);
const token = await getToken();
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = `${API_BASE_URL}/api/fitness-profile` || "http://localhost:3000";
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
const response = await fetch(`${apiUrl}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
+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",
},
});
+3 -2
View File
@@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__
? 'https://5cb23f31d8c1.ngrok-free.app'
? 'https://0ccbc9f6f846.ngrok-free.app'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
@@ -17,4 +17,5 @@ export const API_ENDPOINTS = {
CHECK_OUT: '/api/attendance/check-out',
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.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
View File
File renamed without changes.
+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'
+32 -1
View File
@@ -6,7 +6,7 @@ export const users = sqliteTable("users", {
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
password: text("password"), // Optional - Clerk handles authentication
role: text("role", { enum: ["admin", "trainer", "client"] })
role: text("role", { enum: ["superAdmin", "admin", "trainer", "client"] })
.notNull()
.default("client"),
phone: text("phone"),
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
## TODOS
<!-- - fix fitness-profile network error -->
- enhance mobile up ui and styling
- implement create admin/trainer/client flow