Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febcdc111e | ||
|
|
73d2c4c1ed | ||
|
|
fc12cecd30 | ||
|
|
803c205994 | ||
|
|
28b5b52a8f | ||
|
|
684481fd2c | ||
|
|
bf532ba35d | ||
|
|
0896dd46d1 | ||
|
|
e9ba9e2700 | ||
|
|
3ab9908588 | ||
|
|
0183a8ea6a | ||
|
|
5d1d881f16 | ||
|
|
1b50bfd2ee | ||
|
|
6cbed4e8d1 | ||
|
|
76dafe9cd5 | ||
|
|
a96f1902c5 | ||
|
|
8b4cef33dc | ||
|
|
118efad70f | ||
|
|
daaf5aa2dd | ||
|
|
67636120d6 | ||
|
|
892cf1a040 | ||
|
|
4909815807 | ||
|
|
a87b94219d | ||
|
|
b1bb5d8166 | ||
|
|
39021dca35 | ||
|
|
33a698066f | ||
|
|
55ae37f304 | ||
|
|
b9f33fdcc6 |
No files matched your search
@@ -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
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib",
|
||||
"ui": "@/components/ui"
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
Generated
+1164
-1085
File diff suppressed because it is too large.
Load diff
+12
-4
@@ -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,12 +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",
|
||||
@@ -35,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,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = path.join(__dirname, '../data/fitai.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log('Creating fitness_goals table...');
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS fitness_goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
fitness_profile_id TEXT,
|
||||
goal_type TEXT NOT NULL CHECK(goal_type IN ('weight_target', 'strength_milestone', 'endurance_target', 'flexibility_goal', 'habit_building', 'custom')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
target_value REAL,
|
||||
current_value REAL,
|
||||
unit TEXT,
|
||||
start_date INTEGER NOT NULL,
|
||||
target_date INTEGER,
|
||||
completed_date INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned', 'paused')),
|
||||
progress REAL DEFAULT 0,
|
||||
priority TEXT DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high')),
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles(userId) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes for better query performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_fitness_goals_user_id ON fitness_goals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fitness_goals_status ON fitness_goals(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_fitness_goals_profile_id ON fitness_goals(fitness_profile_id);
|
||||
`);
|
||||
|
||||
console.log('✅ fitness_goals table created successfully');
|
||||
console.log('✅ Indexes created successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,27 @@
|
||||
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 {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// 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 })
|
||||
}
|
||||
|
||||
const attendance = await db.getAllAttendance()
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Admin attendance error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
// Check if already checked in
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
if (activeCheckIn) {
|
||||
return new NextResponse('Already checked in', { status: 400 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { type = 'gym', notes } = body
|
||||
|
||||
const attendance = await db.checkIn(client.id, type, notes)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
return new NextResponse('Client profile not found', { status: 404 })
|
||||
}
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
if (!activeCheckIn) {
|
||||
return new NextResponse('No active check-in found', { status: 404 })
|
||||
}
|
||||
|
||||
const attendance = await db.checkOut(activeCheckIn.id)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-out error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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) {
|
||||
console.log('GET /api/attendance/history called')
|
||||
console.log('Headers:', Object.fromEntries(req.headers.entries()))
|
||||
|
||||
try {
|
||||
const authResult = await auth()
|
||||
const { userId } = authResult
|
||||
console.log('Auth result:', JSON.stringify(authResult, null, 2))
|
||||
|
||||
if (!userId) {
|
||||
console.log('No userId found in auth result')
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const db = await getDatabase()
|
||||
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
const history = await db.getAttendanceHistory(client.id)
|
||||
return NextResponse.json(history)
|
||||
} catch (error) {
|
||||
console.error('History error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
|
||||
// POST - Mark goal as complete
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
const completedGoal = await db.completeGoal(id);
|
||||
|
||||
return NextResponse.json(completedGoal);
|
||||
} catch (error) {
|
||||
console.error('Error completing fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
|
||||
// GET - Get specific goal
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
const goal = await db.getFitnessGoalById(id);
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (goal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(goal);
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update goal
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const updates = await req.json();
|
||||
|
||||
// Don't allow changing userId or id
|
||||
delete updates.userId;
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
|
||||
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||
|
||||
return NextResponse.json(updatedGoal);
|
||||
} catch (error) {
|
||||
console.error('Error updating fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete goal
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const db = await getDatabase();
|
||||
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json({ error: 'Goal not found' }, { status: 404 });
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const deleted = await db.deleteFitnessGoal(id);
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Failed to delete goal' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
// GET - List user's fitness goals
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const targetUserId = searchParams.get('userId') || userId;
|
||||
const status = searchParams.get('status'); // active, completed, all
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch goals with optional status filter
|
||||
const goals = await db.getFitnessGoalsByUserId(
|
||||
targetUserId,
|
||||
status && status !== 'all' ? status : undefined
|
||||
);
|
||||
|
||||
return NextResponse.json(goals);
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goals:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create new fitness goal
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
goalType,
|
||||
title,
|
||||
description,
|
||||
targetValue,
|
||||
currentValue,
|
||||
unit,
|
||||
targetDate,
|
||||
priority,
|
||||
notes,
|
||||
fitnessProfileId
|
||||
} = body;
|
||||
|
||||
// Validation
|
||||
if (!goalType || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: 'goalType and title are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// Create the goal
|
||||
const goal = await db.createFitnessGoal({
|
||||
id: randomBytes(16).toString('hex'),
|
||||
userId,
|
||||
fitnessProfileId: fitnessProfileId || undefined,
|
||||
goalType,
|
||||
title,
|
||||
description: description || undefined,
|
||||
targetValue: targetValue || undefined,
|
||||
currentValue: currentValue || 0,
|
||||
unit: unit || undefined,
|
||||
startDate: new Date(),
|
||||
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||
status: 'active',
|
||||
progress: 0,
|
||||
priority: priority || 'medium',
|
||||
notes: notes || undefined
|
||||
});
|
||||
|
||||
return NextResponse.json(goal, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating fitness goal:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const db = new Database("./data/fitai.db");
|
||||
|
||||
// GET - Fetch fitness profile for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const profile = db
|
||||
.prepare(
|
||||
`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);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch fitness profile" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create or update fitness profile for the authenticated user
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender,
|
||||
fitnessGoals, // Changed from fitnessGoal
|
||||
activityLevel,
|
||||
medicalConditions,
|
||||
allergies,
|
||||
injuries,
|
||||
exerciseHabits,
|
||||
dietHabits
|
||||
} = body;
|
||||
|
||||
// Check if profile exists
|
||||
const existingProfile = db
|
||||
.prepare(`SELECT userId FROM fitness_profiles WHERE userId = ?`)
|
||||
.get(userId) as { userId: string } | undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const fitnessGoalsJson = JSON.stringify(fitnessGoals || []);
|
||||
|
||||
if (existingProfile) {
|
||||
// Update existing profile
|
||||
db.prepare(
|
||||
`UPDATE fitness_profiles
|
||||
SET height = ?,
|
||||
weight = ?,
|
||||
age = ?,
|
||||
gender = ?,
|
||||
fitnessGoals = ?,
|
||||
activityLevel = ?,
|
||||
medicalConditions = ?,
|
||||
allergies = ?,
|
||||
injuries = ?,
|
||||
exerciseHabits = ?,
|
||||
dietHabits = ?,
|
||||
updatedAt = ?
|
||||
WHERE userId = ?`
|
||||
).run(
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
now,
|
||||
userId
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Fitness profile updated successfully",
|
||||
userId: userId,
|
||||
});
|
||||
} else {
|
||||
// Create new profile
|
||||
db.prepare(
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
||||
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
userId,
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Fitness profile created successfully",
|
||||
userId,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving fitness profile:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save fitness profile" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
}
|
||||
@@ -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,191 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { buildAIContext } from "@/lib/ai/ai-context";
|
||||
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
||||
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build AI context with goals and recommendations
|
||||
let prompt: string;
|
||||
try {
|
||||
const context = await buildAIContext(userId);
|
||||
prompt = buildEnhancedPrompt(context);
|
||||
console.log('Using enhanced AI context with goals and history');
|
||||
} catch (error) {
|
||||
// Fallback to basic prompt if context building fails
|
||||
console.warn('Failed to build AI context, using basic prompt:', error);
|
||||
prompt = buildBasicPrompt(profile);
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface Attendance {
|
||||
id: string
|
||||
clientId: string
|
||||
checkInTime: string
|
||||
checkOutTime?: string
|
||||
type: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export default function AttendancePage() {
|
||||
const [attendance, setAttendance] = useState<Attendance[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/attendance')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAttendance(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Attendance Monitoring</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Client ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Check In
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Check Out
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{attendance.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{record.clientId}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||
{record.type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{format(new Date(record.checkInTime), 'PP p')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{record.checkOutTime ? format(new Date(record.checkOutTime), 'PP p') : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${record.checkOutTime
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{record.checkOutTime ? 'Completed' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import type { FitnessGoal } from '@/lib/database/types';
|
||||
|
||||
// Import types from database package
|
||||
import type {
|
||||
FitnessProfile as DBFitnessProfile,
|
||||
Recommendation as DBRecommendation,
|
||||
} from '@fitai/database';
|
||||
|
||||
export interface AIContext {
|
||||
profile: DBFitnessProfile;
|
||||
activeGoals: FitnessGoal[];
|
||||
completedGoals: FitnessGoal[];
|
||||
recentRecommendations: DBRecommendation[];
|
||||
progressSummary: {
|
||||
goalsCompleted: number;
|
||||
goalsActive: number;
|
||||
averageProgress: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build comprehensive AI context for a user
|
||||
* Aggregates fitness profile, goals, and recommendation history
|
||||
*/
|
||||
export async function buildAIContext(userId: string): Promise<AIContext> {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch fitness profile
|
||||
const profile = await db.getFitnessProfileByUserId(userId);
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Fitness profile not found for user ${userId}`);
|
||||
}
|
||||
|
||||
// Fetch all fitness goals
|
||||
const allGoals = await db.getFitnessGoalsByUserId(userId);
|
||||
|
||||
// Separate active and completed goals
|
||||
const activeGoals = allGoals.filter((g) => g.status === 'active');
|
||||
const completedGoals = allGoals.filter((g) => g.status === 'completed');
|
||||
|
||||
// Get all recommendations
|
||||
const allRecommendations = await db.getRecommendationsByUserId(userId);
|
||||
|
||||
// Filter recent recommendations (last 7 days)
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
|
||||
const recentRecommendations = allRecommendations
|
||||
.filter((r) => new Date(r.createdAt) > sevenDaysAgo)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
// Calculate progress summary
|
||||
const progressSummary = {
|
||||
goalsCompleted: completedGoals.length,
|
||||
goalsActive: activeGoals.length,
|
||||
averageProgress:
|
||||
activeGoals.length > 0
|
||||
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) / activeGoals.length
|
||||
: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
activeGoals,
|
||||
completedGoals,
|
||||
recentRecommendations,
|
||||
progressSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format goal for display in AI prompt
|
||||
*/
|
||||
export function formatGoalForPrompt(goal: FitnessGoal): string {
|
||||
const progress = goal.progress || 0;
|
||||
const current = goal.currentValue || 0;
|
||||
const target = goal.targetValue;
|
||||
const unit = goal.unit || '';
|
||||
|
||||
if (target) {
|
||||
return `${goal.title}: ${current}/${target} ${unit} (${progress.toFixed(1)}% complete)`;
|
||||
}
|
||||
|
||||
return `${goal.title}: ${progress.toFixed(1)}% complete`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AIContext } from './ai-context';
|
||||
import { formatGoalForPrompt } from './ai-context';
|
||||
|
||||
/**
|
||||
* Build enhanced AI prompt with comprehensive user context
|
||||
* Includes profile, goals, progress, and recommendation history
|
||||
*/
|
||||
export function buildEnhancedPrompt(context: AIContext): string {
|
||||
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
|
||||
|
||||
// Build goals section
|
||||
const activeGoalsText =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
|
||||
: '- No active goals set';
|
||||
|
||||
const completedGoalsText =
|
||||
completedGoals.length > 0
|
||||
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
|
||||
: 'None yet';
|
||||
|
||||
// Build context about recent recommendations
|
||||
const recommendationContextText =
|
||||
recentRecommendations.length > 0
|
||||
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
|
||||
|
||||
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||
|
||||
## User Profile
|
||||
- Height: ${profile.height || 'Not specified'} cm
|
||||
- Weight: ${profile.weight || 'Not specified'} kg
|
||||
- Age: ${profile.age || 'Not specified'}
|
||||
- Gender: ${profile.gender || 'Not specified'}
|
||||
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
|
||||
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||
- Allergies: ${profile.allergies || 'None'}
|
||||
- Injuries: ${profile.injuries || 'None'}
|
||||
|
||||
## Active Goals (${activeGoals.length})
|
||||
${activeGoalsText}
|
||||
|
||||
## Recent Progress
|
||||
- Goals Completed: ${progressSummary.goalsCompleted}
|
||||
- Active Goals: ${progressSummary.goalsActive}
|
||||
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
|
||||
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
|
||||
|
||||
## Recommendation History
|
||||
${recommendationContextText}
|
||||
|
||||
## Task
|
||||
Generate a personalized daily recommendation that:
|
||||
1. Acknowledges their progress on active goals
|
||||
2. Provides specific, actionable steps toward goal completion
|
||||
3. Builds on previous recommendations without being repetitive
|
||||
4. Adapts to their current progress level
|
||||
5. Takes into account their medical conditions, allergies, and injuries
|
||||
|
||||
Respond in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
|
||||
{
|
||||
"recommendationText": "Personalized motivation and progress acknowledgment (2-3 sentences)",
|
||||
"activityPlan": "Specific workout plan aligned with active goals (detailed, 3-5 exercises or activities)",
|
||||
"dietPlan": "Nutrition plan supporting their goals and restrictions (detailed, include meals and portions)"
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build basic prompt for users without complete context
|
||||
* Fallback when goals or other data is missing
|
||||
*/
|
||||
export function buildBasicPrompt(profile: any): string {
|
||||
return `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.fitnessGoal}
|
||||
- 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."
|
||||
}`;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
password: string
|
||||
phone?: string
|
||||
role: 'admin' | 'client'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string
|
||||
userId: string
|
||||
membershipType: 'basic' | 'premium' | 'vip'
|
||||
membershipStatus: 'active' | 'inactive' | 'expired'
|
||||
joinDate: Date
|
||||
}
|
||||
|
||||
export interface FitnessProfile {
|
||||
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
|
||||
}
|
||||
|
||||
// In-memory database
|
||||
export const users: User[] = []
|
||||
export const clients: Client[] = []
|
||||
export const fitnessProfiles: FitnessProfile[] = []
|
||||
@@ -1,7 +1,7 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { IDatabase, User, Client, FitnessProfile, DatabaseConfig } from './types'
|
||||
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
|
||||
|
||||
export class SQLiteDatabase implements IDatabase {
|
||||
private db: Database.Database | null = null
|
||||
@@ -14,21 +14,21 @@ export class SQLiteDatabase implements IDatabase {
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
const dbPath = this.config.connection.filename || path.join(process.cwd(), 'data', 'fitai.db')
|
||||
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
|
||||
this.db = new Database(dbPath)
|
||||
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
|
||||
// Create tables
|
||||
await this.createTables()
|
||||
|
||||
|
||||
if (this.config.options?.logging) {
|
||||
console.log('SQLite database connected successfully at:', dbPath)
|
||||
}
|
||||
@@ -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
|
||||
@@ -103,29 +105,76 @@ export class SQLiteDatabase implements IDatabase {
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
|
||||
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
|
||||
`)
|
||||
|
||||
// Attendance table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id TEXT PRIMARY KEY,
|
||||
clientId TEXT NOT NULL,
|
||||
checkInTime DATETIME NOT NULL,
|
||||
checkOutTime DATETIME,
|
||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||
notes TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// 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);
|
||||
`)
|
||||
}
|
||||
|
||||
// User operations
|
||||
async createUser(userData: Omit<User, 'id' | '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 = Math.random().toString(36).substr(2, 9)
|
||||
const id = userData.id || Math.random().toString(36).substr(2, 9)
|
||||
const now = new Date()
|
||||
|
||||
|
||||
const user: User = {
|
||||
id,
|
||||
...userData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
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(
|
||||
user.id, user.email, user.firstName, user.lastName, user.password,
|
||||
user.id, user.email, user.firstName, user.lastName, user.password,
|
||||
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
@@ -170,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)
|
||||
@@ -184,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')
|
||||
@@ -192,12 +255,12 @@ 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(
|
||||
client.id, client.userId, client.membershipType,
|
||||
client.id, client.userId, client.membershipType,
|
||||
client.membershipStatus, client.joinDate.toISOString()
|
||||
)
|
||||
|
||||
@@ -238,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)
|
||||
@@ -264,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
|
||||
@@ -311,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)
|
||||
@@ -325,6 +388,79 @@ export class SQLiteDatabase implements IDatabase {
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
// Attendance operations
|
||||
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const now = new Date()
|
||||
|
||||
const attendance: Attendance = {
|
||||
id,
|
||||
clientId,
|
||||
checkInTime: now,
|
||||
type,
|
||||
notes,
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
|
||||
VALUES(?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
|
||||
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
|
||||
}
|
||||
|
||||
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const stmt = this.db.prepare('UPDATE attendance SET checkOutTime = ? WHERE id = ?')
|
||||
stmt.run(now.toISOString(), attendanceId)
|
||||
|
||||
return this.getAttendanceById(attendanceId)
|
||||
}
|
||||
|
||||
async getAttendanceById(id: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
return row ? this.mapRowToAttendance(row) : null
|
||||
}
|
||||
|
||||
async getAttendanceHistory(clientId: string): Promise<Attendance[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
|
||||
const rows = stmt.all(clientId)
|
||||
return rows.map(row => this.mapRowToAttendance(row))
|
||||
}
|
||||
|
||||
async getAllAttendance(): Promise<Attendance[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance ORDER BY checkInTime DESC')
|
||||
const rows = stmt.all()
|
||||
return rows.map(row => this.mapRowToAttendance(row))
|
||||
}
|
||||
|
||||
async getActiveCheckIn(clientId: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
|
||||
const row = stmt.get(clientId)
|
||||
return row ? this.mapRowToAttendance(row) : null
|
||||
}
|
||||
|
||||
// Helper methods to map database rows to entities
|
||||
private mapRowToUser(row: any): User {
|
||||
return {
|
||||
@@ -346,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +499,325 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToAttendance(row: any): Attendance {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
type: row.type,
|
||||
notes: row.notes,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Fitness Goals operations
|
||||
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const goal: import('./types').FitnessGoal = {
|
||||
...goalData,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO fitness_goals (
|
||||
id, user_id, fitness_profile_id, goal_type, title, description,
|
||||
target_value, current_value, unit, start_date, target_date,
|
||||
completed_date, status, progress, priority, notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
goal.id,
|
||||
goal.userId,
|
||||
goal.fitnessProfileId || null,
|
||||
goal.goalType,
|
||||
goal.title,
|
||||
goal.description || null,
|
||||
goal.targetValue || null,
|
||||
goal.currentValue || null,
|
||||
goal.unit || null,
|
||||
goal.startDate.toISOString(),
|
||||
goal.targetDate ? goal.targetDate.toISOString() : null,
|
||||
goal.completedDate ? goal.completedDate.toISOString() : null,
|
||||
goal.status,
|
||||
goal.progress,
|
||||
goal.priority,
|
||||
goal.notes || null,
|
||||
goal.createdAt.toISOString(),
|
||||
goal.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
return goal
|
||||
}
|
||||
|
||||
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
|
||||
return row ? this.mapRowToFitnessGoal(row) : null
|
||||
}
|
||||
|
||||
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
|
||||
const params: any[] = [userId]
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC'
|
||||
|
||||
const stmt = this.db.prepare(query)
|
||||
const rows = stmt.all(...params)
|
||||
|
||||
return rows.map(row => this.mapRowToFitnessGoal(row))
|
||||
}
|
||||
|
||||
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
|
||||
if (fields.length === 0) return this.getFitnessGoalById(id)
|
||||
|
||||
// Map camelCase to snake_case for database columns
|
||||
const columnMap: Record<string, string> = {
|
||||
userId: 'user_id',
|
||||
fitnessProfileId: 'fitness_profile_id',
|
||||
goalType: 'goal_type',
|
||||
targetValue: 'target_value',
|
||||
currentValue: 'current_value',
|
||||
startDate: 'start_date',
|
||||
targetDate: 'target_date',
|
||||
completedDate: 'completed_date',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
|
||||
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
|
||||
const values = fields.map(field => {
|
||||
const val = (updates as any)[field]
|
||||
return val instanceof Date ? val.toISOString() : val
|
||||
})
|
||||
values.push(new Date().toISOString()) // updatedAt
|
||||
values.push(id)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
|
||||
stmt.run(values)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
async deleteFitnessGoal(id: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
|
||||
const result = stmt.run(id)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
// Get the goal to calculate progress
|
||||
const goal = await this.getFitnessGoalById(id)
|
||||
if (!goal) return null
|
||||
|
||||
let progress = goal.progress
|
||||
if (goal.targetValue && goal.targetValue > 0) {
|
||||
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
stmt.run(currentValue, progress, new Date().toISOString(), id)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const stmt = this.db.prepare(
|
||||
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
fitnessProfileId: row.fitness_profile_id,
|
||||
goalType: row.goal_type,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
targetValue: row.target_value,
|
||||
currentValue: row.current_value,
|
||||
unit: row.unit,
|
||||
startDate: new Date(row.start_date),
|
||||
targetDate: row.target_date ? new Date(row.target_date) : undefined,
|
||||
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
|
||||
status: row.status,
|
||||
progress: row.progress,
|
||||
priority: row.priority,
|
||||
notes: row.notes,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,53 @@ export interface FitnessProfile {
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
allergies?: string;
|
||||
injuries?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
clientId: string;
|
||||
checkInTime: Date;
|
||||
checkOutTime?: Date;
|
||||
type: "gym" | "class" | "personal_training";
|
||||
notes?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: Date;
|
||||
targetDate?: Date;
|
||||
completedDate?: Date;
|
||||
status: "active" | "completed" | "abandoned" | "paused";
|
||||
progress: number;
|
||||
priority: "low" | "medium" | "high";
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -41,12 +89,15 @@ export interface IDatabase {
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
// User operations
|
||||
createUser(user: Omit<User, "id" | "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>;
|
||||
@@ -67,6 +118,51 @@ export interface IDatabase {
|
||||
updates: Partial<FitnessProfile>,
|
||||
): Promise<FitnessProfile | null>;
|
||||
deleteFitnessProfile(userId: string): Promise<boolean>;
|
||||
|
||||
// Attendance operations
|
||||
checkIn(
|
||||
clientId: string,
|
||||
type: "gym" | "class" | "personal_training",
|
||||
notes?: string,
|
||||
): Promise<Attendance>;
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
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>;
|
||||
|
||||
// Fitness Goals operations
|
||||
createFitnessGoal(
|
||||
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">
|
||||
): Promise<FitnessGoal>;
|
||||
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
|
||||
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>;
|
||||
updateFitnessGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>
|
||||
): Promise<FitnessGoal | null>;
|
||||
deleteFitnessGoal(id: string): Promise<boolean>;
|
||||
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>;
|
||||
completeGoal(id: string): Promise<FitnessGoal | null>;
|
||||
|
||||
// Dashboard operations
|
||||
getDashboardStats(): Promise<{
|
||||
totalUsers: number;
|
||||
activeClients: number;
|
||||
totalRevenue: number;
|
||||
revenueGrowth: number; // Percentage vs last month
|
||||
}>;
|
||||
}
|
||||
|
||||
// Database configuration
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { currentUser } from '@clerk/nextjs/server'
|
||||
import { IDatabase } from './database/types'
|
||||
|
||||
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 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,
|
||||
firstName: clerkUser.firstName || '',
|
||||
lastName: clerkUser.lastName || '',
|
||||
password: '', // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
|
||||
})
|
||||
|
||||
return user
|
||||
}
|
||||
+11
-23
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const isPublicRoute = createRouteMatcher([
|
||||
"/sign-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/api/webhooks(.*)",
|
||||
"/api/attendance(.*)",
|
||||
]);
|
||||
|
||||
// Define routes that require authentication
|
||||
@@ -16,7 +17,6 @@ const isProtectedRoute = createRouteMatcher([
|
||||
"/api/users(.*)",
|
||||
"/api/profile(.*)",
|
||||
"/api/payments(.*)",
|
||||
"/api/attendance(.*)",
|
||||
"/api/notifications(.*)",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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")],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
## user flow
|
||||
|
||||
superAdmin -> localAdmin/Gym -> localAdmin -> creates trainers -> can add users
|
||||
@@ -27,8 +27,9 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router"
|
||||
"expo-router",
|
||||
"expo-font"
|
||||
],
|
||||
"scheme": "fitai"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+281
-69
@@ -11,6 +11,8 @@
|
||||
"@clerk/clerk-expo": "^2.18.3",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-picker/picker": "2.11.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-keywords": "^5.1.0",
|
||||
@@ -20,6 +22,9 @@
|
||||
"expo-camera": "~17.0.0",
|
||||
"expo-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.0",
|
||||
"expo-notifications": "~0.32.0",
|
||||
"expo-router": "~6.0.14",
|
||||
@@ -27,11 +32,12 @@
|
||||
"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",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
@@ -4027,6 +4033,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-community/datetimepicker": {
|
||||
"version": "8.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.4.4.tgz",
|
||||
"integrity": "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=52.0.0",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-windows": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-windows": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-picker/picker": {
|
||||
"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"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.81.5",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||
@@ -5556,6 +5598,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@@ -6228,6 +6276,56 @@
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@@ -6453,6 +6551,61 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
@@ -6540,6 +6693,18 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-editor": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
|
||||
@@ -6932,9 +7097,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",
|
||||
@@ -6948,13 +7113,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"
|
||||
@@ -6992,17 +7157,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": {
|
||||
@@ -7056,6 +7236,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",
|
||||
@@ -7070,13 +7260,33 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-haptics": {
|
||||
"version": "15.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.7.tgz",
|
||||
"integrity": "sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linear-gradient": {
|
||||
"version": "15.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
|
||||
"integrity": "sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -7085,9 +7295,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",
|
||||
@@ -7101,9 +7311,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"
|
||||
@@ -7114,9 +7324,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",
|
||||
@@ -7125,7 +7335,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": "*",
|
||||
@@ -7134,9 +7344,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",
|
||||
@@ -7149,7 +7359,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",
|
||||
@@ -7169,7 +7379,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": "*",
|
||||
@@ -7257,31 +7467,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",
|
||||
@@ -9827,6 +10012,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
@@ -10424,6 +10615,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/nullthrows": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||
@@ -11302,23 +11505,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",
|
||||
@@ -11451,6 +11648,21 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-svg": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.0.tgz",
|
||||
"integrity": "sha512-/Wx6F/IZ88B/GcF88bK8K7ZseJDYt+7WGaiggyzLvTowChQ8BM5idmcd4pK+6QJP6a6DmzL2sfOMukFUn/NArg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"css-tree": "^1.1.3",
|
||||
"warn-once": "0.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-url-polyfill": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@clerk/clerk-expo": "^2.18.3",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-picker/picker": "2.11.1",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-keywords": "^5.1.0",
|
||||
@@ -26,6 +28,9 @@
|
||||
"expo-camera": "~17.0.0",
|
||||
"expo-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.0",
|
||||
"expo-notifications": "~0.32.0",
|
||||
"expo-router": "~6.0.14",
|
||||
@@ -33,11 +38,12 @@
|
||||
"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",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.15.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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'];
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const API_BASE_URL = Platform.select({
|
||||
android: "http://10.0.2.2:3000", // Android emulator maps this to host's localhost
|
||||
ios: "http://localhost:3000",
|
||||
default: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000",
|
||||
});
|
||||
import { apiClient } from "./client";
|
||||
import { API_ENDPOINTS } from "../config/api";
|
||||
|
||||
export interface FitnessProfile {
|
||||
id?: string;
|
||||
@@ -28,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}`,
|
||||
@@ -55,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: {
|
||||
@@ -80,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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tabs, useRouter, useSegments } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { useEffect } from "react";
|
||||
import { CustomTabBar } from "../../components/CustomTabBar";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { isSignedIn, isLoaded } = useAuth();
|
||||
@@ -25,46 +25,40 @@ export default function TabLayout() {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
tabBar={(props) => <CustomTabBar {...props} />}
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: "#2563eb",
|
||||
tabBarInactiveTintColor: "#6b7280",
|
||||
headerShown: true,
|
||||
headerStyle: {
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
headerTitleStyle: {
|
||||
fontWeight: "600",
|
||||
},
|
||||
headerShown: false, // We'll use custom headers in screens or layout
|
||||
tabBarShowLabel: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
headerTitle: "FitAI",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="goals"
|
||||
options={{
|
||||
title: "Goals",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="recommendations"
|
||||
options={{
|
||||
title: "AI",
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="attendance"
|
||||
options={{
|
||||
title: "Attendance",
|
||||
headerTitle: "Attendance",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="calendar" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
headerTitle: "My Profile",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="person" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,28 +1,354 @@
|
||||
import { View, Text, StyleSheet } from 'react-native'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '@clerk/clerk-expo'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { attendanceApi, Attendance } from '../../api/attendance'
|
||||
import { theme } from '../../styles/theme'
|
||||
import { Animated } from 'react-native'
|
||||
|
||||
export default function AttendanceScreen() {
|
||||
const { getToken, userId } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
|
||||
const [history, setHistory] = useState<Attendance[]>([])
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCheckIn) {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.05,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
pulse.start()
|
||||
return () => pulse.stop()
|
||||
}
|
||||
}, [activeCheckIn])
|
||||
|
||||
const fetchAttendance = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
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)
|
||||
if (data.length > 0 && !data[0].checkOutTime) {
|
||||
setActiveCheckIn(data[0])
|
||||
} else {
|
||||
setActiveCheckIn(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching attendance:', error)
|
||||
Alert.alert('Error', 'Failed to load attendance data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendance()
|
||||
}, [])
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
await attendanceApi.checkIn('gym', token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked in successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-in error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check in')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
try {
|
||||
const token = await getToken()
|
||||
if (!token) return
|
||||
|
||||
await attendanceApi.checkOut(token)
|
||||
fetchAttendance()
|
||||
Alert.alert('Success', 'Checked out successfully!')
|
||||
} catch (error: any) {
|
||||
console.error('Check-out error:', error)
|
||||
Alert.alert('Error', error.response?.data || 'Failed to check out')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !history.length) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Attendance</Text>
|
||||
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||
</View>
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.title}>Attendance</Text>
|
||||
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<View style={styles.actionContainer}>
|
||||
{activeCheckIn ? (
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.15)', 'rgba(5, 150, 105, 0.1)']}
|
||||
style={[styles.activeCard, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.activeCardContent}>
|
||||
<View style={styles.activeIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.activeIcon}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={32} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<View style={styles.activeTextContainer}>
|
||||
<Text style={styles.activeText}>Currently Checked In</Text>
|
||||
<Text style={styles.timeText}>
|
||||
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleCheckOut} activeOpacity={0.8}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.danger}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkOutButton, theme.shadows.medium]}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.buttonText}>Check Out</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<TouchableOpacity onPress={handleCheckIn} activeOpacity={0.8}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.checkInButton, theme.shadows.glow]}
|
||||
>
|
||||
<Ionicons name="log-in-outline" size={24} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.checkInButtonText}>Check In</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Recent History</Text>
|
||||
{history.map((item) => (
|
||||
<LinearGradient
|
||||
key={item.id}
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
style={[styles.historyItem, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.historyLeft}>
|
||||
<View style={styles.historyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={item.checkOutTime ? theme.gradients.success : theme.gradients.primary}
|
||||
style={styles.historyIcon}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.checkOutTime ? "checkmark" : "time-outline"}
|
||||
size={16}
|
||||
color="#fff"
|
||||
/>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.dateText}>
|
||||
{new Date(item.checkInTime).toLocaleDateString()}
|
||||
</Text>
|
||||
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.historyTime}>
|
||||
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
{item.checkOutTime && (
|
||||
<Text style={styles.historyTime}>
|
||||
Out: {new Date(item.checkOutTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
))}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
actionContainer: {
|
||||
marginBottom: 32,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
checkInButton: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkInButtonText: {
|
||||
color: theme.colors.white,
|
||||
fontSize: theme.typography.fontSize.xl,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
},
|
||||
checkOutButton: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
color: theme.colors.white,
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
},
|
||||
activeCard: {
|
||||
padding: 20,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(16, 185, 129, 0.2)',
|
||||
},
|
||||
activeCardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
activeIconContainer: {
|
||||
marginRight: 16,
|
||||
},
|
||||
activeIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
activeTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
activeText: {
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 4,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray600,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.xl,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 20,
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
historyItem: {
|
||||
padding: 16,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
historyLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
historyIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
historyIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
typeText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
marginTop: 2,
|
||||
},
|
||||
timeContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
historyTime: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, TouchableOpacity, Animated, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../../styles/theme';
|
||||
import { GoalProgressCard } from '../../components/GoalProgressCard';
|
||||
import { GoalCreationModal } from '../../components/GoalCreationModal';
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { fitnessGoalsService, type FitnessGoal, type CreateGoalData } from '../../services/fitnessGoals';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const { user } = useUser();
|
||||
const { getToken } = useAuth();
|
||||
const [goals, setGoals] = useState<FitnessGoal[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const fabScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const loadGoals = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const token = await getToken();
|
||||
const loadedGoals = await fitnessGoalsService.getGoals(user.id, token);
|
||||
setGoals(loadedGoals);
|
||||
} catch (error) {
|
||||
console.error('Error loading goals:', error);
|
||||
}
|
||||
}, [user?.id]); // Removed getToken from dependencies
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadGoals();
|
||||
}, [loadGoals])
|
||||
);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadGoals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleCreateGoal = async (newGoal: CreateGoalData) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.createGoal(newGoal, token);
|
||||
await loadGoals();
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCompleteGoal = async (goal: FitnessGoal) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.completeGoal(goal.id, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const handleDeleteGoal = async (goalId: string) => {
|
||||
const token = await getToken();
|
||||
await fitnessGoalsService.deleteGoal(goalId, token);
|
||||
await loadGoals();
|
||||
};
|
||||
|
||||
const activeGoals = goals.filter(g => g.status === 'active');
|
||||
const completedGoals = goals.filter(g => g.status === 'completed');
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.header}
|
||||
>
|
||||
<View>
|
||||
<Text style={styles.headerTitle}>Fitness Goals</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Track your fitness journey progress
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{goals.length > 0 && (
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{activeGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>{completedGoals.length}</Text>
|
||||
<Text style={styles.statLabel}>Completed</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statValue}>
|
||||
{activeGoals.length > 0
|
||||
? Math.round(
|
||||
activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||
activeGoals.length
|
||||
)
|
||||
: 0}%
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Avg Progress</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Active Goals */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Active Goals ({activeGoals.length})
|
||||
</Text>
|
||||
{activeGoals.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="flag-outline" size={48} color="#d1d5db" />
|
||||
<Text style={styles.emptyText}>No active goals yet</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Tap the + button to create your first goal
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
activeGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onComplete={() => handleCompleteGoal(goal)}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Completed Goals */}
|
||||
{completedGoals.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Completed Goals ({completedGoals.length})
|
||||
</Text>
|
||||
{completedGoals.map((goal) => (
|
||||
<GoalProgressCard
|
||||
key={goal.id}
|
||||
goal={goal}
|
||||
onDelete={() => handleDeleteGoal(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Animated.View style={[styles.fabContainer, { transform: [{ scale: fabScale }] }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
onPressIn={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 0.9,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
onPressOut={() => {
|
||||
Animated.spring(fabScale, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.fab}
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#fff" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Create Goal Modal */}
|
||||
<GoalCreationModal
|
||||
visible={isModalVisible}
|
||||
onClose={() => setIsModalVisible(false)}
|
||||
onSubmit={handleCreateGoal}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
marginBottom: 10,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
marginTop: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.white,
|
||||
padding: 16,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
alignItems: 'center',
|
||||
...theme.shadows.medium,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
statValue: {
|
||||
fontSize: theme.typography.fontSize['2xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.primary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
fontWeight: "500",
|
||||
},
|
||||
section: {
|
||||
padding: 20,
|
||||
paddingTop: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
paddingVertical: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: "#6b7280",
|
||||
marginTop: 12,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
height: 100,
|
||||
},
|
||||
fabContainer: {
|
||||
position: "absolute",
|
||||
right: 20,
|
||||
bottom: 110, // Adjusted for tab bar height
|
||||
},
|
||||
fab: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...theme.shadows.glow,
|
||||
},
|
||||
});
|
||||
@@ -1,293 +1,229 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, Image } from "react-native";
|
||||
import { useUser } from "@clerk/clerk-expo";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useState, useCallback } from "react";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { ActivityWidget } from "../../components/ActivityWidget";
|
||||
import { QuickActionGrid } from "../../components/QuickActionGrid";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, isLoaded } = useUser();
|
||||
const { user } = useUser();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
if (!isLoaded || !user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => {
|
||||
setRefreshing(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
const firstName = user.firstName || "User";
|
||||
const greeting = getGreeting();
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good Morning";
|
||||
if (hour < 18) return "Good Afternoon";
|
||||
return "Good Evening";
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* Welcome Header */}
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
}
|
||||
>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.greeting}>{greeting}!</Text>
|
||||
<Text style={styles.name}>{firstName}</Text>
|
||||
</View>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="calendar-outline" size={32} color="#2563eb" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>This Month</Text>
|
||||
<View>
|
||||
<Text style={styles.greeting}>{getGreeting()},</Text>
|
||||
<Text style={styles.name}>{user?.firstName || "Athlete"}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="flame-outline" size={32} color="#ef4444" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Day Streak</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statCard}>
|
||||
<Ionicons name="trophy-outline" size={32} color="#f59e0b" />
|
||||
<Text style={styles.statValue}>0</Text>
|
||||
<Text style={styles.statLabel}>Total Visits</Text>
|
||||
<View style={styles.avatarContainer}>
|
||||
{user?.imageUrl ? (
|
||||
<Image source={{ uri: user.imageUrl }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={styles.placeholderAvatar}>
|
||||
<Ionicons name="person" size={24} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Activity Widget */}
|
||||
<ActivityWidget
|
||||
steps={8432}
|
||||
calories={640}
|
||||
duration={45}
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<QuickActionGrid />
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>Check In</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Start your workout session
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Recent Activity</Text>
|
||||
<Text style={styles.seeAll}>See All</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="calendar-outline" size={24} color="#10b981" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>View Schedule</Text>
|
||||
<Text style={styles.actionSubtitle}>
|
||||
Check your upcoming classes
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButton}>
|
||||
<View style={styles.actionIcon}>
|
||||
<Ionicons name="card-outline" size={24} color="#8b5cf6" />
|
||||
</View>
|
||||
<View style={styles.actionContent}>
|
||||
<Text style={styles.actionTitle}>Payments</Text>
|
||||
<Text style={styles.actionSubtitle}>View payment history</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Membership Info */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Membership</Text>
|
||||
<View style={styles.membershipCard}>
|
||||
<View style={styles.membershipHeader}>
|
||||
<Text style={styles.membershipType}>Basic Plan</Text>
|
||||
<View style={styles.statusBadge}>
|
||||
<Text style={styles.statusText}>Active</Text>
|
||||
<View style={styles.activityCard}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
|
||||
style={[styles.recentItem, theme.shadows.subtle]}
|
||||
>
|
||||
<View style={styles.recentIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.recentIcon}
|
||||
>
|
||||
<Ionicons name="barbell" size={20} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.membershipEmail}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
<Text style={styles.membershipDate}>
|
||||
Member since {new Date(user.createdAt!).toLocaleDateString()}
|
||||
</Text>
|
||||
<View style={styles.recentInfo}>
|
||||
<Text style={styles.recentTitle}>Upper Body Power</Text>
|
||||
<Text style={styles.recentSubtitle}>Today, 10:00 AM</Text>
|
||||
</View>
|
||||
<Text style={styles.recentValue}>45m</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.5)']}
|
||||
style={[styles.recentItem, theme.shadows.subtle]}
|
||||
>
|
||||
<View style={styles.recentIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.recentIcon}
|
||||
>
|
||||
<Ionicons name="bicycle" size={20} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<View style={styles.recentInfo}>
|
||||
<Text style={styles.recentTitle}>Morning Cardio</Text>
|
||||
<Text style={styles.recentSubtitle}>Yesterday, 7:30 AM</Text>
|
||||
</View>
|
||||
<Text style={styles.recentValue}>30m</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Recent Activity</Text>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="barbell-outline" size={48} color="#d1d5db" />
|
||||
<Text style={styles.emptyStateText}>No recent activity</Text>
|
||||
<Text style={styles.emptyStateSubtext}>
|
||||
Check in to start tracking your workouts
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{/* Bottom Spacer for Tab Bar */}
|
||||
<View style={{ height: 100 }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
scrollContent: {
|
||||
paddingTop: 60,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 16,
|
||||
color: "#6b7280",
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: "500",
|
||||
marginBottom: 4,
|
||||
},
|
||||
name: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
fontWeight: "800",
|
||||
color: theme.colors.gray900,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 24,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: "center",
|
||||
marginHorizontal: 4,
|
||||
avatarContainer: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 5,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
avatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 20,
|
||||
borderWidth: 2,
|
||||
borderColor: "#fff",
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
textAlign: "center",
|
||||
placeholderAvatar: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 20,
|
||||
backgroundColor: theme.colors.primary,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 2,
|
||||
borderColor: "#fff",
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 12,
|
||||
fontWeight: "700",
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
actionButton: {
|
||||
seeAll: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
activityCard: {
|
||||
gap: 12,
|
||||
},
|
||||
recentItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 8,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.6)",
|
||||
},
|
||||
actionIcon: {
|
||||
recentIconContainer: {
|
||||
marginRight: 16,
|
||||
},
|
||||
recentIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: "#f0f9ff",
|
||||
borderRadius: 16,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
actionContent: {
|
||||
recentInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
actionTitle: {
|
||||
recentTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 2,
|
||||
},
|
||||
actionSubtitle: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
},
|
||||
membershipCard: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
membershipHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
membershipType: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
statusBadge: {
|
||||
backgroundColor: "#dcfce7",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
color: "#16a34a",
|
||||
},
|
||||
membershipEmail: {
|
||||
fontSize: 14,
|
||||
color: "#6b7280",
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 4,
|
||||
},
|
||||
membershipDate: {
|
||||
recentSubtitle: {
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
emptyState: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
padding: 32,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#6b7280",
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
recentValue: {
|
||||
fontSize: 14,
|
||||
color: "#9ca3af",
|
||||
textAlign: "center",
|
||||
fontWeight: "600",
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
});
|
||||
@@ -1,307 +1,267 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useUser, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
|
||||
import { useUser, useClerk } from "@clerk/clerk-expo";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { theme } from "../../styles/theme";
|
||||
import { AnimatedButton } from "../../components/AnimatedButton";
|
||||
import { GradientBackground } from "../../components/GradientBackground";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user } = useUser();
|
||||
const { signOut } = useAuth();
|
||||
const router = useRouter();
|
||||
const { signOut } = useClerk();
|
||||
|
||||
const handleLogout = async () => {
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Sign Out",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.replace("/(auth)/sign-in");
|
||||
} catch (error) {
|
||||
Alert.alert("Error", "Failed to sign out");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (err) {
|
||||
console.error("Error signing out:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Loading...</Text>
|
||||
</View>
|
||||
const confirmSignOut = () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* Profile Header */}
|
||||
<View style={styles.container}>
|
||||
<GradientBackground variant="primary" style={styles.header}>
|
||||
<View style={styles.profileCard}>
|
||||
<View style={styles.avatarContainer}>
|
||||
{user.imageUrl ? (
|
||||
<View style={styles.avatar}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user.firstName?.charAt(0)}
|
||||
{user.lastName?.charAt(0)}
|
||||
</Text>
|
||||
</View>
|
||||
{user?.imageUrl ? (
|
||||
<Image source={{ uri: user.imageUrl }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={styles.avatar}>
|
||||
<View style={styles.placeholderAvatar}>
|
||||
<Ionicons name="person" size={40} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.editBadge}>
|
||||
<Ionicons name="pencil" size={12} color={theme.colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.name}>{user?.fullName || "User"}</Text>
|
||||
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
|
||||
<View style={styles.memberBadge}>
|
||||
<Text style={styles.memberText}>Premium Member</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.name}>
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
<Text style={styles.email}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
|
||||
{user.primaryPhoneNumber && (
|
||||
<Text style={styles.phone}>
|
||||
{user.primaryPhoneNumber.phoneNumber}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</GradientBackground>
|
||||
|
||||
{/* Account Information */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account Information</Text>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="mail-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Email</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{user.primaryPhoneNumber && (
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="call-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Phone</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryPhoneNumber.phoneNumber}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#666" />
|
||||
<Text style={styles.infoLabelText}>Member Since</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{new Date(user.createdAt!).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoLabel}>
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={20}
|
||||
color="#666"
|
||||
/>
|
||||
<Text style={styles.infoLabelText}>Email Verified</Text>
|
||||
</View>
|
||||
<Text style={styles.infoValue}>
|
||||
{user.primaryEmailAddress?.verification?.status === "verified"
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</Text>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Personal Details</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Fitness Profile</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Notifications</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="person-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Edit Profile</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="notifications-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Notifications</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="card-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Payment History</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionButton}>
|
||||
<Ionicons name="settings-outline" size={24} color="#2563eb" />
|
||||
<Text style={styles.actionButtonText}>Settings</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#999" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.sectionTitle}>Support</Text>
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Help Center</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Privacy & Security</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Ionicons name="log-out-outline" size={20} color="#fff" />
|
||||
<Text style={styles.logoutText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
<AnimatedButton
|
||||
title="Sign Out"
|
||||
onPress={confirmSignOut}
|
||||
variant="danger"
|
||||
style={styles.signOutButton}
|
||||
icon={<Ionicons name="log-out-outline" size={20} color="#fff" />}
|
||||
/>
|
||||
|
||||
{/* App Version */}
|
||||
<Text style={styles.version}>Version 1.0.0</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f5f5f5",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 30,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
profileCard: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
marginBottom: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: "#2563eb",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#fff",
|
||||
placeholderAvatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
editBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: theme.colors.white,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
name: {
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#1a1a1a",
|
||||
fontSize: theme.typography.fontSize['2xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
fontSize: 16,
|
||||
color: "#666",
|
||||
marginBottom: 4,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
phone: {
|
||||
fontSize: 14,
|
||||
color: "#999",
|
||||
memberBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
memberText: {
|
||||
color: theme.colors.white,
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
marginTop: -20,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: "white",
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1a1a1a",
|
||||
marginBottom: 16,
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: theme.colors.white,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.gray100,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
infoLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
infoLabelText: {
|
||||
fontSize: 14,
|
||||
color: "#666",
|
||||
fontWeight: "500",
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
color: "#1a1a1a",
|
||||
fontWeight: "500",
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f0f0f0",
|
||||
},
|
||||
actionButtonText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: "#1a1a1a",
|
||||
marginLeft: 12,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray900,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: "#ef4444",
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.gray100,
|
||||
marginLeft: 60,
|
||||
},
|
||||
signOutButton: {
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
shadowColor: "#ef4444",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
logoutText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
version: {
|
||||
textAlign: "center",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
marginTop: 8,
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
color: theme.colors.gray400,
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
import { theme } from "../../styles/theme";
|
||||
|
||||
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={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerTitle}>AI Recommendations</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{/* AI Context Info Banner with Glassmorphism */}
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.15)', 'rgba(139, 92, 246, 0.1)'] as const}
|
||||
style={styles.infoBanner}
|
||||
>
|
||||
<View style={styles.infoBannerIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.infoBannerIcon}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.infoBannerText}>
|
||||
Personalized based on your active fitness goals and progress
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
contentContainerStyle={styles.listContent}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(209, 213, 219, 0.3)', 'rgba(209, 213, 219, 0.1)'] as const}
|
||||
style={styles.emptyIconGradient}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={48} color="#9ca3af" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.statusBadge}
|
||||
>
|
||||
<Text style={styles.statusText}>{item.status.toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 24,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: theme.typography.fontSize['3xl'],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
infoBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
padding: 14,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.2)',
|
||||
gap: 10,
|
||||
},
|
||||
infoBannerIconContainer: {
|
||||
marginRight: 4,
|
||||
},
|
||||
infoBannerIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoBannerText: {
|
||||
flex: 1,
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 18,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.gray200,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray700,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIconGradient: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.base,
|
||||
color: theme.colors.gray700,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: 'center',
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray500,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
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;
|
||||
weight?: number;
|
||||
age?: number;
|
||||
gender?: string;
|
||||
fitnessGoal?: string;
|
||||
activityLevel?: string;
|
||||
medicalConditions?: string;
|
||||
allergies?: string;
|
||||
injuries?: string;
|
||||
}
|
||||
|
||||
export default function FitnessProfileScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, getToken } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchingProfile, setFetchingProfile] = useState(true);
|
||||
const [profileData, setProfileData] = useState<FitnessProfileData>({});
|
||||
|
||||
const genderOptions = [
|
||||
{ label: "Male", value: "male" },
|
||||
{ label: "Female", value: "female" },
|
||||
{ label: "Other", value: "other" },
|
||||
{ label: "Prefer not to say", value: "prefer_not_to_say" },
|
||||
];
|
||||
|
||||
const fitnessGoalOptions = [
|
||||
{ label: "Weight Loss", value: "weight_loss" },
|
||||
{ label: "Muscle Gain", value: "muscle_gain" },
|
||||
{ label: "Endurance", value: "endurance" },
|
||||
{ label: "Flexibility", value: "flexibility" },
|
||||
{ label: "General Fitness", value: "general_fitness" },
|
||||
];
|
||||
|
||||
const activityLevelOptions = [
|
||||
{ label: "Sedentary", value: "sedentary" },
|
||||
{ label: "Lightly Active", value: "lightly_active" },
|
||||
{ label: "Moderately Active", value: "moderately_active" },
|
||||
{ label: "Very Active", value: "very_active" },
|
||||
{ label: "Extremely Active", value: "extremely_active" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
setFetchingProfile(true);
|
||||
const token = await getToken();
|
||||
const apiUrl = `${API_BASE_URL}` || "http://localhost:3000";
|
||||
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.profile) {
|
||||
setProfileData({
|
||||
height: data.profile.height,
|
||||
weight: data.profile.weight,
|
||||
age: data.profile.age,
|
||||
gender: data.profile.gender || "",
|
||||
fitnessGoal: data.profile.fitnessGoal || "",
|
||||
activityLevel: data.profile.activityLevel || "",
|
||||
medicalConditions: data.profile.medicalConditions || "",
|
||||
allergies: data.profile.allergies || "",
|
||||
injuries: data.profile.injuries || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching profile:", error);
|
||||
} finally {
|
||||
setFetchingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = await getToken();
|
||||
const apiUrl = `${API_BASE_URL}/api/fitness-profile` || "http://localhost:3000";
|
||||
|
||||
const response = await fetch(`${apiUrl}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(profileData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
Alert.alert("Success", "Fitness profile saved successfully!", [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
Alert.alert("Error", error.message || "Failed to save profile");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving profile:", error);
|
||||
Alert.alert("Error", "Failed to save fitness profile");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof FitnessProfileData, value: any) => {
|
||||
setProfileData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (fetchingProfile) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#2563eb" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color="#1f2937" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Fitness Profile</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Basic Information</Text>
|
||||
|
||||
<Input
|
||||
label="Height (cm)"
|
||||
value={profileData.height?.toString() || ""}
|
||||
onChangeText={(text) =>
|
||||
updateField("height", text ? parseFloat(text) : undefined)
|
||||
}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="e.g., 175"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Weight (kg)"
|
||||
value={profileData.weight?.toString() || ""}
|
||||
onChangeText={(text) =>
|
||||
updateField("weight", text ? parseFloat(text) : undefined)
|
||||
}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder="e.g., 70"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Age"
|
||||
value={profileData.age?.toString() || ""}
|
||||
onChangeText={(text) =>
|
||||
updateField("age", text ? parseInt(text, 10) : undefined)
|
||||
}
|
||||
keyboardType="number-pad"
|
||||
placeholder="e.g., 25"
|
||||
/>
|
||||
|
||||
<Picker
|
||||
label="Gender"
|
||||
value={profileData.gender || ""}
|
||||
onValueChange={(value) => updateField("gender", value)}
|
||||
items={genderOptions}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Fitness Goals</Text>
|
||||
|
||||
<Picker
|
||||
label="Primary Goal"
|
||||
value={profileData.fitnessGoal || ""}
|
||||
onValueChange={(value) => updateField("fitnessGoal", value)}
|
||||
items={fitnessGoalOptions}
|
||||
/>
|
||||
|
||||
<Picker
|
||||
label="Activity Level"
|
||||
value={profileData.activityLevel || ""}
|
||||
onValueChange={(value) => updateField("activityLevel", value)}
|
||||
items={activityLevelOptions}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Health Information</Text>
|
||||
|
||||
<Input
|
||||
label="Medical Conditions (optional)"
|
||||
value={profileData.medicalConditions || ""}
|
||||
onChangeText={(text) => updateField("medicalConditions", text)}
|
||||
placeholder="e.g., Asthma, diabetes..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={styles.textArea}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Allergies (optional)"
|
||||
value={profileData.allergies || ""}
|
||||
onChangeText={(text) => updateField("allergies", text)}
|
||||
placeholder="e.g., Peanuts, latex..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={styles.textArea}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Injuries (optional)"
|
||||
value={profileData.injuries || ""}
|
||||
onChangeText={(text) => updateField("injuries", text)}
|
||||
placeholder="e.g., Previous knee injury..."
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={styles.textArea}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, loading && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name="checkmark-circle-outline"
|
||||
size={20}
|
||||
color="white"
|
||||
/>
|
||||
<Text style={styles.saveButtonText}>Save Profile</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 16,
|
||||
backgroundColor: "white",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: "600",
|
||||
color: "#1f2937",
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 32,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
padding: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
color: "#1f2937",
|
||||
marginBottom: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: "top",
|
||||
paddingTop: 12,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: "#2563eb",
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 8,
|
||||
marginBottom: 40,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
@@ -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,157 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface ActivityWidgetProps {
|
||||
steps: number;
|
||||
calories: number;
|
||||
duration: number; // in minutes
|
||||
}
|
||||
|
||||
export function ActivityWidget({ steps, calories, duration }: ActivityWidgetProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.dark}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Daily Activity</Text>
|
||||
<Ionicons name="stats-chart" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(59, 130, 246, 0.2)' }]}>
|
||||
<Ionicons name="footsteps" size={20} color="#3b82f6" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{steps.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>Steps</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(239, 68, 68, 0.2)' }]}>
|
||||
<Ionicons name="flame" size={20} color="#ef4444" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{calories}</Text>
|
||||
<Text style={styles.statLabel}>Kcal</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<View style={[styles.iconContainer, { backgroundColor: 'rgba(16, 185, 129, 0.2)' }]}>
|
||||
<Ionicons name="time" size={20} color="#10b981" />
|
||||
</View>
|
||||
<Text style={styles.statValue}>{duration}m</Text>
|
||||
<Text style={styles.statLabel}>Active</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Simple Bar Chart Visualization */}
|
||||
<View style={styles.chartContainer}>
|
||||
{[0.4, 0.6, 0.3, 0.8, 0.5, 0.9, 0.7].map((height, index) => (
|
||||
<View key={index} style={styles.barContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primaryVertical}
|
||||
style={[styles.bar, { height: height * 60 }]}
|
||||
/>
|
||||
<Text style={styles.dayLabel}>
|
||||
{['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
marginBottom: 2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.gray400,
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
chartContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
height: 80,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
barContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
bar: {
|
||||
width: 6,
|
||||
borderRadius: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 10,
|
||||
color: theme.colors.gray500,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Animated Button Component
|
||||
* Modern button with gradient backgrounds and smooth animations
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
ActivityIndicator,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../styles/theme';
|
||||
import { haptics } from '../utils/haptics';
|
||||
|
||||
interface AnimatedButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AnimatedButton({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle,
|
||||
icon,
|
||||
}: AnimatedButtonProps) {
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 0.95,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scale, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (!disabled && !loading) {
|
||||
haptics.light();
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const getGradientColors = () => {
|
||||
if (disabled) return ['#9ca3af', '#6b7280'] as const;
|
||||
switch (variant) {
|
||||
case 'secondary':
|
||||
return theme.gradients.purple;
|
||||
case 'success':
|
||||
return theme.gradients.success;
|
||||
case 'danger':
|
||||
return theme.gradients.danger;
|
||||
case 'warning':
|
||||
return theme.gradients.warning;
|
||||
default:
|
||||
return theme.gradients.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeStyles = () => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return { paddingVertical: 8, paddingHorizontal: 16, fontSize: 14 };
|
||||
case 'lg':
|
||||
return { paddingVertical: 16, paddingHorizontal: 32, fontSize: 18 };
|
||||
default:
|
||||
return { paddingVertical: 12, paddingHorizontal: 24, fontSize: 16 };
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyles = getSizeStyles();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={handlePress}
|
||||
disabled={disabled || loading}
|
||||
activeOpacity={0.9}
|
||||
style={[styles.container, style]}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale }], width: '100%' }}>
|
||||
<LinearGradient
|
||||
colors={getGradientColors()}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.gradient,
|
||||
{ paddingVertical: sizeStyles.paddingVertical, paddingHorizontal: sizeStyles.paddingHorizontal },
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
{icon && <React.Fragment>{icon}</React.Fragment>}
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{ fontSize: sizeStyles.fontSize },
|
||||
icon ? { marginLeft: 8 } : {},
|
||||
textStyle,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
gradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
...theme.shadows.medium,
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { View, Animated, StyleSheet, Text } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
interface CircularProgressProps {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
progress: number; // 0 to 100
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function CircularProgress({
|
||||
size = 60,
|
||||
strokeWidth = 6,
|
||||
progress,
|
||||
color = theme.colors.primary,
|
||||
backgroundColor = theme.colors.gray200,
|
||||
}: CircularProgressProps) {
|
||||
const animatedValue = useRef(new Animated.Value(0)).current;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: progress,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [progress]);
|
||||
|
||||
const strokeDashoffset = animatedValue.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: [circumference, 0],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={backgroundColor}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
/>
|
||||
<AnimatedCircle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="transparent"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
rotation="-90"
|
||||
origin={`${size / 2}, ${size / 2}`}
|
||||
/>
|
||||
</Svg>
|
||||
<View style={[StyleSheet.absoluteFillObject, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<Text style={{ fontSize: size * 0.25, fontWeight: 'bold', color: theme.colors.gray900 }}>
|
||||
{Math.round(progress)}%
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
|
||||
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../styles/theme';
|
||||
import { Animated } from 'react-native';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 0.9)', 'rgba(255, 255, 255, 0.7)']}
|
||||
style={[styles.tabBar, theme.shadows.medium]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
{state.routes.map((route, index) => {
|
||||
const { options } = descriptors[route.key];
|
||||
const isFocused = state.index === index;
|
||||
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: route.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
const getIconName = (routeName: string, focused: boolean): keyof typeof Ionicons.glyphMap => {
|
||||
switch (routeName) {
|
||||
case 'index':
|
||||
return focused ? 'home' : 'home-outline';
|
||||
case 'goals':
|
||||
return focused ? 'trophy' : 'trophy-outline';
|
||||
case 'attendance':
|
||||
return focused ? 'calendar' : 'calendar-outline';
|
||||
case 'recommendations':
|
||||
return focused ? 'sparkles' : 'sparkles-outline';
|
||||
case 'profile':
|
||||
return focused ? 'person' : 'person-outline';
|
||||
default:
|
||||
return 'ellipse-outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Animation for scale
|
||||
const scaleValue = React.useRef(new Animated.Value(1)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: isFocused ? 1.2 : 1,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
}).start();
|
||||
}, [isFocused]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={isFocused ? { selected: true } : {}}
|
||||
accessibilityLabel={options.tabBarAccessibilityLabel}
|
||||
testID={(options as any).tabBarTestID}
|
||||
onPress={onPress}
|
||||
style={styles.tabItem}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
|
||||
{isFocused ? (
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons
|
||||
name={getIconName(route.name, true)}
|
||||
size={20}
|
||||
color="#fff"
|
||||
/>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<Ionicons
|
||||
name={getIconName(route.name, false)}
|
||||
size={24}
|
||||
color={theme.colors.gray500}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingBottom: Platform.OS === 'ios' ? 30 : 20,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 35,
|
||||
height: 70,
|
||||
width: width - 40,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: theme.colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { View, Text, StyleSheet, Dimensions, ScrollView, TouchableOpacity, Animated } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../styles/theme';
|
||||
import { CircularProgress } from './CircularProgress';
|
||||
import type { FitnessGoal } from '../services/fitnessGoals';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const CARD_WIDTH = width * 0.8;
|
||||
const SPACING = 20;
|
||||
|
||||
interface GoalCarouselProps {
|
||||
goals: FitnessGoal[];
|
||||
onGoalPress: (goal: FitnessGoal) => void;
|
||||
}
|
||||
|
||||
export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) {
|
||||
const scrollX = useRef(new Animated.Value(0)).current;
|
||||
|
||||
if (goals.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No active goals</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Animated.ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + SPACING}
|
||||
decelerationRate="fast"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onScroll={Animated.event(
|
||||
[{ nativeEvent: { contentOffset: { x: scrollX } } }],
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{goals.map((goal, index) => {
|
||||
const inputRange = [
|
||||
(index - 1) * (CARD_WIDTH + SPACING),
|
||||
index * (CARD_WIDTH + SPACING),
|
||||
(index + 1) * (CARD_WIDTH + SPACING),
|
||||
];
|
||||
|
||||
const scale = scrollX.interpolate({
|
||||
inputRange,
|
||||
outputRange: [0.9, 1, 0.9],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const opacity = scrollX.interpolate({
|
||||
inputRange,
|
||||
outputRange: [0.7, 1, 0.7],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={goal.id}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => onGoalPress(goal)}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.cardContainer,
|
||||
{ transform: [{ scale }], opacity },
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#ffffff', '#f9fafb']}
|
||||
style={[styles.card, theme.shadows.medium]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.iconGradient}
|
||||
>
|
||||
<Ionicons name="trophy" size={24} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<View style={styles.priorityBadge}>
|
||||
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.title} numberOfLines={1}>{goal.title}</Text>
|
||||
<Text style={styles.description} numberOfLines={2}>{goal.description}</Text>
|
||||
|
||||
<View style={styles.progressContainer}>
|
||||
<CircularProgress
|
||||
progress={goal.progress || 0}
|
||||
size={80}
|
||||
strokeWidth={8}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
<View style={styles.progressDetails}>
|
||||
<Text style={styles.progressValue}>
|
||||
{goal.currentValue} / {goal.targetValue}
|
||||
</Text>
|
||||
<Text style={styles.progressUnit}>{goal.unit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.date}>
|
||||
Target: {goal.targetDate ? new Date(goal.targetDate).toLocaleDateString() : 'No date'}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
paddingHorizontal: (width - CARD_WIDTH) / 2,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
height: 200,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.gray500,
|
||||
fontSize: 16,
|
||||
},
|
||||
cardContainer: {
|
||||
width: CARD_WIDTH,
|
||||
marginRight: SPACING,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
height: 320,
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
iconContainer: {
|
||||
shadowColor: theme.colors.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
iconGradient: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
priorityBadge: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
priorityText: {
|
||||
color: theme.colors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.gray500,
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
},
|
||||
progressContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
},
|
||||
progressDetails: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
progressValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
progressUnit: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.gray500,
|
||||
fontWeight: '500',
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.gray100,
|
||||
paddingTop: 16,
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.gray400,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import type { CreateGoalData } from '../services/fitnessGoals';
|
||||
|
||||
interface GoalCreationModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (goalData: CreateGoalData) => Promise<void>;
|
||||
}
|
||||
|
||||
const GOAL_TYPES = [
|
||||
{ value: 'weight_target', label: 'Weight Target' },
|
||||
{ value: 'strength_milestone', label: 'Strength Milestone' },
|
||||
{ value: 'endurance_target', label: 'Endurance Target' },
|
||||
{ value: 'flexibility_goal', label: 'Flexibility Goal' },
|
||||
{ value: 'habit_building', label: 'Habit Building' },
|
||||
{ value: 'custom', label: 'Custom Goal' },
|
||||
] as const;
|
||||
|
||||
const PRIORITIES = [
|
||||
{ value: 'low', label: 'Low', color: '#10b981' },
|
||||
{ value: 'medium', label: 'Medium', color: '#f59e0b' },
|
||||
{ value: 'high', label: 'High', color: '#ef4444' },
|
||||
] as const;
|
||||
|
||||
export function GoalCreationModal({ visible, onClose, onSubmit }: GoalCreationModalProps) {
|
||||
const [goalType, setGoalType] = useState<CreateGoalData['goalType']>('weight_target');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [targetValue, setTargetValue] = useState('');
|
||||
const [currentValue, setCurrentValue] = useState('');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>('medium');
|
||||
const [targetDate, setTargetDate] = useState<Date | undefined>();
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setGoalType('weight_target');
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setTargetValue('');
|
||||
setCurrentValue('');
|
||||
setUnit('');
|
||||
setPriority('medium');
|
||||
setTargetDate(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
Alert.alert('Error', 'Please enter a goal title');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const goalData: CreateGoalData = {
|
||||
goalType,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
targetValue: targetValue ? parseFloat(targetValue) : undefined,
|
||||
currentValue: currentValue ? parseFloat(currentValue) : undefined,
|
||||
unit: unit.trim() || undefined,
|
||||
targetDate: targetDate?.toISOString(),
|
||||
priority,
|
||||
};
|
||||
|
||||
await onSubmit(goalData);
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating goal:', error);
|
||||
Alert.alert('Error', 'Failed to create goal. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Create Fitness Goal</Text>
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={28} color="#111827" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* Goal Type */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Goal Type *</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.typeScroll}>
|
||||
{GOAL_TYPES.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value}
|
||||
style={[
|
||||
styles.typeButton,
|
||||
goalType === type.value && styles.typeButtonActive,
|
||||
]}
|
||||
onPress={() => setGoalType(type.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
goalType === type.value && styles.typeButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Title *</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="e.g., Lose 5kg"
|
||||
placeholderTextColor="#9ca3af"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Description</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Optional description"
|
||||
placeholderTextColor="#9ca3af"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Target Value & Unit */}
|
||||
<View style={styles.row}>
|
||||
<View style={[styles.field, styles.flex1]}>
|
||||
<Text style={styles.label}>Target Value</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={targetValue}
|
||||
onChangeText={setTargetValue}
|
||||
placeholder="e.g., 70"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.field, styles.flex1]}>
|
||||
<Text style={styles.label}>Unit</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={unit}
|
||||
onChangeText={setUnit}
|
||||
placeholder="e.g., kg"
|
||||
placeholderTextColor="#9ca3af"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Current Value */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Current Value</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={currentValue}
|
||||
onChangeText={setCurrentValue}
|
||||
placeholder="Starting value (optional)"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Target Date */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Target Date</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowDatePicker(!showDatePicker)}
|
||||
>
|
||||
<Text style={targetDate ? styles.dateText : styles.datePlaceholder}>
|
||||
{targetDate ? targetDate.toLocaleDateString() : 'Select target date'}
|
||||
</Text>
|
||||
<Ionicons name={showDatePicker ? "chevron-up" : "calendar-outline"} size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{showDatePicker && (
|
||||
<View style={Platform.OS === 'ios' ? styles.datePickerContainer : undefined}>
|
||||
<DateTimePicker
|
||||
value={targetDate || new Date()}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'default'}
|
||||
onChange={(event, selectedDate) => {
|
||||
setShowDatePicker(Platform.OS === 'ios');
|
||||
if (selectedDate) {
|
||||
setTargetDate(selectedDate);
|
||||
}
|
||||
}}
|
||||
minimumDate={new Date()}
|
||||
themeVariant="light"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Priority */}
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Priority</Text>
|
||||
<View style={styles.priorityContainer}>
|
||||
{PRIORITIES.map((p) => (
|
||||
<TouchableOpacity
|
||||
key={p.value}
|
||||
style={[
|
||||
styles.priorityButton,
|
||||
priority === p.value && { backgroundColor: p.color },
|
||||
]}
|
||||
onPress={() => setPriority(p.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.priorityButtonText,
|
||||
priority === p.value && styles.priorityButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{p.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, submitting && styles.submitButtonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text style={styles.submitButtonText}>
|
||||
{submitting ? 'Creating...' : 'Create Goal'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: Platform.OS === 'ios' ? 60 : 20,
|
||||
backgroundColor: '#fff',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
field: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
flex1: {
|
||||
flex: 1,
|
||||
},
|
||||
typeScroll: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
typeButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: '#fff',
|
||||
marginRight: 8,
|
||||
},
|
||||
typeButtonActive: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderColor: '#2563eb',
|
||||
},
|
||||
typeButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
},
|
||||
typeButtonTextActive: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
dateButton: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
},
|
||||
datePlaceholder: {
|
||||
fontSize: 16,
|
||||
color: '#9ca3af',
|
||||
},
|
||||
priorityContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
priorityButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
},
|
||||
priorityButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
fontWeight: '500',
|
||||
},
|
||||
priorityButtonTextActive: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
footer: {
|
||||
padding: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e5e7eb',
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#2563eb',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
submitButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
submitButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
datePickerContainer: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
marginTop: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import type { FitnessGoal } from '../services/fitnessGoals';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
interface GoalProgressCardProps {
|
||||
goal: FitnessGoal;
|
||||
onPress?: () => void;
|
||||
onComplete?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function GoalProgressCard({ goal, onPress, onComplete, onDelete }: GoalProgressCardProps) {
|
||||
const isCompleted = goal.status === 'completed';
|
||||
const progress = goal.progress || 0;
|
||||
|
||||
// Calculate days remaining
|
||||
const daysRemaining = goal.targetDate
|
||||
? Math.ceil((new Date(goal.targetDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: null;
|
||||
|
||||
const getGoalTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'weight_target': return 'scale-outline';
|
||||
case 'strength_milestone': return 'barbell-outline';
|
||||
case 'endurance_target': return 'bicycle-outline';
|
||||
case 'flexibility_goal': return 'body-outline';
|
||||
case 'habit_building': return 'calendar-outline';
|
||||
default: return 'flag-outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityGradient = (priority: string): readonly [string, string] => {
|
||||
switch (priority) {
|
||||
case 'high': return theme.gradients.danger;
|
||||
case 'medium': return theme.gradients.warning;
|
||||
case 'low': return theme.gradients.success;
|
||||
default: return theme.gradients.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Delete Goal',
|
||||
'Are you sure you want to delete this goal?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: onDelete },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={isCompleted
|
||||
? ['rgba(16, 185, 129, 0.05)', 'rgba(5, 150, 105, 0.02)'] as const
|
||||
: ['rgba(255, 255, 255, 1)', 'rgba(249, 250, 251, 1)'] as const
|
||||
}
|
||||
style={[
|
||||
styles.card,
|
||||
theme.shadows.medium,
|
||||
isCompleted && styles.cardCompleted
|
||||
]}
|
||||
>
|
||||
{/* Priority Accent Bar */}
|
||||
<LinearGradient
|
||||
colors={getPriorityGradient(goal.priority)}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.priorityAccent}
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<LinearGradient
|
||||
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons
|
||||
name={getGoalTypeIcon(goal.goalType) as any}
|
||||
size={20}
|
||||
color="#fff"
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, isCompleted && styles.titleCompleted]}>
|
||||
{goal.title}
|
||||
</Text>
|
||||
{goal.description && (
|
||||
<Text style={styles.description} numberOfLines={2}>
|
||||
{goal.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
{!isCompleted && onComplete && (
|
||||
<TouchableOpacity onPress={onComplete} style={styles.actionButton}>
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color={theme.colors.success} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onDelete && (
|
||||
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
|
||||
<Ionicons name="trash-outline" size={22} color={theme.colors.danger} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{goal.targetValue && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressInfo}>
|
||||
<Text style={styles.progressText}>
|
||||
{goal.currentValue || 0} / {goal.targetValue} {goal.unit || ''}
|
||||
</Text>
|
||||
<Text style={[styles.progressPercentage, isCompleted && { color: theme.colors.success }]}>
|
||||
{progress.toFixed(0)}%
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBarContainer}>
|
||||
<LinearGradient
|
||||
colors={isCompleted ? theme.gradients.success : getPriorityGradient(goal.priority)}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${Math.min(progress, 100)}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<LinearGradient
|
||||
colors={getPriorityGradient(goal.priority)}
|
||||
style={styles.priorityBadge}
|
||||
>
|
||||
<Text style={styles.priorityText}>{goal.priority.toUpperCase()}</Text>
|
||||
</LinearGradient>
|
||||
|
||||
{daysRemaining !== null && !isCompleted && (
|
||||
<Text style={[styles.daysRemaining, daysRemaining < 0 && styles.overdue]}>
|
||||
{daysRemaining < 0
|
||||
? `${Math.abs(daysRemaining)} days overdue`
|
||||
: `${daysRemaining} days remaining`
|
||||
}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isCompleted && goal.completedDate && (
|
||||
<Text style={styles.completedDate}>
|
||||
Completed {new Date(goal.completedDate).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(59, 130, 246, 0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardCompleted: {
|
||||
borderColor: 'rgba(16, 185, 129, 0.2)',
|
||||
},
|
||||
priorityAccent: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.typography.fontSize.lg,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleCompleted: {
|
||||
color: theme.colors.gray600,
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
description: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: theme.colors.gray600,
|
||||
lineHeight: 18,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 4,
|
||||
},
|
||||
progressSection: {
|
||||
marginBottom: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
progressInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
progressPercentage: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 8,
|
||||
backgroundColor: theme.colors.gray200,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginLeft: 8,
|
||||
},
|
||||
priorityBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
priorityText: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
color: theme.colors.white,
|
||||
},
|
||||
daysRemaining: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.gray600,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
overdue: {
|
||||
color: theme.colors.danger,
|
||||
fontWeight: theme.typography.fontWeight.semibold,
|
||||
},
|
||||
completedDate: {
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
color: theme.colors.success,
|
||||
fontWeight: theme.typography.fontWeight.medium,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Gradient Background Component
|
||||
* Reusable gradient background with multiple presets
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
interface GradientBackgroundProps {
|
||||
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'purple' | 'ocean' | 'sunset' | 'dark';
|
||||
colors?: string[];
|
||||
style?: ViewStyle;
|
||||
children?: React.ReactNode;
|
||||
start?: { x: number; y: number };
|
||||
end?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export function GradientBackground({
|
||||
variant = 'primary',
|
||||
colors,
|
||||
style,
|
||||
children,
|
||||
start = { x: 0, y: 0 },
|
||||
end = { x: 1, y: 1 },
|
||||
}: GradientBackgroundProps) {
|
||||
const gradientColors = (colors || theme.gradients[variant]) as readonly [string, string, ...string[]];
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={gradientColors}
|
||||
start={start}
|
||||
end={end}
|
||||
style={[styles.gradient, style]}
|
||||
>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { View, Text, TextInput, StyleSheet, TextInputProps } from "react-native";
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, style, ...props }: InputProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, error && styles.inputError, style]}
|
||||
placeholderTextColor="#9ca3af"
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: "white",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: "#1f2937",
|
||||
},
|
||||
inputError: {
|
||||
borderColor: "#ef4444",
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: "#ef4444",
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Animated, Dimensions, ImageSourcePropType } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const HEADER_HEIGHT = 300;
|
||||
|
||||
interface ParallaxScrollViewProps {
|
||||
children: React.ReactNode;
|
||||
headerImage?: ImageSourcePropType;
|
||||
headerBackgroundColor?: string;
|
||||
headerContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor = theme.colors.primary,
|
||||
headerContent,
|
||||
}: ParallaxScrollViewProps) {
|
||||
const scrollY = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
const headerTranslateY = scrollY.interpolate({
|
||||
inputRange: [0, HEADER_HEIGHT],
|
||||
outputRange: [0, -HEADER_HEIGHT / 2],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const imageScale = scrollY.interpolate({
|
||||
inputRange: [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
outputRange: [2, 1, 1],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const headerOpacity = scrollY.interpolate({
|
||||
inputRange: [0, HEADER_HEIGHT / 2],
|
||||
outputRange: [1, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onScroll={Animated.event(
|
||||
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.contentContainer}>{children}</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
transform: [{ translateY: headerTranslateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
transform: [{ scale: imageScale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{headerImage && (
|
||||
<Animated.Image
|
||||
source={headerImage}
|
||||
style={[styles.headerImage, { opacity: 0.5 }]}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.headerContent, { opacity: headerOpacity }]}>
|
||||
{headerContent}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: HEADER_HEIGHT,
|
||||
},
|
||||
contentContainer: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
marginTop: -32,
|
||||
paddingTop: 32,
|
||||
minHeight: 800,
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
},
|
||||
headerImage: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 32,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { Picker as RNPicker } from "@react-native-picker/picker";
|
||||
|
||||
interface PickerProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
items: { label: string; value: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Picker({ label, value, onValueChange, items, error }: PickerProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<View style={[styles.pickerWrapper, error && styles.pickerError]}>
|
||||
<RNPicker
|
||||
selectedValue={value}
|
||||
onValueChange={onValueChange}
|
||||
style={styles.picker}
|
||||
>
|
||||
<RNPicker.Item label="Select..." value="" />
|
||||
{items.map((item) => (
|
||||
<RNPicker.Item key={item.value} label={item.label} value={item.value} />
|
||||
))}
|
||||
</RNPicker>
|
||||
</View>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#374151",
|
||||
marginBottom: 8,
|
||||
},
|
||||
pickerWrapper: {
|
||||
backgroundColor: "white",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
},
|
||||
pickerError: {
|
||||
borderColor: "#ef4444",
|
||||
},
|
||||
picker: {
|
||||
height: 50,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: "#ef4444",
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2
|
||||
|
||||
interface QuickActionProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
gradient: readonly [string, string, ...string[]];
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={styles.itemContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)']}
|
||||
style={[styles.item, theme.shadows.subtle]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradient}
|
||||
style={styles.iconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name={icon} size={24} color="#fff" />
|
||||
</LinearGradient>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.gray400} style={styles.arrow} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActionGrid() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
<View style={styles.grid}>
|
||||
<QuickActionItem
|
||||
icon="barbell"
|
||||
label="Log Workout"
|
||||
gradient={theme.gradients.primary}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="restaurant"
|
||||
label="Track Meal"
|
||||
gradient={theme.gradients.success}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="water"
|
||||
label="Add Water"
|
||||
gradient={theme.gradients.ocean}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon="scan"
|
||||
label="Scan Food"
|
||||
gradient={theme.gradients.purple}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 16,
|
||||
},
|
||||
grid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
},
|
||||
itemContainer: {
|
||||
width: ITEM_WIDTH,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.gray800,
|
||||
flex: 1,
|
||||
},
|
||||
arrow: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'http://192.168.1.24:3000'
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'https://221260dc4a2b.ngrok-free.app'
|
||||
: 'https://your-production-url.com'
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
@@ -12,4 +12,18 @@ export const API_ENDPOINTS = {
|
||||
},
|
||||
CLIENTS: '/api/clients',
|
||||
USERS: '/api/users',
|
||||
}
|
||||
ATTENDANCE: {
|
||||
CHECK_IN: '/api/attendance/check-in',
|
||||
CHECK_OUT: '/api/attendance/check-out',
|
||||
HISTORY: '/api/attendance/history',
|
||||
},
|
||||
RECOMMENDATIONS: '/api/recommendations',
|
||||
FITNESS_GOALS: {
|
||||
LIST: '/api/fitness-goals',
|
||||
CREATE: '/api/fitness-goals',
|
||||
GET: (id: string) => `/api/fitness-goals/${id}`,
|
||||
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
||||
},
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { API_BASE_URL, API_ENDPOINTS } from '../config/api';
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType: 'weight_target' | 'strength_milestone' | 'endurance_target' | 'flexibility_goal' | 'habit_building' | 'custom';
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: string;
|
||||
targetDate?: string;
|
||||
completedDate?: string;
|
||||
status: 'active' | 'completed' | 'abandoned' | 'paused';
|
||||
progress: number;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateGoalData {
|
||||
goalType: FitnessGoal['goalType'];
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
targetDate?: string;
|
||||
priority?: FitnessGoal['priority'];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class FitnessGoalsService {
|
||||
private async getAuthHeaders(token: string | null): Promise<any> {
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async getGoals(userId: string, token: string | null, status?: string): Promise<FitnessGoal[]> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
let url = `${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.LIST}?userId=${userId}`;
|
||||
|
||||
if (status && status !== 'all') {
|
||||
url += `&status=${status}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch goals: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching fitness goals:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createGoal(goalData: CreateGoalData, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.CREATE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(goalData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create goal');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error creating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGoal(id: string, updates: Partial<FitnessGoal>, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.UPDATE(id)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update goal');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error updating fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProgress(id: string, currentValue: number, token: string | null): Promise<FitnessGoal> {
|
||||
return this.updateGoal(id, { currentValue }, token);
|
||||
}
|
||||
|
||||
async completeGoal(id: string, token: string | null): Promise<FitnessGoal> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.COMPLETE(id)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to complete goal');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error completing fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteGoal(id: string, token: string | null): Promise<void> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders(token);
|
||||
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.FITNESS_GOALS.DELETE(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete goal');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting fitness goal:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fitnessGoalsService = new FitnessGoalsService();
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Modern Design System Theme
|
||||
* Centralized theme configuration with gradients, colors, shadows, and spacing
|
||||
*/
|
||||
|
||||
export const theme = {
|
||||
// Color Palette
|
||||
colors: {
|
||||
// Primary colors
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#60a5fa',
|
||||
secondary: '#8b5cf6',
|
||||
|
||||
// Accent colors
|
||||
purple: '#8b5cf6',
|
||||
purpleDark: '#7c3aed',
|
||||
pink: '#ec4899',
|
||||
|
||||
// Success
|
||||
success: '#10b981',
|
||||
successDark: '#059669',
|
||||
successLight: '#34d399',
|
||||
|
||||
// Warning
|
||||
warning: '#f59e0b',
|
||||
warningDark: '#d97706',
|
||||
|
||||
// Danger
|
||||
danger: '#ef4444',
|
||||
dangerDark: '#dc2626',
|
||||
|
||||
// Neutrals
|
||||
white: '#ffffff',
|
||||
black: '#000000',
|
||||
gray50: '#f9fafb',
|
||||
gray100: '#f3f4f6',
|
||||
gray200: '#e5e7eb',
|
||||
gray300: '#d1d5db',
|
||||
gray400: '#9ca3af',
|
||||
gray500: '#6b7280',
|
||||
gray600: '#4b5563',
|
||||
gray700: '#374151',
|
||||
gray800: '#1f2937',
|
||||
gray900: '#111827',
|
||||
|
||||
// Backgrounds
|
||||
background: '#f5f5f5',
|
||||
backgroundDark: '#0f172a',
|
||||
surface: '#ffffff',
|
||||
surfaceDark: '#1e293b',
|
||||
},
|
||||
|
||||
// Gradient Definitions
|
||||
gradients: {
|
||||
primary: ['#3b82f6', '#8b5cf6'] as const,
|
||||
primaryVertical: ['#3b82f6', '#2563eb'] as const,
|
||||
success: ['#10b981', '#059669'] as const,
|
||||
warning: ['#f59e0b', '#d97706'] as const,
|
||||
danger: ['#ef4444', '#ec4899'] as const,
|
||||
purple: ['#8b5cf6', '#7c3aed'] as const,
|
||||
ocean: ['#06b6d4', '#3b82f6'] as const,
|
||||
sunset: ['#f59e0b', '#ef4444'] as const,
|
||||
forest: ['#10b981', '#059669'] as const,
|
||||
lavender: ['#a78bfa', '#ec4899'] as const,
|
||||
dark: ['#1e293b', '#0f172a'] as const,
|
||||
},
|
||||
|
||||
// Shadow System
|
||||
shadows: {
|
||||
subtle: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
medium: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
strong: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
glow: {
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
glowDanger: {
|
||||
shadowColor: '#ef4444',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
},
|
||||
|
||||
// Typography
|
||||
typography: {
|
||||
// Font sizes
|
||||
fontSize: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 28,
|
||||
'4xl': 32,
|
||||
'5xl': 36,
|
||||
},
|
||||
// Font weights
|
||||
fontWeight: {
|
||||
normal: '400' as const,
|
||||
medium: '500' as const,
|
||||
semibold: '600' as const,
|
||||
bold: '700' as const,
|
||||
extrabold: '800' as const,
|
||||
},
|
||||
// Line heights
|
||||
lineHeight: {
|
||||
tight: 1.2,
|
||||
normal: 1.5,
|
||||
relaxed: 1.75,
|
||||
},
|
||||
},
|
||||
|
||||
// Spacing Scale
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 32,
|
||||
'4xl': 40,
|
||||
'5xl': 48,
|
||||
},
|
||||
|
||||
// Border Radius
|
||||
borderRadius: {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
'2xl': 20,
|
||||
'3xl': 24,
|
||||
full: 9999,
|
||||
},
|
||||
|
||||
// Animation Timing
|
||||
animation: {
|
||||
duration: {
|
||||
fast: 150,
|
||||
normal: 250,
|
||||
slow: 350,
|
||||
},
|
||||
easing: {
|
||||
easeIn: 'ease-in',
|
||||
easeOut: 'ease-out',
|
||||
easeInOut: 'ease-in-out',
|
||||
},
|
||||
},
|
||||
|
||||
// Glassmorphism
|
||||
glass: {
|
||||
light: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
dark: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Theme = typeof theme;
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Reusable Animation Utilities
|
||||
* Pre-configured animations for consistent motion design
|
||||
*/
|
||||
|
||||
import { Animated, Easing } from 'react-native';
|
||||
|
||||
export const animations = {
|
||||
// Fade animations
|
||||
fadeIn: (animatedValue: Animated.Value, duration = 250) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
fadeOut: (animatedValue: Animated.Value, duration = 250) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
easing: Easing.in(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Scale animations
|
||||
scaleIn: (animatedValue: Animated.Value, duration = 250) => {
|
||||
return Animated.spring(animatedValue, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
scaleOut: (animatedValue: Animated.Value, duration = 200) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
easing: Easing.in(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Press animation (scale down slightly)
|
||||
pressIn: (animatedValue: Animated.Value) => {
|
||||
return Animated.spring(animatedValue, {
|
||||
toValue: 0.95,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
pressOut: (animatedValue: Animated.Value) => {
|
||||
return Animated.spring(animatedValue, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Slide animations
|
||||
slideInUp: (animatedValue: Animated.Value, duration = 300) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
slideOutDown: (animatedValue: Animated.Value, duration = 250) => {
|
||||
return Animated.timing(animatedValue, {
|
||||
toValue: 100,
|
||||
duration,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
});
|
||||
},
|
||||
|
||||
// Pulse animation (for FAB or notifications)
|
||||
pulse: (animatedValue: Animated.Value) => {
|
||||
return Animated.sequence([
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1.1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
easing: Easing.in(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
// Stagger animation for lists
|
||||
stagger: (animations: Animated.CompositeAnimation[], delay = 50) => {
|
||||
return Animated.stagger(delay, animations);
|
||||
},
|
||||
|
||||
// Parallel animations
|
||||
parallel: (animations: Animated.CompositeAnimation[]) => {
|
||||
return Animated.parallel(animations);
|
||||
},
|
||||
|
||||
// Sequence animations
|
||||
sequence: (animations: Animated.CompositeAnimation[]) => {
|
||||
return Animated.sequence(animations);
|
||||
},
|
||||
};
|
||||
|
||||
// Spring configurations
|
||||
export const springConfig = {
|
||||
gentle: {
|
||||
friction: 10,
|
||||
tension: 40,
|
||||
},
|
||||
bouncy: {
|
||||
friction: 5,
|
||||
tension: 40,
|
||||
},
|
||||
stiff: {
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
},
|
||||
};
|
||||
|
||||
// Timing configurations
|
||||
export const timingConfig = {
|
||||
fast: {
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.ease),
|
||||
},
|
||||
normal: {
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.ease),
|
||||
},
|
||||
slow: {
|
||||
duration: 350,
|
||||
easing: Easing.out(Easing.ease),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const haptics = {
|
||||
light: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
},
|
||||
medium: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
},
|
||||
heavy: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
}
|
||||
},
|
||||
success: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
},
|
||||
warning: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
}
|
||||
},
|
||||
selection: () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
Haptics.selectionAsync();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
"lib": [
|
||||
"es2017"
|
||||
],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
@@ -35,4 +35,4 @@
|
||||
"jest.config.js"
|
||||
],
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
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.
@@ -0,0 +1,217 @@
|
||||
# Database Setup Guide
|
||||
|
||||
## Database Location
|
||||
|
||||
**Primary Database**: `apps/admin/data/fitai.db`
|
||||
|
||||
This is the single source of truth for all application data. All API routes, migration scripts, and database operations use this file.
|
||||
|
||||
## Database Path Configuration
|
||||
|
||||
### Database Package (`@fitai/database`)
|
||||
|
||||
The database package supports configurable paths via environment variables:
|
||||
|
||||
```typescript
|
||||
// Default behavior:
|
||||
// - Development: ../../apps/admin/data/fitai.db
|
||||
// - Production: ./data/fitai.db
|
||||
// - Custom: Set DATABASE_URL environment variable
|
||||
```
|
||||
|
||||
**Environment Variable**:
|
||||
```bash
|
||||
# Optional: Override default database path
|
||||
DATABASE_URL=/path/to/your/fitai.db
|
||||
```
|
||||
|
||||
### Admin App
|
||||
|
||||
The admin app uses the following database path configuration:
|
||||
|
||||
```typescript
|
||||
// Default: ./data/fitai.db (relative to apps/admin/)
|
||||
filename: './data/fitai.db'
|
||||
```
|
||||
|
||||
All API routes and scripts use this consistent path.
|
||||
|
||||
## Migration Scripts
|
||||
|
||||
All migration scripts are located in `apps/admin/scripts/` and use the correct database path:
|
||||
|
||||
- `seed-superadmin.js` - Creates super admin user
|
||||
- `migrate-roles.js` - Adds role column to users table
|
||||
- `migrate-fitness-profile.js` - Adds allergies and injuries columns
|
||||
- `migrate-last-visit.js` - Adds lastVisit column to clients table
|
||||
|
||||
**Running Migrations**:
|
||||
```bash
|
||||
cd apps/admin
|
||||
node scripts/migrate-fitness-profile.js
|
||||
node scripts/migrate-roles.js
|
||||
node scripts/migrate-last-visit.js
|
||||
```
|
||||
|
||||
## Drizzle ORM
|
||||
|
||||
Drizzle Kit is configured to use the admin database:
|
||||
|
||||
```typescript
|
||||
// packages/database/drizzle.config.ts
|
||||
dbCredentials: {
|
||||
url: "../../apps/admin/data/fitai.db"
|
||||
}
|
||||
```
|
||||
|
||||
**Drizzle Commands**:
|
||||
```bash
|
||||
cd packages/database
|
||||
|
||||
# Push schema changes to database
|
||||
npm run db:push
|
||||
|
||||
# Open Drizzle Studio (database GUI)
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Creating a Backup
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
cp apps/admin/data/fitai.db backups/fitai-$(date +%Y%m%d-%H%M%S).db
|
||||
```
|
||||
|
||||
### Restoring from Backup
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
cp backups/fitai-YYYYMMDD-HHMMSS.db apps/admin/data/fitai.db
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The database uses SQLite with the following main tables:
|
||||
|
||||
- `users` - User accounts (admin, trainer, client)
|
||||
- `clients` - Client profiles linked to users
|
||||
- `fitness_profiles` - Fitness goals, preferences, and health info
|
||||
- `payments` - Payment records
|
||||
- `attendance` - Check-in/check-out records
|
||||
- `notifications` - User notifications
|
||||
- `recommendations` - AI-generated fitness recommendations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Not Found Error
|
||||
|
||||
If you see "database not found" errors:
|
||||
|
||||
1. Ensure you're running commands from the correct directory
|
||||
2. Check that `apps/admin/data/fitai.db` exists
|
||||
3. Verify file permissions (should be readable/writable)
|
||||
|
||||
### Multiple Database Files
|
||||
|
||||
**There should only be ONE database file**: `apps/admin/data/fitai.db`
|
||||
|
||||
If you find other `fitai.db` files:
|
||||
- `data/fitai.db` (root level) - Old duplicate, should not exist
|
||||
- `packages/database/fitai.db` - Old duplicate, should not exist
|
||||
|
||||
These were consolidated on 2025-11-26. Backups are in `backups/database-consolidation-2025-11-26/`.
|
||||
|
||||
### Migration Script Errors
|
||||
|
||||
If migration scripts fail:
|
||||
|
||||
1. Check that you're in the `apps/admin` directory
|
||||
2. Verify the database file exists and is writable
|
||||
3. Check the script output for specific error messages
|
||||
4. Ensure no other process has the database locked
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Starting Fresh
|
||||
|
||||
```bash
|
||||
# Remove existing database
|
||||
rm apps/admin/data/fitai.db
|
||||
|
||||
# Run migrations to create tables
|
||||
cd packages/database
|
||||
npm run db:push
|
||||
|
||||
# Seed super admin user
|
||||
cd ../../apps/admin
|
||||
node scripts/seed-superadmin.js
|
||||
```
|
||||
|
||||
### Viewing Database Contents
|
||||
|
||||
**Option 1: Drizzle Studio (Recommended)**
|
||||
```bash
|
||||
cd packages/database
|
||||
npm run db:studio
|
||||
# Opens at http://localhost:4983
|
||||
```
|
||||
|
||||
**Option 2: SQLite CLI**
|
||||
```bash
|
||||
cd apps/admin/data
|
||||
sqlite3 fitai.db
|
||||
# Then run SQL queries
|
||||
SELECT * FROM users;
|
||||
.exit
|
||||
```
|
||||
|
||||
**Option 3: VS Code Extension**
|
||||
Install "SQLite Viewer" extension and open `apps/admin/data/fitai.db`
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the following in production:
|
||||
|
||||
```bash
|
||||
# Optional: Custom database path
|
||||
DATABASE_URL=/var/data/fitai.db
|
||||
|
||||
# Set production mode
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
### Database Location in Production
|
||||
|
||||
Ensure the database directory exists and has proper permissions:
|
||||
|
||||
```bash
|
||||
mkdir -p /path/to/data
|
||||
chmod 755 /path/to/data
|
||||
```
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
Implement automated backups in production:
|
||||
|
||||
```bash
|
||||
# Daily backup cron job
|
||||
0 2 * * * cp /var/data/fitai.db /var/backups/fitai-$(date +\%Y\%m\%d).db
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Never commit the database file to git** - It's in `.gitignore`
|
||||
2. **Protect database file permissions** - Only application should have access
|
||||
3. **Regular backups** - Implement automated backup strategy
|
||||
4. **Encryption at rest** - Consider encrypting the database file in production
|
||||
5. **Access control** - Ensure only authorized users can access the database
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Clerk Webhook Setup](./CLERK_WEBHOOK_SETUP.md) - User synchronization
|
||||
- [Testing Fitness Profile](./TESTING_FITNESS_PROFILE.md) - Feature testing
|
||||
- [Webhook Database Fix](./WEBHOOK_DATABASE_FIX.md) - Webhook implementation details
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user