basic stracture and auth
in memory db :)
This commit is contained in:
commit
3a554ba434
178
.gitignore
vendored
Normal file
178
.gitignore
vendored
Normal 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
22
AGENTS.md
Normal 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
214
PROGRESS.md
Normal 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
117
README.md
Normal 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
16
apps/admin/.eslintrc.json
Normal 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
45
apps/admin/.gitignore
vendored
Normal 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
15
apps/admin/jest.config.js
Normal 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
1
apps/admin/jest.setup.js
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
5
apps/admin/next-env.d.ts
vendored
Normal file
5
apps/admin/next-env.d.ts
vendored
Normal 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.
|
||||
8
apps/admin/next.config.js
Normal file
8
apps/admin/next.config.js
Normal 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
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
46
apps/admin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/admin/postcss.config.js
Normal file
6
apps/admin/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
11
apps/admin/src/app/analytics/page.tsx
Normal file
11
apps/admin/src/app/analytics/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
apps/admin/src/app/api/auth/login/route.ts
Normal file
47
apps/admin/src/app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
73
apps/admin/src/app/api/auth/register/route.ts
Normal file
73
apps/admin/src/app/api/auth/register/route.ts
Normal 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 })
|
||||
}
|
||||
28
apps/admin/src/app/api/users/route.ts
Normal file
28
apps/admin/src/app/api/users/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
3
apps/admin/src/app/globals.css
Normal file
3
apps/admin/src/app/globals.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
22
apps/admin/src/app/layout.tsx
Normal file
22
apps/admin/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
apps/admin/src/app/page.tsx
Normal file
64
apps/admin/src/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
apps/admin/src/app/users/page.tsx
Normal file
11
apps/admin/src/app/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
apps/admin/src/components/analytics/AnalyticsDashboard.tsx
Normal file
140
apps/admin/src/components/analytics/AnalyticsDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
99
apps/admin/src/components/charts/RevenueChart.tsx
Normal file
99
apps/admin/src/components/charts/RevenueChart.tsx
Normal 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} />
|
||||
}
|
||||
78
apps/admin/src/components/charts/UserGrowthChart.tsx
Normal file
78
apps/admin/src/components/charts/UserGrowthChart.tsx
Normal 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} />
|
||||
}
|
||||
23
apps/admin/src/components/ui/Button.tsx
Normal file
23
apps/admin/src/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
apps/admin/src/components/ui/Card.tsx
Normal file
30
apps/admin/src/components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
apps/admin/src/components/users/UserGrid.tsx
Normal file
167
apps/admin/src/components/users/UserGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
apps/admin/src/components/users/UserManagement.tsx
Normal file
180
apps/admin/src/components/users/UserManagement.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/admin/src/lib/database.ts
Normal file
23
apps/admin/src/lib/database.ts
Normal 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[] = []
|
||||
26
apps/admin/src/lib/utils.ts
Normal file
26
apps/admin/src/lib/utils.ts
Normal 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)
|
||||
}
|
||||
21
apps/admin/tailwind.config.js
Normal file
21
apps/admin/tailwind.config.js
Normal 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
51
apps/admin/tsconfig.json
Normal 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
16
apps/mobile/.eslintrc.js
Normal 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
44
apps/mobile/.gitignore
vendored
Normal 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
34
apps/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/mobile/assets/adaptive-icon.png
Normal file
1
apps/mobile/assets/adaptive-icon.png
Normal file
@ -0,0 +1 @@
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
1
apps/mobile/assets/favicon.png
Normal file
1
apps/mobile/assets/favicon.png
Normal file
@ -0,0 +1 @@
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
1
apps/mobile/assets/icon.png
Normal file
1
apps/mobile/assets/icon.png
Normal file
@ -0,0 +1 @@
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
1
apps/mobile/assets/splash.png
Normal file
1
apps/mobile/assets/splash.png
Normal file
@ -0,0 +1 @@
|
||||
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
|
||||
6
apps/mobile/babel.config.js
Normal file
6
apps/mobile/babel.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
}
|
||||
}
|
||||
11
apps/mobile/jest.config.js
Normal file
11
apps/mobile/jest.config.js
Normal 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
11
apps/mobile/jest.setup.js
Normal 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
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
49
apps/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
apps/mobile/src/app/(tabs)/_layout.tsx
Normal file
36
apps/mobile/src/app/(tabs)/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
apps/mobile/src/app/(tabs)/attendance.tsx
Normal file
28
apps/mobile/src/app/(tabs)/attendance.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
45
apps/mobile/src/app/(tabs)/index.tsx
Normal file
45
apps/mobile/src/app/(tabs)/index.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
116
apps/mobile/src/app/(tabs)/profile.tsx
Normal file
116
apps/mobile/src/app/(tabs)/profile.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
15
apps/mobile/src/app/_layout.tsx
Normal file
15
apps/mobile/src/app/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
apps/mobile/src/app/login.tsx
Normal file
139
apps/mobile/src/app/login.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
164
apps/mobile/src/app/register.tsx
Normal file
164
apps/mobile/src/app/register.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
12
apps/mobile/src/config/api.ts
Normal file
12
apps/mobile/src/config/api.ts
Normal 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',
|
||||
}
|
||||
76
apps/mobile/src/contexts/AuthContext.tsx
Normal file
76
apps/mobile/src/contexts/AuthContext.tsx
Normal 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
|
||||
}
|
||||
16
apps/mobile/src/hooks/useRequireAuth.ts
Normal file
16
apps/mobile/src/hooks/useRequireAuth.ts
Normal 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
38
apps/mobile/tsconfig.json
Normal 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
5
extras.md
Normal file
@ -0,0 +1,5 @@
|
||||
## extras
|
||||
|
||||
- clerk auth [and probably payment]
|
||||
- stripe
|
||||
- ag grid and charts
|
||||
2923
package-lock.json
generated
Normal file
2923
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
packages/database/drizzle.config.ts
Normal file
10
packages/database/drizzle.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
22
packages/database/package.json
Normal file
22
packages/database/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/database/src/index.ts
Normal file
8
packages/database/src/index.ts
Normal 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'
|
||||
72
packages/database/src/schema.ts
Normal file
72
packages/database/src/schema.ts
Normal 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
|
||||
19
packages/database/tsconfig.json
Normal file
19
packages/database/tsconfig.json
Normal 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"]
|
||||
}
|
||||
17
packages/shared/package.json
Normal file
17
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/shared/src/index.ts
Normal file
3
packages/shared/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './types'
|
||||
export * from './schemas'
|
||||
export * from './utils'
|
||||
46
packages/shared/src/schemas/index.ts
Normal file
46
packages/shared/src/schemas/index.ts
Normal 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(),
|
||||
})
|
||||
57
packages/shared/src/types/index.ts
Normal file
57
packages/shared/src/types/index.ts
Normal 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
|
||||
}
|
||||
26
packages/shared/src/utils/index.ts
Normal file
26
packages/shared/src/utils/index.ts
Normal 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)
|
||||
}
|
||||
19
packages/shared/tsconfig.json
Normal file
19
packages/shared/tsconfig.json
Normal 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
15
readme.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user