basic stracture and auth

in memory db :)
This commit is contained in:
echo 2025-11-07 20:59:56 +01:00
commit 3a554ba434
71 changed files with 28535 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@ -0,0 +1,178 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Production builds
.next/
out/
build/
dist/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Temporary folders
tmp/
temp/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Expo
.expo/
web-build/
# Metro
.metro-health-check*
# React Native
# macOS
.DS_Store
# Windows
Thumbs.db
# Linux
*~
# Watchman
.watchmanconfig
# Flipper
ios/Pods/Flipper
ios/Pods/Flipper-Folly
ios/Pods/Flipper-Glog
ios/Pods/Flipper-PeerTalk
ios/Pods/Flipper-RSocket
# Ruby / CocoaPods
/ios/Pods/
/vendor/bundle/
# Fastlane
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
*/fastlane/test_output
# Bundle artifact
*.jsbundle
# CocoaPods
/ios/Pods/
# Expo
.expo-shared/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# IDE
.vscode/
.idea/
# Testing
coverage/
.nyc_output/
# Storybook build outputs
storybook-static
# Temporary files
*.tmp
*.temp

22
AGENTS.md Normal file
View File

@ -0,0 +1,22 @@
# AGENTS.md
## Project Overview
FitAI is a fitness management solution with admin web app and React Native mobile app for client management, payments, attendance tracking, and notifications.
## Build/Test Commands
This project is in early prototype stage - no build system configured yet.
- When implementing: expect standard npm/yarn commands (npm run dev, npm run build, npm test)
- For single tests: use npm test -- --testNamePattern="test name" or npm test path/to/test.test.js
## Code Style Guidelines
- Use TypeScript for type safety
- Follow React/React Native conventions
- Use camelCase for variables and functions
- Use PascalCase for components
- Import order: external libraries → internal modules → relative imports
- Implement proper error handling with try/catch blocks
- Use semantic HTML and accessible components
- Follow mobile-first responsive design principles
- Implement proper state management (Context API or Redux)
- Use environment variables for configuration
- Write comprehensive tests for critical functionality

214
PROGRESS.md Normal file
View File

@ -0,0 +1,214 @@
# FitAI Development Progress
## Current State
### ✅ Completed Features
#### 1. Project Structure
- Monorepo setup with separate admin and mobile apps
- Admin: Next.js 14 + TypeScript + Tailwind CSS
- Mobile: React Native + Expo + TypeScript
- Shared utilities and types package
#### 2. Authentication System
- **User Registration**: Mobile app registration form with validation
- **User Login**: Secure login with password hashing (bcryptjs)
- **Data Storage**: In-memory database (demo) with user and client schemas
- **Admin Access**: User management dashboard with role-based filtering
#### 3. API Endpoints
- `POST /api/auth/register` - User registration with client creation
- `POST /api/auth/login` - User authentication
- `GET /api/users` - User listing with role filtering
- All endpoints include proper error handling and validation
#### 4. Mobile App Features
- Registration screen with form validation
- Login screen with secure credential storage
- Authentication context with protected routes
- Tab navigation (Home, Profile, Attendance)
- User profile display with logout functionality
#### 5. Admin Dashboard Features
- User management interface
- Role-based filtering (All, Clients, Trainers, Admins)
- User cards showing membership details
- Responsive design with Tailwind CSS
- Real-time user data display
## Current User Flow
### Registration Flow
1. User opens mobile app → Registration screen
2. Fills form (email, password, name, phone)
3. Submits → API creates user + client record
4. Success → Redirect to login screen
5. User can login and access protected tabs
### Admin Management Flow
1. Admin opens dashboard → User management section
2. Views all users with role filtering
3. Sees user details: membership type, status, join date
4. Can filter by user role for easier management
## Technical Implementation
### Database Schema (In-Memory)
```typescript
User: {
id, email, firstName, lastName, password, role, phone, createdAt
}
Client: {
id, userId, membershipType, membershipStatus, joinDate, lastVisit
}
```
### Authentication
- Password hashing with bcryptjs
- JWT-like session storage in mobile (SecureStore)
- Protected routes with authentication context
### API Design
- RESTful endpoints with proper HTTP status codes
- Input validation and error handling
- TypeScript interfaces for type safety
## 🚧 Next Steps
### Phase 1: Core Features (Priority: High)
#### 1.1 Database Implementation
- [ ] Replace in-memory storage with persistent database
- [ ] Set up PostgreSQL or MongoDB with Drizzle ORM
- [ ] Create database migration scripts
- [ ] Add database connection pooling
#### 1.2 Enhanced User Management
- [ ] User CRUD operations (edit, delete, deactivate)
- [ ] Bulk user operations
- [ ] User search and pagination
- [ ] User activity logs
#### 1.3 Payment System
- [ ] Payment schema and API endpoints
- [ ] Payment status tracking
- [ ] Payment history for clients
- [ ] Automated payment reminders
#### 1.4 Attendance Tracking
- [ ] Check-in/check-out functionality
- [ ] QR code or NFC check-in
- [ ] Attendance analytics and reports
- [ ] Class scheduling integration
### Phase 2: Advanced Features (Priority: Medium)
#### 2.1 Notifications System
- [ ] Push notification setup
- [ ] Email notifications
- [ ] SMS notifications
- [ ] Notification preferences
#### 2.2 Enhanced Mobile Features
- [ ] Offline mode support
- [ ] Workout tracking
- [ ] Progress photos and measurements
- [ ] Goal setting and tracking
#### 2.3 Admin Analytics
- [ ] Dashboard metrics and KPIs
- [ ] Revenue tracking
- [ ] Member retention analytics
- [ ] Peak hours analysis
#### 2.4 Trainer Features
- [ ] Trainer-client assignment
- [ ] Workout plan creation
- [ ] Progress tracking tools
- [ ] Communication system
### Phase 3: AI Integration (Priority: Low)
#### 3.1 Fitness AI
- [ ] Workout recommendation engine
- [ ] Progress prediction algorithms
- [ ] Nutrition suggestions
- [ ] Injury risk assessment
#### 3.2 Business Intelligence
- [ ] Predictive analytics
- [ ] Churn prediction
- [ ] Revenue optimization
- [ ] Capacity planning
## Technical Debt & Improvements
### Immediate (Next Sprint)
- [ ] Add comprehensive error boundaries
- [ ] Implement proper logging system
- [ ] Add unit and integration tests
- [ ] Set up CI/CD pipeline
- [ ] Add input sanitization and security headers
### Medium Term
- [ ] Implement proper state management (Redux/Zustand)
- [ ] Add API rate limiting
- [ ] Implement caching strategy
- [ ] Add monitoring and alerting
- [ ] Performance optimization
### Long Term
- [ ] Microservices architecture
- [ ] Multi-tenant support
- [ ] Advanced security features
- [ ] Internationalization
- [ ] Progressive Web App (PWA)
## Development Guidelines
### Code Standards
- TypeScript for type safety
- ESLint + Prettier for code formatting
- Conventional commits for version control
- Feature branch development workflow
### Testing Strategy
- Unit tests for business logic
- Integration tests for API endpoints
- E2E tests for critical user flows
- Manual testing checklist for releases
### Deployment
- Staging environment for testing
- Blue-green deployment strategy
- Database migration scripts
- Rollback procedures
## Current Limitations
1. **Data Persistence**: Using in-memory storage (demo only)
2. **Security**: Basic authentication, no advanced security features
3. **Scalability**: Single-instance deployment
4. **Testing**: No automated tests implemented
5. **Monitoring**: No logging or monitoring setup
6. **Performance**: No optimization or caching
## Success Metrics
### Technical Metrics
- [ ] API response time < 200ms
- [ ] Mobile app load time < 3s
- [ ] 99.9% uptime
- [ ] Zero critical security vulnerabilities
### Business Metrics
- [ ] User registration conversion rate > 80%
- [ ] Admin task completion time < 2min
- [ ] User retention rate > 90%
- [ ] System adoption rate > 95%
---
**Last Updated**: November 7, 2025
**Version**: 1.0.0-alpha
**Next Review**: After Phase 1 completion

117
README.md Normal file
View File

@ -0,0 +1,117 @@
# FitAI
Integrated AI solution for fitness houses and their clients.
## Project Structure
```
fitai/
├── apps/
│ ├── admin/ # Next.js admin dashboard
│ └── mobile/ # React Native mobile app (Expo)
├── packages/
│ └── shared/ # Shared types and utilities
└── AGENTS.md # Development guidelines
```
## Getting Started
### Prerequisites
- Node.js >= 18.0.0
- npm >= 9.0.0
### Installation
```bash
# Install root dependencies
npm install
# Install admin dependencies
cd apps/admin && npm install
# Install mobile dependencies
cd apps/mobile && npm install
```
### Development
```bash
# Start both apps together
npm run dev
# Or start individually:
# Admin dashboard (http://localhost:3000)
cd apps/admin && npm run dev
# Mobile app (http://localhost:8081) - Requires Expo SDK 54
cd apps/mobile && npm start
```
### Mobile App Setup
- **Expo SDK**: 50 (stable, compatible with Expo Go)
- **Assets**: Placeholder icons and splash screen included
- **Navigation**: Expo Router with tab-based layout
- **Authentication**: Secure storage with expo-secure-store
- **Babel**: babel-preset-expo for proper transpilation
### Known Compatibility Notes
- Use Expo Go with SDK 50 for mobile testing
- For SDK 54, upgrade all dependencies to latest versions
- Current setup prioritizes stability over latest features
### Build & Test
```bash
# Build all apps
npm run build
# Run tests
npm test
# Lint code
npm run lint
# Type checking
npm run typecheck
```
## Features
### Admin Dashboard
- Client management
- Payment tracking
- Attendance monitoring
- Data visualization
### Mobile App
- Client profile management
- Attendance tracking
- Payment notifications
- Fitness progress tracking
## Tech Stack
- **Admin**: Next.js 14, React 18, TypeScript, Tailwind CSS
- **Mobile**: React Native, Expo Router, TypeScript
- **Shared**: TypeScript, Zod for validation
- **State Management**: React Query, React Hook Form
- **Data Grid**: AG Grid for advanced user management
- **Charts**: AG Charts for analytics and visualization
## Features
### Admin Dashboard
- **User Management**: Advanced AG Grid with filtering, sorting, pagination
- **Analytics**: Interactive charts (line, pie, bar) with AG Charts
- **Data Export**: CSV export functionality
- **Real-time Updates**: Live user data synchronization
- **Responsive Design**: Mobile-first responsive interface
### Mobile App
- **Authentication**: Secure registration and login
- **User Profile**: Personal information management
- **Protected Routes**: Authentication-based navigation
- **Secure Storage**: Encrypted credential storage
### Data Visualization
- **User Growth**: Line chart showing user acquisition over time
- **Membership Distribution**: Pie chart of membership types
- **Revenue Analytics**: Bar chart for monthly revenue tracking
- **Key Metrics**: Real-time KPI dashboard

16
apps/admin/.eslintrc.json Normal file
View File

@ -0,0 +1,16 @@
{
"root": true,
"extends": [
"next/core-web-vitals",
"@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn",
"prefer-const": "error",
"no-var": "error"
},
"ignorePatterns": ["node_modules", ".next", "dist"]
}

45
apps/admin/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Admin app specific
.next/
out/
.env.local
# Mobile app specific
.expo/
.expo-shared/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
.metro-health-check*
# iOS
ios/Pods/
*.xcode.env
ios/Pods/Flipper*
ios/Pods/Flipper-Folly
ios/Pods/Flipper-Glog
ios/Pods/Flipper-PeerTalk
ios/Pods/Flipper-RSocket
# Android
android/app/build/
android/gradle/
android/gradlew
android/gradlew.bat
android/local.properties
android/*.iml
android/.gradle/
android/app/release/
android/app/debug/
android/app/build/generated/
# Fastlane
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
*/fastlane/test_output
# Bundle artifact
*.jsbundle

15
apps/admin/jest.config.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: [
'**/__tests__/**/*.(ts|tsx|js)',
'**/*.(test|spec).(ts|tsx|js)',
],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
}

1
apps/admin/jest.setup.js Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

5
apps/admin/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: false,
},
}
module.exports = nextConfig

10176
apps/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
apps/admin/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "@fitai/admin",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0",
"@types/bcryptjs": "^2.4.6",
"autoprefixer": "^10.4.0",
"axios": "^1.6.0",
"bcryptjs": "^2.4.3",
"lucide-react": "^0.294.0",
"next": "^14.0.0",
"postcss": "^8.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-hook-form": "^7.47.0",
"recharts": "^2.8.0",
"tailwindcss": "^3.3.0",
"zod": "^3.22.0",
"ag-grid-community": "^32.0.0",
"ag-grid-react": "^32.0.0",
"ag-charts-community": "^9.0.0",
"ag-charts-react": "^9.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.0",
"@testing-library/react": "^13.4.0",
"@types/node": "^20.0.0",
"@types/react": "18.3.26",
"@types/react-dom": "^18.0.0",
"eslint": "^8.45.0",
"eslint-config-next": "^14.0.0",
"jest": "^29.7.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,11 @@
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard'
export default function AnalyticsPage() {
return (
<main className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<AnalyticsDashboard />
</div>
</main>
)
}

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { users } from '../../../../lib/database'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
)
}
const user = users.find(u => u.email === email)
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
}
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
message: 'Login successful',
user: userWithoutPassword
})
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { users, clients } from '../../../../lib/database'
export async function POST(request: NextRequest) {
try {
const { email, password, firstName, lastName, phone } = await request.json()
if (!email || !password || !firstName || !lastName) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
}
const existingUser = users.find(u => u.email === email)
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 409 }
)
}
const hashedPassword = await bcrypt.hash(password, 10)
const userId = Math.random().toString(36).substr(2, 9)
const newUser = {
id: userId,
email,
firstName,
lastName,
password: hashedPassword,
phone,
role: 'client',
createdAt: new Date(),
updatedAt: new Date(),
}
users.push(newUser)
const newClient = {
id: Math.random().toString(36).substr(2, 9),
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date(),
}
clients.push(newClient)
const { password: _, ...userWithoutPassword } = newUser
return NextResponse.json(
{
message: 'User registered successfully',
user: { ...userWithoutPassword, client: newClient }
},
{ status: 201 }
)
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function GET() {
const usersWithoutPassword = users.map(({ password: _, ...user }) => user)
return NextResponse.json({ users: usersWithoutPassword })
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { users, clients } from '../../../lib/database'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const role = searchParams.get('role')
let filteredUsers = users
if (role) {
filteredUsers = users.filter(user => user.role === role)
}
const usersWithClients = filteredUsers.map(({ password: _, ...user }) => {
const client = clients.find(c => c.userId === user.id)
return { ...user, client }
})
return NextResponse.json({ users: usersWithClients })
} catch (error) {
console.error('Get users error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

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

View File

@ -0,0 +1,64 @@
'use client'
import Link from 'next/link'
import { UserManagement } from '@/components/users/UserManagement'
import { AnalyticsDashboard } from '@/components/analytics/AnalyticsDashboard'
export default function Home() {
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>
<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 className="h-96">
<UserManagement />
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6">Quick Analytics</h2>
<div className="h-96">
<AnalyticsDashboard />
</div>
</div>
</div>
</div>
</main>
)
}

View File

@ -0,0 +1,11 @@
import { UserManagement } from '@/components/users/UserManagement'
export default function UsersPage() {
return (
<main className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<UserManagement />
</div>
</main>
)
}

View File

@ -0,0 +1,140 @@
'use client'
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'
interface ChartData {
label: string
value: number
color?: string
}
export function AnalyticsDashboard() {
const [userGrowthData, setUserGrowthData] = useState<ChartData[]>([])
const [membershipData, setMembershipData] = useState<ChartData[]>([])
const [revenueData, setRevenueData] = useState<ChartData[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchAnalyticsData()
}, [])
const fetchAnalyticsData = async () => {
setLoading(true)
try {
// Mock data for demonstration - replace with real API calls
const mockUserGrowth = [
{ label: 'Jan', value: 45 },
{ label: 'Feb', value: 52 },
{ label: 'Mar', value: 61 },
{ label: 'Apr', value: 58 },
{ label: 'May', value: 67 },
{ label: 'Jun', value: 74 },
]
const mockMembershipData = [
{ label: 'Basic', value: 45, color: '#6b7280' },
{ label: 'Premium', value: 28, color: '#3b82f6' },
{ label: 'VIP', value: 12, color: '#f59e0b' },
]
const mockRevenueData = [
{ label: 'Jan', value: 12500, color: '#10b981' },
{ label: 'Feb', value: 14200, color: '#10b981' },
{ label: 'Mar', value: 16800, color: '#10b981' },
{ label: 'Apr', value: 15900, color: '#10b981' },
{ label: 'May', value: 18200, color: '#10b981' },
{ label: 'Jun', value: 19400, color: '#10b981' },
]
setUserGrowthData(mockUserGrowth)
setMembershipData(mockMembershipData)
setRevenueData(mockRevenueData)
} catch (error) {
console.error('Failed to fetch analytics data:', error)
} finally {
setLoading(false)
}
}
const totalUsers = userGrowthData.length > 0 ? userGrowthData[userGrowthData.length - 1].value : 0
const totalRevenue = revenueData.reduce((sum, item) => sum + item.value, 0)
const activeMembers = membershipData.reduce((sum, item) => sum + item.value, 0)
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-lg">Loading analytics...</div>
</div>
)
}
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold">Analytics Dashboard</h2>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{totalUsers}</div>
<div className="text-gray-600">Total Users</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">${totalRevenue.toLocaleString()}</div>
<div className="text-gray-600">Total Revenue</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{activeMembers}</div>
<div className="text-gray-600">Active Members</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">User Growth</h3>
</CardHeader>
<CardContent>
<UserGrowthChart data={userGrowthData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Membership Distribution</h3>
</CardHeader>
<CardContent>
<MembershipDistributionChart data={membershipData} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Monthly Revenue</h3>
</CardHeader>
<CardContent>
<RevenueChart data={revenueData} />
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,88 @@
'use client'
import React, { useMemo } from 'react'
import { AgChartsReact } from 'ag-charts-react'
import { AgChartOptions } from 'ag-charts-community'
interface PieData {
label: string
value: number
color?: string
}
interface MembershipDistributionChartProps {
data: PieData[]
title?: string
}
export function MembershipDistributionChart({ data, title = 'Membership Distribution' }: MembershipDistributionChartProps) {
const chartOptions: AgChartOptions = useMemo(() => ({
title: {
text: title,
fontSize: 18,
fontWeight: 'bold',
},
data,
series: [
{
type: 'pie',
calloutLabelKey: 'label',
angleKey: 'value',
sectorLabelKey: 'label',
fills: data.map(item => item.color || '#3b82f6'),
strokes: ['#ffffff'],
strokeWidth: 2,
calloutLabel: {
enabled: true,
fontSize: 12,
fontWeight: 'bold',
},
sectorLabel: {
enabled: true,
fontSize: 14,
fontWeight: 'bold',
color: '#ffffff',
formatter: (params: any) => {
const percentage = ((params.datum.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1)
return `${params.datum.label}: ${percentage}%`
},
},
highlightStyle: {
item: {
fillOpacity: 0.8,
stroke: '#000000',
strokeWidth: 2,
},
},
tooltip: {
enabled: true,
renderer: (params: any) => {
const percentage = ((params.datum.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1)
return `<div class="bg-white p-2 rounded shadow-lg border">
<div class="font-bold">${params.datum.label}</div>
<div class="text-sm">Count: ${params.datum.value}</div>
<div class="text-sm">Percentage: ${percentage}%</div>
</div>`
},
},
},
],
legend: {
enabled: true,
position: 'right',
fontSize: 12,
marker: {
shape: 'square',
size: 12,
},
},
padding: {
top: 20,
right: 20,
bottom: 20,
left: 20,
},
}), [data, title])
return <AgChartsReact options={chartOptions} />
}

View File

@ -0,0 +1,99 @@
'use client'
import React, { useMemo } from 'react'
import { AgChartsReact } from 'ag-charts-react'
import { AgChartOptions } from 'ag-charts-community'
interface BarData {
category: string
value: number
color?: string
}
interface RevenueChartProps {
data: BarData[]
title?: string
}
export function RevenueChart({ data, title = 'Monthly Revenue' }: RevenueChartProps) {
const chartOptions: AgChartOptions = useMemo(() => ({
title: {
text: title,
fontSize: 18,
fontWeight: 'bold',
},
data,
series: [
{
type: 'bar',
xKey: 'category',
yKey: 'value',
fills: data.map(item => item.color || '#10b981'),
strokes: ['#ffffff'],
strokeWidth: 2,
cornerRadius: 4,
highlightStyle: {
item: {
fill: '#059669',
stroke: '#ffffff',
strokeWidth: 2,
},
},
label: {
enabled: true,
position: 'top',
fontSize: 12,
fontWeight: 'bold',
color: '#374151',
formatter: (params: any) => `$${params.value.toLocaleString()}`,
},
tooltip: {
enabled: true,
renderer: (params: any) => {
return `<div class="bg-white p-2 rounded shadow-lg border">
<div class="font-bold">${params.datum.category}</div>
<div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div>
</div>`
},
},
},
],
axes: [
{
type: 'category',
position: 'bottom',
title: {
text: 'Month',
fontSize: 14,
},
label: {
fontSize: 12,
rotation: 45,
},
},
{
type: 'number',
position: 'left',
title: {
text: 'Revenue ($)',
fontSize: 14,
},
label: {
fontSize: 12,
formatter: (params: any) => `$${params.value.toLocaleString()}`,
},
},
],
legend: {
enabled: false,
},
padding: {
top: 20,
right: 20,
bottom: 60,
left: 80,
},
}), [data, title])
return <AgChartsReact options={chartOptions} />
}

View File

@ -0,0 +1,78 @@
'use client'
import React, { useMemo } from 'react'
import { AgChartsReact } from 'ag-charts-react'
import { AgChartOptions } from 'ag-charts-community'
interface ChartData {
label: string
value: number
color?: string
}
interface UserGrowthChartProps {
data: ChartData[]
title?: string
}
export function UserGrowthChart({ data, title = 'User Growth' }: UserGrowthChartProps) {
const chartOptions: AgChartOptions = useMemo(() => ({
title: {
text: title,
fontSize: 18,
fontWeight: 'bold',
},
data,
series: [
{
type: 'line',
xKey: 'label',
yKey: 'value',
stroke: '#3b82f6',
strokeWidth: 3,
marker: {
size: 6,
fill: '#3b82f6',
stroke: '#ffffff',
strokeWidth: 2,
},
highlightStyle: {
item: {
fill: '#1d4ed8',
stroke: '#ffffff',
strokeWidth: 2,
},
},
},
],
axes: [
{
type: 'category',
position: 'bottom',
title: {
text: 'Time Period',
fontSize: 14,
},
},
{
type: 'number',
position: 'left',
title: {
text: 'Number of Users',
fontSize: 14,
},
},
],
legend: {
enabled: false,
},
padding: {
top: 20,
right: 20,
bottom: 20,
left: 20,
},
}), [data, title])
return <AgChartsReact options={chartOptions} />
}

View File

@ -0,0 +1,23 @@
import React from 'react'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
children: React.ReactNode
}
export function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
}
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</button>
)
}

View File

@ -0,0 +1,30 @@
import React from 'react'
interface CardProps {
children: React.ReactNode
className?: string
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
{children}
</div>
)
}
export function CardHeader({ children, className = '' }: CardProps) {
return (
<div className={`mb-4 ${className}`}>
{children}
</div>
)
}
export function CardContent({ children, className = '' }: CardProps) {
return (
<div className={className}>
{children}
</div>
)
}

View File

@ -0,0 +1,167 @@
'use client'
import React, { useState, useMemo } from 'react'
import { AgGridReact } from 'ag-grid-react'
import { ColDef } from 'ag-grid-community'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import { formatDate } from '@/lib/utils'
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
phone?: string
createdAt: Date
client?: {
id: string
membershipType: string
membershipStatus: string
joinDate: Date
lastVisit?: Date
}
}
interface UserGridProps {
users: User[]
onUserSelect?: (user: User) => void
loading?: boolean
}
export function UserGrid({ users, onUserSelect, loading = false }: UserGridProps) {
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const columnDefs: ColDef<User>[] = useMemo(() => [
{
headerName: 'Name',
valueGetter: (params) => `${params.data?.firstName} ${params.data?.lastName}`,
filter: 'agTextColumnFilter',
sortable: true,
minWidth: 150,
},
{
headerName: 'Email',
field: 'email',
filter: 'agTextColumnFilter',
sortable: true,
minWidth: 200,
},
{
headerName: 'Role',
field: 'role',
filter: 'agSetColumnFilter',
sortable: true,
cellRenderer: (params: any) => {
const roleColors = {
admin: 'bg-purple-100 text-purple-800',
trainer: 'bg-blue-100 text-blue-800',
client: 'bg-green-100 text-green-800',
}
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>`
},
minWidth: 120,
},
{
headerName: 'Phone',
field: 'phone',
filter: 'agTextColumnFilter',
sortable: true,
minWidth: 130,
},
{
headerName: 'Membership',
valueGetter: (params) => params.data?.client?.membershipType || 'N/A',
filter: 'agSetColumnFilter',
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === 'N/A') return 'N/A'
const membershipColors = {
vip: 'bg-yellow-100 text-yellow-800',
premium: 'bg-blue-100 text-blue-800',
basic: 'bg-gray-100 text-gray-800',
}
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>`
},
minWidth: 120,
},
{
headerName: 'Status',
valueGetter: (params) => params.data?.client?.membershipStatus || 'N/A',
filter: 'agSetColumnFilter',
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === 'N/A') return 'N/A'
const statusColors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-red-100 text-red-800',
suspended: 'bg-yellow-100 text-yellow-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>`
},
minWidth: 120,
},
{
headerName: 'Join Date',
valueGetter: (params) => params.data?.client?.joinDate || params.data?.createdAt,
filter: 'agDateColumnFilter',
sortable: true,
valueFormatter: (params: any) => formatDate(new Date(params.value)),
minWidth: 120,
},
{
headerName: 'Last Visit',
valueGetter: (params) => params.data?.client?.lastVisit,
filter: 'agDateColumnFilter',
sortable: true,
valueFormatter: (params: any) => params.value ? formatDate(new Date(params.value)) : 'Never',
minWidth: 120,
},
], [])
const defaultColDef: ColDef = useMemo(() => ({
flex: 1,
resizable: true,
floatingFilter: true,
suppressMenu: true,
}), [])
const onSelectionChanged = () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes()
if (selectedNodes?.length > 0) {
const user = selectedNodes[0].data
setSelectedUser(user)
onUserSelect?.(user)
}
}
const gridRef = React.useRef<AgGridReact<User>>(null)
const gridOptions = {
columnDefs,
defaultColDef,
rowData: users,
rowSelection: 'single',
onSelectionChanged,
enableRangeSelection: true,
enableCellTextSelection: true,
suppressRowClickSelection: false,
animateRows: true,
loading: loading,
pagination: true,
paginationPageSize: 20,
paginationPageSizeSelector: [10, 20, 50, 100],
}
return (
<div className="ag-theme-alpine" style={{ height: '600px', width: '100%' }}>
<AgGridReact<User> {...gridOptions} ref={gridRef} />
</div>
)
}

View File

@ -0,0 +1,180 @@
'use client'
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'
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
phone?: string
createdAt: Date
client?: {
id: string
membershipType: string
membershipStatus: string
joinDate: Date
lastVisit?: Date
}
}
export function UserManagement() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>('all')
const [selectedUser, setSelectedUser] = useState<User | null>(null)
useEffect(() => {
fetchUsers()
}, [filter])
const fetchUsers = async () => {
setLoading(true)
try {
const url = filter === 'all'
? '/api/users'
: `/api/users?role=${filter}`
const response = await fetch(url)
const data = await response.json()
setUsers(data.users || [])
} catch (error) {
console.error('Failed to fetch users:', error)
} finally {
setLoading(false)
}
}
const handleUserSelect = (user: User) => {
setSelectedUser(user)
}
const handleExport = () => {
const csvContent = [
['Name', 'Email', 'Role', 'Phone', 'Membership', 'Status', 'Join Date', 'Last Visit'],
...users.map(user => [
`${user.firstName} ${user.lastName}`,
user.email,
user.role,
user.phone || '',
user.client?.membershipType || '',
user.client?.membershipStatus || '',
user.client?.joinDate || user.createdAt,
user.client?.lastVisit || ''
])
].map(row => row.join(',')).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users_${new Date().toISOString().split('T')[0]}.csv`
a.click()
window.URL.revokeObjectURL(url)
}
const handleRefresh = () => {
fetchUsers()
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">User Management</h2>
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'primary' : 'secondary'}
onClick={() => setFilter('all')}
>
All Users
</Button>
<Button
variant={filter === 'client' ? 'primary' : 'secondary'}
onClick={() => setFilter('client')}
>
Clients
</Button>
<Button
variant={filter === 'trainer' ? 'primary' : 'secondary'}
onClick={() => setFilter('trainer')}
>
Trainers
</Button>
<Button
variant={filter === 'admin' ? 'primary' : 'secondary'}
onClick={() => setFilter('admin')}
>
Admins
</Button>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleRefresh}>
Refresh
</Button>
<Button variant="secondary" onClick={handleExport}>
Export CSV
</Button>
</div>
</div>
<Card>
<CardContent className="p-0">
<UserGrid
users={users}
onUserSelect={handleUserSelect}
loading={loading}
/>
</CardContent>
</Card>
{selectedUser && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">User Details</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-2">Basic Information</h4>
<div className="space-y-1 text-sm">
<p><span className="font-medium">Name:</span> {selectedUser.firstName} {selectedUser.lastName}</p>
<p><span className="font-medium">Email:</span> {selectedUser.email}</p>
<p><span className="font-medium">Phone:</span> {selectedUser.phone || 'N/A'}</p>
<p><span className="font-medium">Role:</span> {selectedUser.role}</p>
<p><span className="font-medium">Joined:</span> {selectedUser.createdAt.toLocaleDateString()}</p>
</div>
</div>
{selectedUser.client && (
<div>
<h4 className="font-medium mb-2">Client Information</h4>
<div className="space-y-1 text-sm">
<p><span className="font-medium">Membership:</span> {selectedUser.client.membershipType}</p>
<p><span className="font-medium">Status:</span> {selectedUser.client.membershipStatus}</p>
<p><span className="font-medium">Member Since:</span> {selectedUser.client.joinDate.toLocaleDateString()}</p>
<p><span className="font-medium">Last Visit:</span> {selectedUser.client.lastVisit?.toLocaleDateString() || 'Never'}</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,23 @@
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
}
// In-memory database
export const users: User[] = []
export const clients: Client[] = []

View File

@ -0,0 +1,26 @@
export const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
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)
}

View File

@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
},
},
},
plugins: [],
}

51
apps/admin/tsconfig.json Normal file
View File

@ -0,0 +1,51 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/components/*": [
"./src/components/*"
],
"@/lib/*": [
"./src/lib/*"
],
"@/types/*": [
"./src/types/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

16
apps/mobile/.eslintrc.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
root: true,
extends: [
'@react-native-community',
'@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'prefer-const': 'error',
'no-var': 'error',
},
ignorePatterns: ['node_modules', 'dist', 'expo'],
}

44
apps/mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# Mobile app specific
.expo/
.expo-shared/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
.metro-health-check*
# iOS
ios/Pods/
*.xcode.env
ios/Pods/Flipper*
ios/Pods/Flipper-Folly
ios/Pods/Flipper-Glog
ios/Pods/Flipper-PeerTalk
ios/Pods/Flipper-RSocket
# Android
android/app/build/
android/gradle/
android/gradlew
android/gradlew.bat
android/local.properties
android/*.iml
android/.gradle/
android/app/release/
android/app/debug/
android/app/build/generated/
# Fastlane
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
*/fastlane/test_output
# Bundle artifact
*.jsbundle
# Environment
.env.local
.env.*.local

34
apps/mobile/app.json Normal file
View File

@ -0,0 +1,34 @@
{
"expo": {
"name": "FitAI",
"slug": "fitai",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
],
"scheme": "fitai"
}
}

View File

@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==

View File

@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==

View File

@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==

View File

@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==

View File

@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}

View File

@ -0,0 +1,11 @@
module.exports = {
preset: 'react-native',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transformIgnorePatterns: [
'node_modules/(?!(jest-)?react-native|@react-native|expo|@expo|@react-navigation)',
],
}

11
apps/mobile/jest.setup.js Normal file
View File

@ -0,0 +1,11 @@
import 'react-native-gesture-handler/jestSetup'
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock')
Reanimated.default.call = () => {}
return Reanimated
})
jest.mock('@expo/vector-icons', () => ({
Ionicons: 'Ionicons',
}))

12361
apps/mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
apps/mobile/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "@fitai/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"private": true,
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"build": "expo build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
"ajv-keywords": "^5.1.0",
"axios": "^1.6.0",
"expo": "~54.0.0",
"expo-camera": "~17.0.9",
"expo-linking": "~8.0.8",
"expo-notifications": "~0.32.12",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
"react": "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",
"zod": "^3.22.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"@types/react-native": "^0.73.0",
"typescript": "^5.1.3",
"eslint": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"jest": "^29.2.1",
"@testing-library/react-native": "^12.4.0",
"react-test-renderer": "19.1.0",
"babel-preset-expo": "~54.0.0"
}
}

View File

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

View File

@ -0,0 +1,28 @@
import { View, Text, StyleSheet } from 'react-native'
export default function AttendanceScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
},
})

View File

@ -0,0 +1,45 @@
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { useRequireAuth } from '@/hooks/useRequireAuth'
export default function HomeScreen() {
const { user } = useRequireAuth()
if (!user) {
return null
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome back!</Text>
<Text style={styles.subtitle}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 24,
fontWeight: '600',
marginBottom: 4,
color: '#333',
},
email: {
fontSize: 16,
color: '#666',
},
})

View File

@ -0,0 +1,116 @@
import React from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const router = useRouter()
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
await logout()
router.replace('/login')
} catch (error) {
Alert.alert('Error', 'Failed to logout')
}
},
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.profileCard}>
<Text style={styles.title}>Profile</Text>
<Text style={styles.name}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.email}>{user?.email}</Text>
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
<View style={styles.roleBadge}>
<Text style={styles.roleText}>
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
</Text>
</View>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#f5f5f5',
},
profileCard: {
backgroundColor: 'white',
borderRadius: 12,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 16,
},
name: {
fontSize: 20,
fontWeight: '600',
marginBottom: 4,
},
email: {
fontSize: 16,
color: '#666',
marginBottom: 4,
},
phone: {
fontSize: 16,
color: '#666',
marginBottom: 16,
},
roleBadge: {
backgroundColor: '#3b82f6',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
roleText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
logoutButton: {
backgroundColor: '#ef4444',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginTop: 24,
},
logoutText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
})

View File

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

View File

@ -0,0 +1,139 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
export default function LoginScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleLogin = async () => {
if (!formData.email || !formData.password) {
Alert.alert('Error', 'Please fill in all fields')
return
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
if (response.data.user) {
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
Alert.alert('Success', 'Login successful!', [
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
])
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Login to your FitAI account</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/register')}
>
<Text style={styles.linkText}>
Don't have an account? Register
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})

View File

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

View File

@ -0,0 +1,12 @@
export const API_BASE_URL = __DEV__
? 'http://192.168.0.100:3000'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
},
CLIENTS: '/api/clients',
USERS: '/api/users',
}

View File

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

View File

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

38
apps/mobile/tsconfig.json Normal file
View File

@ -0,0 +1,38 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"jsx": "react-native",
"lib": [
"es2017"
],
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"target": "esnext",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/components/*": [
"./src/components/*"
],
"@/lib/*": [
"./src/lib/*"
],
"@/types/*": [
"./src/types/*"
]
}
},
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js"
],
"extends": "expo/tsconfig.base"
}

5
extras.md Normal file
View File

@ -0,0 +1,5 @@
## extras
- clerk auth [and probably payment]
- stripe
- ag grid and charts

2923
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "fitai",
"version": "1.0.0",
"description": "Integrated AI solution for fitness houses and their clients",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:admin\" \"npm run dev:mobile\"",
"dev:admin": "cd apps/admin && npx next dev",
"dev:mobile": "cd apps/mobile && npx expo start",
"build": "npm run build:admin && npm run build:mobile",
"build:admin": "cd apps/admin && npx next build",
"build:mobile": "cd apps/mobile && npx expo build",
"test": "npm run test:admin && npm run test:mobile",
"test:admin": "cd apps/admin && npx jest",
"test:mobile": "cd apps/mobile && npx jest",
"lint": "npm run lint:admin && npm run lint:mobile",
"lint:admin": "cd apps/admin && npx eslint . --ext .js,.jsx,.ts,.tsx",
"lint:mobile": "cd apps/mobile && npx eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "npm run typecheck:admin && npm run typecheck:mobile",
"typecheck:admin": "cd apps/admin && npx tsc --noEmit",
"typecheck:mobile": "cd apps/mobile && npx tsc --noEmit"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"concurrently": "^8.2.2",
"eslint": "^8.45.0",
"prettier": "^3.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"dependencies": {
"next": "^16.0.1"
}
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
driver: 'better-sqlite',
dbCredentials: {
url: './fitai.db',
},
})

View File

@ -0,0 +1,22 @@
{
"name": "@fitai/database",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit",
"db:push": "drizzle-kit push:sqlite",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.29.0",
"better-sqlite3": "^9.0.0",
"@types/better-sqlite3": "^7.6.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"drizzle-kit": "^0.20.0"
}
}

View File

@ -0,0 +1,8 @@
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from './schema'
const sqlite = new Database('./fitai.db')
export const db = drizzle(sqlite, { schema })
export * from './schema'

View File

@ -0,0 +1,72 @@
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
password: text('password').notNull(),
role: text('role', { enum: ['admin', 'trainer', 'client'] }).notNull().default('client'),
phone: text('phone'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})
export const clients = sqliteTable('clients', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
membershipType: text('membership_type', { enum: ['basic', 'premium', 'vip'] }).notNull().default('basic'),
membershipStatus: text('membership_status', { enum: ['active', 'inactive', 'suspended'] }).notNull().default('active'),
joinDate: integer('join_date', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
lastVisit: integer('last_visit', { mode: 'timestamp' }),
emergencyContactName: text('emergency_contact_name'),
emergencyContactPhone: text('emergency_contact_phone'),
emergencyContactRelationship: text('emergency_contact_relationship'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})
export const payments = sqliteTable('payments', {
id: text('id').primaryKey(),
clientId: text('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
amount: real('amount').notNull(),
currency: text('currency').notNull().default('USD'),
status: text('status', { enum: ['pending', 'completed', 'failed', 'refunded'] }).notNull().default('pending'),
paymentMethod: text('payment_method', { enum: ['cash', 'card', 'bank_transfer'] }).notNull(),
dueDate: integer('due_date', { mode: 'timestamp' }).notNull(),
paidAt: integer('paid_at', { mode: 'timestamp' }),
description: text('description').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})
export const attendance = sqliteTable('attendance', {
id: text('id').primaryKey(),
clientId: text('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
checkInTime: integer('check_in_time', { mode: 'timestamp' }).notNull(),
checkOutTime: integer('check_out_time', { mode: 'timestamp' }),
type: text('type', { enum: ['gym', 'class', 'personal_training'] }).notNull().default('gym'),
notes: text('notes'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})
export const notifications = sqliteTable('notifications', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
message: text('message').notNull(),
type: text('type', { enum: ['payment_reminder', 'attendance', 'promotion', 'system'] }).notNull(),
read: integer('read', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
})
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Client = typeof clients.$inferSelect
export type NewClient = typeof clients.$inferInsert
export type Payment = typeof payments.$inferSelect
export type NewPayment = typeof payments.$inferInsert
export type Attendance = typeof attendance.$inferSelect
export type NewAttendance = typeof attendance.$inferInsert
export type Notification = typeof notifications.$inferSelect
export type NewNotification = typeof notifications.$inferInsert

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"lib": ["es2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,17 @@
{
"name": "@fitai/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.22.0"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,3 @@
export * from './types'
export * from './schemas'
export * from './utils'

View File

@ -0,0 +1,46 @@
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
role: z.enum(['admin', 'trainer', 'client']),
createdAt: z.date(),
updatedAt: z.date(),
})
export const ClientSchema = z.object({
id: z.string(),
userId: z.string(),
membershipType: z.enum(['basic', 'premium', 'vip']),
membershipStatus: z.enum(['active', 'inactive', 'suspended']),
joinDate: z.date(),
lastVisit: z.date().optional(),
emergencyContact: z.object({
name: z.string(),
phone: z.string(),
relationship: z.string(),
}),
})
export const PaymentSchema = z.object({
id: z.string(),
clientId: z.string(),
amount: z.number().positive(),
currency: z.string(),
status: z.enum(['pending', 'completed', 'failed', 'refunded']),
paymentMethod: z.enum(['cash', 'card', 'bank_transfer']),
dueDate: z.date(),
paidAt: z.date().optional(),
description: z.string(),
})
export const AttendanceSchema = z.object({
id: z.string(),
clientId: z.string(),
checkInTime: z.date(),
checkOutTime: z.date().optional(),
type: z.enum(['gym', 'class', 'personal_training']),
notes: z.string().optional(),
})

View File

@ -0,0 +1,57 @@
export interface User {
id: string
email: string
firstName: string
lastName: string
role: 'admin' | 'trainer' | 'client'
createdAt: Date
updatedAt: Date
}
export interface Client {
id: string
userId: string
user: User
membershipType: 'basic' | 'premium' | 'vip'
membershipStatus: 'active' | 'inactive' | 'suspended'
joinDate: Date
lastVisit?: Date
emergencyContact: {
name: string
phone: string
relationship: string
}
}
export interface Payment {
id: string
clientId: string
client: Client
amount: number
currency: string
status: 'pending' | 'completed' | 'failed' | 'refunded'
paymentMethod: 'cash' | 'card' | 'bank_transfer'
dueDate: Date
paidAt?: Date
description: string
}
export interface Attendance {
id: string
clientId: string
client: Client
checkInTime: Date
checkOutTime?: Date
type: 'gym' | 'class' | 'personal_training'
notes?: string
}
export interface Notification {
id: string
userId: string
title: string
message: string
type: 'payment_reminder' | 'attendance' | 'promotion' | 'system'
read: boolean
createdAt: Date
}

View File

@ -0,0 +1,26 @@
export const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date)
}
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)
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"lib": ["es2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

15
readme.md Normal file
View File

@ -0,0 +1,15 @@
## fitai
# description
- fitai is integrated ai solution for fitness houses and their clients,
its allow to easy menagment of clients, tracking of payments, usage of resourcess,
attendance, habits etc.
these will be phase one:
solution is composed of a admin app, where we are doing managment tasks, we visualize and
expose importatnt data to menagment and trainers, and a expo/reactnative mobile app for users.
via app we will be tracking attendance and payments, we will be sending notification etc.
# phase 2
we will be tracking user inputs via manual input and devices, backend will analyze data and propose
excercises etc.