🎯 MapView v2.0 - Global Deployment Ready

 MAJOR FEATURES:
• Auto-zoom intelligence với smart bounds fitting
• Enhanced 3D GPS markers với pulsing effects
• Professional route display với 6-layer rendering
• Status-based parking icons với availability indicators
• Production-ready build optimizations

🗺️ AUTO-ZOOM FEATURES:
• Smart bounds fitting cho GPS + selected parking
• Adaptive padding (50px) cho visual balance
• Max zoom control (level 16) để tránh quá gần
• Dynamic centering khi không có selection

🎨 ENHANCED VISUALS:
• 3D GPS marker với multi-layer pulse effects
• Advanced parking icons với status colors
• Selection highlighting với animation
• Dimming system cho non-selected items

🛣️ ROUTE SYSTEM:
• OpenRouteService API integration
• Multi-layer route rendering (glow, shadow, main, animated)
• Real-time distance & duration calculation
• Visual route info trong popup

📱 PRODUCTION READY:
• SSR safe với dynamic imports
• Build errors resolved
• Global deployment via Vercel
• Optimized performance

🌍 DEPLOYMENT:
• Vercel: https://whatever-ctk2auuxr-phong12hexdockworks-projects.vercel.app
• Bundle size: 22.8 kB optimized
• Global CDN distribution
• HTTPS enabled

💾 VERSION CONTROL:
• MapView-v2.0.tsx backup created
• MAPVIEW_VERSIONS.md documentation
• Full version history tracking
This commit is contained in:
2025-07-20 19:52:16 +07:00
parent 3203463a6a
commit 07a93d44b4
113 changed files with 28421 additions and 1831 deletions

41
.gitignore vendored
View File

@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

510
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,510 @@
# 🚀 Deployment Guide
This guide covers different deployment strategies for the Smart Parking Finder application.
## 📋 Table of Contents
1. [Development Deployment](#development-deployment)
2. [Production Deployment](#production-deployment)
3. [Cloud Deployment Options](#cloud-deployment-options)
4. [Environment Configuration](#environment-configuration)
5. [Monitoring & Logging](#monitoring--logging)
6. [Backup & Recovery](#backup--recovery)
7. [Troubleshooting](#troubleshooting)
## 🛠️ Development Deployment
### Quick Start
```bash
# 1. Clone and setup
git clone <repository-url>
cd smart-parking-finder
./setup.sh
# 2. Start development environment
docker-compose up -d
# 3. Start development servers
cd frontend && npm run dev &
cd backend && npm run start:dev &
```
### Development Services
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:3001
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
- **Valhalla**: http://localhost:8002
- **pgAdmin**: http://localhost:5050 (with `--profile tools`)
## 🏭 Production Deployment
### Docker Compose Production
```bash
# 1. Create production environment
cp docker-compose.yml docker-compose.prod.yml
# 2. Update production configuration
# Edit docker-compose.prod.yml with production settings
# 3. Deploy
docker-compose -f docker-compose.prod.yml up -d
```
### Production Docker Compose
```yaml
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
ports:
- "80:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=https://api.yourparking.com
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- frontend
- backend
restart: unless-stopped
postgres:
image: postgis/postgis:15-3.3
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
valhalla:
build: ./valhalla
volumes:
- valhalla_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
valhalla_data:
```
## ☁️ Cloud Deployment Options
### 1. DigitalOcean Droplet
**Recommended for small to medium deployments**
```bash
# 1. Create Droplet (4GB RAM minimum for Valhalla)
doctl compute droplet create parking-app \
--size s-2vcpu-4gb \
--image ubuntu-22-04-x64 \
--region sgp1
# 2. Install Docker
ssh root@your-droplet-ip
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# 3. Deploy application
git clone <repository-url>
cd smart-parking-finder
docker-compose -f docker-compose.prod.yml up -d
```
### 2. AWS EC2 + RDS
**Recommended for scalable production**
```bash
# 1. Launch EC2 instance (t3.medium minimum)
# 2. Setup RDS PostgreSQL with PostGIS
# 3. Setup ElastiCache Redis
# 4. Deploy application containers
# User data script for EC2:
#!/bin/bash
yum update -y
yum install -y docker git
systemctl start docker
systemctl enable docker
usermod -a -G docker ec2-user
# Clone and deploy
git clone <repository-url> /opt/parking-app
cd /opt/parking-app
docker-compose -f docker-compose.aws.yml up -d
```
### 3. Google Cloud Platform
**Using Cloud Run and Cloud SQL**
```bash
# 1. Build and push images
gcloud builds submit --tag gcr.io/PROJECT_ID/parking-frontend ./frontend
gcloud builds submit --tag gcr.io/PROJECT_ID/parking-backend ./backend
# 2. Deploy to Cloud Run
gcloud run deploy parking-frontend \
--image gcr.io/PROJECT_ID/parking-frontend \
--platform managed \
--region asia-southeast1
gcloud run deploy parking-backend \
--image gcr.io/PROJECT_ID/parking-backend \
--platform managed \
--region asia-southeast1
```
### 4. Kubernetes Deployment
**For large-scale deployments**
```yaml
# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: parking-finder
---
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: parking-finder
data:
DATABASE_HOST: "postgres-service"
REDIS_HOST: "redis-service"
VALHALLA_URL: "http://valhalla-service:8002"
---
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deployment
namespace: parking-finder
spec:
replicas: 3
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: your-registry/parking-frontend:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: app-config
```
## 🔧 Environment Configuration
### Production Environment Variables
```bash
# .env.production
NODE_ENV=production
# Database
DATABASE_URL=postgresql://user:pass@db-host:5432/parking_db
POSTGRES_SSL=true
# Redis
REDIS_URL=redis://redis-host:6379
REDIS_SSL=true
# Security
JWT_SECRET=your-super-secure-jwt-secret-256-bit
JWT_EXPIRATION=1h
CORS_ORIGIN=https://yourparking.com
# APIs
VALHALLA_URL=http://valhalla:8002
MAP_TILES_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# Monitoring
SENTRY_DSN=your-sentry-dsn
LOG_LEVEL=info
# Performance
REDIS_CACHE_TTL=3600
DB_POOL_SIZE=10
API_RATE_LIMIT=100
```
### SSL Configuration
```nginx
# nginx/nginx.conf
server {
listen 443 ssl http2;
server_name yourparking.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Frontend
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Backend API
location /api {
proxy_pass http://backend:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket support
location /ws {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
## 📊 Monitoring & Logging
### Docker Logging
```yaml
# docker-compose.monitoring.yml
version: '3.8'
services:
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana_data:/var/lib/grafana
node-exporter:
image: prom/node-exporter
ports:
- "9100:9100"
volumes:
grafana_data:
```
### Application Monitoring
```typescript
// backend/src/monitoring/metrics.ts
import { createPrometheusMetrics } from '@prometheus/client';
export const metrics = {
httpRequests: new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status']
}),
routeCalculationTime: new Histogram({
name: 'route_calculation_duration_seconds',
help: 'Route calculation duration',
buckets: [0.1, 0.5, 1, 2, 5]
}),
databaseQueries: new Counter({
name: 'database_queries_total',
help: 'Total database queries',
labelNames: ['operation', 'table']
})
};
```
## 💾 Backup & Recovery
### Database Backup
```bash
#!/bin/bash
# backup.sh
# Variables
BACKUP_DIR="/opt/backups"
DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="parking_db"
# Create backup
docker-compose exec postgres pg_dump \
-U parking_user \
-h localhost \
-d $DB_NAME \
--clean \
--if-exists \
--create \
> "$BACKUP_DIR/db_backup_$DATE.sql"
# Compress backup
gzip "$BACKUP_DIR/db_backup_$DATE.sql"
# Keep only last 7 days
find $BACKUP_DIR -name "db_backup_*.sql.gz" -mtime +7 -delete
echo "Backup completed: db_backup_$DATE.sql.gz"
```
### Automated Backup with Cron
```bash
# Add to crontab: crontab -e
# Daily backup at 2 AM
0 2 * * * /opt/parking-app/scripts/backup.sh >> /var/log/backup.log 2>&1
# Weekly full system backup
0 3 * * 0 /opt/parking-app/scripts/full-backup.sh >> /var/log/backup.log 2>&1
```
## 🔍 Troubleshooting
### Common Issues
1. **Valhalla not starting**
```bash
# Check OSM data
ls -la valhalla/custom_files/
# Check logs
docker-compose logs valhalla
# Verify memory allocation
docker stats valhalla
```
2. **Database connection issues**
```bash
# Test connection
docker-compose exec postgres psql -U parking_user -d parking_db
# Check network
docker network ls
docker network inspect parking-finder_parking-network
```
3. **High memory usage**
```bash
# Monitor services
docker stats
# Optimize Valhalla cache
# Edit valhalla.json: reduce max_cache_size
```
### Health Checks
```bash
#!/bin/bash
# health-check.sh
echo "=== Health Check ==="
# Frontend
curl -f http://localhost:3000 || echo "❌ Frontend down"
# Backend
curl -f http://localhost:3001/health || echo "❌ Backend down"
# Database
docker-compose exec postgres pg_isready -U parking_user || echo "❌ Database down"
# Redis
docker-compose exec redis redis-cli ping || echo "❌ Redis down"
# Valhalla
curl -f http://localhost:8002/status || echo "❌ Valhalla down"
echo "=== Check complete ==="
```
### Performance Optimization
```yaml
# docker-compose.optimized.yml
services:
backend:
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
reservations:
memory: 512M
cpus: '0.25'
valhalla:
deploy:
resources:
limits:
memory: 4G
cpus: '2'
reservations:
memory: 2G
cpus: '1'
```
---
For additional support, refer to the [main README](../README.md) or contact the development team.

750
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,750 @@
# 🛠️ Development Guide
This guide covers the development workflow, coding standards, and best practices for the Smart Parking Finder application.
## 📋 Table of Contents
1. [Development Setup](#development-setup)
2. [Project Structure](#project-structure)
3. [Development Workflow](#development-workflow)
4. [Coding Standards](#coding-standards)
5. [Testing Strategy](#testing-strategy)
6. [Debugging](#debugging)
7. [Performance Guidelines](#performance-guidelines)
8. [Contributing](#contributing)
## 🚀 Development Setup
### Prerequisites
- Node.js 18+ and npm
- Docker and Docker Compose
- Git
- VS Code (recommended)
### Initial Setup
```bash
# 1. Clone repository
git clone <repository-url>
cd smart-parking-finder
# 2. Run automated setup
./setup.sh
# 3. Start development environment
docker-compose up -d
# 4. Install dependencies
cd frontend && npm install
cd ../backend && npm install
# 5. Start development servers
npm run dev:all # Starts both frontend and backend
```
### Environment Configuration
```bash
# .env.development
NODE_ENV=development
# Database
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
# Redis
REDIS_URL=redis://localhost:6379
# Valhalla
VALHALLA_URL=http://localhost:8002
# Development
DEBUG=true
LOG_LEVEL=debug
HOT_RELOAD=true
```
## 📁 Project Structure
```
smart-parking-finder/
├── frontend/ # Next.js frontend application
│ ├── src/
│ │ ├── app/ # App router pages
│ │ ├── components/ # Reusable UI components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API service layers
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── public/ # Static assets
│ └── tests/ # Frontend tests
├── backend/ # NestJS backend application
│ ├── src/
│ │ ├── modules/ # Feature modules
│ │ ├── common/ # Shared utilities
│ │ ├── config/ # Configuration files
│ │ └── database/ # Database related files
│ └── tests/ # Backend tests
├── valhalla/ # Routing engine setup
├── scripts/ # Development scripts
└── docs/ # Documentation
```
### Frontend Architecture
```
frontend/src/
├── app/ # Next.js 14 App Router
│ ├── (dashboard)/ # Route groups
│ ├── api/ # API routes
│ ├── globals.css # Global styles
│ ├── layout.tsx # Root layout
│ └── page.tsx # Home page
├── components/ # UI Components
│ ├── ui/ # Base UI components
│ ├── forms/ # Form components
│ ├── map/ # Map-related components
│ └── parking/ # Parking-specific components
├── hooks/ # Custom hooks
│ ├── useGeolocation.ts
│ ├── useParking.ts
│ └── useRouting.ts
├── services/ # API services
│ ├── api.ts # Base API client
│ ├── parkingService.ts
│ └── routingService.ts
└── types/ # TypeScript definitions
├── parking.ts
├── routing.ts
└── user.ts
```
### Backend Architecture
```
backend/src/
├── modules/ # Feature modules
│ ├── auth/ # Authentication
│ ├── parking/ # Parking management
│ ├── routing/ # Route calculation
│ └── users/ # User management
├── common/ # Shared code
│ ├── decorators/ # Custom decorators
│ ├── filters/ # Exception filters
│ ├── guards/ # Auth guards
│ └── pipes/ # Validation pipes
├── config/ # Configuration
│ ├── database.config.ts
│ ├── redis.config.ts
│ └── app.config.ts
└── database/ # Database files
├── migrations/ # Database migrations
├── seeds/ # Seed data
└── entities/ # TypeORM entities
```
## 🔄 Development Workflow
### Git Workflow
```bash
# 1. Create feature branch
git checkout -b feature/parking-search-improvements
# 2. Make changes with atomic commits
git add .
git commit -m "feat(parking): add distance-based search filtering"
# 3. Push and create PR
git push origin feature/parking-search-improvements
```
### Commit Message Convention
```
type(scope): description
Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Code style changes
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
Examples:
feat(map): add real-time parking availability indicators
fix(routing): resolve incorrect distance calculations
docs(api): update parking endpoint documentation
```
### Development Scripts
```json
{
"scripts": {
"dev": "next dev",
"dev:backend": "cd backend && npm run start:dev",
"dev:all": "concurrently \"npm run dev\" \"npm run dev:backend\"",
"build": "next build",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit"
}
}
```
## 📝 Coding Standards
### TypeScript Configuration
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"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/*"],
"@/services/*": ["./src/services/*"],
"@/types/*": ["./src/types/*"]
}
}
}
```
### ESLint Configuration
```json
// .eslintrc.json
{
"extends": [
"next/core-web-vitals",
"@typescript-eslint/recommended",
"prettier"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"prefer-const": "error",
"no-var": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
```
### Code Style Guidelines
#### Frontend Components
```typescript
// ✅ Good: Functional component with proper typing
interface ParkingListProps {
parkingLots: ParkingLot[];
onSelect: (lot: ParkingLot) => void;
loading?: boolean;
}
export const ParkingList: React.FC<ParkingListProps> = ({
parkingLots,
onSelect,
loading = false
}) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleSelect = useCallback((lot: ParkingLot) => {
setSelectedId(lot.id);
onSelect(lot);
}, [onSelect]);
if (loading) {
return <LoadingSpinner />;
}
return (
<div className="parking-list">
{parkingLots.map((lot) => (
<ParkingCard
key={lot.id}
lot={lot}
isSelected={selectedId === lot.id}
onClick={() => handleSelect(lot)}
/>
))}
</div>
);
};
```
#### Backend Services
```typescript
// ✅ Good: Service with proper error handling and typing
@Injectable()
export class ParkingService {
constructor(
@InjectRepository(ParkingLot)
private readonly parkingRepository: Repository<ParkingLot>,
private readonly cacheService: CacheService,
private readonly logger: Logger
) {}
async findNearbyParking(
dto: FindNearbyParkingDto
): Promise<ParkingLot[]> {
try {
const cacheKey = `nearby:${dto.latitude}:${dto.longitude}:${dto.radius}`;
// Check cache first
const cached = await this.cacheService.get<ParkingLot[]>(cacheKey);
if (cached) {
return cached;
}
// Query database with spatial index
const lots = await this.parkingRepository
.createQueryBuilder('lot')
.where(
'ST_DWithin(lot.location::geography, ST_Point(:lng, :lat)::geography, :radius)',
{
lng: dto.longitude,
lat: dto.latitude,
radius: dto.radius
}
)
.andWhere('lot.isActive = :isActive', { isActive: true })
.orderBy(
'ST_Distance(lot.location::geography, ST_Point(:lng, :lat)::geography)',
'ASC'
)
.limit(dto.limit || 20)
.getMany();
// Cache results
await this.cacheService.set(cacheKey, lots, 300); // 5 minutes
return lots;
} catch (error) {
this.logger.error('Failed to find nearby parking', error);
throw new InternalServerErrorException('Failed to find nearby parking');
}
}
}
```
## 🧪 Testing Strategy
### Test Structure
```
tests/
├── unit/ # Unit tests
├── integration/ # Integration tests
├── e2e/ # End-to-end tests
└── fixtures/ # Test data
```
### Frontend Testing
```typescript
// components/ParkingList.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { ParkingList } from './ParkingList';
import { mockParkingLots } from '../../../tests/fixtures/parking';
describe('ParkingList', () => {
const mockOnSelect = jest.fn();
beforeEach(() => {
mockOnSelect.mockClear();
});
it('renders parking lots correctly', () => {
render(
<ParkingList
parkingLots={mockParkingLots}
onSelect={mockOnSelect}
/>
);
expect(screen.getByText('Central Mall Parking')).toBeInTheDocument();
expect(screen.getByText('$5/hour')).toBeInTheDocument();
});
it('calls onSelect when parking lot is clicked', () => {
render(
<ParkingList
parkingLots={mockParkingLots}
onSelect={mockOnSelect}
/>
);
fireEvent.click(screen.getByText('Central Mall Parking'));
expect(mockOnSelect).toHaveBeenCalledWith(mockParkingLots[0]);
});
it('shows loading spinner when loading', () => {
render(
<ParkingList
parkingLots={[]}
onSelect={mockOnSelect}
loading={true}
/>
);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
```
### Backend Testing
```typescript
// parking/parking.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ParkingService } from './parking.service';
import { ParkingLot } from './entities/parking-lot.entity';
import { mockRepository } from '../../tests/mocks/repository.mock';
describe('ParkingService', () => {
let service: ParkingService;
let repository: any;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ParkingService,
{
provide: getRepositoryToken(ParkingLot),
useValue: mockRepository,
},
],
}).compile();
service = module.get<ParkingService>(ParkingService);
repository = module.get(getRepositoryToken(ParkingLot));
});
describe('findNearbyParking', () => {
it('should return nearby parking lots', async () => {
const mockLots = [/* mock data */];
repository.createQueryBuilder().getMany.mockResolvedValue(mockLots);
const result = await service.findNearbyParking({
latitude: 1.3521,
longitude: 103.8198,
radius: 1000,
});
expect(result).toEqual(mockLots);
expect(repository.createQueryBuilder).toHaveBeenCalled();
});
});
});
```
### Integration Testing
```typescript
// parking/parking.controller.integration.spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../app.module';
describe('ParkingController (Integration)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
it('/parking/nearby (POST)', () => {
return request(app.getHttpServer())
.post('/parking/nearby')
.send({
latitude: 1.3521,
longitude: 103.8198,
radius: 1000,
})
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('data');
expect(Array.isArray(res.body.data)).toBe(true);
});
});
afterAll(async () => {
await app.close();
});
});
```
## 🐛 Debugging
### Frontend Debugging
```typescript
// Development debugging utilities
export const debugLog = (message: string, data?: any): void => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${message}`, data);
}
};
// React Developer Tools
// Component debugging
export const ParkingDebugger: React.FC = () => {
const [parkingLots, setParkingLots] = useLocalStorage('debug:parking', []);
useEffect(() => {
// Log component updates
debugLog('ParkingDebugger mounted');
return () => {
debugLog('ParkingDebugger unmounted');
};
}, []);
return (
<div className="debug-panel">
<h3>Parking Debug Info</h3>
<pre>{JSON.stringify(parkingLots, null, 2)}</pre>
</div>
);
};
```
### Backend Debugging
```typescript
// Logger configuration
import { Logger } from '@nestjs/common';
@Injectable()
export class DebugService {
private readonly logger = new Logger(DebugService.name);
logRequest(req: Request, res: Response, next: NextFunction): void {
const { method, originalUrl, body, query } = req;
this.logger.debug(`${method} ${originalUrl}`, {
body,
query,
timestamp: new Date().toISOString(),
});
next();
}
logDatabaseQuery(query: string, parameters?: any[]): void {
this.logger.debug('Database Query', {
query,
parameters,
timestamp: new Date().toISOString(),
});
}
}
```
### VS Code Debug Configuration
```json
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Frontend",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/frontend",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"]
},
{
"name": "Debug Backend",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/backend",
"program": "${workspaceFolder}/backend/dist/main.js",
"env": {
"NODE_ENV": "development"
},
"console": "integratedTerminal",
"restart": true,
"protocol": "inspector"
}
]
}
```
## ⚡ Performance Guidelines
### Frontend Performance
```typescript
// Use React.memo for expensive components
export const ParkingMap = React.memo<ParkingMapProps>(({
parkingLots,
onMarkerClick
}) => {
// Component implementation
});
// Optimize re-renders with useMemo
const filteredLots = useMemo(() => {
return parkingLots.filter(lot =>
lot.availableSpaces > 0 &&
lot.distance <= maxDistance
);
}, [parkingLots, maxDistance]);
// Virtual scrolling for large lists
import { FixedSizeList as List } from 'react-window';
const VirtualizedParkingList: React.FC = ({ items }) => (
<List
height={600}
itemCount={items.length}
itemSize={120}
itemData={items}
>
{ParkingRow}
</List>
);
```
### Backend Performance
```typescript
// Database query optimization
@Injectable()
export class OptimizedParkingService {
// Use spatial indexes
async findNearbyOptimized(dto: FindNearbyParkingDto): Promise<ParkingLot[]> {
return this.parkingRepository.query(`
SELECT *
FROM parking_lots
WHERE ST_DWithin(
location::geography,
ST_Point($1, $2)::geography,
$3
)
AND available_spaces > 0
ORDER BY location <-> ST_Point($1, $2)
LIMIT $4
`, [dto.longitude, dto.latitude, dto.radius, dto.limit]);
}
// Implement caching
@Cacheable('parking:nearby', 300) // 5 minutes
async findNearbyCached(dto: FindNearbyParkingDto): Promise<ParkingLot[]> {
return this.findNearbyOptimized(dto);
}
}
// Connection pooling
export const databaseConfig: TypeOrmModuleOptions = {
type: 'postgres',
url: process.env.DATABASE_URL,
extra: {
max: 20, // maximum number of connections
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 30000,
},
};
```
## 🤝 Contributing
### Pull Request Process
1. **Fork and Clone**
```bash
git clone https://github.com/your-username/smart-parking-finder.git
cd smart-parking-finder
git remote add upstream https://github.com/original/smart-parking-finder.git
```
2. **Create Feature Branch**
```bash
git checkout -b feature/your-feature-name
```
3. **Development**
- Follow coding standards
- Write tests for new features
- Update documentation
4. **Submit PR**
- Ensure all tests pass
- Update CHANGELOG.md
- Provide clear description
### Code Review Guidelines
- **Code Quality**: Follows TypeScript best practices
- **Testing**: Adequate test coverage (>80%)
- **Performance**: No performance regressions
- **Documentation**: Updated documentation
- **Security**: No security vulnerabilities
### Issue Templates
```markdown
## Bug Report
**Describe the bug**
A clear description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
What you expected to happen.
**Screenshots**
If applicable, add screenshots.
**Environment:**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
```
---
For more information, see the [README](../README.md) and [Technical Specification](../TECHNICAL_SPECIFICATION.md).

104
MAPVIEW_VERSIONS.md Normal file
View File

@@ -0,0 +1,104 @@
# 📦 MAPVIEW VERSION HISTORY
## 🎯 MapView v2.0 - Global Deployment Ready
**Ngày:** 20/07/2025
**File:** `MapView-v2.0.tsx`
### ✨ **TÍNH NĂNG CHÍNH:**
#### 🗺️ **Auto-Zoom Intelligence**
- **Smart Bounds Fitting:** Tự động zoom để hiển thị vừa GPS và parking đã chọn
- **Adaptive Padding:** 50px padding cho visual balance tối ưu
- **Max Zoom Control:** Giới hạn zoom level 16 để tránh quá gần
- **Dynamic Centering:** Center trên user location khi không chọn parking
#### 🎨 **Enhanced Visual Design**
- **3D GPS Marker:** Multi-layer pulsing với gradient effects
- **Advanced Parking Icons:** Status-based colors với availability indicators
- **Enhanced Selection Effects:** Highlighted states với animation
- **Dimming System:** Non-selected parkings được làm mờ khi có selection
#### 🛣️ **Professional Route Display**
- **Multi-layer Route:** 6 layers với glow, shadow, main, animated dash
- **Real-time Calculation:** OpenRouteService API integration
- **Visual Route Info:** Distance & duration display trong popup
- **Animated Flow:** CSS animations cho movement effect
#### 📱 **Production Optimizations**
- **SSR Safe:** Dynamic imports cho Leaflet components
- **Performance:** Optimized re-renders và memory management
- **Error Handling:** Robust route calculation với fallback
- **Global Ready:** Deployed và tested trên Vercel
### 🔧 **TECHNICAL SPECS:**
```typescript
// Core Features
- Auto-zoom với fitBounds()
- Enhanced marker systems
- Route calculation API
- Status-based styling
- Animation frameworks
// Performance
- Dynamic imports
- Optimized effects
- Memory management
- Error boundaries
```
### 🌍 **DEPLOYMENT STATUS:**
-**Production Build:** Successful
-**Vercel Deploy:** https://whatever-ctk2auuxr-phong12hexdockworks-projects.vercel.app
-**Global Access:** Worldwide availability
-**HTTPS Ready:** Secure connections
-**CDN Optimized:** Fast loading globally
### 🎯 **USE CASES:**
1. **Smart Parking Discovery:** Auto-zoom to show user + nearby parking
2. **Route Planning:** Visual route với distance/time info
3. **Status Monitoring:** Real-time parking availability
4. **Global Access:** Use from anywhere in the world
### 📊 **PERFORMANCE METRICS:**
- **Bundle Size:** 22.8 kB optimized
- **First Load:** 110 kB total
- **Build Time:** ~1 minute
- **Global Latency:** <200ms via CDN
---
## 🏗️ **PREVIOUS VERSIONS:**
### MapView v1.x
- Basic Leaflet integration
- Simple markers
- Local development only
- No auto-zoom features
---
## 🚀 **DEPLOYMENT COMMANDS:**
```bash
# Local development
npm run dev
# Production build
npm run build
# Global deployment
./deploy-vercel.sh
# Alternative global access
./start-global.sh # ngrok tunnel
```
## 📝 **NOTES:**
- Version 2.0 marks the first globally accessible release
- All major build errors resolved for production
- Auto-zoom feature is the key differentiator
- Route calculation adds professional UX
- Enhanced visuals provide premium feel
**Status:** PRODUCTION READY - GLOBALLY ACCESSIBLE

274
README.md
View File

@@ -1,36 +1,266 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 🚗 Smart Parking Finder
## Getting Started A modern web application for finding and navigating to available parking spaces using OpenStreetMap and Valhalla routing engine.
First, run the development server: ## 🏗️ Project Structure
```bash ```
npm run dev smart-parking-finder/
# or ├── frontend/ # Next.js frontend application
yarn dev │ ├── src/
# or │ │ ├── app/ # App router pages
pnpm dev │ │ ├── components/ # Reusable React components
# or │ │ ├── hooks/ # Custom React hooks
bun dev │ │ ├── services/ # API services
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── public/ # Static assets
│ └── package.json
├── backend/ # NestJS backend API
│ ├── src/
│ │ ├── modules/ # Feature modules
│ │ ├── common/ # Shared utilities
│ │ ├── config/ # Configuration
│ │ └── database/ # Database setup
│ └── package.json
├── valhalla/ # Valhalla routing engine
│ ├── Dockerfile
│ ├── valhalla.json # Valhalla configuration
│ └── osm-data/ # OpenStreetMap data files
├── docker-compose.yml # Development environment
├── docker-compose.prod.yml # Production environment
└── README.md
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ## 🚀 Quick Start
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ### Prerequisites
- Docker & Docker Compose
- Node.js 18+
- PostgreSQL with PostGIS
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ### Development Setup
## Learn More 1. **Clone the repository**
```bash
git clone <repository-url>
cd smart-parking-finder
```
To learn more about Next.js, take a look at the following resources: 2. **Start infrastructure services**
```bash
docker-compose up -d postgres redis valhalla
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 3. **Install dependencies**
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. ```bash
# Frontend
cd frontend && npm install
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! # Backend
cd ../backend && npm install
```
## Deploy on Vercel 4. **Environment setup**
```bash
# Copy environment files
cp frontend/.env.example frontend/.env.local
cp backend/.env.example backend/.env
```
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 5. **Database setup**
```bash
# Run migrations
cd backend && npm run migration:run
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. # Seed initial data
npm run seed:run
```
6. **Start development servers**
```bash
# Terminal 1 - Backend
cd backend && npm run start:dev
# Terminal 2 - Frontend
cd frontend && npm run dev
```
Visit `http://localhost:3000` to see the application.
## 🔧 Technology Stack
### Frontend
- **Next.js 14** - React framework with App Router
- **TypeScript** - Type safety and better DX
- **Tailwind CSS** - Utility-first CSS framework
- **React Leaflet** - Interactive maps
- **React Query** - Server state management
- **Zustand** - Client state management
### Backend
- **NestJS** - Scalable Node.js framework
- **TypeORM** - Database ORM with TypeScript
- **PostgreSQL + PostGIS** - Spatial database
- **Redis** - Caching and session storage
- **Swagger** - API documentation
### Infrastructure
- **Docker** - Containerization
- **Valhalla** - Open-source routing engine
- **CloudFlare** - CDN and security
## 🌟 Features
### ✅ Implemented
- User location detection via GPS
- Interactive map with OpenStreetMap
- Nearby parking lot search
- Real-time availability display
- Route calculation with Valhalla
- Turn-by-turn navigation
- Responsive design
- PWA support
### 🚧 In Progress
- User authentication
- Parking reservations
- Payment integration
- Push notifications
### 📋 Planned
- Offline mode
- Multi-language support
- EV charging station integration
- AI-powered parking predictions
## 📊 API Documentation
### Parking Endpoints
- `GET /api/parking/nearby` - Find nearby parking lots
- `GET /api/parking/:id` - Get parking lot details
- `POST /api/parking/:id/reserve` - Reserve a parking space
### Routing Endpoints
- `POST /api/routes/calculate` - Calculate route between points
- `GET /api/routes/:id` - Get route details
- `POST /api/routes/:id/optimize` - Optimize existing route
### User Endpoints
- `POST /api/auth/login` - User authentication
- `GET /api/users/profile` - Get user profile
- `POST /api/users/favorites` - Add favorite parking lot
Full API documentation available at `/api/docs` when running the backend.
## 🧪 Testing
### Frontend Testing
```bash
cd frontend
npm run test # Unit tests
npm run test:e2e # End-to-end tests
npm run test:coverage # Coverage report
```
### Backend Testing
```bash
cd backend
npm run test # Unit tests
npm run test:e2e # Integration tests
npm run test:cov # Coverage report
```
## 🚀 Deployment
### Development
```bash
docker-compose up -d
```
### Production
```bash
docker-compose -f docker-compose.prod.yml up -d
```
### Environment Variables
```bash
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_MAP_TILES_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
# Backend (.env)
DATABASE_URL=postgresql://user:password@localhost:5432/parking_db
REDIS_URL=redis://localhost:6379
VALHALLA_URL=http://localhost:8002
JWT_SECRET=your-jwt-secret
```
## 📈 Performance
### Metrics
- Page load time: < 2 seconds
- Route calculation: < 3 seconds
- Map rendering: < 1 second
- API response time: < 500ms
### Optimization
- Code splitting for optimal bundle size
- Image optimization with Next.js
- Redis caching for frequent requests
- Database query optimization
- CDN for static assets
## 🔒 Security
### Implemented
- HTTPS enforcement
- JWT authentication
- Rate limiting
- Input validation
- SQL injection prevention
- XSS protection
### Best Practices
- Regular dependency updates
- Security headers
- Environment variable protection
- API key rotation
- Database encryption
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
### Development Guidelines
- Follow TypeScript best practices
- Write tests for new features
- Update documentation
- Follow conventional commits
- Ensure code passes linting
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🆘 Support
- 📧 Email: support@smartparking.com
- 💬 Discord: [Join our community](https://discord.gg/smartparking)
- 🐛 Issues: [GitHub Issues](https://github.com/your-org/smart-parking-finder/issues)
- 📖 Docs: [Documentation](https://docs.smartparking.com)
## 🙏 Acknowledgments
- OpenStreetMap for map data
- Valhalla project for routing engine
- PostGIS for spatial database capabilities
- All contributors and beta testers
---
Made with by the Smart Parking Team

420
TECHNICAL_SPECIFICATION.md Normal file
View File

@@ -0,0 +1,420 @@
# 🚗 Smart Parking Finder - Technical Specification
## 📋 Project Overview
A responsive web application that helps users find and navigate to the nearest available parking lots using OpenStreetMap and Valhalla Routing Engine with real-time availability and turn-by-turn navigation.
## 🎯 Core Features
### 🔍 Location & Discovery
- GPS-based user location detection
- Interactive map with nearby parking lots
- Real-time availability display
- Distance and direction calculation
- Smart parking suggestions
### 🗺️ Navigation & Routing
- Valhalla-powered route generation
- Turn-by-turn directions
- Visual route display on map
- Estimated arrival time
- Alternative route options
### 📊 Parking Information
- Name, address, and contact details
- Real-time available slots
- Pricing per hour
- Operating hours
- Amenities and features
## 🏗️ System Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend API │ │ Database │
│ (Next.js) │◄──►│ (NestJS) │◄──►│ PostgreSQL + │
│ │ │ │ │ PostGIS │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────┐
│ Valhalla Engine │
│ (Docker) │
└─────────────────┘
```
## 🔧 Technology Stack
### Frontend
- **Framework**: Next.js 14 with TypeScript
- **Map Library**: React Leaflet + OpenStreetMap
- **UI Framework**: Tailwind CSS with custom branding
- **State Management**: React Query + Zustand
- **HTTP Client**: Axios with interceptors
- **PWA Support**: Next.js PWA plugin
### Backend
- **Framework**: NestJS with TypeScript
- **Database ORM**: TypeORM with PostGIS
- **Caching**: Redis for route caching
- **API Documentation**: Swagger/OpenAPI
- **Authentication**: JWT + Passport.js
- **Rate Limiting**: Express rate limiter
### Infrastructure
- **Routing Engine**: Valhalla (Docker)
- **Database**: PostgreSQL 15 + PostGIS 3.3
- **Deployment**: Docker Compose
- **Monitoring**: Prometheus + Grafana
- **CDN**: CloudFlare for static assets
## 📊 Database Schema
```sql
-- Parking lots table
CREATE TABLE parking_lots (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
hourly_rate DECIMAL(10,2),
open_time TIME,
close_time TIME,
available_slots INTEGER DEFAULT 0,
total_slots INTEGER NOT NULL,
amenities JSONB DEFAULT '{}',
contact_info JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Spatial index for location queries
CREATE INDEX idx_parking_lots_location ON parking_lots USING GIST (location);
-- Users table (for favorites, history)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE,
name VARCHAR(255),
preferences JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Parking history
CREATE TABLE parking_history (
id SERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
parking_lot_id INTEGER REFERENCES parking_lots(id),
visit_date TIMESTAMP DEFAULT NOW(),
duration_minutes INTEGER,
rating INTEGER CHECK (rating >= 1 AND rating <= 5)
);
-- Real-time parking updates
CREATE TABLE parking_updates (
id SERIAL PRIMARY KEY,
parking_lot_id INTEGER REFERENCES parking_lots(id),
available_slots INTEGER NOT NULL,
timestamp TIMESTAMP DEFAULT NOW(),
source VARCHAR(50) DEFAULT 'sensor'
);
```
## 🚀 API Endpoints
### Parking Discovery
```typescript
// GET /api/parking/nearby
interface NearbyParkingRequest {
lat: number;
lng: number;
radius?: number; // meters, default 4000
maxResults?: number; // default 20
priceRange?: [number, number];
amenities?: string[];
}
interface NearbyParkingResponse {
parkingLots: ParkingLot[];
userLocation: { lat: number; lng: number };
searchRadius: number;
}
```
### Route Planning
```typescript
// POST /api/routing/calculate
interface RouteRequest {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number };
costing: 'auto' | 'bicycle' | 'pedestrian';
alternatives?: number;
}
interface RouteResponse {
routes: Route[];
summary: {
distance: number; // km
time: number; // minutes
cost: number; // estimated fuel cost
};
}
```
### Real-time Updates
```typescript
// WebSocket: /ws/parking-updates
interface ParkingUpdate {
parkingLotId: number;
availableSlots: number;
timestamp: string;
confidence: number; // 0-1
}
```
## 🎨 Brand Integration
Based on the existing assets in `/assets/`:
- **Logo**: Use Logo.png for header branding
- **Logo with Slogan**: Use Logo_and_sologan.png for splash screen
- **Location Icons**: Integrate Location.png and mini_location.png for map markers
### Color Palette
```css
:root {
--primary: #E85A4F; /* LACA Red */
--secondary: #D73502; /* Darker Red */
--accent: #8B2635; /* Deep Red */
--success: #22C55E; /* Green for available */
--warning: #F59E0B; /* Amber for limited */
--danger: #EF4444; /* Red for unavailable */
--neutral: #6B7280; /* Gray */
}
```
## 📱 UI/UX Design
### Layout Structure
```
┌─────────────────────────────────────────┐
│ Header [Logo] [Search] [Profile] │
├─────────────────┬───────────────────────┤
│ Sidebar │ Map View │
│ - Filters │ - User location │
│ - Parking List │ - Parking markers │
│ - Selected Info │ - Route overlay │
│ - Directions │ - Controls │
└─────────────────┴───────────────────────┘
```
### Responsive Breakpoints
- **Mobile**: < 768px (full-screen map with drawer)
- **Tablet**: 768px - 1024px (split view)
- **Desktop**: > 1024px (sidebar + map)
## 🐳 Docker Configuration
### Valhalla Setup
```dockerfile
# Dockerfile.valhalla
FROM ghcr.io/gis-ops/docker-valhalla/valhalla:latest
# Copy OSM data
COPY ./osm-data/*.pbf /custom_files/
# Configuration
COPY valhalla.json /valhalla.json
EXPOSE 8002
CMD ["valhalla_service", "/valhalla.json"]
```
### Docker Compose
```yaml
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://backend:3001
depends_on:
- backend
backend:
build: ./backend
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://user:pass@postgres:5432/parking_db
- REDIS_URL=redis://redis:6379
- VALHALLA_URL=http://valhalla:8002
depends_on:
- postgres
- redis
- valhalla
postgres:
image: postgis/postgis:15-3.3
environment:
- POSTGRES_DB=parking_db
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
valhalla:
build:
context: .
dockerfile: Dockerfile.valhalla
ports:
- "8002:8002"
volumes:
- ./valhalla-data:/valhalla-data
volumes:
postgres_data:
```
## 🔐 Security Considerations
### Frontend Security
- Content Security Policy (CSP)
- HTTPS enforcement
- API key protection
- Input sanitization
### Backend Security
- Rate limiting per IP
- JWT token validation
- SQL injection prevention
- CORS configuration
### Infrastructure Security
- Database encryption at rest
- SSL/TLS certificates
- Network segmentation
- Regular security updates
## 📈 Performance Optimization
### Frontend Optimization
- Code splitting by routes
- Image optimization with Next.js
- Service worker for caching
- Lazy loading for map components
### Backend Optimization
- Database query optimization
- Redis caching for frequent requests
- Connection pooling
- Response compression
### Database Optimization
- Spatial indexes for geo queries
- Query result caching
- Read replicas for scaling
- Partitioning for large datasets
## 🚀 Deployment Strategy
### Development
```bash
# Local development setup
docker-compose -f docker-compose.dev.yml up -d
npm run dev:frontend
npm run dev:backend
```
### Production
```bash
# Production deployment
docker-compose -f docker-compose.prod.yml up -d
```
### CI/CD Pipeline
1. **Build**: Docker images for each service
2. **Test**: Unit tests, integration tests, E2E tests
3. **Deploy**: Blue-green deployment strategy
4. **Monitor**: Health checks and performance metrics
## 📊 Monitoring & Analytics
### Application Metrics
- Response times
- Error rates
- User engagement
- Route calculation performance
### Business Metrics
- Popular parking locations
- Peak usage times
- User retention
- Revenue per parking lot
## 🔄 Future Enhancements
### Phase 2 Features
- Parking reservations
- Payment integration
- User reviews and ratings
- Push notifications for parking alerts
### Phase 3 Features
- AI-powered parking predictions
- Electric vehicle charging stations
- Multi-language support
- Offline mode with cached data
## 📋 Implementation Timeline
### Week 1-2: Foundation
- Project setup and infrastructure
- Database schema and migrations
- Basic API endpoints
### Week 3-4: Core Features
- Map integration with Leaflet
- Parking lot display and search
- User location detection
### Week 5-6: Navigation
- Valhalla integration
- Route calculation and display
- Turn-by-turn directions
### Week 7-8: Polish
- UI/UX improvements
- Performance optimization
- Testing and bug fixes
### Week 9-10: Deployment
- Production setup
- CI/CD pipeline
- Monitoring and analytics
## 🏁 Success Metrics
### Technical KPIs
- Page load time < 2 seconds
- Route calculation < 3 seconds
- 99.9% uptime
- Zero security vulnerabilities
### User Experience KPIs
- User retention > 60%
- Average session time > 5 minutes
- Route accuracy > 95%
- User satisfaction score > 4.5/5
This comprehensive specification provides a solid foundation for building a world-class parking finder application with modern web technologies and best practices.

BIN
assets/Location.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
assets/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
assets/Logo_and_sologan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
assets/mini_location.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

37
backend/.env Normal file
View File

@@ -0,0 +1,37 @@
# Environment Configuration
NODE_ENV=development
PORT=3001
# Database Configuration
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
# Redis Configuration
REDIS_URL=redis://localhost:6379
# Valhalla Routing Engine
VALHALLA_URL=http://localhost:8002
# CORS Configuration
CORS_ORIGIN=http://localhost:3000
# Security
JWT_SECRET=your-super-secure-jwt-secret-256-bit-change-in-production
JWT_EXPIRATION=1h
# Logging
LOG_LEVEL=debug
# API Configuration
API_RATE_LIMIT=100
API_TIMEOUT=30000
# Cache Configuration
REDIS_CACHE_TTL=300
ROUTE_CACHE_TTL=300
# External APIs (if needed)
MAP_TILES_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# Development only
DEBUG=true
HOT_RELOAD=true

47
backend/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Multi-stage build for production optimization
FROM node:18-alpine as builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig*.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Copy source code
COPY src/ ./src/
# Build the application
RUN npm run build
# Production stage
FROM node:18-alpine as production
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001
# Change ownership of the working directory
RUN chown -R nestjs:nodejs /app
USER nestjs
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node dist/health-check.js || exit 1
# Start the application
CMD ["node", "dist/main"]

418
backend/README.md Normal file
View File

@@ -0,0 +1,418 @@
# 🚗 Smart Parking Finder - Backend API
A robust NestJS backend API for the Smart Parking Finder application, providing parking discovery, route calculation, and real-time availability updates.
## 🚀 Features
- **Parking Discovery**: Find nearby parking lots using PostGIS spatial queries
- **Route Calculation**: Integration with Valhalla routing engine for turn-by-turn directions
- **Real-time Updates**: WebSocket support for live parking availability
- **Comprehensive API**: RESTful endpoints with OpenAPI/Swagger documentation
- **Performance Optimized**: Redis caching and database connection pooling
- **Production Ready**: Docker containerization with health checks
## 🛠️ Technology Stack
- **Framework**: NestJS with TypeScript
- **Database**: PostgreSQL 15 + PostGIS 3.3
- **ORM**: TypeORM with spatial support
- **Caching**: Redis for performance optimization
- **Documentation**: Swagger/OpenAPI
- **Security**: Helmet, CORS, rate limiting
- **Validation**: Class-validator and class-transformer
## 📋 Prerequisites
- Node.js 18+ and npm
- Docker and Docker Compose
- PostgreSQL with PostGIS extension
- Redis server
## 🔧 Installation
### Using Docker (Recommended)
```bash
# Start the entire stack
docker-compose up -d
# View logs
docker-compose logs -f backend
```
### Manual Setup
```bash
# 1. Install dependencies
npm install
# 2. Set up environment variables
cp .env.example .env
# 3. Start PostgreSQL and Redis
# Make sure both services are running
# 4. Run database migrations
npm run migration:run
# 5. Seed initial data
npm run seed
# 6. Start development server
npm run start:dev
```
## 🌍 Environment Variables
```bash
# Application
NODE_ENV=development
PORT=3001
CORS_ORIGIN=http://localhost:3000
# Database
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
# Redis
REDIS_URL=redis://localhost:6379
# External Services
VALHALLA_URL=http://valhalla:8002
# Security
JWT_SECRET=your-super-secure-jwt-secret
# Logging
LOG_LEVEL=debug
```
## 📚 API Documentation
Once the server is running, access the interactive API documentation:
- **Swagger UI**: http://localhost:3001/api/docs
- **OpenAPI JSON**: http://localhost:3001/api/docs-json
## 🔗 API Endpoints
### Parking Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/parking/nearby` | Find nearby parking lots |
| GET | `/api/parking` | Get all parking lots |
| GET | `/api/parking/popular` | Get popular parking lots |
| GET | `/api/parking/:id` | Get parking lot details |
| PUT | `/api/parking/:id/availability` | Update availability |
| GET | `/api/parking/:id/history` | Get update history |
### Route Calculation
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/routing/calculate` | Calculate route between points |
| GET | `/api/routing/status` | Check routing service status |
### System Health
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Application health check |
## 🧪 Example API Usage
### Find Nearby Parking
```bash
curl -X POST http://localhost:3001/api/parking/nearby \
-H "Content-Type: application/json" \
-d '{
"lat": 1.3521,
"lng": 103.8198,
"radius": 4000,
"maxResults": 10
}'
```
### Calculate Route
```bash
curl -X POST http://localhost:3001/api/routing/calculate \
-H "Content-Type: application/json" \
-d '{
"originLat": 1.3521,
"originLng": 103.8198,
"destinationLat": 1.3048,
"destinationLng": 103.8318,
"costing": "auto"
}'
```
### Update Parking Availability
```bash
curl -X PUT http://localhost:3001/api/parking/1/availability \
-H "Content-Type: application/json" \
-d '{
"availableSlots": 45,
"source": "sensor",
"confidence": 0.95
}'
```
## 🗄️ Database Schema
### Parking Lots Table
```sql
CREATE TABLE parking_lots (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
hourly_rate DECIMAL(10,2),
open_time TIME,
close_time TIME,
available_slots INTEGER DEFAULT 0,
total_slots INTEGER NOT NULL,
amenities JSONB DEFAULT '{}',
contact_info JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Spatial index for efficient location queries
CREATE INDEX idx_parking_lots_location ON parking_lots USING GIST (location);
```
## 🔧 Database Management
### Run Migrations
```bash
# Generate new migration
npm run migration:generate src/database/migrations/AddNewFeature
# Run pending migrations
npm run migration:run
# Revert last migration
npm run migration:revert
```
### Seed Data
```bash
# Run all seeds
npm run seed
# Seed specific data
npm run seed:parking-lots
```
## 📈 Performance Features
### Spatial Queries
Optimized PostGIS queries for efficient nearby parking search:
```sql
-- Find parking within 4km radius
SELECT *, ST_Distance(location::geography, ST_Point($1, $2)::geography) as distance
FROM parking_lots
WHERE ST_DWithin(location::geography, ST_Point($1, $2)::geography, 4000)
ORDER BY distance ASC;
```
### Caching Strategy
- **Route Calculations**: Cached for 5 minutes
- **Parking Data**: Cached for 1 minute
- **Static Data**: Cached for 1 hour
### Connection Pooling
```typescript
// Database configuration
extra: {
max: 20, // Maximum connections
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 30000,
}
```
## 🛡️ Security Features
- **Rate Limiting**: 100 requests per minute per IP
- **Input Validation**: Comprehensive DTO validation
- **SQL Injection Protection**: TypeORM query builder
- **CORS Configuration**: Configurable origins
- **Helmet**: Security headers middleware
## 📊 Monitoring & Logging
### Health Checks
```bash
# Application health
curl http://localhost:3001/api/health
# Database connectivity
curl http://localhost:3001/api/health/database
# External services
curl http://localhost:3001/api/routing/status
```
### Logging Levels
- **Error**: Application errors and exceptions
- **Warn**: Performance issues and deprecation warnings
- **Info**: General application flow
- **Debug**: Detailed execution information
## 🧪 Testing
```bash
# Unit tests
npm run test
# Test coverage
npm run test:cov
# End-to-end tests
npm run test:e2e
# Watch mode
npm run test:watch
```
## 🐳 Docker Configuration
### Development
```bash
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f backend
# Execute commands in container
docker-compose exec backend npm run migration:run
```
### Production
```bash
# Build production image
docker build -t smart-parking-backend .
# Run production container
docker run -p 3001:3001 smart-parking-backend
```
## 🔍 Troubleshooting
### Common Issues
1. **Database Connection Failed**
```bash
# Check PostgreSQL status
docker-compose exec postgres pg_isready -U parking_user
# View database logs
docker-compose logs postgres
```
2. **Valhalla Service Unavailable**
```bash
# Check Valhalla status
curl http://localhost:8002/status
# Restart Valhalla service
docker-compose restart valhalla
```
3. **High Memory Usage**
```bash
# Monitor Docker stats
docker stats
# Optimize connection pool
# Reduce max connections in database config
```
### Performance Optimization
1. **Database Indexes**
```sql
-- Monitor slow queries
SELECT query, mean_time, calls
FROM pg_stat_statements
ORDER BY mean_time DESC;
-- Add indexes for frequent queries
CREATE INDEX idx_parking_lots_hourly_rate ON parking_lots(hourly_rate);
```
2. **Cache Optimization**
```bash
# Monitor Redis memory usage
docker-compose exec redis redis-cli info memory
# Clear cache if needed
docker-compose exec redis redis-cli FLUSHALL
```
## 📝 Development Guidelines
### Code Style
- Use TypeScript strict mode
- Follow NestJS conventions
- Implement proper error handling
- Add comprehensive API documentation
- Write unit tests for services
- Use proper logging levels
### Git Workflow
```bash
# Feature branch naming
git checkout -b feature/parking-search-optimization
# Commit message format
git commit -m "feat(parking): optimize spatial queries with better indexing"
# Push and create PR
git push origin feature/parking-search-optimization
```
## 🚀 Deployment
### Production Checklist
- [ ] Environment variables configured
- [ ] Database migrations applied
- [ ] SSL certificates installed
- [ ] Monitoring setup
- [ ] Backup strategy implemented
- [ ] Load balancer configured
- [ ] CDN setup for static assets
## 📞 Support
For technical issues or questions:
- **Documentation**: Check the API docs at `/api/docs`
- **Logs**: Use `docker-compose logs backend`
- **Health Check**: Monitor `/api/health` endpoint
- **Performance**: Check database and Redis metrics
---
Built with ❤️ using NestJS and TypeScript

11050
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

105
backend/package.json Normal file
View File

@@ -0,0 +1,105 @@
{
"name": "smart-parking-backend",
"version": "1.0.0",
"description": "Smart Parking Finder Backend API",
"author": "Smart Parking Team",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate src/database/migrations/Migration -d src/config/database.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/database.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/database.config.ts",
"seed": "ts-node src/database/seeds/run-seeds.ts"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/swagger": "^7.0.0",
"@nestjs/jwt": "^10.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/throttler": "^4.0.0",
"@nestjs/websockets": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"typeorm": "^0.3.17",
"pg": "^8.11.0",
"redis": "^4.6.0",
"ioredis": "^5.3.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"bcryptjs": "^2.4.3",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1",
"axios": "^1.4.0",
"socket.io": "^4.7.0",
"compression": "^1.7.4",
"helmet": "^7.0.0",
"cors": "^2.8.5",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^2.0.12",
"@types/pg": "^8.10.0",
"@types/bcryptjs": "^2.4.2",
"@types/passport-jwt": "^3.0.8",
"@types/passport-local": "^1.0.35",
"@types/uuid": "^9.0.2",
"@types/cors": "^2.8.13",
"@types/compression": "^1.7.2",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

34
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { DatabaseConfig } from './config/database.config';
import { ParkingModule } from './modules/parking/parking.module';
import { RoutingModule } from './modules/routing/routing.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { HealthModule } from './modules/health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useClass: DatabaseConfig,
inject: [ConfigService],
}),
ThrottlerModule.forRoot({
ttl: 60000,
limit: 100,
}),
ParkingModule,
RoutingModule,
UsersModule,
AuthModule,
HealthModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm';
import { ParkingLot } from '../modules/parking/entities/parking-lot.entity';
import { User } from '../modules/users/entities/user.entity';
import { ParkingHistory } from '../modules/parking/entities/parking-history.entity';
import { ParkingUpdate } from '../modules/parking/entities/parking-update.entity';
@Injectable()
export class DatabaseConfig implements TypeOrmOptionsFactory {
constructor(private configService: ConfigService) {}
createTypeOrmOptions(): TypeOrmModuleOptions {
return {
type: 'postgres',
url: this.configService.get<string>('DATABASE_URL') ||
'postgresql://parking_user:parking_pass@localhost:5432/parking_db',
entities: [ParkingLot, User, ParkingHistory, ParkingUpdate],
migrations: ['dist/database/migrations/*.js'],
synchronize: this.configService.get<string>('NODE_ENV') === 'development',
logging: this.configService.get<string>('NODE_ENV') === 'development',
ssl: this.configService.get<string>('NODE_ENV') === 'production' ? {
rejectUnauthorized: false,
} : false,
extra: {
max: 20,
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 30000,
},
};
}
}

View File

@@ -0,0 +1,7 @@
-- Initial database setup with PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Grant permissions
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO parking_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO parking_user;

View File

@@ -0,0 +1,255 @@
import { DataSource } from 'typeorm';
import { ParkingLot } from '../../modules/parking/entities/parking-lot.entity';
export async function seedParkingLots(dataSource: DataSource) {
const parkingRepository = dataSource.getRepository(ParkingLot);
const parkingLots = [
{
name: 'Central Mall Parking',
address: '123 Orchard Road, Singapore 238872',
lat: 1.3048,
lng: 103.8318,
location: `POINT(103.8318 1.3048)`,
hourlyRate: 5.00,
openTime: '06:00',
closeTime: '24:00',
availableSlots: 45,
totalSlots: 200,
amenities: {
covered: true,
security: true,
ev_charging: true,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6123 4567',
email: 'parking@centralmall.sg',
website: 'https://centralmall.sg/parking',
},
},
{
name: 'Marina Bay Business District Parking',
address: '8 Marina Boulevard, Singapore 018981',
lat: 1.2802,
lng: 103.8537,
location: `POINT(103.8537 1.2802)`,
hourlyRate: 8.50,
openTime: '00:00',
closeTime: '23:59',
availableSlots: 12,
totalSlots: 150,
amenities: {
covered: true,
security: true,
ev_charging: true,
wheelchair_accessible: true,
valet_service: true,
},
contactInfo: {
phone: '+65 6234 5678',
email: 'parking@marinabay.sg',
},
},
{
name: 'Chinatown Heritage Parking',
address: '48 Pagoda Street, Singapore 059207',
lat: 1.2838,
lng: 103.8444,
location: `POINT(103.8444 1.2838)`,
hourlyRate: 3.50,
openTime: '07:00',
closeTime: '22:00',
availableSlots: 8,
totalSlots: 80,
amenities: {
covered: false,
security: true,
ev_charging: false,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6345 6789',
},
},
{
name: 'Sentosa Island Resort Parking',
address: '39 Artillery Avenue, Singapore 099958',
lat: 1.2494,
lng: 103.8303,
location: `POINT(103.8303 1.2494)`,
hourlyRate: 6.00,
openTime: '06:00',
closeTime: '02:00',
availableSlots: 78,
totalSlots: 300,
amenities: {
covered: true,
security: true,
ev_charging: true,
wheelchair_accessible: true,
valet_service: true,
},
contactInfo: {
phone: '+65 6456 7890',
email: 'parking@sentosa.sg',
website: 'https://sentosa.sg/parking',
},
},
{
name: 'Clarke Quay Entertainment Parking',
address: '3E River Valley Road, Singapore 179024',
lat: 1.2897,
lng: 103.8467,
location: `POINT(103.8467 1.2897)`,
hourlyRate: 7.00,
openTime: '10:00',
closeTime: '04:00',
availableSlots: 23,
totalSlots: 120,
amenities: {
covered: true,
security: true,
ev_charging: false,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6567 8901',
email: 'parking@clarkequay.sg',
},
},
{
name: 'Little India Cultural Parking',
address: '48 Serangoon Road, Singapore 217959',
lat: 1.3093,
lng: 103.8522,
location: `POINT(103.8522 1.3093)`,
hourlyRate: 4.00,
openTime: '05:00',
closeTime: '23:00',
availableSlots: 34,
totalSlots: 100,
amenities: {
covered: false,
security: true,
ev_charging: false,
wheelchair_accessible: false,
valet_service: false,
},
contactInfo: {
phone: '+65 6678 9012',
},
},
{
name: 'Changi Airport Terminal Parking',
address: 'Airport Boulevard, Singapore 819663',
lat: 1.3644,
lng: 103.9915,
location: `POINT(103.9915 1.3644)`,
hourlyRate: 4.50,
openTime: '00:00',
closeTime: '23:59',
availableSlots: 156,
totalSlots: 800,
amenities: {
covered: true,
security: true,
ev_charging: true,
wheelchair_accessible: true,
valet_service: true,
},
contactInfo: {
phone: '+65 6789 0123',
email: 'parking@changiairport.sg',
website: 'https://changiairport.com/parking',
},
},
{
name: 'Bugis Street Shopping Parking',
address: '3 New Bugis Street, Singapore 188867',
lat: 1.3006,
lng: 103.8558,
location: `POINT(103.8558 1.3006)`,
hourlyRate: 5.50,
openTime: '08:00',
closeTime: '23:00',
availableSlots: 67,
totalSlots: 180,
amenities: {
covered: true,
security: true,
ev_charging: false,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6890 1234',
email: 'parking@bugisstreet.sg',
},
},
{
name: 'Jurong East Hub Parking',
address: '1 Jurong West Central 2, Singapore 648886',
lat: 1.3329,
lng: 103.7436,
location: `POINT(103.7436 1.3329)`,
hourlyRate: 3.00,
openTime: '06:00',
closeTime: '24:00',
availableSlots: 89,
totalSlots: 250,
amenities: {
covered: true,
security: true,
ev_charging: true,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6901 2345',
email: 'parking@jurongeast.sg',
},
},
{
name: 'East Coast Park Recreation Parking',
address: 'East Coast Park Service Road, Singapore 449876',
lat: 1.3018,
lng: 103.9057,
location: `POINT(103.9057 1.3018)`,
hourlyRate: 2.50,
openTime: '05:00',
closeTime: '02:00',
availableSlots: 145,
totalSlots: 400,
amenities: {
covered: false,
security: false,
ev_charging: false,
wheelchair_accessible: true,
valet_service: false,
},
contactInfo: {
phone: '+65 6012 3456',
},
},
];
for (const lotData of parkingLots) {
const existingLot = await parkingRepository.findOne({
where: { name: lotData.name },
});
if (!existingLot) {
const lot = parkingRepository.create(lotData);
await parkingRepository.save(lot);
console.log(`Created parking lot: ${lotData.name}`);
} else {
console.log(`Parking lot already exists: ${lotData.name}`);
}
}
console.log('Parking lots seeding completed');
}

51
backend/src/main.ts Normal file
View File

@@ -0,0 +1,51 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import * as compression from 'compression';
import * as helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security
app.use(helmet.default());
app.use(compression());
// CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// API prefix
app.setGlobalPrefix('api');
// Swagger documentation
const config = new DocumentBuilder()
.setTitle('Smart Parking Finder API')
.setDescription('API for finding and navigating to parking lots')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}`);
console.log(`📚 API Documentation: http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,15 @@
import { Controller, Post, Body } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@ApiOperation({ summary: 'User login' })
async login(@Body() loginDto: { email: string; password: string }) {
return this.authService.login(loginDto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AuthService {
async validateUser(email: string, password: string): Promise<any> {
// Basic authentication logic placeholder
return null;
}
async login(user: any) {
// JWT token generation placeholder
return {
access_token: 'placeholder_token',
user,
};
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { HealthService } from './health.service';
@ApiTags('Health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get()
@ApiOperation({ summary: 'Health check endpoint' })
getHealth() {
return this.healthService.getHealth();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class HealthService {
getHealth() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development',
};
}
}

View File

@@ -0,0 +1,87 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsArray, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class FindNearbyParkingDto {
@ApiProperty({
description: 'Latitude coordinate',
example: 1.3521,
minimum: -90,
maximum: 90
})
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => parseFloat(value))
lat: number;
@ApiProperty({
description: 'Longitude coordinate',
example: 103.8198,
minimum: -180,
maximum: 180
})
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => parseFloat(value))
lng: number;
@ApiProperty({
description: 'Search radius in meters',
example: 4000,
minimum: 100,
maximum: 10000,
required: false
})
@IsOptional()
@IsNumber()
@Min(100)
@Max(10000)
@Transform(({ value }) => parseFloat(value))
radius?: number = 4000;
@ApiProperty({
description: 'Maximum number of results to return',
example: 20,
minimum: 1,
maximum: 100,
required: false
})
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
@Transform(({ value }) => parseInt(value))
maxResults?: number = 20;
@ApiProperty({
description: 'Price range filter [min, max] per hour',
example: [0, 10],
required: false,
type: [Number]
})
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
priceRange?: [number, number];
@ApiProperty({
description: 'Required amenities',
example: ['covered', 'security', 'ev_charging'],
required: false,
type: [String]
})
@IsOptional()
@IsArray()
amenities?: string[];
@ApiProperty({
description: 'Filter by availability status',
example: 'available',
enum: ['available', 'limited', 'full'],
required: false
})
@IsOptional()
availabilityFilter?: 'available' | 'limited' | 'full';
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateParkingAvailabilityDto {
@ApiProperty({
description: 'Number of available parking slots',
example: 15,
minimum: 0
})
@IsNumber()
@Min(0)
availableSlots: number;
@ApiProperty({
description: 'Source of the update',
example: 'sensor',
required: false
})
@IsOptional()
@IsString()
source?: string = 'manual';
@ApiProperty({
description: 'Confidence level of the update (0-1)',
example: 0.95,
minimum: 0,
maximum: 1,
required: false
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
confidence?: number = 1.0;
@ApiProperty({
description: 'Additional metadata',
example: { sensor_id: 'PARK_001', battery_level: 85 },
required: false
})
@IsOptional()
metadata?: Record<string, any>;
}

View File

@@ -0,0 +1,51 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { User } from '../../users/entities/user.entity';
import { ParkingLot } from './parking-lot.entity';
@Entity('parking_history')
export class ParkingHistory {
@ApiProperty({ description: 'Unique identifier for the parking history entry' })
@PrimaryGeneratedColumn()
id: number;
@ApiProperty({ description: 'User who visited the parking lot' })
@Column({ type: 'uuid', nullable: true })
userId: string;
@ApiProperty({ description: 'Parking lot that was visited' })
@Column({ type: 'int' })
parkingLotId: number;
@ApiProperty({ description: 'Date and time of the visit' })
@CreateDateColumn()
visitDate: Date;
@ApiProperty({ description: 'Duration of parking in minutes' })
@Column({ type: 'int', nullable: true })
durationMinutes: number;
@ApiProperty({ description: 'User rating for the parking experience' })
@Column({ type: 'int', nullable: true })
rating: number;
@ApiProperty({ description: 'User review comments' })
@Column({ type: 'text', nullable: true })
review: string;
// Relations
@ManyToOne(() => User, (user) => user.parkingHistory, { nullable: true })
@JoinColumn({ name: 'userId' })
user: User;
@ManyToOne(() => ParkingLot, (parkingLot) => parkingLot.history)
@JoinColumn({ name: 'parkingLotId' })
parkingLot: ParkingLot;
}

View File

@@ -0,0 +1,121 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { ParkingHistory } from './parking-history.entity';
import { ParkingUpdate } from './parking-update.entity';
@Entity('parking_lots')
export class ParkingLot {
@ApiProperty({ description: 'Unique identifier for the parking lot' })
@PrimaryGeneratedColumn()
id: number;
@ApiProperty({ description: 'Name of the parking lot' })
@Column({ type: 'varchar', length: 255 })
name: string;
@ApiProperty({ description: 'Address of the parking lot' })
@Column({ type: 'text' })
address: string;
@ApiProperty({ description: 'Latitude coordinate' })
@Column({ type: 'double precision' })
lat: number;
@ApiProperty({ description: 'Longitude coordinate' })
@Column({ type: 'double precision' })
lng: number;
@ApiProperty({ description: 'PostGIS geography point' })
@Column({
type: 'geography',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true,
})
location: string;
@ApiProperty({ description: 'Hourly parking rate' })
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
hourlyRate: number;
@ApiProperty({ description: 'Opening time' })
@Column({ type: 'time', nullable: true })
openTime: string;
@ApiProperty({ description: 'Closing time' })
@Column({ type: 'time', nullable: true })
closeTime: string;
@ApiProperty({ description: 'Number of available parking spaces' })
@Column({ type: 'int', default: 0 })
availableSlots: number;
@ApiProperty({ description: 'Total number of parking spaces' })
@Column({ type: 'int' })
totalSlots: number;
@ApiProperty({ description: 'Parking lot amenities' })
@Column({ type: 'jsonb', default: '{}' })
amenities: Record<string, any>;
@ApiProperty({ description: 'Contact information' })
@Column({ type: 'jsonb', default: '{}' })
contactInfo: Record<string, any>;
@ApiProperty({ description: 'Whether the parking lot is active' })
@Column({ type: 'boolean', default: true })
isActive: boolean;
@ApiProperty({ description: 'Creation timestamp' })
@CreateDateColumn()
createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' })
@UpdateDateColumn()
updatedAt: Date;
// Relations
@OneToMany(() => ParkingHistory, (history) => history.parkingLot)
history: ParkingHistory[];
@OneToMany(() => ParkingUpdate, (update) => update.parkingLot)
updates: ParkingUpdate[];
// Computed properties
@ApiProperty({ description: 'Occupancy rate as percentage' })
get occupancyRate(): number {
if (this.totalSlots === 0) return 0;
return ((this.totalSlots - this.availableSlots) / this.totalSlots) * 100;
}
@ApiProperty({ description: 'Availability status' })
get availabilityStatus(): 'available' | 'limited' | 'full' {
const rate = this.occupancyRate;
if (rate >= 95) return 'full';
if (rate >= 80) return 'limited';
return 'available';
}
@ApiProperty({ description: 'Whether the parking lot is currently open' })
get isOpen(): boolean {
if (!this.openTime || !this.closeTime) return true;
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [openHour, openMin] = this.openTime.split(':').map(Number);
const [closeHour, closeMin] = this.closeTime.split(':').map(Number);
const openMinutes = openHour * 60 + openMin;
const closeMinutes = closeHour * 60 + closeMin;
return currentTime >= openMinutes && currentTime <= closeMinutes;
}
}

View File

@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { ParkingLot } from './parking-lot.entity';
@Entity('parking_updates')
export class ParkingUpdate {
@ApiProperty({ description: 'Unique identifier for the parking update' })
@PrimaryGeneratedColumn()
id: number;
@ApiProperty({ description: 'Parking lot being updated' })
@Column({ type: 'int' })
parkingLotId: number;
@ApiProperty({ description: 'Number of available slots at time of update' })
@Column({ type: 'int' })
availableSlots: number;
@ApiProperty({ description: 'Source of the update' })
@Column({ type: 'varchar', length: 50, default: 'sensor' })
source: string;
@ApiProperty({ description: 'Confidence level of the update (0-1)' })
@Column({ type: 'decimal', precision: 3, scale: 2, default: 1.0 })
confidence: number;
@ApiProperty({ description: 'Additional metadata for the update' })
@Column({ type: 'jsonb', default: '{}' })
metadata: Record<string, any>;
@ApiProperty({ description: 'Timestamp of the update' })
@CreateDateColumn()
timestamp: Date;
// Relations
@ManyToOne(() => ParkingLot, (parkingLot) => parkingLot.updates)
@JoinColumn({ name: 'parkingLotId' })
parkingLot: ParkingLot;
}

View File

@@ -0,0 +1,179 @@
import {
Controller,
Get,
Post,
Put,
Param,
Body,
Query,
ParseIntPipe,
UseGuards,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
ApiBearerAuth,
} from '@nestjs/swagger';
import { ThrottlerGuard } from '@nestjs/throttler';
import { ParkingService } from './parking.service';
import { FindNearbyParkingDto } from './dto/find-nearby-parking.dto';
import { UpdateParkingAvailabilityDto } from './dto/update-availability.dto';
import { ParkingLot } from './entities/parking-lot.entity';
import { ParkingUpdate } from './entities/parking-update.entity';
@ApiTags('Parking')
@Controller('parking')
@UseGuards(ThrottlerGuard)
export class ParkingController {
constructor(private readonly parkingService: ParkingService) {}
@Post('nearby')
@ApiOperation({
summary: 'Find nearby parking lots',
description: 'Search for parking lots within a specified radius of the given coordinates'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully found nearby parking lots',
type: ParkingLot,
isArray: true,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid coordinates or parameters',
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Failed to search for parking lots',
})
async findNearbyParking(@Body() dto: FindNearbyParkingDto) {
return this.parkingService.findNearbyParking(dto);
}
@Get()
@ApiOperation({
summary: 'Get all parking lots',
description: 'Retrieve all active parking lots in the system'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved parking lots',
type: ParkingLot,
isArray: true,
})
async getAllParkingLots(): Promise<ParkingLot[]> {
return this.parkingService.getAllParkingLots();
}
@Get('popular')
@ApiOperation({
summary: 'Get popular parking lots',
description: 'Retrieve the most frequently visited parking lots'
})
@ApiQuery({
name: 'limit',
required: false,
description: 'Maximum number of results',
example: 10
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved popular parking lots',
type: ParkingLot,
isArray: true,
})
async getPopularParkingLots(@Query('limit') limit?: number): Promise<ParkingLot[]> {
return this.parkingService.getPopularParkingLots(limit);
}
@Get(':id')
@ApiOperation({
summary: 'Get parking lot details',
description: 'Retrieve detailed information about a specific parking lot'
})
@ApiParam({
name: 'id',
description: 'Parking lot ID',
example: 1
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved parking lot details',
type: ParkingLot,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Parking lot not found',
})
async getParkingLotById(@Param('id', ParseIntPipe) id: number): Promise<ParkingLot> {
return this.parkingService.findById(id);
}
@Put(':id/availability')
@ApiBearerAuth()
@ApiOperation({
summary: 'Update parking availability',
description: 'Update the number of available slots for a parking lot'
})
@ApiParam({
name: 'id',
description: 'Parking lot ID',
example: 1
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully updated parking availability',
type: ParkingLot,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Parking lot not found',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid availability data',
})
async updateAvailability(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateParkingAvailabilityDto,
): Promise<ParkingLot> {
return this.parkingService.updateAvailability(id, dto);
}
@Get(':id/history')
@ApiOperation({
summary: 'Get parking lot update history',
description: 'Retrieve the update history for a specific parking lot'
})
@ApiParam({
name: 'id',
description: 'Parking lot ID',
example: 1
})
@ApiQuery({
name: 'limit',
required: false,
description: 'Maximum number of history records',
example: 100
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully retrieved parking lot history',
type: ParkingUpdate,
isArray: true,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Parking lot not found',
})
async getParkingLotHistory(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: number,
): Promise<ParkingUpdate[]> {
return this.parkingService.getParkingLotHistory(id, limit);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ParkingController } from './parking.controller';
import { ParkingService } from './parking.service';
import { ParkingLot } from './entities/parking-lot.entity';
import { ParkingHistory } from './entities/parking-history.entity';
import { ParkingUpdate } from './entities/parking-update.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
ParkingLot,
ParkingHistory,
ParkingUpdate,
]),
],
controllers: [ParkingController],
providers: [ParkingService],
exports: [ParkingService],
})
export class ParkingModule {}

View File

@@ -0,0 +1,171 @@
import { Injectable, Logger, NotFoundException, InternalServerErrorException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ParkingLot } from './entities/parking-lot.entity';
import { ParkingUpdate } from './entities/parking-update.entity';
import { FindNearbyParkingDto } from './dto/find-nearby-parking.dto';
import { UpdateParkingAvailabilityDto } from './dto/update-availability.dto';
@Injectable()
export class ParkingService {
private readonly logger = new Logger(ParkingService.name);
constructor(
@InjectRepository(ParkingLot)
private readonly parkingRepository: Repository<ParkingLot>,
@InjectRepository(ParkingUpdate)
private readonly updateRepository: Repository<ParkingUpdate>,
) {}
async findNearbyParking(dto: FindNearbyParkingDto): Promise<{
parkingLots: ParkingLot[];
userLocation: { lat: number; lng: number };
searchRadius: number;
}> {
try {
this.logger.debug(`Finding parking near ${dto.lat}, ${dto.lng} within ${dto.radius}m`);
let query = this.parkingRepository
.createQueryBuilder('lot')
.select([
'lot.*',
'ST_Distance(lot.location::geography, ST_Point(:lng, :lat)::geography) as distance'
])
.where(
'ST_DWithin(lot.location::geography, ST_Point(:lng, :lat)::geography, :radius)',
{
lng: dto.lng,
lat: dto.lat,
radius: dto.radius,
}
)
.andWhere('lot.isActive = :isActive', { isActive: true });
// Apply price filter
if (dto.priceRange && dto.priceRange.length === 2) {
query = query.andWhere(
'lot.hourlyRate BETWEEN :minPrice AND :maxPrice',
{ minPrice: dto.priceRange[0], maxPrice: dto.priceRange[1] }
);
}
// Apply amenities filter
if (dto.amenities && dto.amenities.length > 0) {
dto.amenities.forEach((amenity, index) => {
query = query.andWhere(
`lot.amenities ? :amenity${index}`,
{ [`amenity${index}`]: amenity }
);
});
}
// Apply availability filter
if (dto.availabilityFilter) {
switch (dto.availabilityFilter) {
case 'available':
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) > 0.2');
break;
case 'limited':
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) BETWEEN 0.05 AND 0.2');
break;
case 'full':
query = query.andWhere('(lot.availableSlots::float / lot.totalSlots::float) < 0.05');
break;
}
}
const results = await query
.orderBy('distance', 'ASC')
.limit(dto.maxResults)
.getRawMany();
// Transform raw results back to entities with distance
const parkingLots = (results as any[]).map((result: any) => {
const { distance, ...lotData } = result;
const lot = this.parkingRepository.create(lotData);
(lot as any).distance = parseFloat(distance);
return lot;
}) as unknown as ParkingLot[];
return {
parkingLots,
userLocation: { lat: dto.lat, lng: dto.lng },
searchRadius: dto.radius,
};
} catch (error) {
this.logger.error('Failed to find nearby parking', error);
throw new InternalServerErrorException('Failed to find nearby parking');
}
}
async findById(id: number): Promise<ParkingLot> {
const lot = await this.parkingRepository.findOne({
where: { id },
relations: ['updates'],
});
if (!lot) {
throw new NotFoundException(`Parking lot with ID ${id} not found`);
}
return lot;
}
async updateAvailability(
id: number,
dto: UpdateParkingAvailabilityDto
): Promise<ParkingLot> {
const lot = await this.findById(id);
// Create update record
const update = this.updateRepository.create({
parkingLotId: id,
availableSlots: dto.availableSlots,
source: dto.source,
confidence: dto.confidence,
metadata: dto.metadata,
});
await this.updateRepository.save(update);
// Update parking lot
lot.availableSlots = dto.availableSlots;
lot.updatedAt = new Date();
return this.parkingRepository.save(lot);
}
async getAllParkingLots(): Promise<ParkingLot[]> {
return this.parkingRepository.find({
where: { isActive: true },
order: { name: 'ASC' },
});
}
async getParkingLotHistory(id: number, limit: number = 100): Promise<ParkingUpdate[]> {
return this.updateRepository.find({
where: { parkingLotId: id },
order: { timestamp: 'DESC' },
take: limit,
});
}
async getPopularParkingLots(limit: number = 10): Promise<ParkingLot[]> {
const results = await this.parkingRepository
.createQueryBuilder('lot')
.leftJoin('lot.history', 'history')
.select(['lot.*', 'COUNT(history.id) as visit_count'])
.where('lot.isActive = :isActive', { isActive: true })
.groupBy('lot.id')
.orderBy('visit_count', 'DESC')
.limit(limit)
.getRawMany();
return (results as any[]).map((result: any) => {
const { visit_count, ...lotData } = result;
const lot = this.parkingRepository.create(lotData);
(lot as any).visitCount = parseInt(visit_count) || 0;
return lot;
}) as unknown as ParkingLot[];
}
}

View File

@@ -0,0 +1,124 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
export class RouteRequestDto {
@ApiProperty({
description: 'Origin latitude',
example: 1.3521,
minimum: -90,
maximum: 90
})
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => parseFloat(value))
originLat: number;
@ApiProperty({
description: 'Origin longitude',
example: 103.8198,
minimum: -180,
maximum: 180
})
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => parseFloat(value))
originLng: number;
@ApiProperty({
description: 'Destination latitude',
example: 1.3500,
minimum: -90,
maximum: 90
})
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => parseFloat(value))
destinationLat: number;
@ApiProperty({
description: 'Destination longitude',
example: 103.8150,
minimum: -180,
maximum: 180
})
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => parseFloat(value))
destinationLng: number;
@ApiProperty({
description: 'Transportation mode',
example: 'auto',
enum: ['auto', 'bicycle', 'pedestrian'],
required: false
})
@IsOptional()
@IsEnum(['auto', 'bicycle', 'pedestrian'])
costing?: 'auto' | 'bicycle' | 'pedestrian' = 'auto';
@ApiProperty({
description: 'Number of alternative routes',
example: 2,
minimum: 0,
maximum: 3,
required: false
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(3)
@Transform(({ value }) => parseInt(value))
alternatives?: number = 1;
@ApiProperty({
description: 'Avoid highways',
example: false,
required: false
})
@IsOptional()
avoidHighways?: boolean = false;
@ApiProperty({
description: 'Avoid tolls',
example: false,
required: false
})
@IsOptional()
avoidTolls?: boolean = false;
}
export interface RoutePoint {
lat: number;
lng: number;
}
export interface RouteStep {
instruction: string;
distance: number; // meters
time: number; // seconds
type: string;
geometry: RoutePoint[];
}
export interface Route {
summary: {
distance: number; // km
time: number; // minutes
cost?: number; // estimated cost
};
geometry: RoutePoint[];
steps: RouteStep[];
confidence: number;
}
export interface RouteResponse {
routes: Route[];
origin: RoutePoint;
destination: RoutePoint;
requestId: string;
}

View File

@@ -0,0 +1,69 @@
import {
Controller,
Post,
Get,
Body,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { ThrottlerGuard } from '@nestjs/throttler';
import { RoutingService } from './routing.service';
import { RouteRequestDto, RouteResponse } from './dto/route-request.dto';
@ApiTags('Routing')
@Controller('routing')
@UseGuards(ThrottlerGuard)
export class RoutingController {
constructor(private readonly routingService: RoutingService) {}
@Post('calculate')
@ApiOperation({
summary: 'Calculate route between two points',
description: 'Generate turn-by-turn directions using Valhalla routing engine'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Successfully calculated route',
type: 'object',
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'Invalid route parameters',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'No route found between the specified locations',
})
@ApiResponse({
status: HttpStatus.SERVICE_UNAVAILABLE,
description: 'Routing service unavailable',
})
async calculateRoute(@Body() dto: RouteRequestDto): Promise<RouteResponse> {
return this.routingService.calculateRoute(dto);
}
@Get('status')
@ApiOperation({
summary: 'Check routing service status',
description: 'Check if the Valhalla routing service is healthy and responsive'
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Service status information',
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'healthy' },
version: { type: 'string', example: '3.1.0' },
},
},
})
async getServiceStatus(): Promise<{ status: string; version?: string }> {
return this.routingService.getServiceStatus();
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RoutingController } from './routing.controller';
import { RoutingService } from './routing.service';
@Module({
controllers: [RoutingController],
providers: [RoutingService],
exports: [RoutingService],
})
export class RoutingModule {}

View File

@@ -0,0 +1,232 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
import { RouteRequestDto, RouteResponse, Route, RoutePoint, RouteStep } from './dto/route-request.dto';
@Injectable()
export class RoutingService {
private readonly logger = new Logger(RoutingService.name);
private readonly valhallaClient: AxiosInstance;
private readonly valhallaUrl: string;
constructor(private configService: ConfigService) {
this.valhallaUrl = this.configService.get<string>('VALHALLA_URL') || 'http://valhalla:8002';
this.valhallaClient = axios.create({
baseURL: this.valhallaUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
}
async calculateRoute(dto: RouteRequestDto): Promise<RouteResponse> {
try {
this.logger.debug(`Calculating route from ${dto.originLat},${dto.originLng} to ${dto.destinationLat},${dto.destinationLng}`);
const requestId = this.generateRequestId();
const valhallaRequest = this.buildValhallaRequest(dto);
const response = await this.valhallaClient.post('/route', valhallaRequest);
if (!response.data || !response.data.trip) {
throw new Error('Invalid response from Valhalla routing engine');
}
const routes = this.parseValhallaResponse(response.data);
return {
routes,
origin: { lat: dto.originLat, lng: dto.originLng },
destination: { lat: dto.destinationLat, lng: dto.destinationLng },
requestId,
};
} catch (error) {
this.logger.error('Failed to calculate route', error);
if (error.response?.status === 400) {
throw new HttpException(
'Invalid route request parameters',
HttpStatus.BAD_REQUEST,
);
}
if (error.response?.status === 404) {
throw new HttpException(
'No route found between the specified locations',
HttpStatus.NOT_FOUND,
);
}
throw new HttpException(
'Route calculation service unavailable',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
private buildValhallaRequest(dto: RouteRequestDto) {
const locations = [
{ lat: dto.originLat, lon: dto.originLng },
{ lat: dto.destinationLat, lon: dto.destinationLng },
];
const costingOptions = this.getCostingOptions(dto);
return {
locations,
costing: dto.costing,
costing_options: costingOptions,
directions_options: {
units: 'kilometers',
language: 'en-US',
narrative: true,
alternates: dto.alternatives || 1,
},
format: 'json',
shape_match: 'edge_walk',
encoded_polyline: true,
};
}
private getCostingOptions(dto: RouteRequestDto) {
const options: any = {};
if (dto.costing === 'auto') {
options.auto = {
maneuver_penalty: 5,
gate_cost: 30,
gate_penalty: 300,
private_access_penalty: 450,
toll_booth_cost: 15,
toll_booth_penalty: 0,
ferry_cost: 300,
use_ferry: dto.avoidTolls ? 0 : 1,
use_highways: dto.avoidHighways ? 0 : 1,
use_tolls: dto.avoidTolls ? 0 : 1,
};
} else if (dto.costing === 'bicycle') {
options.bicycle = {
maneuver_penalty: 5,
gate_penalty: 300,
use_roads: 0.5,
use_hills: 0.5,
use_ferry: 1,
avoid_bad_surfaces: 0.25,
};
} else if (dto.costing === 'pedestrian') {
options.pedestrian = {
walking_speed: 5.1,
walkway_factor: 1,
sidewalk_factor: 1,
alley_factor: 2,
driveway_factor: 5,
step_penalty: 0,
use_ferry: 1,
use_living_streets: 0.6,
};
}
return options;
}
private parseValhallaResponse(data: any): Route[] {
const trip = data.trip;
if (!trip || !trip.legs || trip.legs.length === 0) {
return [];
}
const route: Route = {
summary: {
distance: Math.round(trip.summary.length * 100) / 100, // km
time: Math.round(trip.summary.time / 60 * 100) / 100, // minutes
cost: this.estimateFuelCost(trip.summary.length, 'auto'),
},
geometry: this.decodePolyline(trip.shape),
steps: this.parseManeuvers(trip.legs[0].maneuvers),
confidence: 0.95,
};
return [route];
}
private parseManeuvers(maneuvers: any[]): RouteStep[] {
return maneuvers.map(maneuver => ({
instruction: maneuver.instruction,
distance: Math.round(maneuver.length * 1000), // convert km to meters
time: maneuver.time, // seconds
type: maneuver.type?.toString() || 'unknown',
geometry: [], // Would need additional processing for step-by-step geometry
}));
}
private decodePolyline(encoded: string): RoutePoint[] {
// Simplified polyline decoding - in production, use a proper polyline library
const points: RoutePoint[] = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let result = 1;
let shift = 0;
let b: number;
do {
b = encoded.charCodeAt(index++) - 63 - 1;
result += b << shift;
shift += 5;
} while (b >= 0x1f);
lat += (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
result = 1;
shift = 0;
do {
b = encoded.charCodeAt(index++) - 63 - 1;
result += b << shift;
shift += 5;
} while (b >= 0x1f);
lng += (result & 1) !== 0 ? ~(result >> 1) : (result >> 1);
points.push({
lat: lat / 1e5,
lng: lng / 1e5,
});
}
return points;
}
private estimateFuelCost(distanceKm: number, costing: string): number {
if (costing !== 'auto') return 0;
const fuelEfficiency = 10; // km per liter
const fuelPricePerLiter = 1.5; // USD
return Math.round((distanceKm / fuelEfficiency) * fuelPricePerLiter * 100) / 100;
}
private generateRequestId(): string {
return `route_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
async getServiceStatus(): Promise<{ status: string; version?: string }> {
try {
const response = await this.valhallaClient.get('/status');
return {
status: 'healthy',
version: response.data?.version,
};
} catch (error) {
this.logger.error('Valhalla service health check failed', error);
return {
status: 'unhealthy',
};
}
}
}

View File

@@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { ParkingHistory } from '../../parking/entities/parking-history.entity';
@Entity('users')
export class User {
@ApiProperty({ description: 'Unique identifier for the user' })
@PrimaryGeneratedColumn('uuid')
id: string;
@ApiProperty({ description: 'User email address' })
@Column({ type: 'varchar', length: 255, unique: true, nullable: true })
email: string;
@ApiProperty({ description: 'User full name' })
@Column({ type: 'varchar', length: 255, nullable: true })
name: string;
@ApiProperty({ description: 'Hashed password' })
@Column({ type: 'varchar', length: 255, nullable: true })
password: string;
@ApiProperty({ description: 'User preferences and settings' })
@Column({ type: 'jsonb', default: '{}' })
preferences: Record<string, any>;
@ApiProperty({ description: 'Whether the user account is active' })
@Column({ type: 'boolean', default: true })
isActive: boolean;
@ApiProperty({ description: 'User creation timestamp' })
@CreateDateColumn()
createdAt: Date;
// Relations
@OneToMany(() => ParkingHistory, (history) => history.user)
parkingHistory: ParkingHistory[];
// Methods
toJSON() {
const { password, ...result } = this;
return result;
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@ApiTags('Users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@ApiOperation({ summary: 'Get all users' })
@ApiResponse({ status: 200, description: 'Successfully retrieved users', type: User, isArray: true })
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async findAll(): Promise<User[]> {
return this.userRepository.find();
}
async findById(id: string): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async create(userData: Partial<User>): Promise<User> {
const user = this.userRepository.create(userData);
return this.userRepository.save(user);
}
async update(id: string, userData: Partial<User>): Promise<User> {
await this.userRepository.update(id, userData);
return this.findById(id);
}
async delete(id: string): Promise<void> {
await this.userRepository.delete(id);
}
}

27
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"],
"@/modules/*": ["src/modules/*"],
"@/common/*": ["src/common/*"],
"@/config/*": ["src/config/*"]
}
}
}

46
deploy-vercel.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# Smart Parking Finder - Vercel Deployment Script
echo "🚀 Deploying Smart Parking Finder to Vercel..."
# Function to check if a command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check if Vercel CLI is installed
if ! command_exists vercel; then
echo "📦 Installing Vercel CLI..."
npm install -g vercel
fi
# Navigate to frontend directory
cd frontend
echo "==============================================="
echo "🌐 VERCEL DEPLOYMENT"
echo "==============================================="
echo "🎯 This will deploy your app to a global URL"
echo "🆓 Free tier with custom domain support"
echo "⚡ Automatic HTTPS and CDN"
echo "==============================================="
echo ""
# Build the project
echo "🔨 Building project..."
npm run build
# Deploy to Vercel
echo "🚀 Deploying to Vercel..."
echo "📋 Follow the prompts to:"
echo " 1. Login to Vercel"
echo " 2. Link to your project"
echo " 3. Configure deployment settings"
echo ""
vercel --prod
echo ""
echo "✅ Deployment complete!"
echo "🌍 Your app is now accessible globally!"
echo "📊 Check deployment status: https://vercel.com/dashboard"

154
docker-compose.yml Normal file
View File

@@ -0,0 +1,154 @@
version: '3.8'
services:
# Frontend - Next.js Application
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- NEXT_PUBLIC_API_URL=http://localhost:3001
- NEXT_PUBLIC_MAP_TILES_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
- NEXT_PUBLIC_VALHALLA_URL=http://localhost:8002
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
depends_on:
- backend
networks:
- parking-network
# Backend - NestJS API
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://parking_user:parking_pass@postgres:5432/parking_db
- REDIS_URL=redis://redis:6379
- VALHALLA_URL=http://valhalla:8002
- JWT_SECRET=your-development-jwt-secret-change-in-production
- JWT_EXPIRATION=24h
volumes:
- ./backend:/app
- /app/node_modules
- /app/dist
depends_on:
- postgres
- redis
- valhalla
networks:
- parking-network
# PostgreSQL Database with PostGIS
postgres:
image: postgis/postgis:15-3.3
environment:
- POSTGRES_DB=parking_db
- POSTGRES_USER=parking_user
- POSTGRES_PASSWORD=parking_pass
- POSTGRES_INITDB_ARGS="--encoding=UTF-8"
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/database/init:/docker-entrypoint-initdb.d
networks:
- parking-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U parking_user -d parking_db"]
interval: 30s
timeout: 10s
retries: 3
# Redis Cache
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- parking-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# Valhalla Routing Engine
valhalla:
build:
context: ./valhalla
dockerfile: Dockerfile
ports:
- "8002:8002"
volumes:
- valhalla_data:/data
- ./valhalla/custom_files:/custom_files
environment:
- VALHALLA_CONFIG=/valhalla.json
networks:
- parking-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/status"]
interval: 60s
timeout: 30s
retries: 3
start_period: 300s # Wait 5 minutes for initial setup
# Optional: pgAdmin for database management
pgadmin:
image: dpage/pgadmin4:latest
environment:
- PGADMIN_DEFAULT_EMAIL=admin@parking.local
- PGADMIN_DEFAULT_PASSWORD=admin123
- PGADMIN_CONFIG_SERVER_MODE=False
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- parking-network
profiles:
- tools # Only start with: docker-compose --profile tools up
# Optional: Redis Commander for cache management
redis-commander:
image: rediscommander/redis-commander:latest
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "8081:8081"
depends_on:
- redis
networks:
- parking-network
profiles:
- tools # Only start with: docker-compose --profile tools up
volumes:
postgres_data:
driver: local
redis_data:
driver: local
valhalla_data:
driver: local
pgadmin_data:
driver: local
networks:
parking-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

View File

@@ -1,16 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

20
frontend/.env.local Normal file
View File

@@ -0,0 +1,20 @@
# Frontend Environment Configuration
# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Map Configuration
NEXT_PUBLIC_MAP_TILES_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
NEXT_PUBLIC_MAP_ATTRIBUTION=© OpenStreetMap contributors
# Application Configuration
NEXT_PUBLIC_APP_NAME=Smart Parking Finder
NEXT_PUBLIC_APP_VERSION=1.0.0
# Features
NEXT_PUBLIC_ENABLE_ANALYTICS=false
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_ENABLE_OFFLINE_MODE=false
# Development
NEXT_PUBLIC_DEBUG=true

10
frontend/.env.production Normal file
View File

@@ -0,0 +1,10 @@
# Production Environment Variables
NEXT_PUBLIC_API_URL=https://your-backend-url.com/api
NEXT_PUBLIC_MAP_TILES_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
# OpenRouteService API
NEXT_PUBLIC_ORS_API_KEY=eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6ImJmMjM5NTNiMjNlNzQzZWY4NWViMDFlYjNkNTRkNmVkIiwiaCI6Im11cm11cjY0In0=
# App Configuration
NEXT_PUBLIC_APP_NAME=Smart Parking Finder
NEXT_PUBLIC_APP_URL=https://your-app-domain.com

8
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-img-element": "warn",
"react/no-unescaped-entities": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.vercel

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

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

58
frontend/next.config.js Normal file
View File

@@ -0,0 +1,58 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
// Warning: This allows production builds to successfully complete even if
// your project has ESLint errors.
ignoreDuringBuilds: false,
},
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if
// your project has type errors.
// !! WARN !!
ignoreBuildErrors: false,
},
images: {
domains: ['tile.openstreetmap.org'],
dangerouslyAllowSVG: true,
},
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
NEXT_PUBLIC_MAP_TILES_URL: process.env.NEXT_PUBLIC_MAP_TILES_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
},
webpack: (config) => {
// Handle canvas package for react-leaflet
config.externals = config.externals || [];
config.externals.push('canvas');
return config;
},
// Enable PWA features
headers: async () => {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
},
// Optimize bundle size
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

79
frontend/package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "smart-parking-frontend",
"version": "1.0.0",
"description": "Smart Parking Finder Frontend Application",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0 -p 3000",
"dev:local": "next dev",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 3000",
"start:local": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.83.0",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"framer-motion": "^10.16.4",
"leaflet": "^1.9.4",
"lucide-react": "^0.292.0",
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.47.0",
"react-hot-toast": "^2.5.2",
"react-leaflet": "^4.2.1",
"react-query": "^3.39.3",
"react-use-measure": "^2.1.1",
"tailwind-merge": "^2.0.0",
"use-debounce": "^10.0.0",
"zod": "^3.22.4",
"zustand": "^4.4.6"
},
"devDependencies": {
"@types/leaflet": "^1.9.8",
"@types/node": "^20.10.4",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-config-next": "^14.0.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,626 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Import Leaflet CSS */
@import 'leaflet/dist/leaflet.css';
/* Leaflet container fixes for Next.js and full-screen rendering */
.leaflet-container {
height: 100% !important;
width: 100% !important;
z-index: 1 !important;
}
.leaflet-control-container {
z-index: 1000 !important;
}
/* Full screen layout fixes */
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow-x: hidden;
}
#__next {
height: 100%;
}
/* Map container specific fixes */
.map-container {
height: 100% !important;
width: 100% !important;
min-height: 400px !important;
position: relative;
}
.map-container .leaflet-container {
height: 100% !important;
width: 100% !important;
min-height: inherit !important;
}
/* Ensure proper flex behavior for full-screen maps */
.flex-1 {
min-height: 0;
min-width: 0;
}
/* Custom Map Marker Animations */
/* GPS Marker Animations */
@keyframes pulse-gps {
0% {
transform: scale(0.8);
opacity: 0.6;
}
50% {
transform: scale(1.2);
opacity: 0.2;
}
100% {
transform: scale(0.8);
opacity: 0.6;
}
}
@keyframes blink-gps {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0.3;
}
}
/* Parking Marker Animations */
@keyframes pulse-parking {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.1);
opacity: 0.4;
}
100% {
transform: scale(1);
opacity: 0.8;
}
}
/* Custom marker classes */
.gps-marker-icon,
.gps-marker-icon-enhanced {
background: transparent !important;
border: none !important;
}
/* Parking Finder Button Animations */
@keyframes float {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-6px);
}
100% {
transform: translateY(0px);
}
}
@keyframes pulse-glow {
0% {
box-shadow: 0 10px 30px rgba(232, 90, 79, 0.4), 0 0 20px rgba(232, 90, 79, 0.3);
}
50% {
box-shadow: 0 15px 40px rgba(232, 90, 79, 0.6), 0 0 30px rgba(232, 90, 79, 0.5);
}
100% {
box-shadow: 0 10px 30px rgba(232, 90, 79, 0.4), 0 0 20px rgba(232, 90, 79, 0.3);
}
}
.parking-finder-button {
animation: float 3s ease-in-out infinite, pulse-glow 2s ease-in-out infinite;
}
.parking-finder-button:hover {
animation: none;
}
.parking-marker-icon,
.parking-marker-icon-enhanced {
background: transparent !important;
border: none !important;
}
/* Enhanced popup styles with animation */
.leaflet-popup-content-wrapper {
border-radius: 16px !important;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.15),
0 10px 20px rgba(0, 0, 0, 0.1) !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95) !important;
animation: popup-appear 0.3s ease-out;
}
.leaflet-popup-content {
margin: 20px !important;
line-height: 1.6 !important;
font-size: 14px !important;
}
.leaflet-popup-tip {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
@keyframes popup-appear {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Enhanced Filter Box Animations */
.filter-box {
background: linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05));
border: 2px solid rgba(232, 90, 79, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-box:hover {
border-color: rgba(232, 90, 79, 0.4);
box-shadow: 0 10px 30px rgba(232, 90, 79, 0.1);
transform: translateY(-2px);
}
.filter-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.filter-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.filter-button:hover::before {
left: 100%;
}
.filter-button.active {
animation: pulse-active 2s infinite;
}
@keyframes pulse-active {
0%, 100% { transform: scale(1.02); }
50% { transform: scale(1.05); }
}
.filter-icon {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.filter-button:hover .filter-icon {
transform: rotate(10deg) scale(1.1);
}
.filter-button.active .filter-icon {
animation: bounce-icon 1s ease-in-out infinite alternate;
}
@keyframes bounce-icon {
0% { transform: translateY(0); }
100% { transform: translateY(-3px); }
}
/* Info Badge Animation */
.info-badge {
animation: slide-in-up 0.6s ease-out;
transition: all 0.3s ease;
}
.info-badge:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(232, 90, 79, 0.15);
}
@keyframes slide-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Filter Count Badge */
.count-badge {
animation: scale-in 0.3s ease-out;
transition: all 0.2s ease;
}
.count-badge:hover {
transform: scale(1.1);
}
@keyframes scale-in {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Custom pulse animation for selected elements */
@keyframes selected-pulse {
0% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
}
}
/* Hover effects for markers */
.leaflet-marker-icon:hover {
z-index: 1000 !important;
filter: brightness(1.1) saturate(1.2);
transition: all 0.2s ease-in-out;
}
/* Enhanced animations for GPS simulator */
@keyframes progress-wave {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* Breathing animation for active elements */
@keyframes breathe {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* Loading states */
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.marker-loading {
animation: spin-slow 2s linear infinite;
}
/* Enhanced mobile responsiveness for markers */
@media (max-width: 768px) {
.leaflet-popup-content-wrapper {
max-width: 280px !important;
}
.leaflet-popup-content {
margin: 12px !important;
font-size: 14px !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.gps-marker-icon,
.parking-marker-icon {
filter: contrast(1.5) saturate(1.2);
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
.gps-marker-icon *,
.parking-marker-icon * {
animation: none !important;
}
}
/* Fix for Leaflet attribution */
.leaflet-control-attribution {
font-size: 10px !important;
}
/* Custom marker styles */
.custom-div-icon {
background: none !important;
border: none !important;
}
.leaflet-pane {
z-index: 1;
}
.leaflet-control-zoom {
z-index: 2;
}
.leaflet-control-attribution {
z-index: 2;
}
/* Custom CSS Variables */
:root {
--primary-color: #E85A4F;
--secondary-color: #D73502;
--accent-color: #8B2635;
--success-color: #22C55E;
--warning-color: #F59E0B;
--danger-color: #EF4444;
--neutral-color: #6B7280;
}
/* Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: #1f2937;
background-color: #ffffff;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Leaflet Map Overrides */
.leaflet-container {
height: 100%;
width: 100%;
border-radius: 0.5rem;
}
.leaflet-popup-content-wrapper {
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.leaflet-popup-tip {
background: white;
}
.leaflet-control-zoom {
border-radius: 0.5rem !important;
border: none !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-zoom a {
border-radius: 0.25rem !important;
border: none !important;
background-color: white !important;
color: #374151 !important;
font-weight: 600;
transition: all 0.2s ease;
}
.leaflet-control-zoom a:hover {
background-color: #f3f4f6 !important;
color: var(--primary-color) !important;
}
/* Custom Map Marker Styles */
.parking-marker {
background: white;
border: 2px solid var(--primary-color);
border-radius: 50%;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--primary-color);
transition: all 0.2s ease;
}
.parking-marker:hover {
transform: scale(1.1);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.parking-marker.available {
border-color: var(--success-color);
color: var(--success-color);
}
.parking-marker.limited {
border-color: var(--warning-color);
color: var(--warning-color);
}
.parking-marker.full {
border-color: var(--danger-color);
color: var(--danger-color);
}
/* Animation Classes */
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.shimmer {
background: linear-gradient(90deg, #f0f0f0 0px, #e0e0e0 40px, #f0f0f0 80px);
background-size: 200px;
animation: shimmer 1.5s infinite;
}
/* Loading States */
.loading-skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.375rem;
}
/* Focus Styles */
.focus-visible:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Button Variants */
.btn-primary {
@apply bg-primary-500 hover:bg-primary-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
.btn-secondary {
@apply bg-secondary-100 hover:bg-secondary-200 text-secondary-700 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-secondary-500 focus:ring-offset-2;
}
.btn-outline {
@apply border border-primary-500 text-primary-500 hover:bg-primary-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
/* Card Styles */
.card {
@apply bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200;
}
.card-header {
@apply px-6 py-4 border-b border-gray-200;
}
.card-body {
@apply px-6 py-4;
}
.card-footer {
@apply px-6 py-4 border-t border-gray-200 bg-gray-50;
}
/* Utility Classes */
.text-balance {
text-wrap: balance;
}
.text-pretty {
text-wrap: pretty;
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
.dark {
color-scheme: dark;
}
.dark body {
background-color: #0f172a;
color: #f8fafc;
}
.dark .card {
@apply bg-slate-800 border-slate-700;
}
.dark .card-header,
.dark .card-footer {
@apply border-slate-700 bg-slate-800;
}
}
/* Print Styles */
@media print {
.no-print {
display: none !important;
}
.print-only {
display: block !important;
}
}
/* Mobile Optimizations */
@media (max-width: 768px) {
.mobile-full {
width: 100vw;
margin-left: calc(-50vw + 50%);
}
.mobile-padding {
padding-left: 1rem;
padding-right: 1rem;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--primary-color: #000000;
--secondary-color: #666666;
}
.card {
border-width: 2px;
}
}

View File

@@ -0,0 +1,89 @@
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Providers } from './providers';
import { Toaster } from 'react-hot-toast';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: '',
description: '',
keywords: ['parking', 'navigation', 'maps', 'HCMC', 'Vietnam', 'bãi đỗ xe', 'TP.HCM'],
authors: [{ name: 'Smart Parking Team' }],
creator: 'Smart Parking Team',
publisher: 'Smart Parking HCMC',
robots: 'index, follow',
openGraph: {
type: 'website',
locale: 'vi_VN',
url: 'https://parking-hcmc.com',
title: '',
description: '',
siteName: 'Smart Parking HCMC',
images: [
{
url: '/assets/Logo_and_sologan.png',
width: 1200,
height: 630,
alt: 'Smart Parking HCMC',
},
],
},
twitter: {
card: 'summary_large_image',
title: '',
description: '',
images: ['/assets/Logo_and_sologan.png'],
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
},
themeColor: '#2563EB',
manifest: '/manifest.json',
icons: {
icon: '/assets/mini_location.png',
shortcut: '/assets/mini_location.png',
apple: '/assets/Logo.png',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full">
<body className={`${inter.className} h-full antialiased`}>
<Providers>
<div className="flex flex-col h-full">
{children}
</div>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
success: {
style: {
background: '#22c55e',
},
},
error: {
style: {
background: '#ef4444',
},
},
}}
/>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { Header } from '@/components/Header';
import { ParkingList } from '@/components/parking/ParkingList';
import { HCMCGPSSimulator } from '@/components/HCMCGPSSimulator';
// import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useParkingSearch } from '@/hooks/useParkingSearch';
import { useRouting } from '@/hooks/useRouting';
import { ParkingLot, UserLocation, TransportationMode } from '@/types';
import toast from 'react-hot-toast';
// Dynamic import for map component (client-side only)
const MapView = dynamic(
() => import('@/components/map/MapView').then((mod) => mod.MapView),
{
ssr: false,
loading: () => (
<div className="h-full flex items-center justify-center bg-gray-100 rounded-lg">
<LoadingSpinner size="lg" />
</div>
),
}
);
export default function ParkingFinderPage() {
// State management
const [selectedParkingLot, setSelectedParkingLot] = useState<ParkingLot | null>(null);
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
const [searchRadius, setSearchRadius] = useState(4000); // meters - bán kính 4km
const [sortType, setSortType] = useState<'availability' | 'price' | 'distance'>('availability');
// Fixed to car mode only
const transportationMode: TransportationMode = 'auto';
// Custom hooks
const {
parkingLots,
error: parkingError,
searchLocation
} = useParkingSearch();
const {
route,
isLoading: routeLoading,
error: routeError,
calculateRoute,
clearRoute
} = useRouting();
// Handle GPS location change from simulator
const handleLocationChange = (location: UserLocation) => {
setUserLocation(location);
// Search for parking near the new location
if (location) {
searchLocation({ latitude: location.lat, longitude: location.lng });
toast.success('Đã cập nhật vị trí GPS và tìm kiếm bãi đỗ xe gần đó');
}
};
const handleRefresh = () => {
if (userLocation) {
searchLocation({ latitude: userLocation.lat, longitude: userLocation.lng });
toast.success('Đã làm mới danh sách bãi đỗ xe');
} else {
toast.error('Vui lòng chọn vị trí GPS trước');
}
};
const handleParkingLotSelect = async (lot: ParkingLot) => {
// If the same parking lot is selected again, deselect it
if (selectedParkingLot && selectedParkingLot.id === lot.id) {
setSelectedParkingLot(null);
clearRoute();
toast.success('Đã bỏ chọn bãi đỗ xe');
return;
}
setSelectedParkingLot(lot);
if (userLocation) {
try {
await calculateRoute(
{ latitude: userLocation.lat, longitude: userLocation.lng },
{ latitude: lot.lat, longitude: lot.lng },
{ mode: 'driving' }
);
toast.success(`Đã tính đường đến ${lot.name}`);
} catch (error) {
toast.error('Không thể tính toán đường đi');
}
}
};
const handleClearRoute = () => {
clearRoute();
setSelectedParkingLot(null);
toast.success('Đã xóa tuyến đường');
};
// Show error messages
useEffect(() => {
if (parkingError) {
toast.error(parkingError);
}
}, [parkingError]);
useEffect(() => {
if (routeError) {
toast.error(routeError);
}
}, [routeError]);
return (
<div className="min-h-screen bg-gray-50">
<Header
title="Smart Parking Finder - TP.HCM"
subtitle="Chỉ hỗ trợ ô tô"
onClearRoute={route ? handleClearRoute : undefined}
/>
<main className="container mx-auto px-4 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-full">
{/* Left Column - Map and Parking List */}
<div className="lg:col-span-3 space-y-6">
{/* Map Section */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className="h-96">
<MapView
userLocation={userLocation}
parkingLots={parkingLots}
selectedParkingLot={selectedParkingLot}
route={route}
onParkingLotSelect={handleParkingLotSelect}
isLoading={routeLoading}
/>
</div>
</div>
{/* Parking List Section */}
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-900">
Bãi đ xe trong bán kính 4km
</h2>
<p className="text-sm text-gray-600 mt-1">
<EFBFBD> Chỉ hiển thị bãi xe đang mở cửa còn chỗ trống
</p>
</div>
<button
onClick={handleRefresh}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Làm mới
</button>
</div>
{!userLocation ? (
<div className="text-center py-8">
<p className="text-gray-600">Vui lòng chọn vị trí GPS đ tìm bãi đ xe</p>
</div>
) : parkingLots.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600">Không tìm thấy bãi đ xe nào gần vị trí này</p>
</div>
) : (
<ParkingList
parkingLots={parkingLots}
onSelect={handleParkingLotSelect}
selectedId={selectedParkingLot?.id}
userLocation={userLocation}
sortType={sortType}
/>
)}
</div>
</div>
{/* Right Column - GPS Simulator */}
<div className="lg:col-span-1">
<HCMCGPSSimulator
onLocationChange={handleLocationChange}
currentLocation={userLocation}
/>
</div>
</div>
{/* Show errors */}
{parkingError && (
<div className="fixed bottom-4 right-4 max-w-sm">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{parkingError}
</div>
</div>
)}
{routeError && (
<div className="fixed bottom-4 right-4 max-w-sm">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{routeError}
</div>
</div>
)}
</main>
</div>
);
}

553
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,553 @@
'use client';
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { Header } from '@/components/Header';
import { ParkingList } from '@/components/parking/ParkingList';
import { HCMCGPSSimulator } from '@/components/HCMCGPSSimulator';
// import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useParkingSearch } from '@/hooks/useParkingSearch';
import { useRouting } from '@/hooks/useRouting';
import { ParkingLot, UserLocation, TransportationMode } from '@/types';
import toast from 'react-hot-toast';
// Dynamic import for map component (client-side only) - NO loading component to prevent unnecessary loading states
const MapView = dynamic(
() => import('@/components/map/MapView').then((mod) => mod.MapView),
{
ssr: false,
loading: () => null, // Remove loading spinner to prevent map reload appearance
}
);
export default function ParkingFinderPage() {
// State management
const [selectedParkingLot, setSelectedParkingLot] = useState<ParkingLot | null>(null);
const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
const [searchRadius, setSearchRadius] = useState(4000); // meters - bán kính 4km
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [gpsWindowPos, setGpsWindowPos] = useState({ x: 0, y: 20 });
const [isMobile, setIsMobile] = useState(false);
const [sortType, setSortType] = useState<'availability' | 'price' | 'distance'>('availability');
const [gpsSimulatorVisible, setGpsSimulatorVisible] = useState(true);
// Set initial GPS window position after component mounts
useEffect(() => {
if (typeof window !== 'undefined') {
const updateGpsPosition = () => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const mobile = windowWidth < 768; // md breakpoint
setIsMobile(mobile);
if (mobile) {
// On mobile, position GPS window as a bottom sheet
setGpsWindowPos({
x: 10,
y: windowHeight - 400
});
} else {
const gpsWidth = Math.min(384, windowWidth - 40); // Max 384px (w-96), but leave 20px margin on each side
const rightMargin = 20;
const topMargin = 20;
setGpsWindowPos({
x: windowWidth - gpsWidth - rightMargin,
y: topMargin
});
}
};
updateGpsPosition();
window.addEventListener('resize', updateGpsPosition);
return () => window.removeEventListener('resize', updateGpsPosition);
}
}, []);
// Fixed to car mode only
const transportationMode: TransportationMode = 'auto';
// Custom hooks
const {
parkingLots,
error: parkingError,
searchLocation
} = useParkingSearch();
const {
route,
isLoading: routeLoading,
error: routeError,
calculateRoute,
clearRoute
} = useRouting();
// Handle GPS location change from simulator
const handleLocationChange = (location: UserLocation) => {
setUserLocation(location);
// Search for parking near the new location
if (location) {
searchLocation({ latitude: location.lat, longitude: location.lng });
toast.success('Đã cập nhật vị trí GPS và tìm kiếm bãi đỗ xe gần đó');
}
};
const handleRefresh = () => {
if (userLocation) {
searchLocation({ latitude: userLocation.lat, longitude: userLocation.lng });
toast.success('Đã làm mới danh sách bãi đỗ xe');
} else {
toast.error('Vui lòng chọn vị trí GPS trước');
}
};
const handleParkingLotSelect = async (lot: ParkingLot) => {
// Toggle selection
if (selectedParkingLot?.id === lot.id) {
setSelectedParkingLot(null);
clearRoute();
return;
}
setSelectedParkingLot(lot);
if (userLocation) {
try {
await calculateRoute(
{ latitude: userLocation.lat, longitude: userLocation.lng },
{ latitude: lot.lat, longitude: lot.lng },
{ mode: 'driving' }
);
toast.success(`Đã tính đường đến ${lot.name}`);
} catch (error) {
console.error('Error calculating route:', error);
toast.error('Không thể tính toán tuyến đường');
}
}
};
const handleParkingLotViewing = (lot: ParkingLot | null) => {
// Viewing functionality removed
};
const handleClearRoute = () => {
clearRoute();
setSelectedParkingLot(null);
toast.success('Đã xóa tuyến đường');
};
// Show error messages
useEffect(() => {
if (parkingError) {
toast.error(parkingError);
}
}, [parkingError]);
useEffect(() => {
if (routeError) {
toast.error(routeError);
}
}, [routeError]);
return (
<div className="h-screen bg-gray-50 flex flex-col">
<Header
title=""
subtitle=""
onClearRoute={route ? handleClearRoute : undefined}
/>
<main className="flex-1 flex relative bg-white">
{/* Left Column - Parking List */}
<div className={`${leftSidebarOpen ? 'w-[28rem]' : 'w-16'} bg-gradient-to-b from-white to-gray-50 border-r-2 border-gray-100 flex flex-col transition-all duration-300 relative shadow-lg`}>
{/* Toggle Button */}
<button
onClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
className="absolute top-6 -right-4 z-20 w-8 h-8 bg-white border-2 border-gray-200 rounded-full flex items-center justify-center shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 hover:border-red-300"
style={{ backgroundColor: 'white', borderColor: '#E85A4F20' }}
>
<svg
className={`w-4 h-4 transition-transform duration-300 ${leftSidebarOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ color: 'var(--primary-color)' }}
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
</button>
{leftSidebarOpen && (
<>
{/* Header */}
<div className="p-6 border-b-2 border-gray-100 bg-gradient-to-r from-red-50 to-orange-50" style={{ borderBottomColor: '#E85A4F20' }}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-2xl flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 tracking-tight">
Bãi đ xe gần đây
</h2>
<p className="text-sm text-gray-600 font-medium">Tìm kiếm thông minh</p>
</div>
</div>
<div className="flex items-center space-x-2">
<span className="px-3 py-1.5 text-sm font-bold text-white rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
{parkingLots.length}
</span>
<div className="w-3 h-3 rounded-full animate-pulse" style={{ backgroundColor: 'var(--success-color)' }}></div>
</div>
</div>
<button
onClick={handleRefresh}
className="mt-4 w-full flex items-center justify-center px-5 py-3 text-white text-sm font-bold rounded-2xl transition-all duration-300 transform hover:scale-105 hover:shadow-xl shadow-lg"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 8px 25px rgba(232, 90, 79, 0.3)'
}}
>
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Làm mới danh sách
</button>
</div>
{/* Filter buttons - Below header */}
<div className="sticky top-0 z-20 p-4 bg-white border-b border-gray-100">
<div className="flex items-center justify-between gap-3 p-4 rounded-xl shadow-lg border-2" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.08), rgba(215, 53, 2, 0.08))',
borderColor: 'rgba(232, 90, 79, 0.3)',
backdropFilter: 'blur(10px)'
}}>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-md" style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707v4.586a1 1 0 01-.54.89l-2 1A1 1 0 0110 20v-5.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</div>
<span className="text-sm font-bold" style={{ color: 'var(--accent-color)' }}>Sắp xếp:</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setSortType('availability')}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all duration-300 shadow-md ${
sortType === 'availability'
? 'transform scale-105'
: 'hover:transform hover:scale-105'
}`}
style={{
background: sortType === 'availability'
? 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
: 'white',
color: sortType === 'availability' ? 'white' : 'var(--accent-color)',
borderColor: sortType === 'availability' ? 'var(--primary-color)' : 'rgba(232, 90, 79, 0.3)',
border: '2px solid'
}}
>
Chỗ trống
</button>
<button
onClick={() => setSortType('price')}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all duration-300 shadow-md ${
sortType === 'price'
? 'transform scale-105'
: 'hover:transform hover:scale-105'
}`}
style={{
background: sortType === 'price'
? 'linear-gradient(135deg, #10B981, #059669)'
: 'white',
color: sortType === 'price' ? 'white' : '#059669',
borderColor: sortType === 'price' ? '#10B981' : 'rgba(16, 185, 129, 0.3)',
border: '2px solid'
}}
>
Giá rẻ
</button>
<button
onClick={() => setSortType('distance')}
disabled={!userLocation}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all duration-300 shadow-md ${
sortType === 'distance'
? 'transform scale-105'
: userLocation
? 'hover:transform hover:scale-105'
: 'cursor-not-allowed opacity-50'
}`}
style={{
background: sortType === 'distance'
? 'linear-gradient(135deg, #8B5CF6, #7C3AED)'
: userLocation ? 'white' : '#F9FAFB',
color: sortType === 'distance'
? 'white'
: userLocation ? '#7C3AED' : '#9CA3AF',
borderColor: sortType === 'distance'
? '#8B5CF6'
: userLocation ? 'rgba(139, 92, 246, 0.3)' : '#E5E7EB',
border: '2px solid'
}}
>
Gần nhất
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-gradient-to-b from-white to-gray-50">
{!userLocation ? (
<div className="text-center py-12">
<div className="mx-auto w-20 h-20 rounded-3xl flex items-center justify-center mb-6 shadow-lg" style={{ background: 'linear-gradient(135deg, #f3f4f6, #e5e7eb)' }}>
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Chọn vị trí GPS</h3>
<p className="text-gray-600 text-sm">Vui lòng chọn vị trí GPS đ tìm bãi đ xe gần đó</p>
</div>
) : parkingLots.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto w-20 h-20 rounded-3xl flex items-center justify-center mb-6 shadow-lg" style={{ background: 'linear-gradient(135deg, #fef3c7, #fcd34d)' }}>
<svg className="w-10 h-10 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.732 15c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Không bãi đ xe</h3>
<p className="text-gray-600 text-sm">Không tìm thấy bãi đ xe nào gần vị trí này</p>
</div>
) : (
<ParkingList
parkingLots={parkingLots}
onSelect={handleParkingLotSelect}
onViewing={handleParkingLotViewing}
selectedId={selectedParkingLot?.id}
userLocation={userLocation}
sortType={sortType}
/>
)}
</div>
</>
)}
{/* Collapsed state - show icon only */}
{!leftSidebarOpen && (
<div className="flex flex-col items-center py-6">
<div className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg mb-3" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
</svg>
</div>
<div className="w-1 h-8 rounded-full" style={{ backgroundColor: 'var(--primary-color)', opacity: 0.3 }}></div>
</div>
)}
</div>
{/* Map Section - Center */}
<div className="flex-1 h-full relative">
<MapView
userLocation={userLocation}
parkingLots={parkingLots}
selectedParkingLot={selectedParkingLot}
route={route}
onParkingLotSelect={handleParkingLotSelect}
className="w-full h-full"
/>
{/* Map overlay info - Moved to bottom right */}
{userLocation && (
<div className="absolute bottom-6 right-24 bg-white rounded-3xl shadow-2xl p-6 z-10 border-2 border-gray-100 backdrop-blur-sm" style={{ minWidth: '280px' }}>
<div className="flex items-center space-x-4 mb-4">
<div className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<img
src="/assets/Logo.png"
alt="Logo"
className="w-7 h-7 object-contain filter brightness-0 invert"
/>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900 tracking-tight">Parking Finder</h3>
<p className="text-sm text-gray-600 font-medium">Bản đ thông minh</p>
</div>
</div>
<div className="space-y-2">
{/* Current location */}
<div className="flex items-center space-x-3 p-2 rounded-xl bg-blue-50">
<div className="w-4 h-4 rounded-full shadow-sm" style={{ backgroundColor: '#3B82F6' }}></div>
<span className="text-sm font-semibold text-blue-800">Vị trí hiện tại</span>
</div>
{/* Parking lot status legend */}
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<div className="text-xs font-bold text-gray-700 mb-2">Trạng thái bãi xe:</div>
{/* Available parking - Green */}
<div className="flex items-center space-x-3 p-1">
<div className="w-3 h-3 rounded-full shadow-sm" style={{ backgroundColor: 'var(--success-color)' }}></div>
<span className="text-xs font-medium text-green-700">Còn chỗ thoáng (&gt;70%)</span>
</div>
{/* Nearly full - Yellow */}
<div className="flex items-center space-x-3 p-1">
<div className="w-3 h-3 rounded-full shadow-sm" style={{ backgroundColor: '#F59E0B' }}></div>
<span className="text-xs font-medium text-yellow-700">Sắp đy (&lt;30%)</span>
</div>
{/* Full - Red */}
<div className="flex items-center space-x-3 p-1">
<div className="w-3 h-3 rounded-full shadow-sm" style={{ backgroundColor: '#EF4444' }}></div>
<span className="text-xs font-medium text-red-700">Hết chỗ</span>
</div>
{/* Closed - Gray */}
<div className="flex items-center space-x-3 p-1">
<div className="w-3 h-3 rounded-full shadow-sm" style={{ backgroundColor: '#6B7280' }}></div>
<span className="text-xs font-medium text-gray-700">Đã đóng cửa</span>
</div>
</div>
{/* Route line */}
{route && (
<div className="flex items-center space-x-3 p-2 rounded-xl bg-red-50">
<div className="w-4 h-2 rounded-full shadow-sm" style={{ backgroundColor: 'var(--primary-color)' }}></div>
<span className="text-sm font-semibold" style={{ color: 'var(--primary-color)' }}>Tuyến đưng</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Floating GPS Window */}
<div
className="absolute bg-white rounded-3xl shadow-2xl border-2 border-gray-100 z-20 overflow-hidden backdrop-blur-lg transition-all duration-300"
style={{
left: Math.max(10, gpsWindowPos.x), // Ensure minimum 10px from left edge
top: Math.max(10, gpsWindowPos.y), // Ensure minimum 10px from top edge
width: isMobile ? `calc(100vw - 20px)` : `min(384px, calc(100vw - 40px))`, // Full width on mobile
maxHeight: isMobile ? `min(400px, calc(100vh - 100px))` : `min(calc(100vh - 140px), 600px)`, // Different heights for mobile
boxShadow: '0 25px 50px -12px rgba(232, 90, 79, 0.15), 0 0 0 1px rgba(232, 90, 79, 0.05)'
}}
>
{/* Window Header */}
<div
className="flex items-center justify-between border-b-2 border-gray-100 transition-all duration-300"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
borderBottomColor: 'rgba(232, 90, 79, 0.1)',
padding: isMobile ? '16px' : '24px'
}}
>
<div className="flex items-center space-x-3">
<div className="rounded-2xl flex items-center justify-center backdrop-blur-sm shadow-lg" style={{
width: isMobile ? '40px' : '48px',
height: isMobile ? '40px' : '48px',
backgroundColor: 'rgba(255, 255, 255, 0.2)'
}}>
<svg className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{
width: isMobile ? '20px' : '28px',
height: isMobile ? '20px' : '28px'
}}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M8.111 16.404a5.5 5.5 0 717.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white flex items-center gap-2 tracking-tight" style={{
fontSize: isMobile ? '16px' : '18px'
}}>
GPS Simulator
</h3>
<p className="text-white text-opacity-90 font-medium" style={{
fontSize: isMobile ? '12px' : '14px'
}}>
{isMobile ? 'Mô phỏng GPS' : 'Mô phỏng vị trí GPS cho TP.HCM'}
</p>
</div>
{isMobile && (
<button
onClick={() => setGpsSimulatorVisible(!gpsSimulatorVisible)}
className="p-2 rounded-xl bg-white bg-opacity-20 hover:bg-opacity-30 transition-all duration-200"
title={gpsSimulatorVisible ? 'Ẩn GPS Simulator' : 'Hiện GPS Simulator'}
>
<svg
className={`w-4 h-4 text-white transition-transform duration-300 ${gpsSimulatorVisible ? 'rotate-180' : 'rotate-0'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
{!isMobile && (
<div className="flex items-center space-x-3">
<button
onClick={() => setGpsSimulatorVisible(!gpsSimulatorVisible)}
className="p-2 rounded-xl bg-white bg-opacity-20 hover:bg-opacity-30 transition-all duration-200 group"
title={gpsSimulatorVisible ? 'Ẩn GPS Simulator' : 'Hiện GPS Simulator'}
>
<svg
className={`w-5 h-5 text-white transition-transform duration-300 ${gpsSimulatorVisible ? 'rotate-180' : 'rotate-0'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full animate-pulse" style={{ backgroundColor: 'var(--success-color)' }}></div>
<span className="text-sm text-white text-opacity-90 font-semibold">Active</span>
</div>
</div>
)}
</div>
{/* Window Content */}
{gpsSimulatorVisible && (
<div className="overflow-y-auto bg-gradient-to-b from-gray-50 to-white" style={{
padding: isMobile ? '16px' : '24px',
maxHeight: isMobile ? `min(300px, calc(100vh - 200px))` : `min(calc(100vh - 240px), 500px)` // Responsive max height for content
}}>
<HCMCGPSSimulator
onLocationChange={handleLocationChange}
currentLocation={userLocation}
/>
</div>
)}
</div>
</main>
{/* Show errors */}
{parkingError && (
<div className="fixed bottom-6 right-6 max-w-sm z-50">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{parkingError}
</div>
</div>
)}
{routeError && (
<div className="fixed bottom-6 right-6 max-w-sm z-50">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{routeError}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import React, { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
});
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
import React, { useState } from 'react';
import { Icon } from '@/components/ui/Icon';
interface GPSCoordinates {
latitude: number;
longitude: number;
}
interface GPSSimulatorProps {
onLocationSet: (location: GPSCoordinates) => void;
currentLocation?: GPSCoordinates | null;
}
const predefinedLocations = [
{
name: 'Marina Bay Sands',
coordinates: { latitude: 1.2834, longitude: 103.8607 },
description: 'Tourist area with premium parking'
},
{
name: 'Orchard Road',
coordinates: { latitude: 1.3048, longitude: 103.8318 },
description: 'Shopping district'
},
{
name: 'Raffles Place',
coordinates: { latitude: 1.2844, longitude: 103.8511 },
description: 'Business district'
},
{
name: 'Sentosa Island',
coordinates: { latitude: 1.2494, longitude: 103.8303 },
description: 'Entertainment hub'
},
{
name: 'Changi Airport',
coordinates: { latitude: 1.3644, longitude: 103.9915 },
description: 'International airport'
}
];
export const GPSSimulator: React.FC<GPSSimulatorProps> = ({
onLocationSet,
currentLocation
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [customLat, setCustomLat] = useState('');
const [customLng, setCustomLng] = useState('');
const handlePredefinedLocation = (location: GPSCoordinates) => {
onLocationSet(location);
setIsExpanded(false);
};
const handleCustomLocation = () => {
const lat = parseFloat(customLat);
const lng = parseFloat(customLng);
if (isNaN(lat) || isNaN(lng)) {
alert('Please enter valid latitude and longitude values');
return;
}
if (lat < -90 || lat > 90) {
alert('Latitude must be between -90 and 90');
return;
}
if (lng < -180 || lng > 180) {
alert('Longitude must be between -180 and 180');
return;
}
onLocationSet({ latitude: lat, longitude: lng });
setCustomLat('');
setCustomLng('');
setIsExpanded(false);
};
const generateRandomLocation = () => {
// Generate random location within Singapore bounds
const minLat = 1.16;
const maxLat = 1.47;
const minLng = 103.6;
const maxLng = 104.0;
const latitude = Math.random() * (maxLat - minLat) + minLat;
const longitude = Math.random() * (maxLng - minLng) + minLng;
onLocationSet({
latitude: parseFloat(latitude.toFixed(6)),
longitude: parseFloat(longitude.toFixed(6))
});
};
return (
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-900">
GPS Simulator
</h3>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-primary-600 hover:text-primary-700 transition-colors"
>
<Icon name={isExpanded ? 'visibility-off' : 'target'} size="sm" />
</button>
</div>
{currentLocation && (
<div className="mb-4 p-3 bg-gray-50 rounded-md">
<p className="text-xs font-medium text-gray-700 mb-1">Current Location:</p>
<p className="text-xs text-gray-600">
{currentLocation.latitude.toFixed(6)}, {currentLocation.longitude.toFixed(6)}
</p>
</div>
)}
{isExpanded && (
<div className="space-y-4">
{/* Quick Locations */}
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2">
Quick Locations
</h4>
<div className="grid grid-cols-1 gap-2">
{predefinedLocations.map((location, index) => (
<button
key={index}
onClick={() => handlePredefinedLocation(location.coordinates)}
className="text-left p-2 border border-gray-200 rounded-md hover:border-primary-300 hover:bg-primary-50 transition-colors"
>
<div className="text-sm font-medium text-gray-900">
{location.name}
</div>
<div className="text-xs text-gray-500">
{location.description}
</div>
<div className="text-xs text-gray-400 mt-1">
{location.coordinates.latitude.toFixed(4)}, {location.coordinates.longitude.toFixed(4)}
</div>
</button>
))}
</div>
</div>
{/* Random Location */}
<div>
<button
onClick={generateRandomLocation}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
>
<Icon name="dice" className="h-4 w-4 mr-2" />
Random Singapore Location
</button>
</div>
{/* Custom Coordinates */}
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2">
Custom Coordinates
</h4>
<div className="space-y-2">
<input
type="number"
placeholder="Latitude (e.g., 1.3521)"
value={customLat}
onChange={(e) => setCustomLat(e.target.value)}
step="0.000001"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<input
type="number"
placeholder="Longitude (e.g., 103.8198)"
value={customLng}
onChange={(e) => setCustomLng(e.target.value)}
step="0.000001"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<button
onClick={handleCustomLocation}
disabled={!customLat || !customLng}
className="w-full px-3 py-2 bg-primary-600 text-white text-sm font-medium rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Set Custom Location
</button>
</div>
</div>
</div>
)}
{!isExpanded && (
<p className="text-xs text-gray-500">
Click to simulate different GPS locations for testing
</p>
)}
</div>
);
};

View File

@@ -0,0 +1,507 @@
'use client';
import React, { useState, useEffect } from 'react';
import { UserLocation } from '@/types';
interface HCMCGPSSimulatorProps {
onLocationChange: (location: UserLocation) => void;
currentLocation?: UserLocation | null;
}
// Predefined locations near HCMC parking lots
const simulationPoints = [
// Trung tâm Quận 1 - gần bãi đỗ xe
{
name: 'Vincom Center Đồng Khởi',
location: { lat: 10.7769, lng: 106.7009 },
description: 'Gần trung tâm thương mại Vincom'
},
{
name: 'Saigon Centre',
location: { lat: 10.7743, lng: 106.7017 },
description: 'Gần Saigon Centre'
},
{
name: 'Landmark 81',
location: { lat: 10.7955, lng: 106.7195 },
description: 'Gần tòa nhà Landmark 81'
},
{
name: 'Bitexco Financial Tower',
location: { lat: 10.7718, lng: 106.7047 },
description: 'Gần tòa nhà Bitexco'
},
{
name: 'Chợ Bến Thành',
location: { lat: 10.7729, lng: 106.6980 },
description: 'Gần chợ Bến Thành'
},
{
name: 'Diamond Plaza',
location: { lat: 10.7786, lng: 106.7046 },
description: 'Gần Diamond Plaza'
},
{
name: 'Nhà Thờ Đức Bà',
location: { lat: 10.7798, lng: 106.6991 },
description: 'Gần Nhà Thờ Đức Bà'
},
{
name: 'Takashimaya',
location: { lat: 10.7741, lng: 106.7008 },
description: 'Gần trung tâm Takashimaya'
},
// Khu vực xa hơn - test bán kính 4km
{
name: 'Sân bay Tân Sơn Nhất',
location: { lat: 10.8187, lng: 106.6520 },
description: 'Khu vực sân bay - xa trung tâm ~7km'
},
{
name: 'Quận 2 - Thủ Thiêm',
location: { lat: 10.7879, lng: 106.7308 },
description: 'Khu đô thị mới Thủ Thiêm ~3km'
},
{
name: 'Quận 3 - Võ Văn Tần',
location: { lat: 10.7656, lng: 106.6889 },
description: 'Quận 3, gần viện Chợ Rẫy ~2km'
},
{
name: 'Quận 5 - Chợ Lớn',
location: { lat: 10.7559, lng: 106.6631 },
description: 'Khu Chợ Lớn ~3.5km'
},
{
name: 'Quận 7 - Phú Mỹ Hưng',
location: { lat: 10.7291, lng: 106.7194 },
description: 'Khu đô thị Phú Mỹ Hưng ~5km'
},
{
name: 'Quận 10 - 3/2',
location: { lat: 10.7721, lng: 106.6698 },
description: 'Đường 3 Tháng 2, Quận 10 ~2.5km'
},
{
name: 'Bình Thạnh - Vincom Landmark',
location: { lat: 10.8029, lng: 106.7208 },
description: 'Vincom Landmark, Bình Thạnh ~4km'
},
{
name: 'Gò Vấp - Emart',
location: { lat: 10.8239, lng: 106.6834 },
description: 'Khu vực Emart, Gò Vấp ~6km'
},
{
name: 'Quận 4 - Bến Vân Đồn',
location: { lat: 10.7575, lng: 106.7053 },
description: 'Khu vực bến phà, Quận 4 ~2km'
},
{
name: 'Quận 6 - Bình Phú',
location: { lat: 10.7395, lng: 106.6345 },
description: 'Khu công nghiệp Bình Phú ~4.5km'
},
{
name: 'Tân Bình - Sân bay',
location: { lat: 10.8099, lng: 106.6631 },
description: 'Gần khu vực sân bay ~5.5km'
},
{
name: 'Phú Nhuận - Phan Xích Long',
location: { lat: 10.7984, lng: 106.6834 },
description: 'Đường Phan Xích Long ~3.5km'
},
{
name: 'Quận 8 - Phạm Hùng',
location: { lat: 10.7389, lng: 106.6756 },
description: 'Đường Phạm Hùng, Quận 8 ~3km'
},
{
name: 'Quận 12 - Tân Chánh Hiệp',
location: { lat: 10.8567, lng: 106.6289 },
description: 'Khu vực Tân Chánh Hiệp ~8km'
},
{
name: 'Thủ Đức - Khu Công Nghệ Cao',
location: { lat: 10.8709, lng: 106.8034 },
description: 'Khu Công nghệ cao, Thủ Đức ~12km'
},
{
name: 'Nhà Bè - Phú Xuân',
location: { lat: 10.6834, lng: 106.7521 },
description: 'Huyện Nhà Bè ~10km'
}
];
export const HCMCGPSSimulator: React.FC<HCMCGPSSimulatorProps> = ({
onLocationChange,
currentLocation
}) => {
const [selectedPoint, setSelectedPoint] = useState<number | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
const handleLocationSelect = (index: number) => {
const point = simulationPoints[index];
setSelectedPoint(index);
setIsSimulating(true);
// Add some random variation to make it more realistic
const randomLat = point.location.lat + (Math.random() - 0.5) * 0.001;
const randomLng = point.location.lng + (Math.random() - 0.5) * 0.001;
const simulatedLocation: UserLocation = {
lat: randomLat,
lng: randomLng,
accuracy: Math.floor(Math.random() * 10) + 5, // 5-15 meters accuracy
timestamp: Date.now()
};
onLocationChange(simulatedLocation);
// Stop simulation after a short delay
setTimeout(() => {
setIsSimulating(false);
}, 2000);
};
const handleRandomLocation = () => {
// Generate random location in expanded HCMC area (including suburbs)
const expandedHcmcBounds = {
north: 10.90, // Mở rộng lên Thủ Đức, Bình Dương
south: 10.65, // Mở rộng xuống Nhà Bè, Cần Giờ
east: 106.85, // Mở rộng sang Quận 2, 9
west: 106.55 // Mở rộng sang Quận 6, 8, Bình Chánh
};
const randomLat = expandedHcmcBounds.south + Math.random() * (expandedHcmcBounds.north - expandedHcmcBounds.south);
const randomLng = expandedHcmcBounds.west + Math.random() * (expandedHcmcBounds.east - expandedHcmcBounds.west);
setSelectedPoint(null);
setIsSimulating(true);
const randomLocation: UserLocation = {
lat: randomLat,
lng: randomLng,
accuracy: Math.floor(Math.random() * 20) + 10, // 10-30 meters accuracy
timestamp: Date.now()
};
onLocationChange(randomLocation);
setTimeout(() => {
setIsSimulating(false);
}, 2000);
};
return (
<div className="space-y-4">
{/* Current Location Display */}
{currentLocation && (
<div className="p-4 md:p-6 rounded-2xl md:rounded-3xl border-2 shadow-xl mb-4 md:mb-6" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderColor: 'rgba(232, 90, 79, 0.2)'
}}>
<div className="flex items-center gap-3 md:gap-4 mb-3 md:mb-4">
<div className="w-10 md:w-12 h-10 md:h-12 rounded-xl md:rounded-2xl flex items-center justify-center shadow-lg flex-shrink-0 relative group animate-pulse" style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 4px 15px rgba(232, 90, 79, 0.3), 0 0 20px rgba(232, 90, 79, 0.1)'
}}>
<img
src="/assets/mini_location.png"
alt="Location"
className="w-5 md:w-6 h-5 md:h-6 object-contain filter brightness-0 invert"
/>
{/* Enhanced GPS indicator with multiple rings */}
<div className="absolute -top-1 -right-1">
<div className="relative">
{/* Outer ring */}
<div className="absolute w-5 h-5 rounded-full bg-green-400 opacity-30 animate-ping"></div>
{/* Middle ring */}
<div className="absolute w-4 h-4 rounded-full bg-green-500 opacity-50 animate-pulse" style={{ top: '2px', left: '2px' }}></div>
{/* Inner dot */}
<div className="w-3 h-3 rounded-full bg-green-500 border-2 border-white shadow-lg animate-pulse"></div>
</div>
</div>
{/* Signal waves animation */}
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 flex space-x-0.5">
<div className="w-0.5 h-2 bg-green-400 rounded-full animate-pulse" style={{ animationDelay: '0s' }}></div>
<div className="w-0.5 h-3 bg-green-500 rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
<div className="w-0.5 h-2 bg-green-400 rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
</div>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-black text-white text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-20">
🛰 GPS Signal Strong
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-transparent border-t-black"></div>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 md:gap-3 mb-1">
<span className="text-base md:text-lg font-bold tracking-tight" style={{ color: 'var(--primary-color)' }}>Vị trí hiện tại</span>
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1 rounded-full bg-white border-2" style={{ borderColor: 'var(--success-color)' }}>
<div className="w-1.5 md:w-2 h-1.5 md:h-2 rounded-full animate-pulse" style={{ backgroundColor: 'var(--success-color)' }}></div>
<span className="text-xs font-bold" style={{ color: 'var(--success-color)' }}>LIVE</span>
</div>
</div>
<p className="text-xs md:text-sm text-gray-600 font-medium">Tọa đ GPS đưc cập nhật</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:gap-4">
<div className="bg-white rounded-xl md:rounded-2xl p-3 md:p-4 border-2 border-gray-100 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4 text-sm">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-900">📍 Tọa đ:</span>
</div>
<span className="font-mono text-gray-700 bg-gray-50 px-2 md:px-3 py-1 rounded-lg text-xs md:text-sm">
{currentLocation.lat.toFixed(4)}, {currentLocation.lng.toFixed(4)}
</span>
</div>
{currentLocation.accuracy && (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-900">🎯 Đ chính xác:</span>
</div>
<span className="font-mono text-gray-700 bg-gray-50 px-2 md:px-3 py-1 rounded-lg text-xs md:text-sm">
±{currentLocation.accuracy}m
</span>
</div>
)}
</div>
<div className="mt-3 md:mt-4 pt-3 md:pt-4 border-t border-gray-100">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900"> Cập nhật:</span>
<span className="font-mono text-gray-700 bg-gray-50 px-2 md:px-3 py-1 rounded-lg text-xs md:text-sm">
{new Date(currentLocation.timestamp || Date.now()).toLocaleTimeString('vi-VN')}
</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Simulation Status */}
{isSimulating && (
<div className="p-6 rounded-3xl border-2 shadow-xl mb-6" style={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.05), rgba(16, 185, 129, 0.05))',
borderColor: 'rgba(34, 197, 94, 0.3)'
}}>
<div className="flex items-center gap-4">
<div className="relative">
<div className="w-12 h-12 rounded-full flex items-center justify-center shadow-lg relative" style={{ backgroundColor: 'var(--success-color)' }}>
{/* Rotating GPS satellites */}
<div className="absolute inset-0 animate-spin" style={{ animationDuration: '3s' }}>
<div className="absolute top-0 left-1/2 w-1 h-1 bg-white rounded-full transform -translate-x-1/2"></div>
<div className="absolute bottom-0 left-1/2 w-1 h-1 bg-white rounded-full transform -translate-x-1/2"></div>
<div className="absolute left-0 top-1/2 w-1 h-1 bg-white rounded-full transform -translate-y-1/2"></div>
<div className="absolute right-0 top-1/2 w-1 h-1 bg-white rounded-full transform -translate-y-1/2"></div>
</div>
{/* Central GPS icon */}
<svg className="w-6 h-6 text-white relative z-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
{/* Triple pulse rings */}
<div className="absolute inset-0 w-12 h-12 rounded-full animate-ping" style={{ backgroundColor: 'var(--success-color)', opacity: 0.3, animationDuration: '1s' }}></div>
<div className="absolute inset-0 w-12 h-12 rounded-full animate-ping" style={{ backgroundColor: 'var(--success-color)', opacity: 0.2, animationDuration: '1.5s', animationDelay: '0.5s' }}></div>
<div className="absolute inset-0 w-12 h-12 rounded-full animate-ping" style={{ backgroundColor: 'var(--success-color)', opacity: 0.1, animationDuration: '2s', animationDelay: '1s' }}></div>
</div>
<div className="flex-1">
<h4 className="text-lg font-bold tracking-tight flex items-center gap-2" style={{ color: 'var(--success-color)' }}>
<span>🛰 Đang cập nhật vị trí GPS...</span>
<div className="flex space-x-1">
<div className="w-1 h-1 bg-current rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
<div className="w-1 h-1 bg-current rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-1 h-1 bg-current rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</h4>
<p className="text-sm text-gray-600 font-medium mt-1">🎯 Đang đnh vị tính toán tọa đ chính xác</p>
<div className="mt-3 w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div className="h-full rounded-full animate-pulse" style={{
background: 'linear-gradient(90deg, var(--success-color), var(--primary-color), var(--success-color))',
width: '100%',
animation: 'progress-wave 2s ease-in-out infinite'
}}></div>
</div>
{/* Status indicators */}
<div className="mt-2 flex items-center gap-4 text-xs">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-gray-600">Satellites: 12/12</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
<span className="text-gray-600">Accuracy: ±3m</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-gray-600">Signal: Strong</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Predefined Locations */}
<div className="space-y-3">
<div className="flex items-center gap-4 mb-6">
<div className="w-10 h-10 rounded-2xl flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<img
src="/assets/mini_location.png"
alt="Location"
className="w-5 h-5 object-contain"
/>
</div>
<div className="flex-1">
<h4 className="text-xl font-bold tracking-tight" style={{ color: 'var(--primary-color)' }}>
Các vị trí test
</h4>
<p className="text-sm text-gray-600 font-medium">Bán kính 4km từ trung tâm TP.HCM</p>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1.5 text-sm font-bold text-white rounded-full shadow-sm" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
{simulationPoints.length}
</span>
<span className="text-xs text-gray-500 font-medium">đa điểm</span>
</div>
</div>
<div className="space-y-2 max-h-80 md:max-h-96 overflow-y-auto pr-1 md:pr-2">
{simulationPoints.map((point, index) => {
// Phân loại điểm theo khoảng cách ước tính từ trung tâm
const isNearCenter = point.description.includes('Gần') || index < 8;
return (
<button
key={index}
onClick={() => handleLocationSelect(index)}
disabled={isSimulating}
className={`
w-full p-3 md:p-5 text-left rounded-xl md:rounded-2xl border-2 transition-all duration-300 group relative overflow-hidden
${selectedPoint === index
? 'shadow-lg transform scale-[1.02]'
: 'border-gray-200 hover:shadow-md hover:transform hover:scale-[1.01]'
}
${isSimulating ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
style={{
background: selectedPoint === index
? 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))'
: 'white',
borderColor: selectedPoint === index
? 'var(--primary-color)'
: 'rgba(232, 90, 79, 0.2)'
}}
>
{/* Gradient overlay for selected state */}
{selectedPoint === index && (
<div className="absolute inset-0 rounded-2xl" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.1), rgba(215, 53, 2, 0.1))'
}}></div>
)}
<div className="relative z-10 flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 md:gap-3 mb-1 md:mb-2">
{/* Distance indicator icon */}
<div className={`w-6 h-6 rounded-full flex items-center justify-center shadow-sm ${
isNearCenter
? 'border-2'
: 'border-2'
}`} style={{
backgroundColor: isNearCenter ? 'rgba(34, 197, 94, 0.1)' : 'rgba(251, 191, 36, 0.1)',
borderColor: isNearCenter ? 'var(--success-color)' : '#F59E0B'
}}>
{isNearCenter ? (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" style={{ color: 'var(--success-color)' }}>
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3 h-3 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
</div>
<h5 className="font-bold text-sm md:text-base tracking-tight group-hover:text-gray-800 truncate" style={{ color: 'var(--accent-color)' }}>
{point.name}
</h5>
{selectedPoint === index && (
<span className="ml-auto px-2 md:px-3 py-1 text-xs font-bold text-white rounded-full shadow-sm flex-shrink-0" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
</span>
)}
</div>
<p className="text-xs md:text-sm text-gray-600 mb-2 md:mb-3 leading-relaxed">{point.description}</p>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold" style={{ color: 'var(--accent-color)' }}>Tọa đ:</span>
<span className="text-xs font-mono text-white px-1 md:px-2 py-1 rounded-lg" style={{ backgroundColor: 'var(--primary-color)' }}>
{point.location.lat.toFixed(4)}, {point.location.lng.toFixed(4)}
</span>
</div>
</div>
<div className="ml-2 md:ml-4 flex items-center flex-shrink-0">
{selectedPoint === index ? (
<div className="w-3 md:w-4 h-3 md:h-4 rounded-full shadow-sm animate-pulse" style={{ backgroundColor: 'var(--primary-color)' }}></div>
) : (
<div className="w-2 md:w-3 h-2 md:h-3 rounded-full transition-all duration-300" style={{
backgroundColor: isSimulating ? '#d1d5db' : '#e5e7eb'
}}></div>
)}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* Random Location Button */}
<button
onClick={handleRandomLocation}
disabled={isSimulating}
className="w-full flex items-center gap-3 md:gap-4 p-4 md:p-6 rounded-2xl md:rounded-3xl border-2 border-dashed transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed group transform hover:scale-[1.02] shadow-lg hover:shadow-xl"
style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderColor: 'var(--primary-color)'
}}
>
<div className="rounded-xl md:rounded-2xl flex items-center justify-center shadow-lg transition-all duration-300 group-hover:scale-110 flex-shrink-0" style={{
width: '48px',
height: '48px',
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}>
<svg className="w-5 md:w-7 h-5 md:h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div className="text-left flex-1 min-w-0">
<h5 className="text-base md:text-lg font-bold tracking-tight mb-1" style={{ color: 'var(--accent-color)' }}>Vị trí ngẫu nhiên</h5>
<p className="text-xs md:text-sm text-gray-600 font-medium">Tạo tọa đ tự đng trong TP.HCM</p>
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1 px-2 py-1 rounded-full" style={{ backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" style={{ color: 'var(--primary-color)' }}>
<path fillRule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm1 2a1 1 0 000 2h6a1 1 0 100-2H7zm6 7a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1zm-3 3a1 1 0 100 2h.01a1 1 0 100-2H10zm-4 1a1 1 0 011-1h.01a1 1 0 110 2H7a1 1 0 01-1-1zm1-4a1 1 0 100 2h.01a1 1 0 100-2H7zm2 1a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zm4-4a1 1 0 100 2h.01a1 1 0 100-2H13zm-2 1a1 1 0 011-1h.01a1 1 0 110 2H12a1 1 0 01-1-1zm-2-1a1 1 0 100 2h.01a1 1 0 100-2H9zm-2 1a1 1 0 011-1h.01a1 1 0 110 2H8a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
<span className="text-xs font-bold" style={{ color: 'var(--primary-color)' }}>RANDOM</span>
</div>
<div className="w-1 h-1 rounded-full" style={{ backgroundColor: 'var(--primary-color)' }}></div>
<span className="text-xs text-gray-500 hidden md:inline">Khu vực mở rộng</span>
</div>
</div>
<div className="w-5 md:w-6 h-5 md:h-6 rounded-full border-2 flex items-center justify-center group-hover:border-red-500 transition-colors flex-shrink-0" style={{ borderColor: 'var(--primary-color)' }}>
<svg className="w-2 md:w-3 h-2 md:h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--primary-color)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
</div>
);
};

View File

@@ -0,0 +1,106 @@
'use client';
import React from 'react';
import Image from 'next/image';
interface HeaderProps {
title?: string;
subtitle?: string;
showLogo?: boolean;
onClearRoute?: () => void;
}
export const Header: React.FC<HeaderProps> = ({
title = "Smart Parking Finder",
subtitle = "Find parking with ease",
showLogo = true,
onClearRoute
}) => {
return (
<header className="bg-white shadow-lg border-b-4" style={{ borderBottomColor: 'var(--primary-color)' }}>
<div className="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="flex items-center justify-between h-24 py-3">
{/* Logo and Title */}
<div className="flex items-center space-x-6">
{showLogo && (
<div className="flex-shrink-0">
<div className="relative">
<Image
src="/assets/Logo_and_sologan.png"
alt="Smart Parking Logo"
width={320}
height={80}
className="h-18 w-auto object-contain"
/>
{/* Animated accent line */}
<div className="absolute bottom-0 left-0 right-0 h-1 rounded-full" style={{
background: 'linear-gradient(90deg, var(--primary-color), var(--secondary-color))',
transform: 'scaleX(0.8)',
transformOrigin: 'left'
}}></div>
</div>
</div>
)}
<div className="hidden sm:block">
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">
{title}
</h1>
<p className="text-sm text-gray-600 font-medium mt-1">
{subtitle}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center space-x-4">
{/* Clear Route Button */}
{onClearRoute && (
<button
onClick={onClearRoute}
className="inline-flex items-center px-5 py-3 text-white text-sm font-bold rounded-2xl transition-all duration-300 transform hover:scale-105 hover:shadow-xl shadow-lg"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 8px 25px rgba(232, 90, 79, 0.3)'
}}
>
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
Xóa tuyến đưng
</button>
)}
{/* Live Status */}
<div className="hidden sm:flex items-center space-x-3 px-4 py-3 rounded-2xl border-2 shadow-lg" style={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.05), rgba(16, 185, 129, 0.05))',
borderColor: 'rgba(34, 197, 94, 0.3)'
}}>
<div className="w-3 h-3 rounded-full animate-pulse shadow-sm" style={{ backgroundColor: 'var(--success-color)' }}></div>
<span className="text-sm font-bold" style={{ color: 'var(--success-color)' }}>Dữ liệu trực tuyến</span>
</div>
{/* City Info */}
<div className="hidden sm:flex items-center space-x-3 px-4 py-3 rounded-2xl border-2 shadow-lg" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderColor: 'rgba(232, 90, 79, 0.3)'
}}>
<div className="w-8 h-8 rounded-xl flex items-center justify-center shadow-sm" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<span className="text-sm font-bold" style={{ color: 'var(--primary-color)' }}>TP. Hồ Chí Minh</span>
</div>
</div>
</div>
</div>
{/* Mobile title */}
<div className="sm:hidden bg-gradient-to-r from-gray-50 to-gray-100 px-6 py-4 border-b-2 border-gray-200">
<h1 className="text-xl font-bold text-gray-900 tracking-tight">{title}</h1>
<p className="text-sm text-gray-600 font-medium mt-1">{subtitle}</p>
</div>
</header>
);
};

View File

@@ -0,0 +1,152 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Icon } from '@/components/ui/Icon';
import { LocationPermissionDialog } from '@/components/LocationPermissionDialog';
import { getCurrentLocation, isLocationSupported } from '@/services/location';
interface Coordinates {
latitude: number;
longitude: number;
accuracy?: number;
timestamp?: number;
}
interface LocationDetectorProps {
onLocationDetected: (location: Coordinates) => void;
onLocationError?: (error: string) => void;
autoDetect?: boolean;
}
export const LocationDetector: React.FC<LocationDetectorProps> = ({
onLocationDetected,
onLocationError,
autoDetect = true
}) => {
const [isDetecting, setIsDetecting] = useState(false);
const [showPermissionDialog, setShowPermissionDialog] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [hasLocationPermission, setHasLocationPermission] = useState<boolean | null>(null);
const detectLocation = useCallback(async () => {
if (!isLocationSupported()) {
const error = 'Geolocation is not supported by this browser';
setLastError(error);
onLocationError?.(error);
return;
}
setIsDetecting(true);
setLastError(null);
try {
const position = await getCurrentLocation();
setHasLocationPermission(true);
onLocationDetected(position);
} catch (error: any) {
console.error('Location detection failed:', error);
setHasLocationPermission(false);
let errorMessage = 'Failed to get your location';
if (error.code === 1) {
errorMessage = 'Location access denied. Please enable location permissions.';
setShowPermissionDialog(true);
} else if (error.code === 2) {
errorMessage = 'Location unavailable. Please check your device settings.';
} else if (error.code === 3) {
errorMessage = 'Location request timed out. Please try again.';
} else if (error.message) {
errorMessage = error.message;
}
setLastError(errorMessage);
onLocationError?.(errorMessage);
} finally {
setIsDetecting(false);
}
}, [onLocationDetected, onLocationError]);
const handlePermissionRequest = () => {
setShowPermissionDialog(false);
detectLocation();
};
const handlePermissionClose = () => {
setShowPermissionDialog(false);
};
useEffect(() => {
if (autoDetect && hasLocationPermission === null) {
detectLocation();
}
}, [autoDetect, hasLocationPermission, detectLocation]);
return (
<>
<div className="bg-white rounded-lg shadow-md p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-900">
Your Location
</h3>
<button
onClick={detectLocation}
disabled={isDetecting}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isDetecting ? (
<>
<div className="animate-spin -ml-1 mr-2 h-3 w-3 border border-white border-t-transparent rounded-full" />
Detecting...
</>
) : (
<>
<Icon name="target" className="h-3 w-3 mr-1" />
Detect Location
</>
)}
</button>
</div>
{lastError ? (
<div className="flex items-center p-3 bg-red-50 rounded-md">
<Icon name="warning" className="h-4 w-4 text-red-400 mr-2 flex-shrink-0" />
<p className="text-sm text-red-700">{lastError}</p>
</div>
) : hasLocationPermission === true ? (
<div className="flex items-center p-3 bg-green-50 rounded-md">
<Icon name="check" className="h-4 w-4 text-green-400 mr-2 flex-shrink-0" />
<p className="text-sm text-green-700">Location detected successfully</p>
</div>
) : (
<div className="flex items-center p-3 bg-gray-50 rounded-md">
<Icon name="location" className="h-4 w-4 text-gray-400 mr-2 flex-shrink-0" />
<p className="text-sm text-gray-600">
Click &quot;Detect Location&quot; to find parking near you
</p>
</div>
)}
{/* Location tips */}
<div className="mt-4 p-3 bg-blue-50 rounded-md">
<h4 className="text-xs font-medium text-blue-900 mb-2">
For best results:
</h4>
<ul className="text-xs text-blue-700 space-y-1">
<li> Enable location services in your browser</li>
<li> Ensure you&apos;re connected to the internet</li>
<li> Allow location access when prompted</li>
</ul>
</div>
</div>
<LocationPermissionDialog
isOpen={showPermissionDialog}
onRequestPermission={handlePermissionRequest}
onClose={handlePermissionClose}
error={lastError}
/>
</>
);
};

View File

@@ -0,0 +1,108 @@
'use client';
import React from 'react';
import { Icon } from '@/components/ui/Icon';
interface LocationPermissionDialogProps {
isOpen: boolean;
onRequestPermission: () => void;
onClose: () => void;
error?: string | null;
}
export const LocationPermissionDialog: React.FC<LocationPermissionDialogProps> = ({
isOpen,
onRequestPermission,
onClose,
error = null
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
{/* Dialog */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Location Permission
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<Icon name="delete" size="sm" />
</button>
</div>
{/* Content */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary-100 mb-4">
<Icon name="location" className="h-8 w-8 text-primary-600" />
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">
Enable Location Access
</h4>
<p className="text-gray-600 mb-6">
To find parking lots near you, we need access to your location.
This helps us show you the most relevant parking options and
calculate accurate directions.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 rounded-md">
<div className="flex items-center">
<Icon name="warning" className="h-5 w-5 text-red-400 mr-2" />
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
)}
<div className="bg-blue-50 rounded-md p-4 mb-6">
<div className="flex items-start">
<Icon name="sparkle" className="h-5 w-5 text-blue-400 mt-0.5 mr-2 flex-shrink-0" />
<div className="text-left">
<h5 className="text-sm font-medium text-blue-900 mb-1">
Why we need location:
</h5>
<ul className="text-xs text-blue-700 space-y-1">
<li> Find nearby parking lots</li>
<li> Calculate walking distances</li>
<li> Provide turn-by-turn directions</li>
<li> Show real-time availability</li>
</ul>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex space-x-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
Not Now
</button>
<button
onClick={onRequestPermission}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors"
>
Enable Location
</button>
</div>
<p className="text-xs text-gray-500 text-center mt-4">
You can change this permission anytime in your browser settings.
</p>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,394 @@
'use client';
import React from 'react';
import { ParkingLot, UserLocation } from '@/types';
interface ParkingListProps {
parkingLots: ParkingLot[];
onSelect: (lot: ParkingLot) => void;
onViewing?: (lot: ParkingLot | null) => void; // Keep for compatibility but not used
selectedId?: number;
userLocation?: UserLocation | null;
sortType?: 'availability' | 'price' | 'distance';
}
// Calculate distance between two points using Haversine formula
const calculateDistance = (
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number => {
const R = 6371; // Earth's radius in kilometers
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLng = (lng2 - lng1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) *
Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const formatDistance = (distance: number): string => {
if (distance < 1) {
return `${Math.round(distance * 1000)}m`;
}
return `${distance.toFixed(1)}km`;
};
const getStatusColor = (availableSlots: number, totalSlots: number) => {
const percentage = availableSlots / totalSlots;
if (availableSlots === 0) {
// Hết chỗ - màu đỏ
return {
background: 'rgba(239, 68, 68, 0.15)',
borderColor: '#EF4444',
textColor: '#EF4444'
};
} else if (percentage > 0.7) {
// >70% chỗ trống - màu xanh lá cây
return {
background: 'rgba(34, 197, 94, 0.1)',
borderColor: 'var(--success-color)',
textColor: 'var(--success-color)'
};
} else {
// <30% chỗ trống - màu vàng
return {
background: 'rgba(251, 191, 36, 0.1)',
borderColor: '#F59E0B',
textColor: '#F59E0B'
};
}
};
const getStatusText = (availableSlots: number, totalSlots: number) => {
if (availableSlots === 0) {
return 'Hết chỗ';
} else if (availableSlots / totalSlots > 0.7) {
return `${availableSlots} chỗ trống`;
} else {
return `${availableSlots} chỗ trống (sắp hết)`;
}
};
// Check if parking lot is currently open
const isCurrentlyOpen = (lot: ParkingLot): boolean => {
if (lot.isOpen24Hours) return true;
if (!lot.openTime || !lot.closeTime) return true; // Assume open if no time specified
const now = new Date();
const currentTime = now.getHours() * 100 + now.getMinutes(); // Format: 930 for 9:30
// Parse time strings (assuming format like "08:00" or "8:00")
const parseTime = (timeStr: string): number => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 100 + (minutes || 0);
};
const openTime = parseTime(lot.openTime);
const closeTime = parseTime(lot.closeTime);
if (openTime <= closeTime) {
// Same day operation (e.g., 8:00 - 22:00)
return currentTime >= openTime && currentTime <= closeTime;
} else {
// Cross midnight operation (e.g., 22:00 - 06:00)
return currentTime >= openTime || currentTime <= closeTime;
}
};
export const ParkingList: React.FC<ParkingListProps> = ({
parkingLots,
onSelect,
onViewing,
selectedId,
userLocation,
sortType = 'availability'
}) => {
const listRef = React.useRef<HTMLDivElement>(null);
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
// Filter and sort parking lots
const sortedLots = React.useMemo(() => {
// Separate parking lots into categories
const openLotsWithSpaces = parkingLots.filter(lot =>
lot.availableSlots > 0 && isCurrentlyOpen(lot)
);
const closedLots = parkingLots.filter(lot =>
!isCurrentlyOpen(lot)
);
const fullLots = parkingLots.filter(lot =>
lot.availableSlots === 0 && isCurrentlyOpen(lot)
);
// Sort function for each category
const sortLots = (lots: ParkingLot[]) => {
return [...lots].sort((a, b) => {
switch (sortType) {
case 'price':
// Sort by price (cheapest first) - handle cases where price might be null/undefined
const priceA = a.pricePerHour || a.hourlyRate || 999999;
const priceB = b.pricePerHour || b.hourlyRate || 999999;
return priceA - priceB;
case 'distance':
// Sort by distance (closest first)
if (!userLocation) return 0;
const distanceA = calculateDistance(userLocation.lat, userLocation.lng, a.lat, a.lng);
const distanceB = calculateDistance(userLocation.lat, userLocation.lng, b.lat, b.lng);
return distanceA - distanceB;
case 'availability':
default:
// Sort by available spaces (most available first)
const availabilityDiff = b.availableSlots - a.availableSlots;
if (availabilityDiff !== 0) return availabilityDiff;
// If same availability, sort by distance as secondary criteria
if (userLocation) {
const distanceA = calculateDistance(userLocation.lat, userLocation.lng, a.lat, a.lng);
const distanceB = calculateDistance(userLocation.lat, userLocation.lng, b.lat, b.lng);
return distanceA - distanceB;
}
return a.name.localeCompare(b.name);
}
});
};
// Combine all categories with priority: open with spaces > full > closed
return [
...sortLots(openLotsWithSpaces),
...sortLots(fullLots),
...sortLots(closedLots)
];
}, [parkingLots, userLocation, sortType]);
// Remove auto-viewing functionality - now only supports selection
React.useEffect(() => {
// Auto-viewing disabled
}, [userLocation, sortedLots.length, onViewing, sortedLots]);
// Remove intersection observer functionality
React.useEffect(() => {
// Intersection observer disabled
}, [onViewing, sortedLots]);
return (
<div ref={listRef} className="space-y-4 overflow-y-auto">
{sortedLots.map((lot, index) => {
const distance = userLocation
? calculateDistance(userLocation.lat, userLocation.lng, lot.lat, lot.lng)
: null;
const isSelected = selectedId === lot.id;
const statusColors = getStatusColor(lot.availableSlots, lot.totalSlots);
const isFull = lot.availableSlots === 0;
const isClosed = !isCurrentlyOpen(lot);
const isDisabled = isFull || isClosed;
// Don't hide other parking lots when one is selected - allow viewing other options
const isHidden = false;
return (
<button
key={lot.id}
ref={(el) => {
if (el) {
itemRefs.current.set(lot.id, el);
} else {
itemRefs.current.delete(lot.id);
}
}}
onClick={() => !isDisabled && onSelect(lot)}
disabled={isDisabled}
className={`
w-full p-5 md:p-6 text-left rounded-2xl border-2 transition-all duration-300 group relative overflow-hidden
${isSelected
? 'shadow-xl transform scale-[1.02] z-10'
: 'hover:shadow-lg hover:transform hover:scale-[1.01]'
}
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
`}
style={{
background: isFull
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.15))'
: isClosed
? 'linear-gradient(135deg, rgba(107, 114, 128, 0.15), rgba(75, 85, 99, 0.15))'
: isSelected
? 'linear-gradient(135deg, rgba(232, 90, 79, 0.08), rgba(215, 53, 2, 0.08))'
: 'white',
borderColor: isFull
? '#EF4444'
: isClosed
? '#6B7280'
: isSelected
? 'var(--primary-color)'
: 'rgba(232, 90, 79, 0.15)'
}}
>
<div className="relative z-10">
{/* Warning banners */}
{isFull && (
<div className="absolute -top-2 -left-2 -right-2 bg-red-500 text-white text-center py-2 rounded-t-xl shadow-lg z-20">
<span className="text-sm font-bold">🚫 BÃI XE ĐÃ HẾT CHỖ</span>
</div>
)}
{isClosed && (
<div className="absolute -top-2 -left-2 -right-2 bg-gray-500 text-white text-center py-2 rounded-t-xl shadow-lg z-20">
<span className="text-sm font-bold">🔒 BÃI XE ĐÃ ĐÓNG CỬA</span>
</div>
)}
{/* Header với icon và tên đầy đủ */}
{/* Header với icon và tên đầy đủ */}
<div className={`flex items-start gap-4 mb-4 relative ${(isFull || isClosed) ? 'mt-6' : ''}`}>
<div className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-md flex-shrink-0" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="font-bold text-lg md:text-xl tracking-tight" style={{ color: 'var(--accent-color)' }}>
{lot.name}
</h3>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{lot.address}
</p>
</div>
<div className="flex items-start gap-3 flex-shrink-0">
{distance && (
<span className="text-sm font-bold text-white px-4 py-2 rounded-xl shadow-sm" style={{ backgroundColor: 'var(--primary-color)' }}>
{formatDistance(distance)}
</span>
)}
{/* Selected indicator */}
{isSelected && (
<div className="w-8 h-8 rounded-full flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</div>
{/* Thông tin chính - layout cân đối */}
<div className="grid grid-cols-3 gap-4 p-4 rounded-xl" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
border: '2px solid rgba(232, 90, 79, 0.2)'
}}>
{/* Trạng thái chỗ đỗ */}
<div className="flex flex-col items-center text-center">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: statusColors.borderColor }}></div>
<div className="text-xl font-bold" style={{ color: statusColors.textColor }}>
{lot.availableSlots}
</div>
</div>
<div className="text-sm text-gray-500 font-medium">
chỗ trống
</div>
<div className="text-xs text-gray-400">
/ {lot.totalSlots} chỗ
</div>
{/* Availability percentage */}
<div className="mt-1 w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(lot.availableSlots / lot.totalSlots) * 100}%`,
backgroundColor: statusColors.borderColor
}}
></div>
</div>
<div className="text-xs mt-1" style={{ color: statusColors.textColor }}>
{Math.round((lot.availableSlots / lot.totalSlots) * 100)}% trống
</div>
</div>
{/* Giá tiền */}
<div className="flex flex-col items-center text-center">
{(lot.pricePerHour || lot.hourlyRate) ? (
<>
<div className="text-xl font-bold mb-1" style={{ color: 'var(--primary-color)' }}>
{Math.round((lot.pricePerHour || lot.hourlyRate) / 1000)}k
</div>
<div className="text-sm text-gray-500 font-medium">
mỗi giờ
</div>
<div className="text-xs text-gray-400">
phí gửi xe
</div>
</>
) : (
<>
<div className="text-xl font-bold mb-1 text-gray-400">
--
</div>
<div className="text-xs text-gray-400 font-medium">
liên hệ
</div>
<div className="text-xs text-gray-400">
đ biết giá
</div>
</>
)}
</div>
{/* Giờ hoạt động */}
<div className="flex flex-col items-center text-center">
{(lot.openTime && lot.closeTime) || lot.isOpen24Hours ? (
<>
<div className="flex items-center gap-1 mb-1">
<div className={`w-2 h-2 rounded-full ${isCurrentlyOpen(lot) ? 'bg-green-500' : 'bg-red-500'}`}></div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--accent-color)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-lg font-bold" style={{ color: 'var(--accent-color)' }}>
{lot.isOpen24Hours ? '24/7' : `${lot.openTime}`}
</div>
</div>
<div className={`text-sm font-medium ${isCurrentlyOpen(lot) ? 'text-green-600' : 'text-red-600'}`}>
{isCurrentlyOpen(lot) ? (
lot.isOpen24Hours ? 'Luôn mở cửa' : `đến ${lot.closeTime}`
) : (
'Đã đóng cửa'
)}
</div>
<div className="text-xs text-gray-400">
{isCurrentlyOpen(lot) ? 'Đang mở' : '🔒 Đã đóng'}
</div>
</>
) : (
<>
<div className="text-lg font-bold mb-1 text-gray-400">
--:--
</div>
<div className="text-xs text-gray-400 font-medium">
không
</div>
<div className="text-xs text-gray-400">
giờ mở cửa
</div>
</>
)}
</div>
</div>
</div>
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,366 @@
'use client';
import React from 'react';
import { ParkingLot, UserLocation } from '@/types';
interface ParkingListProps {
parkingLots: ParkingLot[];
onSelect: (lot: ParkingLot) => void;
selectedId?: number;
userLocation?: UserLocation | null;
sortType?: 'availability' | 'price' | 'distance';
}
// Calculate distance between two points using Haversine formula
const calculateDistance = (
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number => {
const R = 6371; // Earth's radius in kilometers
const dLat = (lat2 - lat1) * (Math.PI / 180);
const dLng = (lng2 - lng1) * (Math.PI / 180);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * (Math.PI / 180)) *
Math.cos(lat2 * (Math.PI / 180)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
};
const formatDistance = (distance: number): string => {
if (distance < 1) {
return `${Math.round(distance * 1000)}m`;
}
return `${distance.toFixed(1)}km`;
};
const getStatusColor = (availableSlots: number, totalSlots: number) => {
const percentage = availableSlots / totalSlots;
if (availableSlots === 0) {
// Hết chỗ - màu đỏ
return {
background: 'rgba(239, 68, 68, 0.15)',
borderColor: '#EF4444',
textColor: '#EF4444'
};
} else if (percentage > 0.7) {
// >70% chỗ trống - màu xanh lá cây
return {
background: 'rgba(34, 197, 94, 0.1)',
borderColor: 'var(--success-color)',
textColor: 'var(--success-color)'
};
} else {
// <30% chỗ trống - màu vàng
return {
background: 'rgba(251, 191, 36, 0.1)',
borderColor: '#F59E0B',
textColor: '#F59E0B'
};
}
};
const getStatusText = (availableSlots: number, totalSlots: number) => {
if (availableSlots === 0) {
return 'Hết chỗ';
} else if (availableSlots / totalSlots > 0.7) {
return `${availableSlots} chỗ trống`;
} else {
return `${availableSlots} chỗ trống (sắp hết)`;
}
};
// Check if parking lot is currently open
const isCurrentlyOpen = (lot: ParkingLot): boolean => {
if (lot.isOpen24Hours) return true;
if (!lot.openTime || !lot.closeTime) return true; // Assume open if no time specified
const now = new Date();
const currentTime = now.getHours() * 100 + now.getMinutes(); // Format: 930 for 9:30
// Parse time strings (assuming format like "08:00" or "8:00")
const parseTime = (timeStr: string): number => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 100 + (minutes || 0);
};
const openTime = parseTime(lot.openTime);
const closeTime = parseTime(lot.closeTime);
if (openTime <= closeTime) {
// Same day operation (e.g., 8:00 - 22:00)
return currentTime >= openTime && currentTime <= closeTime;
} else {
// Cross midnight operation (e.g., 22:00 - 06:00)
return currentTime >= openTime || currentTime <= closeTime;
}
};
export const ParkingList: React.FC<ParkingListProps> = ({
parkingLots,
onSelect,
selectedId,
userLocation,
sortType = 'availability'
}) => {
// Filter and sort parking lots
const sortedLots = React.useMemo(() => {
// Separate parking lots into categories
const openLotsWithSpaces = parkingLots.filter(lot =>
lot.availableSlots > 0 && isCurrentlyOpen(lot)
);
const closedLots = parkingLots.filter(lot =>
!isCurrentlyOpen(lot)
);
const fullLots = parkingLots.filter(lot =>
lot.availableSlots === 0 && isCurrentlyOpen(lot)
);
// Sort function for each category
const sortLots = (lots: ParkingLot[]) => {
return [...lots].sort((a, b) => {
switch (sortType) {
case 'price':
// Sort by price (cheapest first) - handle cases where price might be null/undefined
const priceA = a.pricePerHour || a.hourlyRate || 999999;
const priceB = b.pricePerHour || b.hourlyRate || 999999;
return priceA - priceB;
case 'distance':
// Sort by distance (closest first)
if (!userLocation) return 0;
const distanceA = calculateDistance(userLocation.lat, userLocation.lng, a.lat, a.lng);
const distanceB = calculateDistance(userLocation.lat, userLocation.lng, b.lat, b.lng);
return distanceA - distanceB;
case 'availability':
default:
// Sort by available spaces (most available first)
const availabilityDiff = b.availableSlots - a.availableSlots;
if (availabilityDiff !== 0) return availabilityDiff;
// If same availability, sort by distance as secondary criteria
if (userLocation) {
const distanceA = calculateDistance(userLocation.lat, userLocation.lng, a.lat, a.lng);
const distanceB = calculateDistance(userLocation.lat, userLocation.lng, b.lat, b.lng);
return distanceA - distanceB;
}
return a.name.localeCompare(b.name);
}
});
};
// Combine all categories with priority: open with spaces > full > closed
return [
...sortLots(openLotsWithSpaces),
...sortLots(fullLots),
...sortLots(closedLots)
];
}, [parkingLots, userLocation, sortType]);
return (
<div className="space-y-4">
{sortedLots.map((lot, index) => {
const distance = userLocation
? calculateDistance(userLocation.lat, userLocation.lng, lot.lat, lot.lng)
: null;
const isSelected = selectedId === lot.id;
const statusColors = getStatusColor(lot.availableSlots, lot.totalSlots);
const isFull = lot.availableSlots === 0;
const isClosed = !isCurrentlyOpen(lot);
const isDisabled = isFull || isClosed;
return (
<button
key={lot.id}
onClick={() => !isDisabled && onSelect(lot)}
disabled={isDisabled}
className={`
w-full p-5 md:p-6 text-left rounded-2xl border-2 transition-all duration-300 group relative overflow-hidden
${isSelected
? 'shadow-xl transform scale-[1.02]'
: 'hover:shadow-lg hover:transform hover:scale-[1.01]'
}
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
`}
style={{
background: isFull
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.15))'
: isClosed
? 'linear-gradient(135deg, rgba(107, 114, 128, 0.15), rgba(75, 85, 99, 0.15))'
: isSelected
? 'linear-gradient(135deg, rgba(232, 90, 79, 0.08), rgba(215, 53, 2, 0.08))'
: 'white',
borderColor: isFull
? '#EF4444'
: isClosed
? '#6B7280'
: isSelected
? 'var(--primary-color)'
: 'rgba(232, 90, 79, 0.15)'
}}
>
<div className="relative z-10">
{/* Warning banners */}
{isFull && (
<div className="absolute -top-2 -left-2 -right-2 bg-red-500 text-white text-center py-2 rounded-t-xl shadow-lg z-20">
<span className="text-sm font-bold">🚫 BÃI XE ĐÃ HẾT CHỖ</span>
</div>
)}
{isClosed && (
<div className="absolute -top-2 -left-2 -right-2 bg-gray-500 text-white text-center py-2 rounded-t-xl shadow-lg z-20">
<span className="text-sm font-bold">🔒 BÃI XE ĐÃ ĐÓNG CỬA</span>
</div>
)}
{/* Header với icon và tên đầy đủ */}
<div className={`flex items-start gap-4 mb-4 relative ${(isFull || isClosed) ? 'mt-6' : ''}`}>
<div className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-md flex-shrink-0" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg md:text-xl tracking-tight mb-2" style={{ color: 'var(--accent-color)' }}>
{lot.name}
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
{lot.address}
</p>
</div>
<div className="flex items-start gap-3 flex-shrink-0">
{distance && (
<span className="text-sm font-bold text-white px-4 py-2 rounded-xl shadow-sm" style={{ backgroundColor: 'var(--primary-color)' }}>
{formatDistance(distance)}
</span>
)}
{/* Selected indicator */}
{isSelected && (
<div className="w-8 h-8 rounded-full flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</div>
{/* Thông tin chính - layout cân đối */}
<div className="grid grid-cols-3 gap-4 p-4 rounded-xl" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
border: '2px solid rgba(232, 90, 79, 0.2)'
}}>
{/* Trạng thái chỗ đỗ */}
<div className="flex flex-col items-center text-center">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: statusColors.borderColor }}></div>
<div className="text-xl font-bold" style={{ color: statusColors.textColor }}>
{lot.availableSlots}
</div>
</div>
<div className="text-sm text-gray-500 font-medium">
chỗ trống
</div>
<div className="text-xs text-gray-400">
/ {lot.totalSlots} chỗ
</div>
{/* Availability percentage */}
<div className="mt-1 w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(lot.availableSlots / lot.totalSlots) * 100}%`,
backgroundColor: statusColors.borderColor
}}
></div>
</div>
<div className="text-xs mt-1" style={{ color: statusColors.textColor }}>
{Math.round((lot.availableSlots / lot.totalSlots) * 100)}% trống
</div>
</div>
{/* Giá tiền */}
<div className="flex flex-col items-center text-center">
{(lot.pricePerHour || lot.hourlyRate) ? (
<>
<div className="text-xl font-bold mb-1" style={{ color: 'var(--primary-color)' }}>
{Math.round((lot.pricePerHour || lot.hourlyRate) / 1000)}k
</div>
<div className="text-sm text-gray-500 font-medium">
mỗi giờ
</div>
<div className="text-xs text-gray-400">
phí gửi xe
</div>
</>
) : (
<>
<div className="text-xl font-bold mb-1 text-gray-400">
--
</div>
<div className="text-xs text-gray-400 font-medium">
liên hệ
</div>
<div className="text-xs text-gray-400">
đ biết giá
</div>
</>
)}
</div>
{/* Giờ hoạt động */}
<div className="flex flex-col items-center text-center">
{(lot.openTime && lot.closeTime) || lot.isOpen24Hours ? (
<>
<div className="flex items-center gap-1 mb-1">
<div className={`w-2 h-2 rounded-full ${isCurrentlyOpen(lot) ? 'bg-green-500' : 'bg-red-500'}`}></div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--accent-color)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-lg font-bold" style={{ color: 'var(--accent-color)' }}>
{lot.isOpen24Hours ? '24/7' : `${lot.openTime}`}
</div>
</div>
<div className={`text-sm font-medium ${isCurrentlyOpen(lot) ? 'text-green-600' : 'text-red-600'}`}>
{isCurrentlyOpen(lot) ? (
lot.isOpen24Hours ? 'Luôn mở cửa' : `đến ${lot.closeTime}`
) : (
'Đã đóng cửa'
)}
</div>
<div className="text-xs text-gray-400">
{isCurrentlyOpen(lot) ? 'Đang mở' : '🔒 Đã đóng'}
</div>
</>
) : (
<>
<div className="text-lg font-bold mb-1 text-gray-400">
--:--
</div>
<div className="text-xs text-gray-400 font-medium">
không
</div>
<div className="text-xs text-gray-400">
giờ mở cửa
</div>
</>
)}
</div>
</div>
</div>
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,91 @@
'use client';
import React from 'react';
import { Icon } from '@/components/ui/Icon';
export interface TransportationMode {
id: 'driving' | 'walking' | 'cycling';
name: string;
icon: string;
description: string;
}
interface TransportationSelectorProps {
selectedMode: TransportationMode['id'];
onModeChange: (mode: TransportationMode['id']) => void;
disabled?: boolean;
}
const transportationModes: TransportationMode[] = [
{
id: 'driving',
name: 'Driving',
icon: 'car',
description: 'Get driving directions'
},
{
id: 'walking',
name: 'Walking',
icon: 'location',
description: 'Walking directions'
},
{
id: 'cycling',
name: 'Cycling',
icon: 'refresh',
description: 'Bike-friendly routes'
}
];
export const TransportationSelector: React.FC<TransportationSelectorProps> = ({
selectedMode,
onModeChange,
disabled = false
}) => {
return (
<div className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
Transportation Mode
</h3>
<div className="grid grid-cols-3 gap-2">
{transportationModes.map((mode) => (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex flex-col items-center p-3 rounded-lg border-2 transition-all
${selectedMode === mode.id
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}
${disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:shadow-sm'
}
`}
>
<Icon
name={mode.icon}
className={`mb-2 ${
selectedMode === mode.id ? 'text-primary-600' : 'text-gray-500'
}`}
size="md"
/>
<span className="text-xs font-medium text-center">
{mode.name}
</span>
</button>
))}
</div>
{/* Description */}
<div className="mt-3 p-3 bg-gray-50 rounded-md">
<p className="text-xs text-gray-600">
{transportationModes.find(mode => mode.id === selectedMode)?.description}
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
className = '',
children,
disabled,
...props
}) => {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-blue-500',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-blue-500'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
const finalClassName = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button
className={finalClassName}
disabled={disabled}
{...props}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,63 @@
'use client';
import React from 'react';
export interface IconProps {
name: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const iconPaths: Record<string, string> = {
airport: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7v13zM9 7l6-3 2 1v7l-2 1-6-3zm6-3V2a1 1 0 00-1-1H8a1 1 0 00-1 1v2l8 0z",
building: "M3 21h18M5 21V7l8-4v18M13 9h4v12",
car: "M7 17a2 2 0 11-4 0 2 2 0 014 0zM21 17a2 2 0 11-4 0 2 2 0 014 0zM5 17H3v-6l2-5h9l4 5v6h-2m-7-6h7m-7 0l-1-3",
check: "M5 13l4 4L19 7",
clock: "M12 2v10l3 3m5-8a9 9 0 11-18 0 9 9 0 0118 0z",
delete: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16",
dice: "M5 3a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2H5zm3 4a1 1 0 100 2 1 1 0 000-2zm8 0a1 1 0 100 2 1 1 0 000-2zm-8 8a1 1 0 100 2 1 1 0 000-2zm8 0a1 1 0 100 2 1 1 0 000-2z",
location: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z",
map: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7v13zM9 7l6 2-6 3zm6-3l4.553 2.276A1 1 0 0121 7.618v10.764a1 1 0 01-.553.894L15 17V4z",
market: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z M8 7V5a2 2 0 012-2h4a2 2 0 012 2v2",
refresh: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
rocket: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z",
sparkle: "M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z",
target: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
'visibility-off': "M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m6.121-3.879a3 3 0 00-4.243-4.242m4.243 4.242L21 21",
visibility: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
};
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
};
export const Icon: React.FC<IconProps> = ({
name,
className = '',
size = 'md'
}) => {
const path = iconPaths[name];
if (!path) {
console.warn(`Icon "${name}" not found`);
return null;
}
const sizeClass = sizeClasses[size];
const classes = `${sizeClass} ${className}`.trim();
return (
<svg
className={classes}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d={path} />
</svg>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-6 h-6',
lg: 'w-8 h-8',
xl: 'w-12 h-12',
};
return (
<div className={`${sizeClasses[size]} ${className}`} role="status" aria-label="Loading">
<svg
className="animate-spin text-primary-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
}

125
frontend/src/hooks/api.ts Normal file
View File

@@ -0,0 +1,125 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { parkingService, routingService, healthService } from '@/services/api';
import {
FindNearbyParkingRequest,
RouteRequest,
UpdateAvailabilityRequest
} from '@/types';
// Query keys
export const QUERY_KEYS = {
parking: {
all: ['parking'],
nearby: (params: FindNearbyParkingRequest) => ['parking', 'nearby', params],
byId: (id: number) => ['parking', id],
popular: (limit?: number) => ['parking', 'popular', limit],
},
routing: {
route: (params: RouteRequest) => ['routing', 'route', params],
status: ['routing', 'status'],
},
health: ['health'],
} as const;
// Parking hooks
export function useNearbyParking(request: FindNearbyParkingRequest, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.parking.nearby(request),
queryFn: () => parkingService.findNearby(request),
enabled: enabled && !!request.lat && !!request.lng,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
}
export function useAllParkingLots() {
return useQuery({
queryKey: QUERY_KEYS.parking.all,
queryFn: parkingService.getAll,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
export function useParkingLot(id: number, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.parking.byId(id),
queryFn: () => parkingService.getById(id),
enabled: enabled && !!id,
staleTime: 5 * 60 * 1000,
});
}
export function usePopularParkingLots(limit?: number) {
return useQuery({
queryKey: QUERY_KEYS.parking.popular(limit),
queryFn: () => parkingService.getPopular(limit),
staleTime: 15 * 60 * 1000, // 15 minutes
});
}
// Parking mutations
export function useUpdateParkingAvailability() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateAvailabilityRequest }) =>
parkingService.updateAvailability(id, data),
onSuccess: (updatedParkingLot) => {
// Update individual parking lot cache
queryClient.setQueryData(
QUERY_KEYS.parking.byId(updatedParkingLot.id),
updatedParkingLot
);
// Invalidate related queries
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.parking.all,
});
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'parking' && query.queryKey[1] === 'nearby',
});
},
});
}
// Routing hooks
export function useRoute(request: RouteRequest, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.routing.route(request),
queryFn: () => routingService.calculateRoute(request),
enabled: enabled && !!request.originLat && !!request.originLng && !!request.destinationLat && !!request.destinationLng,
staleTime: 15 * 60 * 1000, // 15 minutes
refetchOnWindowFocus: false,
});
}
export function useRoutingStatus() {
return useQuery({
queryKey: QUERY_KEYS.routing.status,
queryFn: routingService.getStatus,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Refresh every minute
});
}
// Health hooks
export function useHealth() {
return useQuery({
queryKey: QUERY_KEYS.health,
queryFn: healthService.getHealth,
staleTime: 30 * 1000,
refetchInterval: 60 * 1000,
});
}
// Custom hook for invalidating all parking-related queries
export function useInvalidateParking() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.parking.all,
});
};
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Coordinates } from '@/types';
import { getCurrentLocation, isLocationSupported } from '@/services/location';
interface GeolocationState {
location: Coordinates | null;
loading: boolean;
error: string | null;
hasPermission: boolean | null;
}
interface UseGeolocationOptions {
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
autoDetect?: boolean;
}
export const useGeolocation = (options: UseGeolocationOptions = {}) => {
const {
enableHighAccuracy = true,
timeout = 10000,
maximumAge = 60000,
autoDetect = false
} = options;
const [state, setState] = useState<GeolocationState>({
location: null,
loading: false,
error: null,
hasPermission: null
});
const getCurrentPosition = useCallback(async () => {
if (!isLocationSupported()) {
setState(prev => ({
...prev,
error: 'Geolocation is not supported by this browser',
hasPermission: false
}));
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const position = await getCurrentLocation();
setState(prev => ({
...prev,
location: position,
loading: false,
hasPermission: true,
error: null
}));
return position;
} catch (error: any) {
let errorMessage = 'Failed to get your location';
let hasPermission: boolean | null = false;
if (error.code === 1) {
errorMessage = 'Location access denied. Please enable location permissions.';
hasPermission = false;
} else if (error.code === 2) {
errorMessage = 'Location unavailable. Please check your device settings.';
hasPermission = null;
} else if (error.code === 3) {
errorMessage = 'Location request timed out. Please try again.';
hasPermission = null;
} else if (error.message) {
errorMessage = error.message;
}
setState(prev => ({
...prev,
loading: false,
error: errorMessage,
hasPermission
}));
throw error;
}
}, [enableHighAccuracy, timeout, maximumAge]);
const clearError = useCallback(() => {
setState(prev => ({ ...prev, error: null }));
}, []);
const reset = useCallback(() => {
setState({
location: null,
loading: false,
error: null,
hasPermission: null
});
}, []);
// Auto-detect location on mount if enabled
useEffect(() => {
if (autoDetect && state.hasPermission === null && !state.loading) {
getCurrentPosition().catch(() => {
// Error already handled in the function
});
}
}, [autoDetect, state.hasPermission, state.loading, getCurrentPosition]);
return {
...state,
getCurrentPosition,
clearError,
reset,
isSupported: isLocationSupported()
};
};

View File

@@ -0,0 +1,595 @@
import { useState, useCallback } from 'react';
import { ParkingLot, Coordinates } from '@/types';
interface ParkingSearchState {
parkingLots: ParkingLot[];
loading: boolean;
error: string | null;
searchLocation: Coordinates | null;
}
export const useParkingSearch = () => {
const [state, setState] = useState<ParkingSearchState>({
parkingLots: [],
loading: false,
error: null,
searchLocation: null
});
// Mock parking data for Ho Chi Minh City
const mockParkingLots: ParkingLot[] = [
{
id: 1,
name: 'Vincom Center Đồng Khởi',
address: '72 Lê Thánh Tôn, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7769,
lng: 106.7009,
availableSlots: 85,
totalSlots: 250,
availableSpaces: 85,
totalSpaces: 250,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security', 'valet'],
contactInfo: { phone: '+84-28-3829-4888' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 2,
name: 'Saigon Centre',
address: '65 Lê Lợi, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7743,
lng: 106.7017,
availableSlots: 42,
totalSlots: 180,
availableSpaces: 42,
totalSpaces: 180,
hourlyRate: 18000,
pricePerHour: 18000,
openTime: '06:00',
closeTime: '00:00',
amenities: ['covered', 'security', 'ev_charging'],
contactInfo: { phone: '+84-28-3914-4999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: true
},
{
id: 3,
name: 'Landmark 81 SkyBar Parking',
address: '720A Điện Biên Phủ, Bình Thạnh, TP.HCM',
lat: 10.7955,
lng: 106.7195,
availableSlots: 156,
totalSlots: 400,
availableSpaces: 156,
totalSpaces: 400,
hourlyRate: 25000,
pricePerHour: 25000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'valet', 'luxury'],
contactInfo: { phone: '+84-28-3645-1234' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 4,
name: 'Bitexco Financial Tower',
address: '2 Hải Triều, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7718,
lng: 106.7047,
availableSlots: 28,
totalSlots: 120,
availableSpaces: 28,
totalSpaces: 120,
hourlyRate: 20000,
pricePerHour: 20000,
openTime: '06:00',
closeTime: '23:00',
amenities: ['covered', 'security', 'premium'],
contactInfo: { phone: '+84-28-3915-6666' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 5,
name: 'Chợ Bến Thành Underground',
address: 'Lê Lợi, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7729,
lng: 106.6980,
availableSlots: 67,
totalSlots: 150,
availableSpaces: 67,
totalSpaces: 150,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:00',
closeTime: '22:00',
amenities: ['underground', 'security'],
contactInfo: { phone: '+84-28-3925-3145' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 6,
name: 'Diamond Plaza Parking',
address: '34 Lê Duẩn, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7786,
lng: 106.7046,
availableSlots: 93,
totalSlots: 200,
availableSpaces: 93,
totalSpaces: 200,
hourlyRate: 16000,
pricePerHour: 16000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3825-7750' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 7,
name: 'Nhà Thờ Đức Bà Parking',
address: '01 Công xã Paris, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7798,
lng: 106.6991,
availableSlots: 15,
totalSlots: 60,
availableSpaces: 15,
totalSpaces: 60,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor', 'heritage'],
contactInfo: { phone: '+84-28-3829-3477' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 8,
name: 'Takashimaya Parking',
address: '92-94 Nam Kỳ Khởi Nghĩa, Quận 1, TP.HCM',
lat: 10.7741,
lng: 106.7008,
availableSlots: 78,
totalSlots: 220,
availableSpaces: 78,
totalSpaces: 220,
hourlyRate: 17000,
pricePerHour: 17000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'valet'],
contactInfo: { phone: '+84-28-3822-7222' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
// Thêm nhiều bãi đỗ xe mới cho test bán kính 4km
{
id: 9,
name: 'Quận 2 - The Vista Parking',
address: '628C Hanoi Highway, Quận 2, TP.HCM',
lat: 10.7879,
lng: 106.7308,
availableSlots: 95,
totalSlots: 200,
availableSpaces: 95,
totalSpaces: 200,
hourlyRate: 20000,
pricePerHour: 20000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3744-5555' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 10,
name: 'Quận 3 - Viện Chợ Rẫy Parking',
address: '201B Nguyễn Chí Thanh, Quận 3, TP.HCM',
lat: 10.7656,
lng: 106.6889,
availableSlots: 45,
totalSlots: 120,
availableSpaces: 45,
totalSpaces: 120,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:00',
closeTime: '23:00',
amenities: ['outdoor', 'security'],
contactInfo: { phone: '+84-28-3855-4321' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 11,
name: 'Quận 5 - Chợ Lớn Plaza',
address: '1362 Trần Hưng Đạo, Quận 5, TP.HCM',
lat: 10.7559,
lng: 106.6631,
availableSlots: 67,
totalSlots: 150,
availableSpaces: 67,
totalSpaces: 150,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '22:00',
amenities: ['covered', 'budget'],
contactInfo: { phone: '+84-28-3855-7890' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 12,
name: 'Quận 7 - Phú Mỹ Hưng Midtown',
address: '20 Nguyễn Lương Bằng, Quận 7, TP.HCM',
lat: 10.7291,
lng: 106.7194,
availableSlots: 112,
totalSlots: 300,
availableSpaces: 112,
totalSpaces: 300,
hourlyRate: 22000,
pricePerHour: 22000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'ev_charging'],
contactInfo: { phone: '+84-28-5412-3456' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: true
},
{
id: 13,
name: 'Quận 10 - Đại học Y khoa Parking',
address: '215 Hồng Bàng, Quận 10, TP.HCM',
lat: 10.7721,
lng: 106.6698,
availableSlots: 33,
totalSlots: 80,
availableSpaces: 33,
totalSpaces: 80,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '06:00',
closeTime: '20:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3864-2222' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 14,
name: 'Bình Thạnh - Vincom Landmark',
address: '800A Điện Biên Phủ, Bình Thạnh, TP.HCM',
lat: 10.8029,
lng: 106.7208,
availableSlots: 189,
totalSlots: 450,
availableSpaces: 189,
totalSpaces: 450,
hourlyRate: 18000,
pricePerHour: 18000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security', 'valet'],
contactInfo: { phone: '+84-28-3512-6789' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 15,
name: 'Gò Vấp - Emart Shopping Center',
address: '242 Lê Đức Thọ, Gò Vấp, TP.HCM',
lat: 10.8239,
lng: 106.6834,
availableSlots: 145,
totalSlots: 380,
availableSpaces: 145,
totalSpaces: 380,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '07:00',
closeTime: '22:00',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3989-1234' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 16,
name: 'Quận 4 - Bến Vân Đồn Port',
address: '5 Bến Vân Đồn, Quận 4, TP.HCM',
lat: 10.7575,
lng: 106.7053,
availableSlots: 28,
totalSlots: 60,
availableSpaces: 28,
totalSpaces: 60,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor'],
contactInfo: { phone: '+84-28-3940-5678' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 17,
name: 'Quận 6 - Bình Phú Industrial',
address: '1578 Hậu Giang, Quận 6, TP.HCM',
lat: 10.7395,
lng: 106.6345,
availableSlots: 78,
totalSlots: 180,
availableSpaces: 78,
totalSpaces: 180,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '05:00',
closeTime: '22:00',
amenities: ['covered', 'budget'],
contactInfo: { phone: '+84-28-3755-9999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 18,
name: 'Tân Bình - Airport Plaza',
address: '1B Hồng Hà, Tân Bình, TP.HCM',
lat: 10.8099,
lng: 106.6631,
availableSlots: 234,
totalSlots: 500,
availableSpaces: 234,
totalSpaces: 500,
hourlyRate: 30000,
pricePerHour: 30000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'valet', 'ev_charging'],
contactInfo: { phone: '+84-28-3844-7777' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: true
},
{
id: 19,
name: 'Phú Nhuận - Phan Xích Long',
address: '453 Phan Xích Long, Phú Nhuận, TP.HCM',
lat: 10.7984,
lng: 106.6834,
availableSlots: 56,
totalSlots: 140,
availableSpaces: 56,
totalSpaces: 140,
hourlyRate: 16000,
pricePerHour: 16000,
openTime: '06:00',
closeTime: '00:00',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3844-3333' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 20,
name: 'Quận 8 - Phạm Hùng Boulevard',
address: '688 Phạm Hùng, Quận 8, TP.HCM',
lat: 10.7389,
lng: 106.6756,
availableSlots: 89,
totalSlots: 200,
availableSpaces: 89,
totalSpaces: 200,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:30',
closeTime: '23:30',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3876-5432' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 21,
name: 'Sân bay Tân Sơn Nhất - Terminal 1',
address: 'Sân bay Tân Sơn Nhất, TP.HCM',
lat: 10.8187,
lng: 106.6520,
availableSlots: 456,
totalSlots: 800,
availableSpaces: 456,
totalSpaces: 800,
hourlyRate: 25000,
pricePerHour: 25000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'security'],
contactInfo: { phone: '+84-28-3848-5555' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 22,
name: 'Quận 12 - Tân Chánh Hiệp Market',
address: '123 Tân Chánh Hiệp, Quận 12, TP.HCM',
lat: 10.8567,
lng: 106.6289,
availableSlots: 67,
totalSlots: 150,
availableSpaces: 67,
totalSpaces: 150,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '05:00',
closeTime: '20:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3718-8888' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 23,
name: 'Thủ Đức - Khu Công Nghệ Cao',
address: 'Xa lộ Hà Nội, Thủ Đức, TP.HCM',
lat: 10.8709,
lng: 106.8034,
availableSlots: 189,
totalSlots: 350,
availableSpaces: 189,
totalSpaces: 350,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '06:00',
closeTime: '22:00',
amenities: ['covered', 'security', 'ev_charging'],
contactInfo: { phone: '+84-28-3725-9999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: true
},
{
id: 24,
name: 'Nhà Bè - Phú Xuân Industrial',
address: '89 Huỳnh Tấn Phát, Nhà Bè, TP.HCM',
lat: 10.6834,
lng: 106.7521,
availableSlots: 45,
totalSlots: 100,
availableSpaces: 45,
totalSpaces: 100,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3781-2345' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
}
];
const searchLocation = useCallback((location: Coordinates) => {
setState(prev => ({
...prev,
loading: true,
error: null,
searchLocation: location
}));
// Simulate API call delay
setTimeout(() => {
try {
// Calculate distances and add to parking lots
const lotsWithDistance = mockParkingLots.map(lot => {
const distance = calculateDistance(location, { latitude: lot.lat, longitude: lot.lng });
return {
...lot,
distance: distance * 1000, // Convert to meters
walkingTime: Math.round(distance * 12), // Rough estimate: 12 minutes per km
};
});
// Filter by 4km radius (4000 meters) and sort by distance
const lotsWithin4km = lotsWithDistance.filter(lot => lot.distance! <= 4000);
const sortedLots = lotsWithin4km.sort((a, b) => a.distance! - b.distance!);
setState(prev => ({
...prev,
loading: false,
parkingLots: sortedLots
}));
} catch (error: any) {
setState(prev => ({
...prev,
loading: false,
error: error.message || 'Failed to search parking lots'
}));
}
}, 1000);
}, []);
return {
parkingLots: state.parkingLots,
error: state.error,
searchLocation
};
};
// Helper function to calculate distance between two coordinates
function calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371; // Earth's radius in kilometers
const dLat = toRadians(coord2.latitude - coord1.latitude);
const dLon = toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(coord1.latitude)) *
Math.cos(toRadians(coord2.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

View File

@@ -0,0 +1,603 @@
import { useState, useCallback } from 'react';
import { ParkingLot, Coordinates } from '@/types';
interface ParkingSearchState {
parkingLots: ParkingLot[];
loading: boolean;
error: string | null;
searchLocation: Coordinates | null;
}
export const useParkingSearch = () => {
const [state, setState] = useState<ParkingSearchState>({
parkingLots: [],
loading: false,
error: null,
searchLocation: null
});
// Mock parking data for Ho Chi Minh City
const mockParkingLots: ParkingLot[] = [
// Test case 1: >70% chỗ trống (màu xanh)
{
id: 1,
name: 'Vincom Center Đồng Khởi (Còn nhiều chỗ)',
address: '72 Lê Thánh Tôn, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7769,
lng: 106.7009,
availableSlots: 200,
totalSlots: 250,
availableSpaces: 200,
totalSpaces: 250,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security', 'valet'],
contactInfo: { phone: '+84-28-3829-4888' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
// Test case 2: <30% chỗ trống (màu vàng)
{
id: 2,
name: 'Saigon Centre (Sắp hết chỗ)',
address: '65 Lê Lợi, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7743,
lng: 106.7017,
availableSlots: 25,
totalSlots: 180,
availableSpaces: 25,
totalSpaces: 180,
hourlyRate: 18000,
pricePerHour: 18000,
openTime: '06:00',
closeTime: '00:00',
amenities: ['covered', 'security', 'ev_charging'],
contactInfo: { phone: '+84-28-3914-4999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: true
},
// Test case 3: 0% chỗ trống (màu đỏ + disabled)
{
id: 3,
name: 'Landmark 81 (Hết chỗ)',
address: '720A Điện Biên Phủ, Bình Thạnh, TP.HCM',
lat: 10.7955,
lng: 106.7195,
availableSlots: 0,
totalSlots: 400,
availableSpaces: 0,
totalSpaces: 400,
hourlyRate: 25000,
pricePerHour: 25000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'valet', 'luxury'],
contactInfo: { phone: '+84-28-3645-1234' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
// Test case 4: >70% chỗ trống (màu xanh)
{
id: 4,
name: 'Bitexco Financial Tower (Còn rộng)',
address: '2 Hải Triều, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7718,
lng: 106.7047,
availableSlots: 100,
totalSlots: 120,
availableSpaces: 100,
totalSpaces: 120,
hourlyRate: 20000,
pricePerHour: 20000,
openTime: '06:00',
closeTime: '23:00',
amenities: ['covered', 'security', 'premium'],
contactInfo: { phone: '+84-28-3915-6666' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
// Test case 5: 0% chỗ trống (màu đỏ + disabled)
{
id: 5,
name: 'Chợ Bến Thành (Đã đầy)',
address: 'Lê Lợi, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7729,
lng: 106.6980,
availableSlots: 0,
totalSlots: 150,
availableSpaces: 0,
totalSpaces: 150,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:00',
closeTime: '22:00',
amenities: ['underground', 'security'],
contactInfo: { phone: '+84-28-3925-3145' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
// Test case 6: <30% chỗ trống (màu vàng)
{
id: 6,
name: 'Diamond Plaza (Gần hết)',
address: '34 Lê Duẩn, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7786,
lng: 106.7046,
availableSlots: 40,
totalSlots: 200,
availableSpaces: 40,
totalSpaces: 200,
hourlyRate: 16000,
pricePerHour: 16000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3825-7750' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
// Test case 7: >70% chỗ trống (màu xanh)
{
id: 7,
name: 'Nhà Thờ Đức Bà (Thoáng)',
address: '01 Công xã Paris, Bến Nghé, Quận 1, TP.HCM',
lat: 10.7798,
lng: 106.6991,
availableSlots: 50,
totalSlots: 60,
availableSpaces: 50,
totalSpaces: 60,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor', 'heritage'],
contactInfo: { phone: '+84-28-3829-3477' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
// Test case 8: <30% chỗ trống (màu vàng)
{
id: 8,
name: 'Takashimaya (Chỉ còn ít)',
address: '92-94 Nam Kỳ Khởi Nghĩa, Quận 1, TP.HCM',
lat: 10.7741,
lng: 106.7008,
availableSlots: 30,
totalSlots: 220,
availableSpaces: 30,
totalSpaces: 220,
hourlyRate: 17000,
pricePerHour: 17000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'valet'],
contactInfo: { phone: '+84-28-3822-7222' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
// Thêm nhiều bãi đỗ xe mới cho test bán kính 4km
{
id: 9,
name: 'Quận 2 - The Vista Parking',
address: '628C Hanoi Highway, Quận 2, TP.HCM',
lat: 10.7879,
lng: 106.7308,
availableSlots: 95,
totalSlots: 200,
availableSpaces: 95,
totalSpaces: 200,
hourlyRate: 20000,
pricePerHour: 20000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3744-5555' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 10,
name: 'Quận 3 - Viện Chợ Rẫy Parking',
address: '201B Nguyễn Chí Thanh, Quận 3, TP.HCM',
lat: 10.7656,
lng: 106.6889,
availableSlots: 45,
totalSlots: 120,
availableSpaces: 45,
totalSpaces: 120,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:00',
closeTime: '23:00',
amenities: ['outdoor', 'security'],
contactInfo: { phone: '+84-28-3855-4321' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 11,
name: 'Quận 5 - Chợ Lớn Plaza',
address: '1362 Trần Hưng Đạo, Quận 5, TP.HCM',
lat: 10.7559,
lng: 106.6631,
availableSlots: 67,
totalSlots: 150,
availableSpaces: 67,
totalSpaces: 150,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '22:00',
amenities: ['covered', 'budget'],
contactInfo: { phone: '+84-28-3855-7890' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 12,
name: 'Quận 7 - Phú Mỹ Hưng Midtown',
address: '20 Nguyễn Lương Bằng, Quận 7, TP.HCM',
lat: 10.7291,
lng: 106.7194,
availableSlots: 112,
totalSlots: 300,
availableSpaces: 112,
totalSpaces: 300,
hourlyRate: 22000,
pricePerHour: 22000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'ev_charging'],
contactInfo: { phone: '+84-28-5412-3456' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: true
},
{
id: 13,
name: 'Quận 10 - Đại học Y khoa Parking',
address: '215 Hồng Bàng, Quận 10, TP.HCM',
lat: 10.7721,
lng: 106.6698,
availableSlots: 33,
totalSlots: 80,
availableSpaces: 33,
totalSpaces: 80,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '06:00',
closeTime: '20:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3864-2222' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 14,
name: 'Bình Thạnh - Vincom Landmark',
address: '800A Điện Biên Phủ, Bình Thạnh, TP.HCM',
lat: 10.8029,
lng: 106.7208,
availableSlots: 189,
totalSlots: 450,
availableSpaces: 189,
totalSpaces: 450,
hourlyRate: 18000,
pricePerHour: 18000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'security', 'valet'],
contactInfo: { phone: '+84-28-3512-6789' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 15,
name: 'Gò Vấp - Emart Shopping Center',
address: '242 Lê Đức Thọ, Gò Vấp, TP.HCM',
lat: 10.8239,
lng: 106.6834,
availableSlots: 145,
totalSlots: 380,
availableSpaces: 145,
totalSpaces: 380,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '07:00',
closeTime: '22:00',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3989-1234' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 16,
name: 'Quận 4 - Bến Vân Đồn Port',
address: '5 Bến Vân Đồn, Quận 4, TP.HCM',
lat: 10.7575,
lng: 106.7053,
availableSlots: 28,
totalSlots: 60,
availableSpaces: 28,
totalSpaces: 60,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor'],
contactInfo: { phone: '+84-28-3940-5678' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 17,
name: 'Quận 6 - Bình Phú Industrial',
address: '1578 Hậu Giang, Quận 6, TP.HCM',
lat: 10.7395,
lng: 106.6345,
availableSlots: 78,
totalSlots: 180,
availableSpaces: 78,
totalSpaces: 180,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '05:00',
closeTime: '22:00',
amenities: ['covered', 'budget'],
contactInfo: { phone: '+84-28-3755-9999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 18,
name: 'Tân Bình - Airport Plaza',
address: '1B Hồng Hà, Tân Bình, TP.HCM',
lat: 10.8099,
lng: 106.6631,
availableSlots: 234,
totalSlots: 500,
availableSpaces: 234,
totalSpaces: 500,
hourlyRate: 30000,
pricePerHour: 30000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'valet', 'ev_charging'],
contactInfo: { phone: '+84-28-3844-7777' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: true
},
{
id: 19,
name: 'Phú Nhuận - Phan Xích Long',
address: '453 Phan Xích Long, Phú Nhuận, TP.HCM',
lat: 10.7984,
lng: 106.6834,
availableSlots: 56,
totalSlots: 140,
availableSpaces: 56,
totalSpaces: 140,
hourlyRate: 16000,
pricePerHour: 16000,
openTime: '06:00',
closeTime: '00:00',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3844-3333' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 20,
name: 'Quận 8 - Phạm Hùng Boulevard',
address: '688 Phạm Hùng, Quận 8, TP.HCM',
lat: 10.7389,
lng: 106.6756,
availableSlots: 89,
totalSlots: 200,
availableSpaces: 89,
totalSpaces: 200,
hourlyRate: 12000,
pricePerHour: 12000,
openTime: '05:30',
closeTime: '23:30',
amenities: ['covered', 'security'],
contactInfo: { phone: '+84-28-3876-5432' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: false
},
{
id: 21,
name: 'Sân bay Tân Sơn Nhất - Terminal 1',
address: 'Sân bay Tân Sơn Nhất, TP.HCM',
lat: 10.8187,
lng: 106.6520,
availableSlots: 456,
totalSlots: 800,
availableSpaces: 456,
totalSpaces: 800,
hourlyRate: 25000,
pricePerHour: 25000,
openTime: '00:00',
closeTime: '23:59',
amenities: ['covered', 'premium', 'security'],
contactInfo: { phone: '+84-28-3848-5555' },
isActive: true,
isOpen24Hours: true,
hasCCTV: true,
isEVCharging: false
},
{
id: 22,
name: 'Quận 12 - Tân Chánh Hiệp Market',
address: '123 Tân Chánh Hiệp, Quận 12, TP.HCM',
lat: 10.8567,
lng: 106.6289,
availableSlots: 67,
totalSlots: 150,
availableSpaces: 67,
totalSpaces: 150,
hourlyRate: 8000,
pricePerHour: 8000,
openTime: '05:00',
closeTime: '20:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3718-8888' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
},
{
id: 23,
name: 'Thủ Đức - Khu Công Nghệ Cao',
address: 'Xa lộ Hà Nội, Thủ Đức, TP.HCM',
lat: 10.8709,
lng: 106.8034,
availableSlots: 189,
totalSlots: 350,
availableSpaces: 189,
totalSpaces: 350,
hourlyRate: 15000,
pricePerHour: 15000,
openTime: '06:00',
closeTime: '22:00',
amenities: ['covered', 'security', 'ev_charging'],
contactInfo: { phone: '+84-28-3725-9999' },
isActive: true,
isOpen24Hours: false,
hasCCTV: true,
isEVCharging: true
},
{
id: 24,
name: 'Nhà Bè - Phú Xuân Industrial',
address: '89 Huỳnh Tấn Phát, Nhà Bè, TP.HCM',
lat: 10.6834,
lng: 106.7521,
availableSlots: 45,
totalSlots: 100,
availableSpaces: 45,
totalSpaces: 100,
hourlyRate: 10000,
pricePerHour: 10000,
openTime: '06:00',
closeTime: '18:00',
amenities: ['outdoor', 'budget'],
contactInfo: { phone: '+84-28-3781-2345' },
isActive: true,
isOpen24Hours: false,
hasCCTV: false,
isEVCharging: false
}
];
const searchLocation = useCallback((location: Coordinates) => {
setState(prev => ({
...prev,
loading: true,
error: null,
searchLocation: location
}));
// Simulate API call delay
setTimeout(() => {
try {
// Calculate distances and add to parking lots
const lotsWithDistance = mockParkingLots.map(lot => {
const distance = calculateDistance(location, { latitude: lot.lat, longitude: lot.lng });
return {
...lot,
distance: distance * 1000, // Convert to meters
walkingTime: Math.round(distance * 12), // Rough estimate: 12 minutes per km
};
});
// Filter by 4km radius (4000 meters) and sort by distance
const lotsWithin4km = lotsWithDistance.filter(lot => lot.distance! <= 4000);
const sortedLots = lotsWithin4km.sort((a, b) => a.distance! - b.distance!);
setState(prev => ({
...prev,
loading: false,
parkingLots: sortedLots
}));
} catch (error: any) {
setState(prev => ({
...prev,
loading: false,
error: error.message || 'Failed to search parking lots'
}));
}
}, 1000);
}, []);
return {
parkingLots: state.parkingLots,
error: state.error,
searchLocation
};
};
// Helper function to calculate distance between two coordinates
function calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371; // Earth's radius in kilometers
const dLat = toRadians(coord2.latitude - coord1.latitude);
const dLon = toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(coord1.latitude)) *
Math.cos(toRadians(coord2.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

View File

@@ -0,0 +1,138 @@
import { useState, useCallback } from 'react';
import { Coordinates } from '@/types';
export interface RouteStep {
instruction: string;
distance: number;
duration: number;
maneuver?: string;
}
export interface Route {
id: string;
distance: number; // in meters
duration: number; // in seconds
geometry: Array<[number, number]>; // [lat, lng] coordinates
steps: RouteStep[];
mode: 'driving' | 'walking' | 'cycling';
}
interface RoutingState {
route: Route | null;
alternatives: Route[];
isLoading: boolean;
error: string | null;
}
interface CalculateRouteOptions {
mode: 'driving' | 'walking' | 'cycling';
avoidTolls?: boolean;
avoidHighways?: boolean;
alternatives?: boolean;
}
export const useRouting = () => {
const [state, setState] = useState<RoutingState>({
route: null,
alternatives: [],
isLoading: false,
error: null
});
const calculateRoute = useCallback(async (
start: Coordinates,
end: Coordinates,
options: CalculateRouteOptions = { mode: 'driving' }
) => {
setState(prev => ({
...prev,
isLoading: true,
error: null
}));
try {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1500));
// Mock route calculation
const distance = calculateDistance(start, end);
const mockRoute: Route = {
id: 'route-1',
distance: distance * 1000, // Convert to meters
duration: Math.round(distance * 180), // Rough estimate: 3 minutes per km for driving
geometry: [
[start.latitude, start.longitude],
[end.latitude, end.longitude]
],
steps: [
{
instruction: `Đi từ vị trí hiện tại`,
distance: distance * 1000 * 0.1,
duration: Math.round(distance * 18)
},
{
instruction: `Đến ${end.latitude.toFixed(4)}, ${end.longitude.toFixed(4)}`,
distance: distance * 1000 * 0.9,
duration: Math.round(distance * 162)
}
],
mode: options.mode
};
setState(prev => ({
...prev,
isLoading: false,
route: mockRoute,
alternatives: []
}));
return { route: mockRoute, alternatives: [] };
} catch (error: any) {
setState(prev => ({
...prev,
isLoading: false,
error: error.message || 'Failed to calculate route'
}));
throw error;
}
}, []);
const clearRoute = useCallback(() => {
setState({
route: null,
alternatives: [],
isLoading: false,
error: null
});
}, []);
return {
route: state.route,
alternatives: state.alternatives,
isLoading: state.isLoading,
error: state.error,
calculateRoute,
clearRoute
};
};
// Helper function to calculate distance between two coordinates
function calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371; // Earth's radius in kilometers
const dLat = toRadians(coord2.latitude - coord1.latitude);
const dLon = toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(coord1.latitude)) *
Math.cos(toRadians(coord2.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

View File

@@ -0,0 +1,138 @@
import { useState, useCallback } from 'react';
import { Coordinates } from '@/types';
export interface RouteStep {
instruction: string;
distance: number;
duration: number;
maneuver?: string;
}
export interface Route {
id: string;
distance: number; // in meters
duration: number; // in seconds
geometry: Array<[number, number]>; // [lat, lng] coordinates
steps: RouteStep[];
mode: 'driving' | 'walking' | 'cycling';
}
interface RoutingState {
route: Route | null;
alternatives: Route[];
isLoading: boolean;
error: string | null;
}
interface CalculateRouteOptions {
mode: 'driving' | 'walking' | 'cycling';
avoidTolls?: boolean;
avoidHighways?: boolean;
alternatives?: boolean;
}
export const useRouting = () => {
const [state, setState] = useState<RoutingState>({
route: null,
alternatives: [],
isLoading: false,
error: null
});
const calculateRoute = useCallback(async (
start: Coordinates,
end: Coordinates,
options: CalculateRouteOptions = { mode: 'driving' }
) => {
setState(prev => ({
...prev,
isLoading: true,
error: null
}));
try {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1500));
// Mock route calculation
const distance = calculateDistance(start, end);
const mockRoute: Route = {
id: 'route-1',
distance: distance * 1000, // Convert to meters
duration: Math.round(distance * 180), // Rough estimate: 3 minutes per km for driving
geometry: [
[start.latitude, start.longitude],
[end.latitude, end.longitude]
],
steps: [
{
instruction: `Đi từ vị trí hiện tại`,
distance: distance * 1000 * 0.1,
duration: Math.round(distance * 18)
},
{
instruction: `Đến ${end.latitude.toFixed(4)}, ${end.longitude.toFixed(4)}`,
distance: distance * 1000 * 0.9,
duration: Math.round(distance * 162)
}
],
mode: options.mode
};
setState(prev => ({
...prev,
isLoading: false,
route: mockRoute,
alternatives: []
}));
return { route: mockRoute, alternatives: [] };
} catch (error: any) {
setState(prev => ({
...prev,
isLoading: false,
error: error.message || 'Failed to calculate route'
}));
throw error;
}
}, []);
const clearRoute = useCallback(() => {
setState({
route: null,
alternatives: [],
isLoading: false,
error: null
});
}, []);
return {
route: state.route,
alternatives: state.alternatives,
isLoading: state.isLoading,
error: state.error,
calculateRoute,
clearRoute
};
};
// Helper function to calculate distance between two coordinates
function calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371; // Earth's radius in kilometers
const dLat = toRadians(coord2.latitude - coord1.latitude);
const dLon = toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(coord1.latitude)) *
Math.cos(toRadians(coord2.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

View File

@@ -0,0 +1,118 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import {
FindNearbyParkingRequest,
FindNearbyParkingResponse,
ParkingLot,
UpdateAvailabilityRequest,
RouteRequest,
RouteResponse
} from '@/types';
class APIClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
this.client.interceptors.request.use(
(config) => {
// Add auth token if available
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
this.client.interceptors.response.use(
(response: AxiosResponse) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
// Parking endpoints
async findNearbyParking(request: FindNearbyParkingRequest): Promise<FindNearbyParkingResponse> {
const response = await this.client.post('/parking/nearby', request);
return response.data;
}
async getAllParkingLots(): Promise<ParkingLot[]> {
const response = await this.client.get('/parking');
return response.data;
}
async getParkingLotById(id: number): Promise<ParkingLot> {
const response = await this.client.get(`/parking/${id}`);
return response.data;
}
async updateParkingAvailability(id: number, data: UpdateAvailabilityRequest): Promise<ParkingLot> {
const response = await this.client.put(`/parking/${id}/availability`, data);
return response.data;
}
async getPopularParkingLots(limit?: number): Promise<ParkingLot[]> {
const response = await this.client.get('/parking/popular', {
params: { limit }
});
return response.data;
}
// Routing endpoints
async calculateRoute(request: RouteRequest): Promise<RouteResponse> {
const response = await this.client.post('/routing/calculate', request);
return response.data;
}
async getRoutingServiceStatus(): Promise<{ status: string; version?: string }> {
const response = await this.client.get('/routing/status');
return response.data;
}
// Health endpoint
async getHealth(): Promise<{ status: string; timestamp: string }> {
const response = await this.client.get('/health');
return response.data;
}
}
// Create singleton instance
export const apiClient = new APIClient();
// Export individual service functions for convenience
export const parkingService = {
findNearby: (request: FindNearbyParkingRequest) => apiClient.findNearbyParking(request),
getAll: () => apiClient.getAllParkingLots(),
getById: (id: number) => apiClient.getParkingLotById(id),
updateAvailability: (id: number, data: UpdateAvailabilityRequest) =>
apiClient.updateParkingAvailability(id, data),
getPopular: (limit?: number) => apiClient.getPopularParkingLots(limit),
};
export const routingService = {
calculateRoute: (request: RouteRequest) => apiClient.calculateRoute(request),
getStatus: () => apiClient.getRoutingServiceStatus(),
};
export const healthService = {
getHealth: () => apiClient.getHealth(),
};

View File

@@ -0,0 +1,213 @@
import { Coordinates } from '@/types';
export interface LocationError {
code: number;
message: string;
}
export interface LocationOptions {
enableHighAccuracy?: boolean;
timeout?: number;
maximumAge?: number;
}
const DEFAULT_OPTIONS: LocationOptions = {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000, // 1 minute
};
export class LocationService {
private watchId: number | null = null;
private lastKnownPosition: Coordinates | null = null;
/**
* Check if geolocation is supported by the browser
*/
isSupported(): boolean {
return 'geolocation' in navigator;
}
/**
* Get current position once
*/
async getCurrentPosition(options: LocationOptions = {}): Promise<Coordinates> {
if (!this.isSupported()) {
throw new Error('Geolocation is not supported by this browser');
}
const finalOptions = { ...DEFAULT_OPTIONS, ...options };
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(position) => {
const coordinates: Coordinates = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
};
this.lastKnownPosition = coordinates;
resolve(coordinates);
},
(error) => {
reject(this.formatError(error));
},
finalOptions
);
});
}
/**
* Watch position changes
*/
watchPosition(
onSuccess: (coordinates: Coordinates) => void,
onError?: (error: LocationError) => void,
options: LocationOptions = {}
): number {
if (!this.isSupported()) {
throw new Error('Geolocation is not supported by this browser');
}
const finalOptions = { ...DEFAULT_OPTIONS, ...options };
this.watchId = navigator.geolocation.watchPosition(
(position) => {
const coordinates: Coordinates = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
timestamp: position.timestamp,
};
this.lastKnownPosition = coordinates;
onSuccess(coordinates);
},
(error) => {
const formattedError = this.formatError(error);
if (onError) {
onError(formattedError);
}
},
finalOptions
);
return this.watchId;
}
/**
* Stop watching position
*/
clearWatch(): void {
if (this.watchId !== null) {
navigator.geolocation.clearWatch(this.watchId);
this.watchId = null;
}
}
/**
* Get last known position without requesting new location
*/
getLastKnownPosition(): Coordinates | null {
return this.lastKnownPosition;
}
/**
* Calculate distance between two coordinates using Haversine formula
*/
calculateDistance(coord1: Coordinates, coord2: Coordinates): number {
const R = 6371; // Earth's radius in kilometers
const dLat = this.toRadians(coord2.latitude - coord1.latitude);
const dLon = this.toRadians(coord2.longitude - coord1.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(coord1.latitude)) *
Math.cos(this.toRadians(coord2.latitude)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in kilometers
}
/**
* Calculate bearing between two coordinates
*/
calculateBearing(coord1: Coordinates, coord2: Coordinates): number {
const dLon = this.toRadians(coord2.longitude - coord1.longitude);
const lat1 = this.toRadians(coord1.latitude);
const lat2 = this.toRadians(coord2.latitude);
const y = Math.sin(dLon) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
const bearing = this.toDegrees(Math.atan2(y, x));
return (bearing + 360) % 360; // Normalize to 0-360
}
/**
* Check if coordinates are within a certain radius
*/
isWithinRadius(center: Coordinates, point: Coordinates, radiusKm: number): boolean {
return this.calculateDistance(center, point) <= radiusKm;
}
/**
* Format geolocation error
*/
private formatError(error: GeolocationPositionError): LocationError {
switch (error.code) {
case error.PERMISSION_DENIED:
return {
code: error.code,
message: 'Location access denied by user',
};
case error.POSITION_UNAVAILABLE:
return {
code: error.code,
message: 'Location information is unavailable',
};
case error.TIMEOUT:
return {
code: error.code,
message: 'Location request timed out',
};
default:
return {
code: error.code,
message: error.message || 'An unknown location error occurred',
};
}
}
/**
* Convert degrees to radians
*/
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Convert radians to degrees
*/
private toDegrees(radians: number): number {
return radians * (180 / Math.PI);
}
}
// Create singleton instance
export const locationService = new LocationService();
// Helper functions
export const getCurrentLocation = () => locationService.getCurrentPosition();
export const watchLocation = (
onSuccess: (coordinates: Coordinates) => void,
onError?: (error: LocationError) => void,
options?: LocationOptions
) => locationService.watchPosition(onSuccess, onError, options);
export const clearLocationWatch = () => locationService.clearWatch();
export const getLastKnownLocation = () => locationService.getLastKnownPosition();
export const calculateDistance = (coord1: Coordinates, coord2: Coordinates) =>
locationService.calculateDistance(coord1, coord2);
export const isLocationSupported = () => locationService.isSupported();

360
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,360 @@
// Core Types
export interface Coordinates {
latitude: number;
longitude: number;
accuracy?: number;
timestamp?: number;
}
export interface UserLocation {
lat: number;
lng: number;
accuracy?: number;
timestamp?: number;
}
export interface ParkingLot {
id: number;
name: string;
address: string;
lat: number;
lng: number;
hourlyRate: number;
pricePerHour?: number; // Alias for hourlyRate
openTime?: string;
closeTime?: string;
availableSlots: number;
totalSlots: number;
availableSpaces: number; // Alias for availableSlots
totalSpaces: number; // Alias for totalSlots
amenities: string[] | {
covered?: boolean;
security?: boolean;
ev_charging?: boolean;
wheelchair_accessible?: boolean;
valet_service?: boolean;
[key: string]: any;
};
contactInfo: {
phone?: string;
email?: string;
website?: string;
[key: string]: any;
};
isActive?: boolean;
isOpen24Hours?: boolean;
hasCCTV?: boolean;
isEVCharging?: boolean;
createdAt?: string;
updatedAt?: string;
// Computed properties
distance?: number; // Distance from user in meters
occupancyRate?: number; // Percentage (0-100)
availabilityStatus?: 'available' | 'limited' | 'full';
isOpen?: boolean;
}
export interface RoutePoint {
lat: number;
lng: number;
}
export interface RouteStep {
instruction: string;
distance: number; // meters
time: number; // seconds
type: string;
geometry: RoutePoint[];
}
export interface Route {
summary: {
distance: number; // km
time: number; // minutes
cost?: number; // estimated cost
};
geometry: RoutePoint[];
steps: RouteStep[];
confidence: number;
}
export interface RouteResponse {
routes: Route[];
origin: RoutePoint;
destination: RoutePoint;
requestId: string;
}
// API Request/Response Types
export interface FindNearbyParkingRequest {
lat: number;
lng: number;
radius?: number;
maxResults?: number;
priceRange?: [number, number];
amenities?: string[];
availabilityFilter?: 'available' | 'limited' | 'full';
}
export interface FindNearbyParkingResponse {
parkingLots: ParkingLot[];
userLocation: UserLocation;
searchRadius: number;
}
export interface RouteRequest {
originLat: number;
originLng: number;
destinationLat: number;
destinationLng: number;
costing?: TransportationMode;
alternatives?: number;
avoidHighways?: boolean;
avoidTolls?: boolean;
}
export interface UpdateAvailabilityRequest {
availableSlots: number;
source?: string;
confidence?: number;
metadata?: Record<string, any>;
}
// UI Component Types
export type TransportationMode = 'auto' | 'bicycle' | 'pedestrian';
export interface MapBounds {
north: number;
south: number;
east: number;
west: number;
}
export interface MapViewport {
center: [number, number];
zoom: number;
}
export interface MarkerData {
id: number;
position: [number, number];
type: 'user' | 'parking' | 'selected';
data?: ParkingLot;
}
// Form Types
export interface SearchFilters {
radius: number;
priceRange: [number, number];
amenities: string[];
availabilityFilter?: 'available' | 'limited' | 'full';
transportationMode: TransportationMode;
}
export interface UserPreferences {
defaultRadius: number;
favoriteAmenities: string[];
preferredTransportation: TransportationMode;
units: 'metric' | 'imperial';
theme: 'light' | 'dark' | 'auto';
notifications: {
parkingReminders: boolean;
routeUpdates: boolean;
priceAlerts: boolean;
};
}
// State Management Types
export interface ParkingState {
userLocation: UserLocation | null;
parkingLots: ParkingLot[];
selectedParkingLot: ParkingLot | null;
searchFilters: SearchFilters;
isLoading: boolean;
error: string | null;
}
export interface RouteState {
currentRoute: Route | null;
isCalculating: boolean;
error: string | null;
history: Route[];
}
export interface AppState {
parking: ParkingState;
routing: RouteState;
userPreferences: UserPreferences;
ui: {
sidebarOpen: boolean;
mapLoaded: boolean;
activeView: 'map' | 'list';
};
}
// Error Types
export interface APIError {
message: string;
code: string;
details?: any;
timestamp: string;
}
export interface GeolocationError {
code: number;
message: string;
PERMISSION_DENIED: number;
POSITION_UNAVAILABLE: number;
TIMEOUT: number;
}
// Event Types
export interface ParkingLotSelectEvent {
lot: ParkingLot;
source: 'map' | 'list' | 'search';
}
export interface RouteCalculatedEvent {
route: Route;
duration: number; // calculation time in ms
}
export interface LocationUpdateEvent {
location: UserLocation;
accuracy: number;
}
// Utility Types
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
export type SortOption =
| 'distance'
| 'price'
| 'availability'
| 'rating'
| 'name';
export type FilterOption = {
label: string;
value: string;
count?: number;
icon?: string;
};
export type AmenityType =
| 'covered'
| 'security'
| 'ev_charging'
| 'wheelchair_accessible'
| 'valet_service'
| 'car_wash'
| 'restrooms'
| 'shopping'
| 'dining';
// Analytics Types
export interface AnalyticsEvent {
name: string;
properties: Record<string, any>;
timestamp: number;
userId?: string;
sessionId: string;
}
export interface SearchAnalytics {
query: string;
filters: SearchFilters;
resultsCount: number;
selectionMade: boolean;
timeToSelection?: number;
}
export interface RouteAnalytics {
origin: RoutePoint;
destination: RoutePoint;
mode: TransportationMode;
distance: number;
duration: number;
completed: boolean;
}
// Configuration Types
export interface AppConfig {
api: {
baseUrl: string;
timeout: number;
retryAttempts: number;
};
map: {
defaultCenter: [number, number];
defaultZoom: number;
maxZoom: number;
minZoom: number;
tileUrl: string;
};
features: {
realTimeUpdates: boolean;
routeOptimization: boolean;
offlineMode: boolean;
analytics: boolean;
};
}
// Hook Return Types
export interface UseGeolocationReturn {
location: UserLocation | null;
isLoading: boolean;
error: GeolocationError | null;
requestPermission: () => Promise<void>;
hasPermission: boolean;
watchPosition: () => void;
clearWatch: () => void;
}
export interface UseParkingSearchReturn {
parkingLots: ParkingLot[] | null;
isLoading: boolean;
error: APIError | null;
refetch: () => void;
hasMore: boolean;
loadMore: () => void;
}
export interface UseRoutingReturn {
route: Route | null;
isLoading: boolean;
error: APIError | null;
calculateRoute: (request: RouteRequest) => Promise<void>;
clearRoute: () => void;
alternatives: Route[];
}
// Component Props Types
export interface HeaderProps {
onRefresh?: () => void;
onClearRoute?: () => void;
isLoading?: boolean;
}
export interface MapViewProps {
userLocation: UserLocation | null;
parkingLots: ParkingLot[];
selectedParkingLot: ParkingLot | null;
route: Route | null;
onParkingLotSelect: (lot: ParkingLot) => void;
isLoading?: boolean;
}
export interface ParkingListProps {
parkingLots: ParkingLot[];
selectedLot: ParkingLot | null;
onLotSelect: (lot: ParkingLot) => void;
isLoading?: boolean;
userLocation: UserLocation | null;
}
export interface TransportationSelectorProps {
value: TransportationMode;
onChange: (mode: TransportationMode) => void;
disabled?: boolean;
}

194
frontend/src/utils/map.ts Normal file
View File

@@ -0,0 +1,194 @@
import L from 'leaflet';
// Fix for default markers in React Leaflet
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
export interface MapBounds {
north: number;
south: number;
east: number;
west: number;
}
export interface MapUtils {
createIcon: (type: 'user' | 'parking' | 'selected') => L.Icon;
createBounds: (coordinates: Array<{ lat: number; lng: number }>) => L.LatLngBounds;
formatDistance: (distanceKm: number) => string;
formatDuration: (durationSeconds: number) => string;
getBoundsFromCoordinates: (coords: Array<[number, number]>) => MapBounds;
}
// Custom icons for different marker types
export const mapIcons = {
user: new L.Icon({
iconUrl: '/icons/location.svg',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32],
className: 'user-location-icon',
}),
parking: new L.Icon({
iconUrl: '/icons/car.svg',
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -28],
className: 'parking-icon',
}),
selected: new L.Icon({
iconUrl: '/icons/target.svg',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32],
className: 'selected-parking-icon',
}),
unavailable: new L.Icon({
iconUrl: '/icons/warning.svg',
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -28],
className: 'unavailable-parking-icon',
}),
};
// Map configuration constants
export const MAP_CONFIG = {
defaultCenter: { lat: 1.3521, lng: 103.8198 }, // Singapore
defaultZoom: 12,
maxZoom: 18,
minZoom: 10,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
tileLayerUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
searchRadius: 5000, // 5km in meters
};
// Utility functions
export const mapUtils: MapUtils = {
createIcon: (type: 'user' | 'parking' | 'selected') => {
return mapIcons[type];
},
createBounds: (coordinates: Array<{ lat: number; lng: number }>) => {
if (coordinates.length === 0) {
return new L.LatLngBounds(
[MAP_CONFIG.defaultCenter.lat, MAP_CONFIG.defaultCenter.lng],
[MAP_CONFIG.defaultCenter.lat, MAP_CONFIG.defaultCenter.lng]
);
}
const latLngs = coordinates.map(coord => new L.LatLng(coord.lat, coord.lng));
return new L.LatLngBounds(latLngs);
},
formatDistance: (distanceKm: number): string => {
if (distanceKm < 1) {
return `${Math.round(distanceKm * 1000)}m`;
}
return `${distanceKm.toFixed(1)}km`;
},
formatDuration: (durationSeconds: number): string => {
const minutes = Math.round(durationSeconds / 60);
if (minutes < 60) {
return `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
},
getBoundsFromCoordinates: (coords: Array<[number, number]>): MapBounds => {
if (coords.length === 0) {
return {
north: MAP_CONFIG.defaultCenter.lat + 0.01,
south: MAP_CONFIG.defaultCenter.lat - 0.01,
east: MAP_CONFIG.defaultCenter.lng + 0.01,
west: MAP_CONFIG.defaultCenter.lng - 0.01,
};
}
const lats = coords.map(coord => coord[0]);
const lngs = coords.map(coord => coord[1]);
return {
north: Math.max(...lats),
south: Math.min(...lats),
east: Math.max(...lngs),
west: Math.min(...lngs),
};
},
};
// Route styling
export const routeStyle = {
color: '#2563eb', // Blue
weight: 4,
opacity: 0.8,
dashArray: '0',
lineJoin: 'round' as const,
lineCap: 'round' as const,
};
export const alternativeRouteStyle = {
color: '#6b7280', // Gray
weight: 3,
opacity: 0.6,
dashArray: '5, 10',
lineJoin: 'round' as const,
lineCap: 'round' as const,
};
// Parking lot status colors
export const parkingStatusColors = {
available: '#10b981', // Green
limited: '#f59e0b', // Amber
full: '#ef4444', // Red
unknown: '#6b7280', // Gray
};
// Helper function to get parking lot color based on availability
export const getParkingStatusColor = (
availableSpaces: number,
totalSpaces: number
): string => {
if (totalSpaces === 0) return parkingStatusColors.unknown;
const occupancyRate = 1 - (availableSpaces / totalSpaces);
if (occupancyRate < 0.7) return parkingStatusColors.available;
if (occupancyRate < 0.9) return parkingStatusColors.limited;
return parkingStatusColors.full;
};
// Animation utilities
export const animateMarker = (marker: L.Marker, newPosition: L.LatLng, duration = 1000) => {
const startPosition = marker.getLatLng();
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentLat = startPosition.lat + (newPosition.lat - startPosition.lat) * progress;
const currentLng = startPosition.lng + (newPosition.lng - startPosition.lng) * progress;
marker.setLatLng([currentLat, currentLng]);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
};
// Bounds padding for better map view
export const boundsOptions = {
padding: [20, 20] as [number, number],
maxZoom: 16,
};

126
frontend/tailwind.config.js Normal file
View File

@@ -0,0 +1,126 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#E85A4F', // LACA Red
600: '#D73502', // Darker Red
700: '#8B2635', // Deep Red
800: '#991b1b',
900: '#7f1d1d',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
spacing: {
'18': '4.5rem',
'88': '22rem',
'128': '32rem',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'bounce-gentle': 'bounceGentle 2s infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
slideDown: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(0)' },
},
bounceGentle: {
'0%, 100%': {
transform: 'translateY(-5%)',
animationTimingFunction: 'cubic-bezier(0.8, 0, 1, 1)',
},
'50%': {
transform: 'translateY(0)',
animationTimingFunction: 'cubic-bezier(0, 0, 0.2, 1)',
},
},
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
'glow': '0 0 20px rgba(232, 90, 79, 0.3)',
},
backdropBlur: {
xs: '2px',
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
};

59
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,59 @@
{
"compilerOptions": {
"target": "ES2017",
"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/*"
],
"@/services/*": [
"./src/services/*"
],
"@/types/*": [
"./src/types/*"
],
"@/hooks/*": [
"./src/hooks/*"
],
"@/utils/*": [
"./src/utils/*"
],
"@/styles/*": [
"./src/styles/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -1,27 +0,0 @@
{
"name": "testing",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.4.2"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.4.2",
"@eslint/eslintrc": "^3"
}
}

View File

@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

288
setup.sh Executable file
View File

@@ -0,0 +1,288 @@
#!/bin/bash
# Smart Parking Finder - Development Setup Script
# This script sets up the complete development environment
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check prerequisites
check_prerequisites() {
print_status "Checking prerequisites..."
local missing_deps=()
if ! command_exists docker; then
missing_deps+=("docker")
fi
if ! command_exists docker-compose; then
missing_deps+=("docker-compose")
fi
if ! command_exists node; then
missing_deps+=("node")
fi
if ! command_exists npm; then
missing_deps+=("npm")
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
print_error "Missing required dependencies: ${missing_deps[*]}"
echo ""
echo "Please install the following:"
echo "- Docker: https://docs.docker.com/get-docker/"
echo "- Docker Compose: https://docs.docker.com/compose/install/"
echo "- Node.js 18+: https://nodejs.org/"
exit 1
fi
print_success "All prerequisites are installed!"
}
# Create directory structure
create_structure() {
print_status "Creating project structure..."
# Create main directories
mkdir -p {frontend,backend,valhalla/custom_files}
# Create subdirectories
mkdir -p frontend/{src,public,components,pages}
mkdir -p backend/{src,test,database}
print_success "Project structure created!"
}
# Download OSM data
download_osm_data() {
print_status "Setting up OSM data for Valhalla..."
if [ ! -f "valhalla/custom_files/vietnam-latest.osm.pbf" ]; then
read -p "Do you want to download Vietnam OSM data now? (y/N): " download_osm
if [[ $download_osm =~ ^[Yy]$ ]]; then
print_status "Downloading Vietnam OSM data (~100MB)..."
cd valhalla
./download-osm-data.sh
cd ..
else
print_warning "OSM data not downloaded. Valhalla may not work properly."
print_warning "You can download it later by running: cd valhalla && ./download-osm-data.sh"
fi
else
print_success "OSM data already exists!"
fi
}
# Setup environment files
setup_environment() {
print_status "Setting up environment files..."
# Frontend environment
if [ ! -f "frontend/.env.local" ]; then
cat > frontend/.env.local << EOF
# Frontend Environment Variables
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_MAP_TILES_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
NEXT_PUBLIC_VALHALLA_URL=http://localhost:8002
NEXT_PUBLIC_DEFAULT_LAT=10.7769
NEXT_PUBLIC_DEFAULT_LNG=106.7009
EOF
print_success "Created frontend/.env.local"
fi
# Backend environment
if [ ! -f "backend/.env" ]; then
cat > backend/.env << EOF
# Backend Environment Variables
NODE_ENV=development
PORT=3001
DATABASE_URL=postgresql://parking_user:parking_pass@localhost:5432/parking_db
REDIS_URL=redis://localhost:6379
VALHALLA_URL=http://localhost:8002
JWT_SECRET=your-development-jwt-secret-$(date +%s)
JWT_EXPIRATION=24h
CORS_ORIGIN=http://localhost:3000
API_PREFIX=api
EOF
print_success "Created backend/.env"
fi
}
# Setup Docker services
setup_docker() {
print_status "Setting up Docker services..."
# Check if Docker is running
if ! docker info >/dev/null 2>&1; then
print_error "Docker is not running. Please start Docker first."
exit 1
fi
# Pull images
print_status "Pulling Docker images..."
docker-compose pull postgres redis
# Start infrastructure services
print_status "Starting infrastructure services..."
docker-compose up -d postgres redis
# Wait for services to be ready
print_status "Waiting for services to be ready..."
sleep 10
# Check if services are healthy
if docker-compose ps postgres | grep -q "healthy\|Up"; then
print_success "PostgreSQL is ready!"
else
print_warning "PostgreSQL may still be starting..."
fi
if docker-compose ps redis | grep -q "healthy\|Up"; then
print_success "Redis is ready!"
else
print_warning "Redis may still be starting..."
fi
}
# Setup Valhalla
setup_valhalla() {
print_status "Setting up Valhalla routing engine..."
if [ ! -f "valhalla/custom_files/vietnam-latest.osm.pbf" ]; then
print_warning "No OSM data found. Skipping Valhalla setup."
print_warning "Download OSM data first: cd valhalla && ./download-osm-data.sh"
return
fi
print_status "Building and starting Valhalla (this may take 10-30 minutes)..."
docker-compose up -d valhalla
print_status "Valhalla is processing OSM data. This may take a while..."
print_status "You can check progress with: docker-compose logs -f valhalla"
}
# Install dependencies
install_dependencies() {
print_status "Installing Node.js dependencies..."
# Frontend dependencies
if [ -f "frontend/package.json" ]; then
print_status "Installing frontend dependencies..."
cd frontend && npm install && cd ..
print_success "Frontend dependencies installed!"
else
print_warning "No frontend/package.json found. Skipping frontend dependencies."
fi
# Backend dependencies
if [ -f "backend/package.json" ]; then
print_status "Installing backend dependencies..."
cd backend && npm install && cd ..
print_success "Backend dependencies installed!"
else
print_warning "No backend/package.json found. Skipping backend dependencies."
fi
}
# Setup database
setup_database() {
print_status "Setting up database..."
# Wait for PostgreSQL to be ready
print_status "Waiting for PostgreSQL to be ready..."
timeout=60
while ! docker-compose exec -T postgres pg_isready -U parking_user -d parking_db >/dev/null 2>&1; do
if [ $timeout -le 0 ]; then
print_error "PostgreSQL is not ready after 60 seconds"
exit 1
fi
sleep 1
((timeout--))
done
print_success "PostgreSQL is ready!"
# Run migrations (if backend exists)
if [ -f "backend/package.json" ]; then
print_status "Running database migrations..."
cd backend
# npm run migration:run # Uncomment when migrations exist
cd ..
print_success "Database migrations completed!"
fi
}
# Main setup function
main() {
echo ""
echo "🚗 Smart Parking Finder - Development Setup"
echo "==========================================="
echo ""
check_prerequisites
create_structure
setup_environment
download_osm_data
setup_docker
install_dependencies
setup_database
setup_valhalla
echo ""
echo "🎉 Setup completed successfully!"
echo ""
echo "Next steps:"
echo "1. Start the development servers:"
echo " - Frontend: cd frontend && npm run dev"
echo " - Backend: cd backend && npm run start:dev"
echo ""
echo "2. Access the applications:"
echo " - Frontend: http://localhost:3000"
echo " - Backend API: http://localhost:3001"
echo " - Database (pgAdmin): http://localhost:5050 (with --profile tools)"
echo " - Redis (Commander): http://localhost:8081 (with --profile tools)"
echo " - Valhalla: http://localhost:8002/status"
echo ""
echo "3. Useful commands:"
echo " - View logs: docker-compose logs -f [service]"
echo " - Stop services: docker-compose down"
echo " - Restart services: docker-compose restart [service]"
echo " - Start with tools: docker-compose --profile tools up -d"
echo ""
echo "💡 If Valhalla is still processing data, wait for it to complete"
echo " Check status: curl http://localhost:8002/status"
}
# Run main function
main "$@"

Some files were not shown because too many files have changed in this diff Show More