🎯 MapView v2.0 - Global Deployment Ready

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

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

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

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

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

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

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

20
frontend/.env.local Normal file
View File

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

10
frontend/.env.production Normal file
View File

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

8
frontend/.eslintrc.json Normal file
View File

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

1
frontend/.gitignore vendored Normal file
View File

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

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

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

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

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

7494
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
frontend/package.json Normal file
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

59
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,59 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/components/*": [
"./src/components/*"
],
"@/services/*": [
"./src/services/*"
],
"@/types/*": [
"./src/types/*"
],
"@/hooks/*": [
"./src/hooks/*"
],
"@/utils/*": [
"./src/utils/*"
],
"@/styles/*": [
"./src/styles/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}