Implement Tourist Guide System
This commit is contained in:
@@ -56,6 +56,8 @@ import PolReports from "./pages/dashboard/politur/Reports";
|
|||||||
import GuideDashboard from "./pages/dashboard/guides/GuideDashboard";
|
import GuideDashboard from "./pages/dashboard/guides/GuideDashboard";
|
||||||
import ItineraryBuilder from "./pages/dashboard/guides/ItineraryBuilder";
|
import ItineraryBuilder from "./pages/dashboard/guides/ItineraryBuilder";
|
||||||
import ContentLibrary from "./pages/dashboard/guides/ContentLibrary";
|
import ContentLibrary from "./pages/dashboard/guides/ContentLibrary";
|
||||||
|
// Tourist App
|
||||||
|
import TouristApp from "./pages/TouristApp";
|
||||||
// Commerce pages (for retail stores)
|
// Commerce pages (for retail stores)
|
||||||
import CommerceStore from "./pages/dashboard/commerce/Store";
|
import CommerceStore from "./pages/dashboard/commerce/Store";
|
||||||
import CommercePOS from "./pages/dashboard/commerce/POSTerminal";
|
import CommercePOS from "./pages/dashboard/commerce/POSTerminal";
|
||||||
@@ -182,6 +184,11 @@ const AppRouter = () => (
|
|||||||
<OrderConfirmation />
|
<OrderConfirmation />
|
||||||
</FrontendLayout>
|
</FrontendLayout>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/tourist-app" element={
|
||||||
|
<FrontendLayout>
|
||||||
|
<TouristApp />
|
||||||
|
</FrontendLayout>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Protected Dashboard Routes */}
|
{/* Protected Dashboard Routes */}
|
||||||
<Route path="/dashboard" element={
|
<Route path="/dashboard" element={
|
||||||
|
|||||||
187
src/components/tourist/ARViewer.tsx
Normal file
187
src/components/tourist/ARViewer.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Camera, Info, Volume2, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
const ARViewer = () => {
|
||||||
|
const [isARActive, setIsARActive] = useState(false);
|
||||||
|
const [detectedMonument, setDetectedMonument] = useState<string | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const startAR = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'environment' }
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsARActive(true);
|
||||||
|
|
||||||
|
// Simulate monument detection after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setDetectedMonument('Catedral Primada de América');
|
||||||
|
toast({
|
||||||
|
title: "Monumento Detectado",
|
||||||
|
description: "Catedral Primada de América (1514)",
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "No se pudo acceder a la cámara. Por favor verifica los permisos.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAR = () => {
|
||||||
|
setIsARActive(false);
|
||||||
|
setDetectedMonument(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">Realidad Aumentada</h3>
|
||||||
|
<p className="text-sm text-gray-600">Apunta tu cámara a monumentos para más información</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isARActive ? (
|
||||||
|
<div className="aspect-video bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<Camera className="w-20 h-20 text-blue-600 mb-4" />
|
||||||
|
<h4 className="text-xl font-semibold mb-2">Experiencia AR</h4>
|
||||||
|
<p className="text-gray-600 mb-6 max-w-md">
|
||||||
|
Descubre la historia de los monumentos apuntando tu cámara.
|
||||||
|
La IA reconocerá automáticamente los lugares históricos.
|
||||||
|
</p>
|
||||||
|
<Button size="lg" onClick={startAR}>
|
||||||
|
<Camera className="w-5 h-5 mr-2" />
|
||||||
|
Activar Cámara AR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Camera View Simulation */}
|
||||||
|
<div className="aspect-video bg-gradient-to-br from-gray-800 to-gray-600 rounded-lg overflow-hidden relative">
|
||||||
|
{/* Simulated camera feed */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<Camera className="w-16 h-16 mx-auto mb-4 animate-pulse" />
|
||||||
|
<p className="text-lg">Vista de Cámara Activa</p>
|
||||||
|
<p className="text-sm opacity-75">Apunta a un monumento</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AR Overlay */}
|
||||||
|
{detectedMonument && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{/* Detection Frame */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-64 h-64 border-4 border-green-500 rounded-lg">
|
||||||
|
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-green-500"></div>
|
||||||
|
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-green-500"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-green-500"></div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-green-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={stopAR}
|
||||||
|
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-colors z-10"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Recording Indicator */}
|
||||||
|
<div className="absolute top-4 left-4 flex items-center gap-2 bg-red-500 text-white px-3 py-1 rounded-full text-sm z-10">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
|
||||||
|
AR Activo
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Information Overlay */}
|
||||||
|
{detectedMonument && (
|
||||||
|
<Card className="mt-4 p-4 animate-fade-in">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||||
|
<Info className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">{detectedMonument}</h4>
|
||||||
|
<p className="text-sm text-gray-600">Monumento Histórico</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Volume2 className="w-4 h-4 mr-1" />
|
||||||
|
Audio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Primera catedral de América, construcción iniciada en 1514.
|
||||||
|
Combina elementos góticos y barrocos. Declarada Patrimonio de la Humanidad por la UNESCO.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button size="sm" variant="outline" className="flex-1">
|
||||||
|
Ver más información
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="flex-1">
|
||||||
|
Reservar Tour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Camera className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-semibold text-sm mb-1">Reconocimiento IA</h5>
|
||||||
|
<p className="text-xs text-gray-600">Identifica monumentos automáticamente</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Info className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-semibold text-sm mb-1">Info Histórica</h5>
|
||||||
|
<p className="text-xs text-gray-600">Datos e historia en tiempo real</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Volume2 className="w-6 h-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="font-semibold text-sm mb-1">Audio Guías</h5>
|
||||||
|
<p className="text-xs text-gray-600">Narración profesional</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ARViewer;
|
||||||
149
src/components/tourist/EmergencyButton.tsx
Normal file
149
src/components/tourist/EmergencyButton.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AlertCircle, Phone, X, MapPin } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
const EmergencyButton = () => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [emergencyType, setEmergencyType] = useState<string | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const emergencyTypes = [
|
||||||
|
{ id: 'medical', label: 'Emergencia Médica', icon: '🏥', color: 'bg-red-500' },
|
||||||
|
{ id: 'security', label: 'Seguridad', icon: '🚨', color: 'bg-orange-500' },
|
||||||
|
{ id: 'accident', label: 'Accidente', icon: '🚗', color: 'bg-yellow-500' },
|
||||||
|
{ id: 'other', label: 'Otra Emergencia', icon: '⚠️', color: 'bg-gray-500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sendEmergencyAlert = (type: string) => {
|
||||||
|
// Get user location
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const { latitude, longitude } = position.coords;
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "¡Alerta Enviada!",
|
||||||
|
description: "POLITUR ha sido notificado de tu emergencia. Te contactarán pronto.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// In production, this would send to the backend
|
||||||
|
console.log('Emergency Alert:', {
|
||||||
|
type,
|
||||||
|
location: { lat: latitude, lng: longitude },
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowDialog(false);
|
||||||
|
setEmergencyType(null);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
toast({
|
||||||
|
title: "Error de Ubicación",
|
||||||
|
description: "No se pudo obtener tu ubicación. Por favor activa el GPS.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Emergency Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
className="fixed bottom-24 right-6 z-50 w-16 h-16 bg-red-500 hover:bg-red-600 text-white rounded-full shadow-2xl flex items-center justify-center transition-all hover:scale-110 animate-pulse"
|
||||||
|
aria-label="Botón de Emergencia"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Emergency Dialog */}
|
||||||
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertCircle className="w-6 h-6" />
|
||||||
|
Emergencia
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!emergencyType ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-600">Selecciona el tipo de emergencia:</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{emergencyTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setEmergencyType(type.id)}
|
||||||
|
className={`${type.color} text-white p-4 rounded-lg hover:opacity-90 transition-all hover:scale-105 flex flex-col items-center gap-2`}
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{type.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-center">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Phone className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-semibold text-blue-900 mb-1">Números de Emergencia:</p>
|
||||||
|
<p className="text-blue-800">POLITUR: *911</p>
|
||||||
|
<p className="text-blue-800">Emergencias: 911</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-red-800 mb-3">
|
||||||
|
Estás a punto de enviar una alerta de emergencia a POLITUR.
|
||||||
|
Tu ubicación actual será compartida.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-700">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>Compartiendo ubicación GPS...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setEmergencyType(null)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1 bg-red-500 hover:bg-red-600"
|
||||||
|
onClick={() => sendEmergencyAlert(emergencyType)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
|
Enviar Alerta
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-blue-600"
|
||||||
|
onClick={() => window.location.href = 'tel:*911'}
|
||||||
|
>
|
||||||
|
<Phone className="w-4 h-4 mr-2" />
|
||||||
|
Llamar a POLITUR (*911)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmergencyButton;
|
||||||
225
src/components/tourist/InteractiveMap.tsx
Normal file
225
src/components/tourist/InteractiveMap.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MapPin, Navigation, Layers } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Attraction {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
distance: string;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractiveMapProps {
|
||||||
|
attractions: Attraction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const InteractiveMap: React.FC<InteractiveMapProps> = ({ attractions }) => {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
|
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get user location
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setUserLocation({
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Ubicación Obtenida",
|
||||||
|
description: "Mostrando atracciones cerca de ti",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Error getting location:', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapContainer.current) return;
|
||||||
|
|
||||||
|
// Simulated interactive map
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = mapContainer.current.clientWidth;
|
||||||
|
canvas.height = 500;
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '500px';
|
||||||
|
canvas.style.borderRadius = '8px';
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Draw map background
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||||
|
gradient.addColorStop(0, '#e8f4f8');
|
||||||
|
gradient.addColorStop(1, '#b8d4e0');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
ctx.strokeStyle = '#d0e8f0';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < canvas.width; i += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(i, 0);
|
||||||
|
ctx.lineTo(i, canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let i = 0; i < canvas.height; i += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, i);
|
||||||
|
ctx.lineTo(canvas.width, i);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw title
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.font = 'bold 20px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Mapa Interactivo', canvas.width / 2, 30);
|
||||||
|
ctx.font = '14px Arial';
|
||||||
|
ctx.fillStyle = '#666';
|
||||||
|
ctx.fillText('(Integración con Mapbox + IA próximamente)', canvas.width / 2, 55);
|
||||||
|
|
||||||
|
// Draw user location (center)
|
||||||
|
if (userLocation) {
|
||||||
|
const centerX = canvas.width / 2;
|
||||||
|
const centerY = canvas.height / 2;
|
||||||
|
|
||||||
|
// User marker
|
||||||
|
ctx.fillStyle = '#3b82f6';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, 12, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pulse effect
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.globalAlpha = 0.3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, 20, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.font = 'bold 12px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Tu ubicación', centerX, centerY + 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw attraction markers
|
||||||
|
attractions.forEach((attraction, index) => {
|
||||||
|
const angle = (index / attractions.length) * Math.PI * 2;
|
||||||
|
const radius = 120;
|
||||||
|
const x = canvas.width / 2 + Math.cos(angle) * radius;
|
||||||
|
const y = canvas.height / 2 + Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
// Marker
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Label background
|
||||||
|
const labelWidth = ctx.measureText(attraction.name).width + 16;
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||||||
|
ctx.fillRect(x - labelWidth / 2, y - 35, labelWidth, 24);
|
||||||
|
ctx.strokeStyle = '#ddd';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(x - labelWidth / 2, y - 35, labelWidth, 24);
|
||||||
|
|
||||||
|
// Label text
|
||||||
|
ctx.fillStyle = '#333';
|
||||||
|
ctx.font = '11px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(attraction.name, x, y - 18);
|
||||||
|
});
|
||||||
|
|
||||||
|
mapContainer.current.innerHTML = '';
|
||||||
|
mapContainer.current.appendChild(canvas);
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
canvas.addEventListener('click', (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) * (canvas.width / rect.width);
|
||||||
|
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||||
|
|
||||||
|
attractions.forEach((attraction, index) => {
|
||||||
|
const angle = (index / attractions.length) * Math.PI * 2;
|
||||||
|
const radius = 120;
|
||||||
|
const markerX = canvas.width / 2 + Math.cos(angle) * radius;
|
||||||
|
const markerY = canvas.height / 2 + Math.sin(angle) * radius;
|
||||||
|
const distance = Math.sqrt(Math.pow(x - markerX, 2) + Math.pow(y - markerY, 2));
|
||||||
|
|
||||||
|
if (distance < 15) {
|
||||||
|
toast({
|
||||||
|
title: attraction.name,
|
||||||
|
description: `${attraction.category} - ${attraction.distance}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
}, [attractions, userLocation, toast]);
|
||||||
|
|
||||||
|
const centerOnUser = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
setUserLocation({
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Centrando Mapa",
|
||||||
|
description: "Mostrando tu ubicación actual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-lg">Explora Cerca de Ti</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={centerOnUser}>
|
||||||
|
<Navigation className="w-4 h-4 mr-2" />
|
||||||
|
Mi Ubicación
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Layers className="w-4 h-4 mr-2" />
|
||||||
|
Capas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={mapContainer} className="w-full h-[500px] rounded-lg overflow-hidden bg-gray-100">
|
||||||
|
{/* Map will be rendered here */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
💡 <strong>Funciones IA próximamente:</strong> Reconocimiento automático de monumentos con la cámara,
|
||||||
|
navegación guiada por voz, y recomendaciones personalizadas basadas en tus preferencias.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveMap;
|
||||||
255
src/pages/TouristApp.tsx
Normal file
255
src/pages/TouristApp.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { MapPin, Camera, ShoppingBag, Calendar, User, Search, AlertCircle, Navigation, Star } from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import InteractiveMap from '@/components/tourist/InteractiveMap';
|
||||||
|
import EmergencyButton from '@/components/tourist/EmergencyButton';
|
||||||
|
import ARViewer from '@/components/tourist/ARViewer';
|
||||||
|
|
||||||
|
interface Attraction {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
distance: string;
|
||||||
|
rating: number;
|
||||||
|
image: string;
|
||||||
|
price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TouristApp = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'explore' | 'map' | 'ar' | 'bookings' | 'shop'>('explore');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const attractions: Attraction[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Zona Colonial',
|
||||||
|
category: 'Histórico',
|
||||||
|
distance: '2.3 km',
|
||||||
|
rating: 4.8,
|
||||||
|
image: '🏛️',
|
||||||
|
price: 25
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Catedral Primada',
|
||||||
|
category: 'Monumento',
|
||||||
|
distance: '2.5 km',
|
||||||
|
rating: 4.9,
|
||||||
|
image: '⛪',
|
||||||
|
price: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Alcázar de Colón',
|
||||||
|
category: 'Museo',
|
||||||
|
distance: '2.8 km',
|
||||||
|
rating: 4.7,
|
||||||
|
image: '🏰',
|
||||||
|
price: 20
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ icon: Calendar, label: 'Reservar Hotel', color: 'bg-blue-500' },
|
||||||
|
{ icon: User, label: 'Contratar Guía', color: 'bg-purple-500' },
|
||||||
|
{ icon: Navigation, label: 'Taxi', color: 'bg-yellow-500' },
|
||||||
|
{ icon: ShoppingBag, label: 'Souvenirs', color: 'bg-green-500' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-primary to-orange-600 text-white p-6 pb-24">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Explora República Dominicana</h1>
|
||||||
|
<Button variant="ghost" size="icon" className="text-white">
|
||||||
|
<User className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar atracciones, restaurantes..."
|
||||||
|
className="pl-12 bg-white text-gray-900 border-0"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emergency Button - Always Visible */}
|
||||||
|
<EmergencyButton />
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto -mt-16 px-4 pb-24">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{quickActions.map((action, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className={`${action.color} w-12 h-12 rounded-full flex items-center justify-center`}>
|
||||||
|
<action.icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-center font-medium">{action.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
{activeTab === 'explore' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold">Cerca de ti</h2>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<MapPin className="w-4 h-4 mr-2" />
|
||||||
|
Ver en mapa
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attractions.map((attraction) => (
|
||||||
|
<Card key={attraction.id} className="overflow-hidden hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-32 h-32 bg-gray-200 flex items-center justify-center text-6xl">
|
||||||
|
{attraction.image}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg mb-1">{attraction.name}</h3>
|
||||||
|
<Badge variant="secondary">{attraction.category}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||||
|
<span className="font-semibold">{attraction.rating}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">{attraction.distance}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-lg font-bold text-primary">${attraction.price}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Camera className="w-4 h-4 mr-1" />
|
||||||
|
Ver AR
|
||||||
|
</Button>
|
||||||
|
<Button size="sm">
|
||||||
|
Reservar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'map' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<InteractiveMap attractions={attractions} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'ar' && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<ARViewer />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'bookings' && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Calendar className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Mis Reservas</h3>
|
||||||
|
<p className="text-gray-600">No tienes reservas activas</p>
|
||||||
|
<Button className="mt-4">Explorar Actividades</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'shop' && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ShoppingBag className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">Tienda de Souvenirs</h3>
|
||||||
|
<p className="text-gray-600">Descubre productos locales</p>
|
||||||
|
<Button className="mt-4">Ver Productos</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Navigation */}
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg">
|
||||||
|
<div className="max-w-4xl mx-auto grid grid-cols-5 gap-1 p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('explore')}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'explore' ? 'text-primary bg-primary/10' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium">Explorar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('map')}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'map' ? 'text-primary bg-primary/10' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium">Mapa</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('ar')}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'ar' ? 'text-primary bg-primary/10' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Camera className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium">AR</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('bookings')}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'bookings' ? 'text-primary bg-primary/10' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium">Reservas</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('shop')}
|
||||||
|
className={`flex flex-col items-center gap-1 py-2 rounded-lg transition-colors ${
|
||||||
|
activeTab === 'shop' ? 'text-primary bg-primary/10' : 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
<span className="text-xs font-medium">Tienda</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TouristApp;
|
||||||
Reference in New Issue
Block a user