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