feat: Enhanced CSS animations, improved UI components, and project reorganization

- Enhanced globals.css with comprehensive animation system
- Added advanced map marker animations (GPS, parking)
- Improved button and filter animations with hover effects
- Added new UI components: BookingModal, ParkingDetails, WheelPicker
- Reorganized project structure with better documentation
- Added optimization scripts and improved development workflow
- Updated deployment guides and technical documentation
- Enhanced mobile responsiveness and accessibility support
This commit is contained in:
2025-08-03 07:00:22 +07:00
parent 4f8d8c40c2
commit bc87a88719
34 changed files with 5851 additions and 2353 deletions

View File

View File

@@ -4,7 +4,9 @@ import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { Header } from '@/components/Header';
import { ParkingList } from '@/components/parking/ParkingList';
import { ParkingDetails } from '@/components/parking/ParkingDetails';
import { HCMCGPSSimulator } from '@/components/HCMCGPSSimulator';
import { Icon } from '@/components/ui/Icon';
// import { ErrorMessage } from '@/components/ui/ErrorMessage';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useParkingSearch } from '@/hooks/useParkingSearch';
@@ -31,6 +33,16 @@ export default function ParkingFinderPage() {
const [isMobile, setIsMobile] = useState(false);
const [sortType, setSortType] = useState<'availability' | 'price' | 'distance'>('availability');
const [gpsSimulatorVisible, setGpsSimulatorVisible] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [currentTime, setCurrentTime] = useState(new Date());
// Update time every minute
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
// Set initial GPS window position after component mounts
useEffect(() => {
@@ -219,6 +231,78 @@ export default function ParkingFinderPage() {
</svg>
Làm mới danh sách
</button>
{/* Status Info Bar - Thiết kế thanh lịch đơn giản */}
<div className="mt-4 bg-white/80 backdrop-blur-sm rounded-xl border border-gray-200/50 shadow-sm">
<div className="px-4 py-3">
<div className="flex items-center justify-between">
{/* Thống kê bãi đỗ */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full bg-emerald-500"></div>
<span className="text-sm text-gray-700 font-medium">
{parkingLots.filter(lot => lot.availableSlots > 0).length} chỗ
</span>
</div>
<div className="w-px h-4 bg-gray-300"></div>
<div className="flex items-center space-x-2">
<div className="w-2 h-2 rounded-full bg-red-500"></div>
<span className="text-sm text-gray-700 font-medium">
{parkingLots.filter(lot => lot.availableSlots === 0).length} đy
</span>
</div>
</div>
{/* Thông tin thời tiết và giờ */}
<div className="flex items-center space-x-4 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<span>{currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? '☀️' : '🌙'}</span>
<span className="font-medium">
{currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? '28°C' : '24°C'}
</span>
</div>
<div className="w-px h-4 bg-gray-300"></div>
<div className="font-medium">
{currentTime.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
</div>
</div>
{/* Search Bar */}
<div className="mt-4 relative">
<div className="relative">
<input
type="text"
placeholder="Tìm kiếm bãi đỗ xe..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 pl-12 pr-10 text-sm font-medium rounded-2xl border-2 transition-all duration-300 focus:outline-none focus:ring-4 focus:ring-orange-100 focus:border-orange-300"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
backgroundColor: 'rgba(255, 255, 255, 0.95)'
}}
/>
{/* Search Icon */}
<div className="absolute left-4 top-1/2 transform -translate-y-1/2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Clear Button */}
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-gray-100 transition-colors duration-200"
>
<svg className="w-4 h-4 text-gray-400 hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
</div>
{/* Filter buttons - Below header */}
@@ -236,13 +320,13 @@ export default function ParkingFinderPage() {
<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>
<span className="text-lg 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 ${
className={`px-3 py-3 text-sm font-bold rounded-lg transition-all duration-300 shadow-md flex items-center justify-center ${
sortType === 'availability'
? 'transform scale-105'
: 'hover:transform hover:scale-105'
@@ -255,13 +339,18 @@ export default function ParkingFinderPage() {
borderColor: sortType === 'availability' ? 'var(--primary-color)' : 'rgba(232, 90, 79, 0.3)',
border: '2px solid'
}}
title="Sắp xếp theo chỗ trống"
>
Chỗ trống
<Icon
name="car"
size="lg"
className={sortType === 'availability' ? 'text-white' : 'text-orange-500'}
/>
</button>
<button
onClick={() => setSortType('price')}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all duration-300 shadow-md ${
className={`px-3 py-3 text-sm font-bold rounded-lg transition-all duration-300 shadow-md flex items-center justify-center ${
sortType === 'price'
? 'transform scale-105'
: 'hover:transform hover:scale-105'
@@ -274,14 +363,19 @@ export default function ParkingFinderPage() {
borderColor: sortType === 'price' ? '#10B981' : 'rgba(16, 185, 129, 0.3)',
border: '2px solid'
}}
title="Sắp xếp theo giá rẻ"
>
Giá rẻ
<Icon
name="currency"
size="xl"
className={sortType === 'price' ? 'text-white' : 'text-green-600'}
/>
</button>
<button
onClick={() => setSortType('distance')}
disabled={!userLocation}
className={`px-4 py-2 text-sm font-bold rounded-lg transition-all duration-300 shadow-md ${
className={`px-3 py-3 text-sm font-bold rounded-lg transition-all duration-300 shadow-md flex items-center justify-center ${
sortType === 'distance'
? 'transform scale-105'
: userLocation
@@ -290,18 +384,27 @@ export default function ParkingFinderPage() {
}`}
style={{
background: sortType === 'distance'
? 'linear-gradient(135deg, #8B5CF6, #7C3AED)'
? 'linear-gradient(135deg, #F59E0B, #D97706)'
: userLocation ? 'white' : '#F9FAFB',
color: sortType === 'distance'
? 'white'
: userLocation ? '#7C3AED' : '#9CA3AF',
: userLocation ? '#D97706' : '#9CA3AF',
borderColor: sortType === 'distance'
? '#8B5CF6'
: userLocation ? 'rgba(139, 92, 246, 0.3)' : '#E5E7EB',
? '#F59E0B'
: userLocation ? 'rgba(245, 158, 11, 0.3)' : '#E5E7EB',
border: '2px solid'
}}
title="Sắp xếp theo khoảng cách gần nhất"
>
Gần nhất
<Icon
name="distance"
size="lg"
className={
sortType === 'distance'
? 'text-white'
: userLocation ? 'text-amber-600' : 'text-gray-400'
}
/>
</button>
</div>
</div>
@@ -337,6 +440,7 @@ export default function ParkingFinderPage() {
selectedId={selectedParkingLot?.id}
userLocation={userLocation}
sortType={sortType}
searchQuery={searchQuery}
/>
)}
</div>
@@ -356,7 +460,25 @@ export default function ParkingFinderPage() {
)}
</div>
{/* Map Section - Center */}
{/* Middle Column - Parking Details */}
{selectedParkingLot && (
<div className="w-[26rem] h-full">
<ParkingDetails
parkingLot={selectedParkingLot}
userLocation={userLocation}
onClose={() => {
setSelectedParkingLot(null);
clearRoute();
}}
onBook={(lot) => {
toast.success(`Đã đặt chỗ tại ${lot.name}!`);
// Here you would typically call an API to book the parking spot
}}
/>
</div>
)}
{/* Map Section - Right */}
<div className="flex-1 h-full relative">
<MapView
userLocation={userLocation}
@@ -367,9 +489,9 @@ export default function ParkingFinderPage() {
className="w-full h-full"
/>
{/* Map overlay info - Moved to bottom right */}
{/* Map overlay info - Position based on layout */}
{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="absolute bottom-6 right-24 z-10 bg-white rounded-3xl shadow-2xl p-6 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

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@ export const MapView: React.FC<MapViewProps> = ({
const [routeInfo, setRouteInfo] = useState<{distance: number, duration: number} | null>(null);
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false);
const [debugInfo, setDebugInfo] = useState<string>('');
const [currentZoom, setCurrentZoom] = useState<number>(13);
// OpenRouteService API key
const ORS_API_KEY = "eyJvcmciOiI1YjNjZTM1OTc4NTExMTAwMDFjZjYyNDgiLCJpZCI6ImJmMjM5NTNiMjNlNzQzZWY4NWViMDFlYjNkNTRkNmVkIiwiaCI6Im11cm11cjY0In0=";
@@ -225,27 +226,122 @@ export const MapView: React.FC<MapViewProps> = ({
}
}, []);
// Auto zoom to fit user location and selected parking lot
// Calculate distance between two points
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;
};
// Smart zoom calculation based on distance with enhanced levels
const calculateOptimalZoom = (distance: number): number => {
if (distance < 0.2) return 18; // Very very close - max detail
if (distance < 0.5) return 17; // Very close - detailed view
if (distance < 1) return 16; // Close - street level
if (distance < 2) return 15; // Nearby - neighborhood
if (distance < 5) return 14; // Medium distance - district
if (distance < 10) return 13; // Far - city area
if (distance < 20) return 12; // Very far - wider area
return 11; // Extremely far - metropolitan area
};
// Debug effect to track selectedParkingLot changes
useEffect(() => {
if (mapRef.current && userLocation) {
if (selectedParkingLot) {
// Create bounds that include both user location and selected parking lot
const bounds = [
[userLocation.lat, userLocation.lng],
[selectedParkingLot.lat, selectedParkingLot.lng]
];
// Fit map to bounds with padding
mapRef.current.fitBounds(bounds, {
padding: [50, 50],
maxZoom: 16
});
console.log('Selected parking lot changed:', {
previous: 'tracked in state',
current: selectedParkingLot?.name || 'None',
id: selectedParkingLot?.id || 'None'
});
}, [selectedParkingLot]);
// Smart camera movement to selected parking station
useEffect(() => {
console.log('Camera movement effect triggered:', {
mapReady: !!mapRef.current,
selectedParkingLot: selectedParkingLot?.name || 'None',
userLocation: !!userLocation
});
// Add a small delay to ensure map is fully ready
const timer = setTimeout(() => {
if (mapRef.current) {
if (selectedParkingLot) {
console.log('Moving camera to selected parking lot:', selectedParkingLot.name);
if (userLocation) {
// Calculate distance between user and parking station
const distance = calculateDistance(
userLocation.lat,
userLocation.lng,
selectedParkingLot.lat,
selectedParkingLot.lng
);
console.log('Distance between user and parking:', distance, 'km');
// If parking station is far from user, show both locations
if (distance > 2) {
const bounds = [
[userLocation.lat, userLocation.lng],
[selectedParkingLot.lat, selectedParkingLot.lng]
];
// Calculate optimal padding based on distance
const padding = distance < 5 ? [80, 80] : distance < 10 ? [60, 60] : [40, 40];
const maxZoom = calculateOptimalZoom(distance);
console.log('Using fitBounds with padding:', padding, 'maxZoom:', maxZoom);
// Fit map to bounds with smart padding and max zoom
mapRef.current.fitBounds(bounds, {
padding: padding,
maxZoom: maxZoom,
animate: true,
duration: 1.5
});
} else {
// If parking station is close, center between user and parking
const centerLat = (userLocation.lat + selectedParkingLot.lat) / 2;
const centerLng = (userLocation.lng + selectedParkingLot.lng) / 2;
console.log('Using setView to center point:', { centerLat, centerLng });
mapRef.current.setView([centerLat, centerLng], 16, {
animate: true,
duration: 1.2
});
}
} else {
// No user location, just focus on parking station
console.log('No user location, focusing only on parking station');
mapRef.current.setView([selectedParkingLot.lat, selectedParkingLot.lng], 17, {
animate: true,
duration: 1.0
});
}
} else if (userLocation) {
// No parking station selected, center on user location
console.log('No parking selected, centering on user location');
mapRef.current.setView([userLocation.lat, userLocation.lng], 15, {
animate: true,
duration: 0.8
});
}
} else {
// Just center on user location
mapRef.current.setView([userLocation.lat, userLocation.lng], 15);
console.log('Map ref not ready yet');
}
}
}, [userLocation, selectedParkingLot]);
}, 200); // Small delay to ensure map is ready
return () => clearTimeout(timer);
}, [selectedParkingLot, userLocation]);
// Calculate route when parking lot is selected
useEffect(() => {
@@ -742,13 +838,206 @@ export const MapView: React.FC<MapViewProps> = ({
// );
// }
// Custom zoom functions
const zoomIn = () => {
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
mapRef.current.setZoom(Math.min(currentZoom + 1, 18), { animate: true });
}
};
const zoomOut = () => {
if (mapRef.current) {
const currentZoom = mapRef.current.getZoom();
mapRef.current.setZoom(Math.max(currentZoom - 1, 3), { animate: true });
}
};
const zoomToUser = () => {
if (mapRef.current && userLocation) {
mapRef.current.setView([userLocation.lat, userLocation.lng], 16, {
animate: true,
duration: 1.0
});
}
};
const zoomToFitAll = () => {
if (mapRef.current && parkingLots.length > 0) {
const bounds = parkingLots.map(lot => [lot.lat, lot.lng]);
if (userLocation) {
bounds.push([userLocation.lat, userLocation.lng]);
}
mapRef.current.fitBounds(bounds, {
padding: [40, 40],
maxZoom: 15,
animate: true
});
}
};
const zoomToSelected = () => {
if (mapRef.current && selectedParkingLot) {
console.log('zoomToSelected called for:', selectedParkingLot.name);
// Focus directly on the selected parking station
mapRef.current.setView([selectedParkingLot.lat, selectedParkingLot.lng], 18, {
animate: true,
duration: 1.2
});
}
};
// Helper function to move camera with better error handling
const moveCameraTo = (lat: number, lng: number, zoom: number = 17, duration: number = 1.2) => {
if (!mapRef.current) {
console.warn('Map ref not available for camera movement');
return;
}
try {
console.log('Moving camera to:', { lat, lng, zoom });
mapRef.current.setView([lat, lng], zoom, {
animate: true,
duration: duration,
easeLinearity: 0.1
});
} catch (error) {
console.error('Error moving camera:', error);
}
};
return (
<div className={`${finalClassName} map-container`} style={{ minHeight: '0', flexGrow: 1, height: '100%' }}>
<div className={`${finalClassName} map-container relative`} style={{ minHeight: '0', flexGrow: 1, height: '100%' }}>
{/* Custom Zoom Controls */}
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-2">
{/* Zoom Level Display */}
<div
className="px-3 py-1 bg-white rounded-lg shadow-lg text-xs font-bold text-center border-2"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))',
color: 'var(--accent-color)'
}}
>
Zoom: {currentZoom.toFixed(0)}
</div>
{/* Zoom In */}
<button
onClick={zoomIn}
className="w-10 h-10 bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center text-gray-700 hover:text-white border-2 hover:scale-110"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))';
}}
>
<svg className="w-5 h-5 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
{/* Zoom Out */}
<button
onClick={zoomOut}
className="w-10 h-10 bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center text-gray-700 hover:text-white border-2 hover:scale-110"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))';
}}
>
<svg className="w-5 h-5 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M18 12H6" />
</svg>
</button>
{/* Zoom to User */}
{userLocation && (
<button
onClick={zoomToUser}
className="w-10 h-10 bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center text-gray-700 hover:text-white border-2 hover:scale-110"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))';
}}
title="Zoom đến vị trí của bạn"
>
<svg className="w-5 h-5 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
<circle cx="12" cy="9" r="2.5" strokeWidth={2.5} />
</svg>
</button>
)}
{/* Zoom to Selected Parking */}
{selectedParkingLot && (
<button
onClick={zoomToSelected}
className="w-10 h-10 bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center text-gray-700 hover:text-white border-2 hover:scale-110"
style={{
borderColor: 'rgba(220, 38, 38, 0.3)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(254, 242, 242, 0.95))'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #DC2626, #B91C1C)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(254, 242, 242, 0.95))';
}}
title="Zoom đến bãi xe đã chọn"
>
<svg className="w-5 h-5 font-bold" 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>
</button>
)}
{/* Fit All */}
{parkingLots.length > 0 && (
<button
onClick={zoomToFitAll}
className="w-10 h-10 bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center text-gray-700 hover:text-white border-2 hover:scale-110"
style={{
borderColor: 'rgba(232, 90, 79, 0.2)',
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.95))';
}}
title="Xem tất cả bãi xe"
>
<svg className="w-5 h-5 font-bold" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 8V4a2 2 0 012-2h2M4 16v4a2 2 0 002 2h2m8-20h2a2 2 0 012 2v4m0 8v4a2 2 0 01-2 2h-2" />
</svg>
</button>
)}
</div>
<MapContainer
center={[center.lat, center.lng]}
zoom={13}
style={{ height: '100%', width: '100%', minHeight: '100%' }}
zoomControl={true}
zoomControl={false}
scrollWheelZoom={true}
doubleClickZoom={true}
touchZoom={true}
@@ -756,12 +1045,39 @@ export const MapView: React.FC<MapViewProps> = ({
keyboard={true}
dragging={true}
attributionControl={true}
zoomAnimation={true}
fadeAnimation={true}
markerZoomAnimation={true}
inertia={true}
worldCopyJump={false}
maxBoundsViscosity={0.3}
ref={mapRef}
whenReady={() => {
// Force invalidate size when map is ready
setTimeout(() => {
if (mapRef.current) {
console.log('Map is ready, invalidating size...');
mapRef.current.invalidateSize();
// Add zoom event listener
mapRef.current.on('zoomend', () => {
if (mapRef.current) {
setCurrentZoom(mapRef.current.getZoom());
}
});
// Add moveend listener for debugging
mapRef.current.on('moveend', () => {
if (mapRef.current) {
const center = mapRef.current.getCenter();
console.log('Map moved to:', { lat: center.lat, lng: center.lng, zoom: mapRef.current.getZoom() });
}
});
// Set initial zoom level
setCurrentZoom(mapRef.current.getZoom());
console.log('Map setup complete');
}
}, 100);
}}
@@ -769,8 +1085,12 @@ export const MapView: React.FC<MapViewProps> = ({
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
maxZoom={18}
maxZoom={19}
minZoom={3}
maxNativeZoom={18}
tileSize={256}
zoomOffset={0}
detectRetina={true}
/>
{/* User Location Marker (GPS với hiệu ứng pulse) */}
@@ -814,7 +1134,15 @@ export const MapView: React.FC<MapViewProps> = ({
)}
eventHandlers={{
click: () => {
console.log('Parking marker clicked:', lot.name);
// First select the parking lot
onParkingLotSelect?.(lot);
// Then smoothly move camera to the parking lot with a slight delay
setTimeout(() => {
moveCameraTo(lot.lat, lot.lng, 17, 1.5);
}, 300);
}
}}
>
@@ -948,20 +1276,34 @@ export const MapView: React.FC<MapViewProps> = ({
</div>
</div>
<button
onClick={() => onParkingLotSelect?.(lot)}
className={`w-full px-4 py-3 rounded-xl text-sm font-bold transition-all duration-300 transform hover:scale-105 border-2 ${
isSelected
? 'bg-red-600 text-white hover:bg-red-700 border-red-700 shadow-lg shadow-red-300/50'
: 'bg-blue-600 text-white hover:bg-blue-700 border-blue-700 shadow-lg shadow-blue-300/50'
}`}
>
{isSelected ? (
<> Bỏ chọn bãi đ xe</>
) : (
<>🎯 Chọn bãi đ xe này</>
)}
</button>
<div className="flex gap-2">
<button
onClick={() => onParkingLotSelect?.(lot)}
className={`flex-1 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-300 transform hover:scale-105 border-2 ${
isSelected
? 'bg-red-600 text-white hover:bg-red-700 border-red-700 shadow-lg shadow-red-300/50'
: 'bg-blue-600 text-white hover:bg-blue-700 border-blue-700 shadow-lg shadow-blue-300/50'
}`}
>
{isSelected ? (
<> Bỏ chọn</>
) : (
<>🎯 Chọn bãi xe</>
)}
</button>
{/* Focus camera button */}
<button
onClick={() => {
console.log('Focus button clicked for:', lot.name);
moveCameraTo(lot.lat, lot.lng, 18, 1.2);
}}
className="px-4 py-3 rounded-xl text-sm font-bold transition-all duration-300 transform hover:scale-105 border-2 bg-green-600 text-white hover:bg-green-700 border-green-700 shadow-lg shadow-green-300/50"
title="Di chuyển camera đến bãi xe"
>
📹 Focus
</button>
</div>
</div>
</div>
</Popup>

View File

@@ -0,0 +1,731 @@
'use client';
import React, { useState } from 'react';
import { ParkingLot, UserLocation } from '@/types';
interface ParkingDetailsProps {
parkingLot: ParkingLot;
userLocation?: UserLocation | null;
onClose?: () => void;
onBook?: (lot: ParkingLot) => void;
}
// Interface for parking slot data
interface ParkingSlot {
id: string;
status: 'available' | 'occupied' | 'reserved';
type: 'regular' | 'ev' | 'disabled';
x: number;
y: number;
width: number;
height: number;
}
interface ParkingFloor {
floor: number;
name: string;
slots: ParkingSlot[];
entrances: { x: number; y: number; type: 'entrance' | 'exit' }[];
evStations: { x: number; y: number; id: string }[];
walkways: { x: number; y: number; width: number; height: number }[];
}
// Thiết kế bãi xe đẹp và chuyên nghiệp
const generateParkingFloorData = (floorNumber: number): ParkingFloor => {
const slots: ParkingSlot[] = [];
const walkways = [];
// Layout tối ưu: 2 khu vực, mỗi khu 2 dãy, mỗi dãy 5 chỗ
const sectionsPerFloor = 2;
const rowsPerSection = 2;
const slotsPerRow = 5;
const slotWidth = 32;
const slotHeight = 45;
const rowSpacing = 50;
const sectionSpacing = 80;
const columnSpacing = 4;
const startX = 40;
const startY = 40;
for (let section = 0; section < sectionsPerFloor; section++) {
for (let row = 0; row < rowsPerSection; row++) {
for (let col = 0; col < slotsPerRow; col++) {
const rowLetter = String.fromCharCode(65 + (section * rowsPerSection + row));
const slotId = `${rowLetter}${String(col + 1).padStart(2, '0')}`;
const x = startX + col * (slotWidth + columnSpacing);
const y = startY + section * sectionSpacing + row * rowSpacing;
// Phân bố trạng thái thực tế
const rand = Math.random();
let status: 'available' | 'occupied' | 'reserved';
if (rand < 0.3) status = 'occupied';
else if (rand < 0.35) status = 'reserved';
else status = 'available';
// Chỗ đặc biệt
let type: 'regular' | 'ev' | 'disabled' = 'regular';
if (section === 0 && row === 0 && col === 0) type = 'disabled';
if (section === 0 && row === 0 && col === slotsPerRow - 1) type = 'ev';
if (section === 1 && row === 0 && col === slotsPerRow - 1) type = 'ev';
slots.push({
id: slotId,
status,
type,
x,
y,
width: slotWidth,
height: slotHeight
});
}
}
}
// Hệ thống đường đi thông minh
walkways.push(
// Đường vào chính
{ x: 15, y: 10, width: 200, height: 20 },
// Đường dọc chính (trái)
{ x: 15, y: 10, width: 20, height: 240 },
// Đường dọc chính (phải)
{ x: 195, y: 10, width: 20, height: 240 },
// Đường ngang giữa section 1
{ x: 15, y: 65, width: 200, height: 15 },
// Đường ngang giữa 2 section
{ x: 15, y: 105, width: 200, height: 20 },
// Đường ngang giữa section 2
{ x: 15, y: 145, width: 200, height: 15 },
// Đường ra
{ x: 15, y: 230, width: 200, height: 20 }
);
return {
floor: floorNumber,
name: `Tầng ${floorNumber}`,
slots,
entrances: [
{ x: 60, y: 10, type: 'entrance' },
{ x: 155, y: 230, type: 'exit' }
],
evStations: [
{ x: 200, y: 40, id: `EV-${floorNumber}-01` },
{ x: 200, y: 120, id: `EV-${floorNumber}-02` }
],
walkways
};
};
// Parking Lot Map Component
const ParkingLotMap: React.FC<{ parkingLot: ParkingLot }> = ({ parkingLot }) => {
const [selectedFloor, setSelectedFloor] = useState(1);
const [lastUpdate, setLastUpdate] = useState(new Date());
// Generate 3 floors for demo
const floors = [1, 2, 3].map(generateParkingFloorData);
const currentFloor = floors.find(f => f.floor === selectedFloor) || floors[0];
// Real-time update simulation
React.useEffect(() => {
const interval = setInterval(() => {
setLastUpdate(new Date());
// Here you would typically fetch real-time data
}, 60000); // Update every 60 seconds (1 minute) instead of 10 seconds
return () => clearInterval(interval);
}, []);
const getSlotColor = (slot: ParkingSlot) => {
switch (slot.status) {
case 'available':
return '#22C55E'; // Green for all available slots
case 'occupied':
return '#EF4444'; // Red
case 'reserved':
return '#F59E0B'; // Amber
default:
return '#6B7280'; // Gray
}
};
const getSlotIcon = (slot: ParkingSlot) => {
// No special icons needed anymore
return null;
};
const floorStats = currentFloor.slots.reduce((acc, slot) => {
acc.total++;
if (slot.status === 'available') acc.available++;
if (slot.status === 'occupied') acc.occupied++;
return acc;
}, { total: 0, available: 0, occupied: 0 });
return (
<div className="w-full space-y-4">
{/* Floor selector */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{floors.map((floor) => (
<button
key={floor.floor}
onClick={() => setSelectedFloor(floor.floor)}
className={`px-3 py-2 rounded-lg font-medium text-sm transition-all ${
selectedFloor === floor.floor
? 'text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
style={{
background: selectedFloor === floor.floor
? 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
: undefined
}}
>
{floor.name}
</button>
))}
</div>
{/* Real-time indicator */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Cập nhật: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
{/* Floor stats */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center p-2 bg-green-50 rounded border border-green-200">
<div className="font-bold text-green-600">{floorStats.available}</div>
<div className="text-green-700">Trống</div>
</div>
<div className="text-center p-2 bg-red-50 rounded border border-red-200">
<div className="font-bold text-red-600">{floorStats.occupied}</div>
<div className="text-red-700">Đã đu</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded border border-gray-200">
<div className="font-bold text-gray-600">{floorStats.total}</div>
<div className="text-gray-700">Tổng</div>
</div>
</div>
</div>
);
};
// Sample reviews data (in real app, this would come from API)
const SAMPLE_REVIEWS = [
{
id: 1,
user: 'Nguyễn Văn A',
rating: 5,
comment: 'Bãi xe rộng rãi, bảo vệ 24/7 rất an toàn. Giá cả hợp lý.',
date: '2024-01-15',
avatar: 'N'
},
{
id: 2,
user: 'Trần Thị B',
rating: 4,
comment: 'Vị trí thuận tiện, dễ tìm. Chỉ hơi xa lối ra một chút.',
date: '2024-01-10',
avatar: 'T'
},
{
id: 3,
user: 'Lê Văn C',
rating: 5,
comment: 'Có sạc điện cho xe điện, rất tiện lợi!',
date: '2024-01-08',
avatar: 'L'
}
];
// Calculate distance between two points
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`;
};
// Check if parking lot is currently open
const isCurrentlyOpen = (lot: ParkingLot): boolean => {
if (lot.isOpen24Hours) return true;
if (!lot.openTime || !lot.closeTime) return true;
const now = new Date();
const currentTime = now.getHours() * 100 + now.getMinutes();
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) {
return currentTime >= openTime && currentTime <= closeTime;
} else {
return currentTime >= openTime || currentTime <= closeTime;
}
};
const getStatusColor = (availableSlots: number, totalSlots: number) => {
const percentage = availableSlots / totalSlots;
if (availableSlots === 0) {
return {
background: 'rgba(239, 68, 68, 0.15)',
borderColor: '#EF4444',
textColor: '#EF4444'
};
} else if (percentage > 0.7) {
return {
background: 'rgba(34, 197, 94, 0.1)',
borderColor: 'var(--success-color)',
textColor: 'var(--success-color)'
};
} else {
return {
background: 'rgba(251, 191, 36, 0.1)',
borderColor: '#F59E0B',
textColor: '#F59E0B'
};
}
};
const formatAmenities = (amenities: string[] | { [key: string]: any }): string[] => {
if (Array.isArray(amenities)) {
return amenities;
}
const amenityList: string[] = [];
if (amenities.covered) amenityList.push('Có mái che');
if (amenities.security) amenityList.push('Bảo vệ 24/7');
if (amenities.ev_charging) amenityList.push('Sạc xe điện');
if (amenities.wheelchair_accessible) amenityList.push('Phù hợp xe lăn');
if (amenities.valet_service) amenityList.push('Dịch vụ đỗ xe');
return amenityList;
};
const renderStars = (rating: number) => {
return [...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < rating ? 'text-yellow-400' : 'text-gray-300'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
));
};
const calculateAverageRating = (reviews: typeof SAMPLE_REVIEWS) => {
if (reviews.length === 0) return 0;
return reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
};
export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
parkingLot,
userLocation,
onClose,
onBook
}) => {
const [activeTab, setActiveTab] = useState<'overview' | 'reviews'>('overview');
const [bookingDuration, setBookingDuration] = useState(2); // hours
const distance = userLocation
? calculateDistance(userLocation.lat, userLocation.lng, parkingLot.lat, parkingLot.lng)
: null;
const statusColors = getStatusColor(parkingLot.availableSlots, parkingLot.totalSlots);
const isFull = parkingLot.availableSlots === 0;
const isClosed = !isCurrentlyOpen(parkingLot);
const isDisabled = isFull || isClosed;
const amenityList = formatAmenities(parkingLot.amenities);
const averageRating = calculateAverageRating(SAMPLE_REVIEWS);
return (
<div className="w-full h-full bg-white shadow-xl border-l-2 flex flex-col overflow-hidden" style={{ borderColor: 'rgba(232, 90, 79, 0.2)' }}>
{/* Header */}
<div className="relative p-6 text-white" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
{onClose && (
<button
onClick={onClose}
className="absolute top-4 right-4 w-8 h-8 rounded-full bg-white bg-opacity-20 flex items-center justify-center hover:bg-opacity-30 transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
<div className="flex items-start gap-4 mb-4">
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-2xl flex items-center justify-center flex-shrink-0">
<svg className="w-8 h-8" 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">
<h2 className="text-xl font-bold mb-2 leading-tight">
{parkingLot.name}
</h2>
<div className="flex items-center gap-2 text-sm opacity-90 mb-2">
<svg className="w-4 h-4" 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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="truncate">{parkingLot.address}</span>
{distance && (
<>
<span className="px-2"></span>
<span className="font-semibold">{formatDistance(distance)}</span>
</>
)}
</div>
{/* Rating */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{renderStars(Math.round(averageRating))}
</div>
<span className="text-sm font-semibold">{averageRating.toFixed(1)}</span>
<span className="text-sm opacity-80">({SAMPLE_REVIEWS.length} đánh giá)</span>
</div>
</div>
</div>
{/* Status banners */}
{isFull && (
<div className="absolute bottom-0 left-0 right-0 bg-red-600 text-center py-2">
<span className="text-sm font-bold">Bãi xe đã hết chỗ</span>
</div>
)}
{isClosed && (
<div className="absolute bottom-0 left-0 right-0 bg-gray-600 text-center py-2">
<span className="text-sm font-bold">Bãi xe đã đóng cửa</span>
</div>
)}
</div>
{/* Quick stats */}
<div className="p-4 border-b-2" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderBottomColor: 'rgba(232, 90, 79, 0.1)'
}}>
<div className="grid grid-cols-3 gap-3">
{/* Availability */}
<div className="text-center p-3 rounded-xl shadow-sm" style={{
backgroundColor: statusColors.background,
border: `2px solid ${statusColors.borderColor}`
}}>
<div className="text-2xl font-bold mb-1" style={{ color: statusColors.textColor }}>
{parkingLot.availableSlots}
</div>
<div className="text-xs font-medium text-gray-600">chỗ trống</div>
<div className="text-xs text-gray-500">/ {parkingLot.totalSlots} tổng</div>
</div>
{/* Price */}
<div className="text-center p-3 rounded-xl shadow-sm border-2" style={{
backgroundColor: 'rgba(232, 90, 79, 0.08)',
borderColor: 'rgba(232, 90, 79, 0.2)'
}}>
<div className="text-2xl font-bold mb-1" style={{ color: 'var(--primary-color)' }}>
{(parkingLot.pricePerHour || parkingLot.hourlyRate) ?
`${Math.round((parkingLot.pricePerHour || parkingLot.hourlyRate) / 1000)}k` : '--'}
</div>
<div className="text-xs font-medium text-gray-600">mỗi giờ</div>
<div className="text-xs text-gray-500">phí gửi xe</div>
</div>
{/* Hours */}
<div className="text-center p-3 rounded-xl shadow-sm bg-blue-50 border-2 border-blue-200">
<div className="flex items-center justify-center gap-1 mb-1">
<div className={`w-2 h-2 rounded-full ${isCurrentlyOpen(parkingLot) ? 'bg-green-500' : 'bg-red-500'}`}></div>
<div className="text-lg font-bold text-blue-600">
{parkingLot.isOpen24Hours ? '24/7' : parkingLot.openTime || '--'}
</div>
</div>
<div className="text-xs font-medium text-gray-600">
{isCurrentlyOpen(parkingLot) ? 'Đang mở' : 'Đã đóng'}
</div>
<div className="text-xs text-gray-500">
{isCurrentlyOpen(parkingLot) ? 'Hoạt động' : 'Ngừng hoạt động'}
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b-2 bg-white" style={{ borderBottomColor: 'rgba(232, 90, 79, 0.1)' }}>
{['overview', 'reviews'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as any)}
className={`flex-1 py-3 px-4 text-sm font-bold transition-all duration-300 ${
activeTab === tab
? 'border-b-2 text-white shadow-lg'
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'
}`}
style={{
borderBottomColor: activeTab === tab ? 'var(--primary-color)' : 'transparent',
background: activeTab === tab
? 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
: 'transparent'
}}
>
{tab === 'overview' && 'Tổng quan'}
{tab === 'reviews' && 'Đánh giá'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'overview' && (
<div className="p-4 space-y-4">
{/* Map section */}
<div>
<h3 className="text-lg font-bold mb-3 flex items-center gap-2" style={{ color: 'var(--accent-color)' }}>
<div className="w-8 h-8 rounded-xl 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="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7l6-3 5.447 2.724A1 1 0 0121 7.618v10.764a1 1 0 01-.553.894L15 17l-6 3z" />
</svg>
</div>
đ bãi xe
</h3>
<ParkingLotMap parkingLot={parkingLot} />
</div>
{/* Amenities */}
{amenityList.length > 0 && (
<div>
<h3 className="text-lg font-bold mb-3 flex items-center gap-2" style={{ color: 'var(--accent-color)' }}>
<div className="w-8 h-8 rounded-xl 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="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" />
</svg>
</div>
Tiện ích
</h3>
<div className="grid grid-cols-1 gap-2">
{amenityList.map((amenity, index) => (
<div key={index} className="flex items-center gap-3 p-3 rounded-xl shadow-sm border" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
borderColor: 'rgba(232, 90, 79, 0.15)'
}}>
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: 'var(--success-color)' }}></div>
<span className="text-sm font-medium text-gray-700">{amenity}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'reviews' && (
<div className="p-4 space-y-4">
{/* Overall rating */}
<div className="text-center p-4 rounded-xl shadow-sm border-2" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
borderColor: 'rgba(232, 90, 79, 0.2)'
}}>
<div className="text-3xl font-bold mb-2" style={{ color: 'var(--accent-color)' }}>{averageRating.toFixed(1)}</div>
<div className="flex items-center justify-center gap-1 mb-2">
{renderStars(Math.round(averageRating))}
</div>
<div className="text-sm font-medium text-gray-600">{SAMPLE_REVIEWS.length} đánh giá</div>
</div>
{/* Reviews list */}
<div className="space-y-3">
{SAMPLE_REVIEWS.map((review) => (
<div key={review.id} className="p-4 rounded-xl shadow-sm border" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
borderColor: 'rgba(232, 90, 79, 0.15)'
}}>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white" style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}>
{review.avatar}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-gray-800">{review.user}</span>
<div className="flex gap-1">
{renderStars(review.rating)}
</div>
</div>
<p className="text-sm text-gray-700 mb-2">{review.comment}</p>
<div className="text-xs text-gray-500">{review.date}</div>
</div>
</div>
</div>
))}
</div>
{/* Add review button */}
<button className="w-full py-3 rounded-xl font-bold text-white shadow-lg hover:shadow-xl transition-all duration-300 transform hover:scale-105" style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}>
Viết đánh giá
</button>
</div>
)}
</div>
{/* Booking section */}
{onBook && (
<div className="p-4 bg-white border-t-2" style={{ borderTopColor: 'rgba(232, 90, 79, 0.2)' }}>
{/* Duration selector */}
<div className="mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1" style={{ color: 'var(--accent-color)' }}>
<div className="w-5 h-5 rounded flex items-center justify-center" style={{
backgroundColor: 'var(--primary-color)'
}}>
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-base font-bold">Thời gian gửi xe</span>
<span className="text-xs text-gray-500">(đơn vị giờ)</span>
</div>
{/* Ultra compact time selector */}
<div className="flex items-center gap-0.5">
<button
onClick={() => setBookingDuration(Math.max(1, bookingDuration - 1))}
disabled={bookingDuration <= 1}
className={`w-4 h-4 rounded flex items-center justify-center font-bold text-xs transition-all ${
bookingDuration <= 1
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'text-white'
}`}
style={{
background: bookingDuration <= 1
? '#E5E7EB'
: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}
>
-
</button>
<div className="flex items-center rounded border text-xs" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
borderColor: 'rgba(232, 90, 79, 0.2)'
}}>
<input
type="number"
min="1"
max="24"
value={bookingDuration}
onChange={(e) => {
const value = parseInt(e.target.value) || 1;
setBookingDuration(Math.min(24, Math.max(1, value)));
}}
className="w-8 px-0.5 py-0.5 text-center text-xs font-bold border-none outline-none bg-transparent"
style={{ color: 'var(--accent-color)' }}
/>
</div>
<button
onClick={() => setBookingDuration(Math.min(24, bookingDuration + 1))}
disabled={bookingDuration >= 24}
className={`w-4 h-4 rounded flex items-center justify-center font-bold text-xs transition-all ${
bookingDuration >= 24
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'text-white'
}`}
style={{
background: bookingDuration >= 24
? '#E5E7EB'
: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}
>
+
</button>
</div>
</div>
</div>
{/* Total summary */}
{(parkingLot.pricePerHour || parkingLot.hourlyRate) && (
<div className="mb-4 p-4 rounded-xl shadow-sm border-2" style={{
backgroundColor: 'rgba(232, 90, 79, 0.05)',
borderColor: 'rgba(232, 90, 79, 0.2)'
}}>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">Thời gian đã chọn:</span>
<span className="text-sm font-bold text-gray-800">{bookingDuration} giờ</span>
</div>
<div className="flex justify-between items-center">
<span className="text-lg font-bold" style={{ color: 'var(--accent-color)' }}>Tổng thanh toán:</span>
<span className="text-xl font-bold" style={{ color: 'var(--primary-color)' }}>
{Math.round(((parkingLot.pricePerHour || parkingLot.hourlyRate) * bookingDuration) / 1000)}k VND
</span>
</div>
</div>
)}
{/* Book button */}
<button
onClick={() => onBook(parkingLot)}
disabled={isDisabled}
className={`
w-full py-4 rounded-xl font-bold text-lg transition-all duration-300 transform shadow-lg
${isDisabled
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'text-white hover:shadow-xl hover:scale-105'
}
`}
style={{
background: isDisabled
? '#D1D5DB'
: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}
>
{isFull ? 'Bãi xe đã hết chỗ' : isClosed ? 'Bãi xe đã đóng cửa' : `Đặt chỗ (${bookingDuration}h)`}
</button>
</div>
)}
</div>
);
};

View File

@@ -10,6 +10,7 @@ interface ParkingListProps {
selectedId?: number;
userLocation?: UserLocation | null;
sortType?: 'availability' | 'price' | 'distance';
searchQuery?: string;
}
// Calculate distance between two points using Haversine formula
@@ -108,22 +109,34 @@ export const ParkingList: React.FC<ParkingListProps> = ({
onViewing,
selectedId,
userLocation,
sortType = 'availability'
sortType = 'availability',
searchQuery = ''
}) => {
const listRef = React.useRef<HTMLDivElement>(null);
const itemRefs = React.useRef<Map<number, HTMLElement>>(new Map());
// Filter and sort parking lots
const sortedLots = React.useMemo(() => {
// First filter by search query
let filteredLots = parkingLots;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim();
filteredLots = parkingLots.filter(lot =>
lot.name.toLowerCase().includes(query) ||
lot.address.toLowerCase().includes(query)
);
}
// Separate parking lots into categories
const openLotsWithSpaces = parkingLots.filter(lot =>
const openLotsWithSpaces = filteredLots.filter(lot =>
lot.availableSlots > 0 && isCurrentlyOpen(lot)
);
const closedLots = parkingLots.filter(lot =>
const closedLots = filteredLots.filter(lot =>
!isCurrentlyOpen(lot)
);
const fullLots = parkingLots.filter(lot =>
const fullLots = filteredLots.filter(lot =>
lot.availableSlots === 0 && isCurrentlyOpen(lot)
);
@@ -167,7 +180,7 @@ export const ParkingList: React.FC<ParkingListProps> = ({
...sortLots(fullLots),
...sortLots(closedLots)
];
}, [parkingLots, userLocation, sortType]);
}, [parkingLots, userLocation, sortType, searchQuery]);
// Remove auto-viewing functionality - now only supports selection
React.useEffect(() => {
@@ -181,7 +194,19 @@ export const ParkingList: React.FC<ParkingListProps> = ({
return (
<div ref={listRef} className="space-y-4 overflow-y-auto">
{sortedLots.map((lot, index) => {
{sortedLots.length === 0 && searchQuery.trim() ? (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Không tìm thấy kết quả</h3>
<p className="text-gray-600 text-sm">Không bãi đ xe nào phù hợp với từ khóa "{searchQuery}"</p>
<p className="text-gray-500 text-xs mt-2">Thử tìm kiếm với từ khóa khác</p>
</div>
) : (
sortedLots.map((lot, index) => {
const distance = userLocation
? calculateDistance(userLocation.lat, userLocation.lng, lot.lat, lot.lng)
: null;
@@ -208,10 +233,10 @@ export const ParkingList: React.FC<ParkingListProps> = ({
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
w-full p-5 md:p-6 text-left rounded-2xl border-2 transition-all duration-300 group relative overflow-visible
${isSelected
? 'shadow-xl transform scale-[1.02] z-10'
: 'hover:shadow-lg hover:transform hover:scale-[1.01]'
? 'shadow-2xl z-10 ring-2 ring-orange-200 ring-opacity-50'
: 'hover:shadow-lg hover:ring-1 hover:ring-gray-200 hover:ring-opacity-30'
}
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
`}
@@ -388,7 +413,7 @@ export const ParkingList: React.FC<ParkingListProps> = ({
</div>
</button>
);
})}
}))}
</div>
);
};

View File

@@ -1,366 +0,0 @@
'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

@@ -5,15 +5,18 @@ import React from 'react';
export interface IconProps {
name: string;
className?: string;
size?: 'sm' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg' | 'xl';
}
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",
availability: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 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",
currency: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1",
distance: "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",
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",
@@ -32,6 +35,7 @@ const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
};
export const Icon: React.FC<IconProps> = ({

View File

@@ -1,595 +0,0 @@
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

@@ -1,138 +0,0 @@
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);
}