🎯 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
41
.gitignore
vendored
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
510
DEPLOYMENT.md
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
smart-parking-finder/
|
||||
├── frontend/ # Next.js frontend application
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # App router pages
|
||||
│ │ ├── components/ # Reusable React components
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── 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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
3. **Install dependencies**
|
||||
```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
@@ -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
|
After Width: | Height: | Size: 9.0 KiB |
BIN
assets/Logo.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/Logo_and_sologan.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/mini_location.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
37
backend/.env
Normal 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
@@ -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
@@ -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
105
backend/package.json
Normal 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
@@ -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 {}
|
||||
32
backend/src/config/database.config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
7
backend/src/database/seeds/initial-setup.sql
Normal 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;
|
||||
255
backend/src/database/seeds/parking-lots.seed.ts
Normal 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
@@ -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();
|
||||
15
backend/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
17
backend/src/modules/auth/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
backend/src/modules/health/health.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
backend/src/modules/health/health.module.ts
Normal 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 {}
|
||||
13
backend/src/modules/health/health.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
87
backend/src/modules/parking/dto/find-nearby-parking.dto.ts
Normal 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';
|
||||
}
|
||||
43
backend/src/modules/parking/dto/update-availability.dto.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
121
backend/src/modules/parking/entities/parking-lot.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
179
backend/src/modules/parking/parking.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
backend/src/modules/parking/parking.module.ts
Normal 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 {}
|
||||
171
backend/src/modules/parking/parking.service.ts
Normal 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[];
|
||||
}
|
||||
}
|
||||
124
backend/src/modules/routing/dto/route-request.dto.ts
Normal 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;
|
||||
}
|
||||
69
backend/src/modules/routing/routing.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/routing/routing.module.ts
Normal 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 {}
|
||||
232
backend/src/modules/routing/routing.service.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
50
backend/src/modules/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
backend/src/modules/users/users.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
backend/src/modules/users/users.module.ts
Normal 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 {}
|
||||
38
backend/src/modules/users/users.service.ts
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
58
frontend/next.config.js
Normal 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;
|
||||
4381
package-lock.json → frontend/package-lock.json
generated
79
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/assets/Location.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/public/assets/Logo.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/assets/Logo_and_sologan.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
frontend/public/assets/mini_location.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
626
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
89
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
frontend/src/app/page-hcmc.tsx
Normal 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 và 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
@@ -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 có 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 (>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 (<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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/components/GPSSimulator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
507
frontend/src/components/HCMCGPSSimulator.tsx
Normal 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ị 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>
|
||||
);
|
||||
};
|
||||
106
frontend/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
152
frontend/src/components/LocationDetector.tsx
Normal 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 "Detect Location" 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'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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
108
frontend/src/components/LocationPermissionDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1090
frontend/src/components/map/MapView-v2.0.tsx
Normal file
1090
frontend/src/components/map/MapView.tsx
Normal file
394
frontend/src/components/parking/ParkingList.tsx
Normal 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 rõ
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
giờ mở cửa
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
366
frontend/src/components/parking/ParkingList.v1.0.tsx
Normal 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 rõ
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
giờ mở cửa
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
43
frontend/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
0
frontend/src/components/ui/ErrorMessage.tsx
Normal file
63
frontend/src/components/ui/Icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
frontend/src/components/ui/LoadingSpinner.tsx
Normal 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
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
115
frontend/src/hooks/useGeolocation.ts
Normal 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()
|
||||
};
|
||||
};
|
||||
595
frontend/src/hooks/useParkingSearch-simple.ts
Normal 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);
|
||||
}
|
||||
603
frontend/src/hooks/useParkingSearch.ts
Normal 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);
|
||||
}
|
||||
138
frontend/src/hooks/useRouting-simple.ts
Normal 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);
|
||||
}
|
||||
138
frontend/src/hooks/useRouting.ts
Normal 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);
|
||||
}
|
||||
118
frontend/src/services/api.ts
Normal 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(),
|
||||
};
|
||||
213
frontend/src/services/location.ts
Normal 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
@@ -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
@@ -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: '© <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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
27
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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 "$@"
|
||||