🚀 Complete Laca City Website with VPS Deployment

- Added complete Next.js frontend with responsive design
- Added NestJS backend with PostgreSQL and Redis
- Added comprehensive VPS deployment script (vps-deploy.sh)
- Added deployment guide and documentation
- Added all assets and static files
- Configured SSL, Nginx, PM2, and monitoring
- Ready for production deployment on any VPS
This commit is contained in:
2025-08-12 07:06:15 +07:00
parent bc87a88719
commit 51f2505839
111 changed files with 4967 additions and 6447 deletions

View File

@@ -2,20 +2,6 @@
@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%;
@@ -28,146 +14,62 @@ html, body {
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 */
/* Ensure proper flex behavior for full-screen layouts */
.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;
}
/* Global custom variables */
:root {
--primary-color: #e85a4f;
--secondary-color: #d2001c;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
}
@keyframes blink-gps {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0.3;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
/* 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;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
/* Custom marker classes */
.gps-marker-icon,
.gps-marker-icon-enhanced {
background: transparent !important;
border: none !important;
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
/* Parking Finder Button Animations */
@keyframes float {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-6px);
}
100% {
transform: translateY(0px);
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
@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);
}
/* Loading spinner animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.parking-finder-button {
animation: float 3s ease-in-out infinite, pulse-glow 2s ease-in-out infinite;
.animate-spin {
animation: spin 1s linear infinite;
}
.parking-finder-button:hover {
animation: none;
/* Smooth transitions for better UX */
button, input, select, textarea, .interactive {
transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.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);
}
/* Focus styles for accessibility */
button:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Enhanced Filter Box Animations */
@@ -273,24 +175,29 @@ html, body {
}
}
/* Custom pulse animation for selected elements */
@keyframes selected-pulse {
0% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7);
/* Animation utilities */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
70% {
box-shadow: 0 0 0 10px rgba(220, 38, 38, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0);
to {
opacity: 1;
transform: translateY(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;
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
/* Enhanced animations for GPS simulator */
@@ -326,79 +233,11 @@ html, body {
}
}
.marker-loading {
.loading-animation {
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;
}
@@ -410,95 +249,26 @@ body {
background-color: #ffffff;
}
/* Custom Scrollbar */
/* Custom Scrollbar (unified) */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
border-radius: 4px;
}
::-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% {

View File

@@ -0,0 +1,618 @@
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
export default function Homepage() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showScrollToTop, setShowScrollToTop] = useState(false);
const handleScrollToSection = (sectionId: string) => {
const element = document.getElementById(sectionId);
if (element) {
const headerOffset = 80;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
setMobileMenuOpen(false);
}
};
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
useEffect(() => {
const checkScrollTop = () => {
if (!showScrollToTop && window.pageYOffset > 400) {
setShowScrollToTop(true);
} else if (showScrollToTop && window.pageYOffset <= 400) {
setShowScrollToTop(false);
}
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (mobileMenuOpen && !target.closest('nav')) {
setMobileMenuOpen(false);
}
};
window.addEventListener('scroll', checkScrollTop);
document.addEventListener('mousedown', handleClickOutside);
return () => {
window.removeEventListener('scroll', checkScrollTop);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showScrollToTop, mobileMenuOpen]);
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="bg-white shadow-lg border-b-4 sticky top-0 z-50" 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-20">
<div className="flex items-center space-x-4">
<div className="relative">
<Image
src="/assets/Logo_and_sologan.png"
alt="Laca City Logo"
width={280}
height={70}
className="h-16 w-auto object-contain"
/>
</div>
</div>
<div className="hidden lg:flex space-x-10">
<button
onClick={() => handleScrollToSection('about')}
className="text-gray-700 hover:text-white hover:bg-primary-500 px-4 py-2 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105"
>
About Us
</button>
<button
onClick={() => handleScrollToSection('how-it-works')}
className="text-gray-700 hover:text-white hover:bg-primary-500 px-4 py-2 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105"
>
How It Works
</button>
<button
onClick={() => handleScrollToSection('community')}
className="text-gray-700 hover:text-white hover:bg-primary-500 px-4 py-2 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105"
>
Community
</button>
<button
onClick={() => handleScrollToSection('contact')}
className="text-gray-700 hover:text-white hover:bg-primary-500 px-4 py-2 rounded-xl font-semibold transition-all duration-300 transform hover:scale-105"
>
Contact
</button>
</div>
{/* Mobile menu button */}
<div className="lg:hidden">
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-white bg-primary-500 p-3 rounded-xl hover:shadow-xl transition-all duration-300 font-bold"
>
MENU
</button>
</div>
<div className="hidden lg:flex space-x-4">
<button
onClick={() => window.location.href = '/?view=parking'}
className="bg-white text-gray-700 border-2 border-gray-300 hover:border-primary-500 hover:text-primary-600 px-6 py-3 rounded-xl font-medium transition-all duration-300 transform hover:scale-105 shadow-lg"
>
Open App
</button>
<button
onClick={() => window.location.href = '/?view=parking'}
className="text-white px-8 py-3 rounded-xl font-medium transition-all duration-300 transform hover:scale-105 shadow-xl"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 8px 25px rgba(232, 90, 79, 0.3)'
}}
>
Get Started
</button>
</div>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="lg:hidden bg-white border-t-2 shadow-xl" style={{ borderTopColor: 'var(--primary-color)' }}>
<div className="px-6 py-6 space-y-3">
<button
onClick={() => handleScrollToSection('about')}
className="block w-full text-left px-4 py-3 text-gray-700 hover:text-white hover:bg-primary-500 rounded-xl font-medium transition-all duration-300"
>
About Us
</button>
<button
onClick={() => handleScrollToSection('how-it-works')}
className="block w-full text-left px-4 py-3 text-gray-700 hover:text-white hover:bg-primary-500 rounded-xl font-medium transition-all duration-300"
>
How It Works
</button>
<button
onClick={() => handleScrollToSection('community')}
className="block w-full text-left px-4 py-3 text-gray-700 hover:text-white hover:bg-primary-500 rounded-xl font-medium transition-all duration-300"
>
Community
</button>
<button
onClick={() => handleScrollToSection('contact')}
className="block w-full text-left px-4 py-3 text-gray-700 hover:text-white hover:bg-primary-500 rounded-xl font-medium transition-all duration-300"
>
Contact
</button>
<div className="pt-4 border-t-2 border-gray-200">
<button
onClick={() => window.location.href = '/?view=parking'}
className="block w-full text-white px-6 py-4 rounded-xl font-medium transition-all duration-300 transform hover:scale-105 shadow-xl"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 8px 25px rgba(232, 90, 79, 0.3)'
}}
>
Get Started
</button>
</div>
</div>
</div>
)}
</nav>
{/* Hero Section */}
<section className="relative overflow-hidden" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))'
}}>
<div className="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10 py-24 md:py-32">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div className="text-center lg:text-left">
<h1 className="text-5xl md:text-6xl lg:text-7xl font-extrabold leading-tight mb-8" style={{ color: 'var(--primary-color)' }}>
Park Easy, Move Breezy
</h1>
<h2 className="text-2xl md:text-3xl text-gray-700 mb-10 leading-relaxed font-medium">
Find and share parking spots in seconds. Save time, fuel, and reduce stress in Ho Chi Minh City & Hanoi.
</h2>
<div className="flex flex-col sm:flex-row gap-6 justify-center lg:justify-start mb-10">
<button
onClick={() => window.location.href = '/?view=parking'}
className="text-white px-10 py-5 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 12px 30px rgba(232, 90, 79, 0.4)'
}}
>
Start Finding Parking
</button>
<button
onClick={() => window.location.href = '/?view=parking'}
className="bg-white text-gray-700 border-3 border-primary-500 hover:text-white hover:bg-primary-500 px-10 py-5 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl"
>
Share a Spot
</button>
</div>
<p className="text-gray-600 text-xl leading-relaxed font-normal">
Join thousands of drivers reimagining streets for people, making urban driving easy and sustainable.
</p>
</div>
<div className="relative">
<div className="bg-white rounded-3xl shadow-2xl p-8 transform rotate-2 hover:rotate-0 transition-transform duration-500 border-4" style={{ borderColor: 'var(--primary-color)' }}>
<Image
src="/assets/Location.png"
alt="Laca City App Interface"
width={600}
height={500}
className="w-full h-auto rounded-2xl"
/>
<div className="absolute -top-6 -right-6 text-white px-6 py-3 rounded-2xl text-lg font-semibold shadow-2xl" style={{ background: 'var(--primary-color)' }}>
Your city's parking assistant
</div>
</div>
</div>
</div>
</div>
</section>
{/* Problem & Story Section */}
<section className="py-24 bg-white">
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8" style={{ color: 'var(--primary-color)' }}>
Tired of Circling for Parking?
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div>
<p className="text-xl text-gray-700 mb-8 leading-relaxed font-normal">
Urban drivers in Vietnam spend 1530 minutes per trip searching for parking—burning fuel, wasting time, and adding to congestion.
</p>
<p className="text-xl text-gray-700 mb-8 leading-relaxed font-normal">
When founder Mai Nguyen returned to Vietnam, she saw sidewalks turned into parking lots, forcing pedestrians into the street. Delivery drivers and gig workers spend hours searching for spots, day after day.
</p>
<p className="text-xl text-gray-700 leading-relaxed font-medium" style={{ color: 'var(--primary-color)' }}>
Laca City was born to end this struggle—making parking easy while reclaiming streets for people.
</p>
</div>
<div className="relative">
<div className="grid grid-cols-2 gap-6">
<div className="bg-red-50 border-4 rounded-2xl p-8 text-center transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--primary-color)', backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--primary-color)' }}>
<span className="text-white font-bold text-2xl">!</span>
</div>
<h4 className="font-semibold mb-3 text-xl" style={{ color: 'var(--primary-color)' }}>Before Laca City</h4>
<p className="font-medium" style={{ color: 'var(--primary-color)' }}>15-30 minutes circling, wasting fuel, stress</p>
</div>
<div className="bg-green-50 border-4 rounded-2xl p-8 text-center transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--secondary-color)', backgroundColor: 'rgba(210, 0, 28, 0.1)' }}>
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--secondary-color)' }}>
<span className="text-white font-bold text-2xl">✓</span>
</div>
<h4 className="font-semibold mb-3 text-xl" style={{ color: 'var(--secondary-color)' }}>With Laca City</h4>
<p className="font-medium" style={{ color: 'var(--secondary-color)' }}>Instant parking, happy drivers</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* How It Works Section */}
<section id="how-it-works" className="py-24 scroll-mt-20" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))'
}}>
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8" style={{ color: 'var(--primary-color)' }}>
Find. Share. Drive Happy.
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 mb-16">
{/* Step 1 */}
<div className="text-center bg-white rounded-3xl p-10 shadow-2xl hover:shadow-3xl transition-all duration-300 transform hover:scale-105 border-4" style={{ borderColor: 'var(--primary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
1
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-6" style={{ color: 'var(--primary-color)' }}>Find Parking Instantly</h3>
<p className="text-gray-700 font-normal text-lg">Open Laca City to see real-time parking spots near you.</p>
</div>
{/* Step 2 */}
<div className="text-center bg-white rounded-3xl p-10 shadow-2xl hover:shadow-3xl transition-all duration-300 transform hover:scale-105 border-4" style={{ borderColor: 'var(--primary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'var(--primary-color)' }}>
2
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-6" style={{ color: 'var(--primary-color)' }}>Share a Spot</h3>
<p className="text-gray-700 font-normal text-lg">Add public, private, or peer-to-peer parking to help other drivers.</p>
</div>
{/* Step 3 */}
<div className="text-center bg-white rounded-3xl p-10 shadow-2xl hover:shadow-3xl transition-all duration-300 transform hover:scale-105 border-4" style={{ borderColor: 'var(--secondary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'var(--secondary-color)' }}>
3
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-6" style={{ color: 'var(--secondary-color)' }}>Get Recognition</h3>
<p className="text-gray-700 font-normal text-lg">Earn badges and leaderboard positions for contributing to a smarter city.</p>
</div>
</div>
<div className="text-center">
<button
onClick={() => window.location.href = '/?view=parking'}
className="text-white px-12 py-6 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 12px 30px rgba(232, 90, 79, 0.4)'
}}
>
Start Finding Parking
</button>
</div>
</div>
</section>
{/* Community & Gamification Section */}
<section id="community" className="py-24 bg-white scroll-mt-20">
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8" style={{ color: 'var(--primary-color)' }}>
Built by Drivers, for Drivers
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div>
<p className="text-xl text-gray-700 mb-10 leading-relaxed font-normal">
Your shared spots power the map. Laca City is entirely community-driven, rewarding contributors with:
</p>
<div className="space-y-8 mb-10">
<div className="flex items-center space-x-6 p-6 rounded-2xl border-4 transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--primary-color)', backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<div className="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center text-white font-bold text-2xl" style={{ background: 'var(--primary-color)' }}>
HERO
</div>
<div>
<h4 className="font-semibold text-gray-900 text-xl">Parking Hero badges</h4>
<p className="text-gray-700 font-normal">Get recognized for your contributions</p>
</div>
</div>
<div className="flex items-center space-x-6 p-6 rounded-2xl border-4 transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--secondary-color)', backgroundColor: 'rgba(210, 0, 28, 0.1)' }}>
<div className="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center text-white font-bold text-2xl" style={{ background: 'var(--secondary-color)' }}>
RANK
</div>
<div>
<h4 className="font-semibold text-gray-900 text-xl">Weekly leaderboards</h4>
<p className="text-gray-700 font-normal">Compete with other contributors</p>
</div>
</div>
<div className="flex items-center space-x-6 p-6 rounded-2xl border-4 transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--primary-color)', backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<div className="flex-shrink-0 w-16 h-16 rounded-2xl flex items-center justify-center text-white font-bold text-2xl" style={{ background: 'var(--primary-color)' }}>
FAME
</div>
<div>
<h4 className="font-semibold text-gray-900 text-xl">Social media shoutouts</h4>
<p className="text-gray-700 font-normal">Get featured for your community impact</p>
</div>
</div>
</div>
<p className="text-xl text-gray-700 mb-10 font-normal">
Help your city run better while getting recognized in the community.
</p>
<button
onClick={() => window.location.href = '/?view=parking'}
className="text-white px-10 py-5 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 12px 30px rgba(232, 90, 79, 0.4)'
}}
>
Share Your First Spot Today
</button>
</div>
<div className="rounded-3xl p-10 border-4 shadow-2xl" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderColor: 'var(--primary-color)'
}}>
<h4 className="text-2xl font-bold text-gray-900 mb-8" style={{ color: 'var(--primary-color)' }}>Weekly Leaderboard</h4>
<div className="space-y-6">
<div className="flex items-center justify-between p-6 border-4 rounded-2xl transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--primary-color)', backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--primary-color)' }}>
<span className="text-white font-bold text-lg">1</span>
</div>
<div>
<p className="font-semibold text-gray-900 text-lg">Minh Nguyen</p>
<p className="text-gray-600 font-normal">District 1</p>
</div>
</div>
<span className="font-semibold text-xl" style={{ color: 'var(--primary-color)' }}>28 spots</span>
</div>
<div className="flex items-center justify-between p-6 border-4 rounded-2xl transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--secondary-color)', backgroundColor: 'rgba(210, 0, 28, 0.1)' }}>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--secondary-color)' }}>
<span className="text-white font-bold text-lg">2</span>
</div>
<div>
<p className="font-semibold text-gray-900 text-lg">Linh Tran</p>
<p className="text-gray-600 font-normal">District 3</p>
</div>
</div>
<span className="font-semibold text-xl" style={{ color: 'var(--secondary-color)' }}>22 spots</span>
</div>
<div className="flex items-center justify-between p-6 border-4 rounded-2xl transform hover:scale-105 transition-transform duration-300" style={{ borderColor: 'var(--primary-color)', backgroundColor: 'rgba(232, 90, 79, 0.1)' }}>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--primary-color)' }}>
<span className="text-white font-bold text-lg">3</span>
</div>
<div>
<p className="font-semibold text-gray-900 text-lg">Duc Le</p>
<p className="text-gray-600 font-normal">District 7</p>
</div>
</div>
<span className="font-semibold text-xl" style={{ color: 'var(--primary-color)' }}>19 spots</span>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Join the Movement Section */}
<section className="py-24 text-white" style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))'
}}>
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10 text-center">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8">
Help Build Vietnam's First Real-Time Parking Map
</h2>
<p className="text-2xl mb-12 max-w-4xl mx-auto leading-relaxed font-medium">
Sign up free. Find parking in seconds. Share your spots. Together, we'll reclaim streets for people and make our cities smarter.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center">
<button
onClick={() => window.location.href = '/?view=parking'}
className="bg-white px-12 py-6 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl"
style={{ color: 'var(--primary-color)' }}
>
Start Finding Parking
</button>
<button
onClick={() => window.location.href = '/?view=parking'}
className="bg-transparent border-4 border-white text-white hover:bg-white px-12 py-6 rounded-2xl font-semibold text-xl transition-all duration-300 transform hover:scale-110 shadow-2xl hover:text-red-500"
>
Start Sharing Spots
</button>
</div>
</div>
</section>
{/* About Us / Mission Section */}
<section id="about" className="py-24 bg-white scroll-mt-20">
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8" style={{ color: 'var(--primary-color)' }}>
Smart Parking for Smart Cities
</h2>
<p className="text-2xl text-gray-700 max-w-5xl mx-auto leading-relaxed font-medium">
Laca City connects drivers with real-time parking spots, reducing congestion and reclaiming sidewalks for pedestrians.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center mb-20">
<div>
<p className="text-xl text-gray-700 mb-8 leading-relaxed font-normal">
As Vietnam's cities prepare for autonomous vehicles and low-emission transport, we're building the digital parking infrastructure they need.
</p>
<p className="text-2xl font-bold leading-relaxed" style={{ color: 'var(--primary-color)' }}>
"Streets for people" - Đường phố cho con người.
</p>
</div>
<div className="rounded-3xl p-10 text-center border-4 shadow-2xl" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))',
borderColor: 'var(--primary-color)'
}}>
<div className="w-32 h-32 rounded-full mx-auto mb-8 overflow-hidden border-4" style={{ borderColor: 'var(--primary-color)' }}>
<div className="w-full h-full flex items-center justify-center text-white text-5xl font-semibold" style={{ background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))' }}>
MN
</div>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">Mai Nguyen</h3>
<p className="font-semibold mb-6 text-xl" style={{ color: 'var(--primary-color)' }}>Founder & CEO</p>
<p className="text-gray-700 leading-relaxed font-normal">
Urban planner with global experience (World Bank, Asia & North America).
Passionate about creating cities where streets belong to people.
</p>
</div>
</div>
</div>
</section>
{/* Social Proof & Partnerships Section */}
<section className="py-24" style={{
background: 'linear-gradient(135deg, rgba(232, 90, 79, 0.05), rgba(215, 53, 2, 0.05))'
}}>
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="text-center mb-20">
<h2 className="text-4xl md:text-5xl font-extrabold mb-8" style={{ color: 'var(--primary-color)' }}>
Trusted by Community and Partners
</h2>
<p className="text-2xl text-gray-700 max-w-5xl mx-auto leading-relaxed font-medium">
We're working with universities, small businesses, and city pilot programs to make urban parking easy.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
<div className="bg-white rounded-3xl p-10 shadow-2xl text-center transform hover:scale-105 transition-transform duration-300 border-4" style={{ borderColor: 'var(--primary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'var(--primary-color)' }}>
UN
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Vietnam National University</h3>
<p className="text-gray-700 font-normal">Student Parking Pilot</p>
</div>
<div className="bg-white rounded-3xl p-10 shadow-2xl text-center transform hover:scale-105 transition-transform duration-300 border-4" style={{ borderColor: 'var(--secondary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'var(--secondary-color)' }}>
CF
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">District 1 Cafe Network</h3>
<p className="text-gray-700 font-normal">Private Spot Sharing</p>
</div>
<div className="bg-white rounded-3xl p-10 shadow-2xl text-center transform hover:scale-105 transition-transform duration-300 border-4" style={{ borderColor: 'var(--primary-color)' }}>
<div className="w-20 h-20 rounded-3xl flex items-center justify-center mx-auto mb-8 text-white text-3xl font-semibold" style={{ background: 'var(--primary-color)' }}>
HN
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Hanoi Transportation Dept</h3>
<p className="text-gray-700 font-normal">Public Lot Integration</p>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer id="contact" className="bg-gray-900 text-white py-20 scroll-mt-20">
<div className="max-w-6xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-16">
<div className="md:col-span-2">
<div className="flex items-center space-x-4 mb-8">
<Image
src="/assets/Footer_page_logo.png"
alt="Laca City Logo"
width={60}
height={60}
className="h-15 w-15 object-contain"
/>
<span className="text-3xl font-black">Laca City</span>
</div>
<p className="text-gray-300 text-2xl mb-8 leading-relaxed font-bold">
Park Easy, Move Breezy.
</p>
<p className="text-gray-400 leading-relaxed font-normal text-lg">
Making urban parking easy while reclaiming streets for people.
</p>
</div>
<div>
<h4 className="text-xl font-semibold mb-8">Quick Links</h4>
<ul className="space-y-4">
<li><button onClick={() => handleScrollToSection('about')} className="text-gray-300 hover:text-white transition-colors cursor-pointer font-normal text-lg">About Us</button></li>
<li><a href="#" className="text-gray-300 hover:text-white transition-colors font-normal text-lg">Blog</a></li>
<li><button onClick={() => handleScrollToSection('contact')} className="text-gray-300 hover:text-white transition-colors cursor-pointer font-normal text-lg">Contact</button></li>
<li><a href="#" className="text-gray-300 hover:text-white transition-colors font-normal text-lg">Terms</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition-colors font-normal text-lg">Privacy</a></li>
</ul>
</div>
<div>
<h4 className="text-xl font-semibold mb-8">Connect</h4>
<div className="flex space-x-6">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Zalo</span>
<div className="w-12 h-12 rounded-2xl flex items-center justify-center font-semibold text-xl text-white" style={{ background: 'var(--primary-color)' }}>
Z
</div>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Facebook</span>
<div className="w-12 h-12 rounded-2xl flex items-center justify-center font-semibold text-xl text-white" style={{ background: 'var(--secondary-color)' }}>
f
</div>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">TikTok</span>
<div className="w-12 h-12 rounded-2xl flex items-center justify-center font-semibold text-xl text-white" style={{ background: 'var(--primary-color)' }}>
T
</div>
</a>
</div>
</div>
</div>
<div className="border-t border-gray-800 pt-10 text-center">
<p className="text-gray-400 font-normal text-lg">
© 2025 Laca City. All rights reserved. Made with love for Vietnamese drivers.
</p>
</div>
</div>
</footer>
{/* Scroll to Top Button */}
{showScrollToTop && (
<button
onClick={scrollToTop}
className="fixed bottom-10 right-10 text-white p-4 rounded-2xl shadow-2xl transition-all duration-300 transform hover:scale-110 z-50 font-semibold text-lg"
style={{
background: 'linear-gradient(135deg, var(--primary-color), var(--secondary-color))',
boxShadow: '0 12px 30px rgba(232, 90, 79, 0.4)'
}}
aria-label="Scroll to top"
>
TOP
</button>
)}
</div>
);
}

View File

@@ -7,46 +7,44 @@ 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',
title: 'Laca City - Park Easy, Move Breezy',
description: 'Find and share parking in seconds. Save time, fuel, and stress in Ho Chi Minh City & Hanoi. Join thousands of drivers reclaiming streets for people.',
keywords: ['parking', 'navigation', 'HCMC', 'Vietnam', 'bãi đỗ xe', 'TP.HCM', 'Hanoi', 'smart parking', 'Laca City'],
authors: [{ name: 'Laca City Team' }],
creator: 'Laca City',
publisher: 'Laca City',
robots: 'index, follow',
openGraph: {
type: 'website',
locale: 'vi_VN',
url: 'https://parking-hcmc.com',
title: '',
description: '',
siteName: 'Smart Parking HCMC',
url: 'https://lacacity.com',
title: 'Laca City - Park Easy, Move Breezy',
description: 'Find and share parking in seconds. Save time, fuel, and stress in Ho Chi Minh City & Hanoi.',
siteName: 'Laca City',
images: [
{
url: '/assets/Logo_and_sologan.png',
url: '/assets/Location.png',
width: 1200,
height: 630,
alt: 'Smart Parking HCMC',
alt: 'Laca City - Smart Parking Solution',
},
],
},
twitter: {
card: 'summary_large_image',
title: '',
description: '',
images: ['/assets/Logo_and_sologan.png'],
title: 'Laca City - Park Easy, Move Breezy',
description: 'Find and share parking in seconds. Save time, fuel, and stress in Ho Chi Minh City & Hanoi.',
images: ['/assets/Location.png'],
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
},
themeColor: '#2563EB',
themeColor: '#E85A4F',
manifest: '/manifest.json',
icons: {
icon: '/assets/mini_location.png',
shortcut: '/assets/mini_location.png',
apple: '/assets/Logo.png',
icon: '/favicon.png?v=5',
},
};

View File

@@ -8,23 +8,9 @@ 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);
@@ -42,14 +28,6 @@ export default function ParkingFinderPage() {
searchLocation
} = useParkingSearch();
const {
route,
isLoading: routeLoading,
error: routeError,
calculateRoute,
clearRoute
} = useRouting();
// Handle GPS location change from simulator
const handleLocationChange = (location: UserLocation) => {
setUserLocation(location);
@@ -70,35 +48,16 @@ export default function ParkingFinderPage() {
}
};
const handleParkingLotSelect = async (lot: ParkingLot) => {
const handleParkingLotSelect = (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');
toast.success(`Đã chọn ${lot.name}`);
};
// Show error messages
@@ -108,35 +67,35 @@ export default function ParkingFinderPage() {
}
}, [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 */}
{/* Summary 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 className="h-96 bg-gradient-to-br from-gray-50 to-blue-50 flex items-center justify-center">
<div className="text-center p-8">
<div className="w-24 h-24 bg-blue-100 rounded-full mx-auto mb-6 flex items-center justify-center">
<svg className="w-12 h-12 text-blue-600" 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>
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-3">Parking Finder - HCMC</h2>
<p className="text-gray-600 mb-4">Find and book parking spots in Ho Chi Minh City</p>
{parkingLots.length > 0 && (
<div className="text-sm text-gray-500">
Found {parkingLots.length} parking locations nearby
</div>
)}
</div>
</div>
</div>
@@ -199,14 +158,6 @@ export default function ParkingFinderPage() {
</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>
);

View File

@@ -1,8 +1,9 @@
'use client';
import React, { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useSearchParams } from 'next/navigation';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { ParkingList } from '@/components/parking/ParkingList';
import { ParkingDetails } from '@/components/parking/ParkingDetails';
import { HCMCGPSSimulator } from '@/components/HCMCGPSSimulator';
@@ -10,24 +11,42 @@ import { Icon } from '@/components/ui/Icon';
// 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 MainPage() {
const searchParams = useSearchParams();
const showApp = searchParams?.get('app') === 'parking';
export default function ParkingFinderPage() {
if (showApp) {
return <ParkingFinderPage />;
}
// Show Canva homepage by default
return <CanvaHomepage />;
}
function CanvaHomepage() {
useEffect(() => {
// Redirect to the Canva homepage in the public directory
window.location.href = '/homepage/index.html';
}, []);
return (
<div className="h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-red-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Loading homepage...</p>
</div>
</div>
);
}
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 [searchRadius, setSearchRadius] = useState(4000); // meters - 4km radius
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [gpsWindowPos, setGpsWindowPos] = useState({ x: 0, y: 20 });
const [isMobile, setIsMobile] = useState(false);
@@ -88,14 +107,6 @@ export default function ParkingFinderPage() {
searchLocation
} = useParkingSearch();
const {
route,
isLoading: routeLoading,
error: routeError,
calculateRoute,
clearRoute
} = useRouting();
// Handle GPS location change from simulator
const handleLocationChange = (location: UserLocation) => {
setUserLocation(location);
@@ -103,16 +114,16 @@ export default function ParkingFinderPage() {
// 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 đó');
toast.success('GPS location updated and searched for nearby parking lots');
}
};
const handleRefresh = () => {
if (userLocation) {
searchLocation({ latitude: userLocation.lat, longitude: userLocation.lng });
toast.success('Đã làm mới danh sách bãi đỗ xe');
toast.success('Parking list refreshed');
} else {
toast.error('Vui lòng chọn vị trí GPS trước');
toast.error('Please select GPS location first');
}
};
@@ -120,25 +131,12 @@ export default function ParkingFinderPage() {
// 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');
}
}
setLeftSidebarOpen(false); // Close sidebar when selecting parking lot
toast.success(`Selected ${lot.name}`);
};
const handleParkingLotViewing = (lot: ParkingLot | null) => {
@@ -146,9 +144,8 @@ export default function ParkingFinderPage() {
};
const handleClearRoute = () => {
clearRoute();
setSelectedParkingLot(null);
toast.success('Đã xóa tuyến đường');
toast.success('Selection cleared');
};
// Show error messages
@@ -158,18 +155,11 @@ export default function ParkingFinderPage() {
}
}, [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">
@@ -206,9 +196,9 @@ export default function ParkingFinderPage() {
</div>
<div>
<h2 className="text-xl font-bold text-gray-900 tracking-tight">
Bãi đ xe gần đây
Nearby Parking Lots
</h2>
<p className="text-sm text-gray-600 font-medium">Tìm kiếm thông minh</p>
<p className="text-sm text-gray-600 font-medium">Smart Search</p>
</div>
</div>
<div className="flex items-center space-x-2">
@@ -229,7 +219,7 @@ export default function ParkingFinderPage() {
<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
Refresh List
</button>
{/* Status Info Bar - Thiết kế thanh lịch đơn giản */}
@@ -241,14 +231,14 @@ export default function ParkingFinderPage() {
<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ỗ
{parkingLots.filter(lot => lot.availableSlots > 0).length} available
</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
{parkingLots.filter(lot => lot.availableSlots === 0).length} full
</span>
</div>
</div>
@@ -275,7 +265,7 @@ export default function ParkingFinderPage() {
<div className="relative">
<input
type="text"
placeholder="Tìm kiếm bãi đỗ xe..."
placeholder="Search parking lots..."
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"
@@ -320,7 +310,7 @@ 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-lg font-bold" style={{ color: 'var(--accent-color)' }}>Sắp xếp:</span>
<span className="text-lg font-bold" style={{ color: 'var(--accent-color)' }}>Sort:</span>
</div>
<div className="flex gap-2">
@@ -339,7 +329,7 @@ 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"
title="Sort by availability"
>
<Icon
name="car"
@@ -363,7 +353,7 @@ 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ẻ"
title="Sort by price"
>
<Icon
name="currency"
@@ -394,7 +384,7 @@ export default function ParkingFinderPage() {
: 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"
title="Sort by nearest distance"
>
<Icon
name="distance"
@@ -419,8 +409,8 @@ export default function ParkingFinderPage() {
<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>
<h3 className="text-lg font-bold text-gray-900 mb-2">Select GPS Location</h3>
<p className="text-gray-600 text-sm">Please select a GPS location to find nearby parking lots</p>
</div>
) : parkingLots.length === 0 ? (
<div className="text-center py-12">
@@ -429,8 +419,8 @@ export default function ParkingFinderPage() {
<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>
<h3 className="text-lg font-bold text-gray-900 mb-2">No Parking Lots</h3>
<p className="text-gray-600 text-sm">No parking lots found near this location</p>
</div>
) : (
<ParkingList
@@ -468,89 +458,34 @@ export default function ParkingFinderPage() {
userLocation={userLocation}
onClose={() => {
setSelectedParkingLot(null);
clearRoute();
}}
onBook={(lot) => {
toast.success(`Đã đặt chỗ tại ${lot.name}!`);
toast.success(`Booked parking at ${lot.name}!`);
// Here you would typically call an API to book the parking spot
}}
/>
</div>
)}
{/* Map Section - Right */}
{/* Right Section - Summary Information */}
<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 - Position based on layout */}
{userLocation && (
<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
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 className="w-full h-full bg-gradient-to-br from-gray-50 to-blue-50 rounded-2xl flex items-center justify-center border border-gray-200">
<div className="text-center p-8">
<div className="w-24 h-24 bg-blue-100 rounded-full mx-auto mb-6 flex items-center justify-center">
<svg className="w-12 h-12 text-blue-600" 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>
</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>
<h2 className="text-2xl font-bold text-gray-800 mb-3">Map in Developing</h2>
<p className="text-gray-600 mb-4">Interactive map feature coming soon</p>
{parkingLots.length > 0 && (
<div className="text-sm text-gray-500">
Found {parkingLots.length} parking locations nearby
</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>
</div>
{/* Floating GPS Window */}
@@ -595,14 +530,14 @@ export default function ParkingFinderPage() {
<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'}
{isMobile ? 'GPS Simulation' : 'GPS Location Simulation for Ho Chi Minh City'}
</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'}
title={gpsSimulatorVisible ? 'Hide GPS Simulator' : 'Show GPS Simulator'}
>
<svg
className={`w-4 h-4 text-white transition-transform duration-300 ${gpsSimulatorVisible ? 'rotate-180' : 'rotate-0'}`}
@@ -620,7 +555,7 @@ export default function ParkingFinderPage() {
<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'}
title={gpsSimulatorVisible ? 'Hide GPS Simulator' : 'Show GPS Simulator'}
>
<svg
className={`w-5 h-5 text-white transition-transform duration-300 ${gpsSimulatorVisible ? 'rotate-180' : 'rotate-0'}`}
@@ -654,6 +589,9 @@ export default function ParkingFinderPage() {
</div>
</main>
{/* Footer */}
<Footer showFullFooter={false} />
{/* Show errors */}
{parkingError && (
<div className="fixed bottom-6 right-6 max-w-sm z-50">
@@ -662,14 +600,6 @@ export default function ParkingFinderPage() {
</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,113 @@
'use client';
import React from 'react';
import Image from 'next/image';
interface FooterProps {
showFullFooter?: boolean;
className?: string;
}
export const Footer: React.FC<FooterProps> = ({
showFullFooter = false,
className = ""
}) => {
if (!showFullFooter) {
return (
<footer className={`bg-black text-white py-6 ${className}`}>
<div className="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="flex items-center justify-center">
<div className="flex items-center space-x-4">
<Image
src="/assets/Footer_page_logo.png"
alt="Laca City Logo"
width={40}
height={40}
className="h-10 w-auto object-contain"
/>
<span className="text-lg font-bold">Laca City</span>
</div>
</div>
</div>
</footer>
);
}
return (
<footer className={`bg-black text-white py-16 ${className}`}>
<div className="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
<div className="md:col-span-2">
<div className="flex items-center space-x-4 mb-6">
<Image
src="/assets/Footer_page_logo.png"
alt="Laca City Logo"
width={60}
height={60}
className="h-16 w-auto object-contain"
/>
<span className="text-2xl font-bold">Laca City</span>
</div>
<p className="text-xl text-gray-200 leading-relaxed mb-6 max-w-lg">
Revolutionizing urban parking with smart technology, real-time data, and user-friendly solutions for Ho Chi Minh City.
</p>
{/* Social Media Links */}
<div className="flex space-x-4">
<a href="#" className="group bg-gray-800 hover:bg-red-500 p-3 rounded-xl transition-all duration-300 transform hover:scale-110">
{/* Facebook Icon */}
<svg className="w-6 h-6 text-gray-300 group-hover:text-white transition-colors duration-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</a>
<a href="#" className="group bg-gray-800 hover:bg-red-500 p-3 rounded-xl transition-all duration-300 transform hover:scale-110">
{/* Twitter Icon */}
<svg className="w-6 h-6 text-gray-300 group-hover:text-white transition-colors duration-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
</svg>
</a>
<a href="#" className="group bg-gray-800 hover:bg-red-500 p-3 rounded-xl transition-all duration-300 transform hover:scale-110">
{/* LinkedIn Icon */}
<svg className="w-6 h-6 text-gray-300 group-hover:text-white transition-colors duration-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</div>
<div>
<h4 className="text-lg font-semibold mb-6">Quick Links</h4>
<ul className="space-y-3">
<li><a href="/" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Home</a></li>
<li><a href="/#features" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Features</a></li>
<li><a href="/#team" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Team</a></li>
<li><a href="/#news" className="text-gray-300 hover:text-red-500 transition-colors duration-300">News</a></li>
</ul>
</div>
<div>
<h4 className="text-lg font-semibold mb-6">App</h4>
<ul className="space-y-3">
<li><a href="/?app=parking" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Launch App</a></li>
<li><a href="#" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Help</a></li>
<li><a href="#" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Privacy</a></li>
<li><a href="#" className="text-gray-300 hover:text-red-500 transition-colors duration-300">Terms</a></li>
</ul>
</div>
</div>
{/* Copyright */}
<div className="border-t border-gray-800 pt-8">
<div className="flex flex-col md:flex-row items-center justify-between">
<p className="text-gray-400 text-sm">
© 2024 Laca City. All rights reserved.
</p>
<p className="text-gray-400 text-sm mt-2 md:mt-0">
Made with in Ho Chi Minh City
</p>
</div>
</div>
</div>
</footer>
);
};

View File

@@ -10,31 +10,31 @@ interface HCMCGPSSimulatorProps {
// Predefined locations near HCMC parking lots
const simulationPoints = [
// Trung tâm Quận 1 - gần bãi đỗ xe
// District 1 Center - near parking lots
{
name: 'Vincom Center Đồng Khởi',
location: { lat: 10.7769, lng: 106.7009 },
description: 'Gần trung tâm thương mại Vincom'
description: 'Near Vincom shopping center'
},
{
name: 'Saigon Centre',
location: { lat: 10.7743, lng: 106.7017 },
description: 'Gần Saigon Centre'
description: 'Near Saigon Centre'
},
{
name: 'Landmark 81',
location: { lat: 10.7955, lng: 106.7195 },
description: 'Gần tòa nhà Landmark 81'
description: 'Near Landmark 81 building'
},
{
name: 'Bitexco Financial Tower',
location: { lat: 10.7718, lng: 106.7047 },
description: 'Gần tòa nhà Bitexco'
description: 'Near Bitexco building'
},
{
name: 'Chợ Bến Thành',
location: { lat: 10.7729, lng: 106.6980 },
description: 'Gần chợ Bến Thành'
description: 'Near Ben Thanh Market'
},
{
name: 'Diamond Plaza',
@@ -240,7 +240,7 @@ export const HCMCGPSSimulator: React.FC<HCMCGPSSimulatorProps> = ({
</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>
<span className="text-base md:text-lg font-bold tracking-tight" style={{ color: 'var(--primary-color)' }}>Current Location</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>
@@ -483,8 +483,8 @@ export const HCMCGPSSimulator: React.FC<HCMCGPSSimulatorProps> = ({
</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>
<h5 className="text-base md:text-lg font-bold tracking-tight mb-1" style={{ color: 'var(--accent-color)' }}>Random Location</h5>
<p className="text-xs md:text-sm text-gray-600 font-medium">Auto-generate coordinates in HCMC</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)' }}>
@@ -493,7 +493,7 @@ export const HCMCGPSSimulator: React.FC<HCMCGPSSimulatorProps> = ({
<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>
<span className="text-xs text-gray-500 hidden md:inline">Extended area</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)' }}>

View File

@@ -26,18 +26,12 @@ export const Header: React.FC<HeaderProps> = ({
<div className="flex-shrink-0">
<div className="relative">
<Image
src="/assets/Logo_and_sologan.png"
src="/assets/Location.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>
)}
@@ -66,7 +60,7 @@ export const Header: React.FC<HeaderProps> = ({
<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
Clear Route
</button>
)}
@@ -76,7 +70,7 @@ export const Header: React.FC<HeaderProps> = ({
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>
<span className="text-sm font-bold" style={{ color: 'var(--success-color)' }}>Live Data</span>
</div>
{/* City Info */}
@@ -90,7 +84,7 @@ export const Header: React.FC<HeaderProps> = ({
<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>
<span className="text-sm font-bold" style={{ color: 'var(--primary-color)' }}>Ho Chi Minh City</span>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ interface ParkingFloor {
walkways: { x: number; y: number; width: number; height: number }[];
}
// Thiết kế bãi xe đẹp và chuyên nghiệp
// Professional and beautiful parking lot design
const generateParkingFloorData = (floorNumber: number): ParkingFloor => {
const slots: ParkingSlot[] = [];
const walkways = [];
@@ -108,7 +108,7 @@ const generateParkingFloorData = (floorNumber: number): ParkingFloor => {
return {
floor: floorNumber,
name: `Tầng ${floorNumber}`,
name: `Floor ${floorNumber}`,
slots,
entrances: [
{ x: 60, y: 10, type: 'entrance' },
@@ -194,7 +194,7 @@ const ParkingLotMap: React.FC<{ parkingLot: ParkingLot }> = ({ parkingLot }) =>
{/* 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>
<span>Updated: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
@@ -202,15 +202,15 @@ const ParkingLotMap: React.FC<{ parkingLot: ParkingLot }> = ({ parkingLot }) =>
<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 className="text-green-700">Available</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 className="text-red-700">Occupied</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 className="text-gray-700">Total</div>
</div>
</div>
</div>
@@ -221,27 +221,27 @@ const ParkingLotMap: React.FC<{ parkingLot: ParkingLot }> = ({ parkingLot }) =>
const SAMPLE_REVIEWS = [
{
id: 1,
user: 'Nguyễn Văn A',
user: 'John Smith',
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ý.',
comment: 'Spacious parking lot with 24/7 security. Very safe and reasonably priced.',
date: '2024-01-15',
avatar: 'N'
avatar: 'J'
},
{
id: 2,
user: 'Trần Thị B',
user: 'Sarah Johnson',
rating: 4,
comment: 'Vị trí thuận tin, dễ tìm. Chỉ hơi xa lối ra một chút.',
comment: 'Convenient location, easy to find. Just a bit far from the exit.',
date: '2024-01-10',
avatar: 'T'
avatar: 'S'
},
{
id: 3,
user: 'Lê Văn C',
user: 'Mike Davis',
rating: 5,
comment: 'Có sạc điện cho xe điện, rất tiện lợi!',
comment: 'Has electric charging stations, very convenient!',
date: '2024-01-08',
avatar: 'L'
avatar: 'M'
}
];
@@ -326,8 +326,8 @@ const formatAmenities = (amenities: string[] | { [key: string]: any }): string[]
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.security) amenityList.push('24/7 Security');
if (amenities.ev_charging) amenityList.push('EV Charging');
if (amenities.wheelchair_accessible) amenityList.push('Phù hợp xe lăn');
if (amenities.valet_service) amenityList.push('Dịch vụ đỗ xe');
@@ -418,7 +418,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
{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>
<span className="text-sm opacity-80">({SAMPLE_REVIEWS.length} reviews)</span>
</div>
</div>
</div>
@@ -426,13 +426,13 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
{/* 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>
<span className="text-sm font-bold">Parking lot is full</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>
<span className="text-sm font-bold">Parking lot is closed</span>
</div>
)}
</div>
@@ -451,7 +451,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
<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 font-medium text-gray-600">available</div>
<div className="text-xs text-gray-500">/ {parkingLot.totalSlots} tổng</div>
</div>
@@ -505,7 +505,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
}}
>
{tab === 'overview' && 'Tổng quan'}
{tab === 'reviews' && 'Đánh giá'}
{tab === 'reviews' && 'Reviews'}
</button>
))}
</div>
@@ -541,7 +541,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
<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
Amenities
</h3>
<div className="grid grid-cols-1 gap-2">
{amenityList.map((amenity, index) => (
@@ -570,7 +570,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
<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 className="text-sm font-medium text-gray-600">{SAMPLE_REVIEWS.length} reviews</div>
</div>
{/* Reviews list */}
@@ -605,7 +605,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
<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á
Write Review
</button>
</div>
)}
@@ -722,7 +722,7 @@ export const ParkingDetails: React.FC<ParkingDetailsProps> = ({
: '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)`}
{isFull ? 'Parking lot is full' : isClosed ? 'Parking lot is closed' : `Book Spot (${bookingDuration}h)`}
</button>
</div>
)}

View File

@@ -43,21 +43,21 @@ const formatDistance = (distance: number): string => {
const getStatusColor = (availableSlots: number, totalSlots: number) => {
const percentage = availableSlots / totalSlots;
if (availableSlots === 0) {
// Hết chỗ - màu đỏ
// Full - red color
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
// >70% available - green color
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
// <30% available - yellow color
return {
background: 'rgba(251, 191, 36, 0.1)',
borderColor: '#F59E0B',
@@ -68,11 +68,11 @@ const getStatusColor = (availableSlots: number, totalSlots: number) => {
const getStatusText = (availableSlots: number, totalSlots: number) => {
if (availableSlots === 0) {
return 'Hết chỗ';
return 'Full';
} else if (availableSlots / totalSlots > 0.7) {
return `${availableSlots} chỗ trống`;
return `${availableSlots} available`;
} else {
return `${availableSlots} chỗ trống (sắp hết)`;
return `${availableSlots} left (filling up)`;
}
};
@@ -201,9 +201,9 @@ export const ParkingList: React.FC<ParkingListProps> = ({
<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>
<h3 className="text-lg font-bold text-gray-900 mb-2">No Results Found</h3>
<p className="text-gray-600 text-sm">No parking lots match the keyword "{searchQuery}"</p>
<p className="text-gray-500 text-xs mt-2">Try searching with different keywords</p>
</div>
) : (
sortedLots.map((lot, index) => {
@@ -262,13 +262,13 @@ export const ParkingList: React.FC<ParkingListProps> = ({
{/* 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>
<span className="text-sm font-bold">🚫 PARKING LOT FULL</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>
<span className="text-sm font-bold">🔒 PARKING LOT CLOSED</span>
</div>
)}
@@ -322,10 +322,10 @@ export const ParkingList: React.FC<ParkingListProps> = ({
</div>
</div>
<div className="text-sm text-gray-500 font-medium">
chỗ trống
available
</div>
<div className="text-xs text-gray-400">
/ {lot.totalSlots} chỗ
/ {lot.totalSlots} total
</div>
{/* Availability percentage */}
<div className="mt-1 w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
@@ -338,7 +338,7 @@ export const ParkingList: React.FC<ParkingListProps> = ({
></div>
</div>
<div className="text-xs mt-1" style={{ color: statusColors.textColor }}>
{Math.round((lot.availableSlots / lot.totalSlots) * 100)}% trống
{Math.round((lot.availableSlots / lot.totalSlots) * 100)}% available
</div>
</div>
@@ -350,10 +350,10 @@ export const ParkingList: React.FC<ParkingListProps> = ({
{Math.round((lot.pricePerHour || lot.hourlyRate) / 1000)}k
</div>
<div className="text-sm text-gray-500 font-medium">
mỗi giờ
per hour
</div>
<div className="text-xs text-gray-400">
p gửi xe
parking fee
</div>
</>
) : (
@@ -362,10 +362,10 @@ export const ParkingList: React.FC<ParkingListProps> = ({
--
</div>
<div className="text-xs text-gray-400 font-medium">
liên hệ
contact
</div>
<div className="text-xs text-gray-400">
đ biết giá
for pricing
</div>
</>
)}
@@ -386,13 +386,13 @@ export const ParkingList: React.FC<ParkingListProps> = ({
</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}`
lot.isOpen24Hours ? 'Always open' : `until ${lot.closeTime}`
) : (
'Đã đóng cửa'
'Closed'
)}
</div>
<div className="text-xs text-gray-400">
{isCurrentlyOpen(lot) ? 'Đang mở' : '🔒 Đã đóng'}
{isCurrentlyOpen(lot) ? 'Open now' : '🔒 Closed'}
</div>
</>
) : (
@@ -401,10 +401,10 @@ export const ParkingList: React.FC<ParkingListProps> = ({
--:--
</div>
<div className="text-xs text-gray-400 font-medium">
không
unknown
</div>
<div className="text-xs text-gray-400">
giờ mở cửa
opening hours
</div>
</>
)}

View File

@@ -20,7 +20,8 @@ const iconPaths: Record<string, string> = {
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",
// map icon removed
marker: "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-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z",
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",

View File

@@ -1,8 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { parkingService, routingService, healthService } from '@/services/api';
import { parkingService, healthService } from '@/services/api';
import {
FindNearbyParkingRequest,
RouteRequest,
UpdateAvailabilityRequest
} from '@/types';
@@ -14,10 +13,6 @@ export const QUERY_KEYS = {
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;
@@ -83,26 +78,6 @@ export function useUpdateParkingAvailability() {
});
}
// 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({

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);
}

View File

@@ -3,9 +3,7 @@ import {
FindNearbyParkingRequest,
FindNearbyParkingResponse,
ParkingLot,
UpdateAvailabilityRequest,
RouteRequest,
RouteResponse
UpdateAvailabilityRequest
} from '@/types';
class APIClient {
@@ -77,17 +75,6 @@ class APIClient {
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');
@@ -108,11 +95,6 @@ export const parkingService = {
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

@@ -54,37 +54,6 @@ export interface ParkingLot {
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;
@@ -102,17 +71,6 @@ export interface FindNearbyParkingResponse {
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;
@@ -174,16 +132,8 @@ export interface ParkingState {
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;
@@ -214,11 +164,6 @@ export interface ParkingLotSelectEvent {
source: 'map' | 'list' | 'search';
}
export interface RouteCalculatedEvent {
route: Route;
duration: number; // calculation time in ms
}
export interface LocationUpdateEvent {
location: UserLocation;
accuracy: number;
@@ -269,15 +214,6 @@ export interface SearchAnalytics {
timeToSelection?: number;
}
export interface RouteAnalytics {
origin: RoutePoint;
destination: RoutePoint;
mode: TransportationMode;
distance: number;
duration: number;
completed: boolean;
}
// Configuration Types
export interface AppConfig {
api: {
@@ -320,19 +256,9 @@ export interface UseParkingSearchReturn {
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;
}
@@ -340,7 +266,6 @@ export interface MapViewProps {
userLocation: UserLocation | null;
parkingLots: ParkingLot[];
selectedParkingLot: ParkingLot | null;
route: Route | null;
onParkingLotSelect: (lot: ParkingLot) => void;
isLoading?: boolean;
}

View File

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