🎯 MapView v2.0 - Global Deployment Ready
✨ MAJOR FEATURES: • Auto-zoom intelligence với smart bounds fitting • Enhanced 3D GPS markers với pulsing effects • Professional route display với 6-layer rendering • Status-based parking icons với availability indicators • Production-ready build optimizations 🗺️ AUTO-ZOOM FEATURES: • Smart bounds fitting cho GPS + selected parking • Adaptive padding (50px) cho visual balance • Max zoom control (level 16) để tránh quá gần • Dynamic centering khi không có selection 🎨 ENHANCED VISUALS: • 3D GPS marker với multi-layer pulse effects • Advanced parking icons với status colors • Selection highlighting với animation • Dimming system cho non-selected items 🛣️ ROUTE SYSTEM: • OpenRouteService API integration • Multi-layer route rendering (glow, shadow, main, animated) • Real-time distance & duration calculation • Visual route info trong popup 📱 PRODUCTION READY: • SSR safe với dynamic imports • Build errors resolved • Global deployment via Vercel • Optimized performance 🌍 DEPLOYMENT: • Vercel: https://whatever-ctk2auuxr-phong12hexdockworks-projects.vercel.app • Bundle size: 22.8 kB optimized • Global CDN distribution • HTTPS enabled 💾 VERSION CONTROL: • MapView-v2.0.tsx backup created • MAPVIEW_VERSIONS.md documentation • Full version history tracking
This commit is contained in:
20
frontend/.env.local
Normal file
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
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
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
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
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
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;
|
||||
7494
frontend/package-lock.json
generated
Normal file
7494
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
frontend/package.json
Normal file
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
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/assets/Location.png
Normal file
BIN
frontend/public/assets/Location.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/public/assets/Logo.png
Normal file
BIN
frontend/public/assets/Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/assets/Logo_and_sologan.png
Normal file
BIN
frontend/public/assets/Logo_and_sologan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
frontend/public/assets/mini_location.png
Normal file
BIN
frontend/public/assets/mini_location.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
626
frontend/src/app/globals.css
Normal file
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
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
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
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
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
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
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
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
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
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-v2.0.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1090
frontend/src/components/map/MapView.tsx
Normal file
1090
frontend/src/components/map/MapView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
394
frontend/src/components/parking/ParkingList.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
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
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
0
frontend/src/components/ui/ErrorMessage.tsx
Normal file
63
frontend/src/components/ui/Icon.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
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
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
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
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
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
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
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
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
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
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
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
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
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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user