Initial commit from remix

This commit is contained in:
gpt-engineer-app[bot]
2025-09-25 16:01:00 +00:00
commit 5ddc52658d
149 changed files with 32798 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
import { useState, useEffect } from 'react';
import { MessageCircle, X, Send, MapPin, Search, Sparkles, Star, Clock, DollarSign } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
interface Message {
id: string;
type: 'user' | 'ai';
content: string;
timestamp: Date;
suggestions?: string[];
results?: SearchResult[];
}
interface SearchResult {
id: string;
title: string;
type: 'restaurant' | 'hotel' | 'attraction' | 'event' | 'shop';
description: string;
rating: number;
price: string;
distance: string;
image: string;
location: string;
available: boolean;
}
const QUICK_ACTIONS = [
{ id: 'restaurants', label: '🍽️ Find Restaurants', query: 'Find the best restaurants near me' },
{ id: 'hotels', label: '🏨 Hotels & Stays', query: 'Show me hotels and accommodations nearby' },
{ id: 'attractions', label: '🎭 Attractions', query: 'What are the top attractions to visit here?' },
{ id: 'events', label: '🎪 Events Today', query: 'What events are happening today in my area?' },
{ id: 'shopping', label: '🛍️ Shopping', query: 'Find shopping centers and stores near me' },
{ id: 'deals', label: '💰 Best Deals', query: 'Show me the best deals and offers available' }
];
const SAMPLE_RESULTS: SearchResult[] = [
{
id: '1',
title: 'La Terrazza Restaurant',
type: 'restaurant',
description: 'Authentic Italian cuisine with ocean views',
rating: 4.8,
price: '$$$',
distance: '0.3 km',
image: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=300&h=200&fit=crop',
location: 'Downtown',
available: true
},
{
id: '2',
title: 'Ocean View Hotel',
type: 'hotel',
description: 'Luxury beachfront hotel with spa services',
rating: 4.6,
price: '$150/night',
distance: '0.8 km',
image: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=300&h=200&fit=crop',
location: 'Beachfront',
available: true
},
{
id: '3',
title: 'Art Museum Moderna',
type: 'attraction',
description: 'Contemporary art collection and exhibitions',
rating: 4.4,
price: '$12',
distance: '1.2 km',
image: 'https://images.unsplash.com/photo-1554907984-15263bfd63bd?w=300&h=200&fit=crop',
location: 'Cultural District',
available: true
}
];
export function AIFloatingAssistant() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [userLocation, setUserLocation] = useState<string>('');
useEffect(() => {
// Get user location
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
// In a real app, you'd reverse geocode these coordinates
setUserLocation(`${latitude.toFixed(4)}, ${longitude.toFixed(4)}`);
},
(error) => {
console.error('Error getting location:', error);
setUserLocation('Location unavailable');
}
);
}
}, []);
useEffect(() => {
if (isOpen && messages.length === 0) {
// Welcome message
const welcomeMessage: Message = {
id: '1',
type: 'ai',
content: `¡Hola! 👋 Soy tu asistente de IA de Karibeo. Estoy aquí para ayudarte a encontrar los mejores lugares, ofertas y experiencias según tu ubicación.
¿En qué puedo ayudarte hoy?`,
timestamp: new Date(),
suggestions: [
'Encuentra restaurantes cerca',
'Hoteles disponibles',
'Mejores ofertas del día',
'Eventos y actividades'
]
};
setMessages([welcomeMessage]);
}
}, [isOpen, messages.length]);
const handleSendMessage = async (query: string) => {
if (!query.trim()) return;
const userMessage: Message = {
id: Date.now().toString(),
type: 'user',
content: query,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsTyping(true);
// Simulate AI processing
setTimeout(() => {
const aiResponse = generateAIResponse(query);
setMessages(prev => [...prev, aiResponse]);
setIsTyping(false);
}, 1500);
};
const generateAIResponse = (query: string): Message => {
const lowerQuery = query.toLowerCase();
let response = '';
let results: SearchResult[] = [];
let suggestions: string[] = [];
if (lowerQuery.includes('restaurant') || lowerQuery.includes('comida') || lowerQuery.includes('comer')) {
response = '🍽️ He encontrado algunos restaurantes excelentes cerca de ti:';
results = SAMPLE_RESULTS.filter(r => r.type === 'restaurant');
suggestions = ['Reservar mesa', 'Ver menú', 'Más restaurantes', 'Ofertas especiales'];
} else if (lowerQuery.includes('hotel') || lowerQuery.includes('hospedaje') || lowerQuery.includes('alojamiento')) {
response = '🏨 Aquí tienes las mejores opciones de hospedaje:';
results = SAMPLE_RESULTS.filter(r => r.type === 'hotel');
suggestions = ['Verificar disponibilidad', 'Comparar precios', 'Ver fotos', 'Reservar ahora'];
} else if (lowerQuery.includes('atracciones') || lowerQuery.includes('lugares') || lowerQuery.includes('visitar')) {
response = '🎭 Estas son las atracciones más populares:';
results = SAMPLE_RESULTS.filter(r => r.type === 'attraction');
suggestions = ['Comprar entradas', 'Ver horarios', 'Más atracciones', 'Tours guiados'];
} else if (lowerQuery.includes('ofertas') || lowerQuery.includes('descuentos') || lowerQuery.includes('deals')) {
response = '💰 ¡Encontré estas ofertas especiales para ti!';
results = SAMPLE_RESULTS.map(r => ({ ...r, price: `${r.price} - 20% OFF` }));
suggestions = ['Ver más ofertas', 'Códigos de descuento', 'Ofertas flash', 'Membresías'];
} else {
response = `He buscando información sobre "${query}" en tu área. Aquí tienes algunas opciones que podrían interesarte:`;
results = SAMPLE_RESULTS.slice(0, 2);
suggestions = ['Búsqueda específica', 'Filtrar por precio', 'Ordenar por distancia', 'Ver mapa'];
}
return {
id: Date.now().toString(),
type: 'ai',
content: response,
timestamp: new Date(),
results,
suggestions
};
};
const handleQuickAction = (action: typeof QUICK_ACTIONS[0]) => {
handleSendMessage(action.query);
};
const renderSearchResults = (results: SearchResult[]) => {
return (
<div className="space-y-3 mt-4">
{results.map((result) => (
<Card key={result.id} className="cursor-pointer hover:shadow-lg transition-shadow">
<CardContent className="p-4">
<div className="flex gap-3">
<img
src={result.image}
alt={result.title}
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h4 className="font-semibold text-sm truncate">{result.title}</h4>
<Badge variant={result.available ? "default" : "secondary"} className="ml-2 text-xs">
{result.available ? 'Disponible' : 'Ocupado'}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{result.description}
</p>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div className="flex items-center">
<Star className="h-3 w-3 text-yellow-400 fill-current" />
<span className="ml-1 font-medium">{result.rating}</span>
</div>
<div className="flex items-center text-muted-foreground">
<MapPin className="h-3 w-3" />
<span className="ml-1">{result.distance}</span>
</div>
</div>
<div className="flex items-center font-semibold text-primary">
<DollarSign className="h-3 w-3" />
<span>{result.price}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
};
return (
<>
{/* Floating Button */}
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={() => setIsOpen(true)}
className={`w-14 h-14 rounded-full shadow-lg transition-all duration-300 ${
isOpen ? 'scale-0' : 'scale-100 hover:scale-110'
} bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600`}
>
<div className="relative">
<Sparkles className="h-6 w-6 text-white" />
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
</div>
</Button>
</div>
{/* Chat Modal */}
{isOpen && (
<div className="fixed bottom-6 right-6 z-50 w-96 h-[600px] bg-background border rounded-2xl shadow-2xl flex flex-col animate-scale-in">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-t-2xl">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src="" />
<AvatarFallback className="bg-white text-purple-500 text-xs font-bold">
AI
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-sm">Asistente Karibeo</h3>
<p className="text-xs opacity-90">
{userLocation ? `📍 ${userLocation}` : '🤖 Conectado'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsOpen(false)}
className="text-white hover:bg-white/20 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Quick Actions */}
{messages.length <= 1 && (
<div className="p-4 border-b">
<p className="text-sm font-medium mb-3">Acciones rápidas:</p>
<div className="grid grid-cols-2 gap-2">
{QUICK_ACTIONS.slice(0, 4).map((action) => (
<Button
key={action.id}
variant="outline"
size="sm"
onClick={() => handleQuickAction(action)}
className="text-xs h-8 justify-start"
>
{action.label}
</Button>
))}
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl p-3 ${
message.type === 'user'
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white'
: 'bg-muted'
}`}
>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
{message.results && renderSearchResults(message.results)}
{message.suggestions && (
<div className="mt-3 space-y-1">
{message.suggestions.map((suggestion, index) => (
<Button
key={index}
variant="outline"
size="sm"
onClick={() => handleSendMessage(suggestion)}
className="text-xs h-6 mr-1 mb-1"
>
{suggestion}
</Button>
))}
</div>
)}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-muted rounded-2xl p-3">
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
</div>
{/* Input */}
<div className="p-4 border-t">
<div className="flex gap-2">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage(inputValue)}
placeholder="Escribe tu pregunta..."
className="flex-1"
disabled={isTyping}
/>
<Button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim() || isTyping}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,155 @@
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Bookmark } from "lucide-react";
const BlogSection = () => {
const articles = [
{
id: 1,
image: "https://themes.easital.com/html/liston/v2.3/assets/images/blog/01-lg.jpg",
category: "Events",
title: "Etiam Dapibus Metus Aliquam Orci Venenatis, Suscipit Efficitur.",
date: "9 hours ago",
author: {
name: "Ethan Blackwood",
role: "Engineer",
avatar: "https://themes.easital.com/html/liston/v2.3/assets/images/avatar/01.jpg"
}
},
{
id: 2,
image: "https://themes.easital.com/html/liston/v2.3/assets/images/blog/02-lg.jpg",
category: "Events",
title: "Praesent sit amet augue tincidunt, venenatis risus ut.",
date: "August 30, 2023",
author: {
name: "Alexander Kaminski",
role: "Data analysis",
avatar: "https://themes.easital.com/html/liston/v2.3/assets/images/avatar/02.jpg"
}
},
{
id: 3,
image: "https://themes.easital.com/html/liston/v2.3/assets/images/blog/03-lg.jpg",
category: "Events",
title: "Duis volutpat ipsum eget pretium posuere.",
date: "Jun 28, 2023",
author: {
name: "Edwin Martins",
role: "Security Engineer",
avatar: "https://themes.easital.com/html/liston/v2.3/assets/images/avatar/03.jpg"
}
},
{
id: 4,
image: "https://themes.easital.com/html/liston/v2.3/assets/images/blog/04-lg.jpg",
category: "Events",
title: "In ut tellus id nisl convallis bibendum eu nec diam.",
date: "Jul 28, 2023",
author: {
name: "Pranoti Deshpande",
role: "Product Manager",
avatar: "https://themes.easital.com/html/liston/v2.3/assets/images/avatar/04.jpg"
}
},
{
id: 5,
image: "https://themes.easital.com/html/liston/v2.3/assets/images/blog/05-lg.jpg",
category: "Events",
title: "Vestibulum scelerisque urna ut velit auctor varius.",
date: "Oct 24, 2023",
author: {
name: "Gabriel North",
role: "DevOps",
avatar: "https://themes.easital.com/html/liston/v2.3/assets/images/avatar/05.jpg"
}
}
];
return (
<section className="py-20 relative overflow-hidden bg-white">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex justify-center">
<div className="w-full max-w-4xl text-center mb-12">
{/* Section Header */}
<div className="inline-block font-caveat text-5xl font-medium text-primary mb-4 capitalize">
Our Latest Articles
</div>
<h2 className="text-4xl md:text-5xl font-semibold mb-6 capitalize">
Discover Our Latest News And Articles
</h2>
<p className="text-lg text-gray-600">
Discover exciting categories. <span className="text-primary font-semibold">Find what you're looking for.</span>
</p>
</div>
</div>
{/* Blog Carousel/Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-x-auto">
{articles.map((article) => (
<Card key={article.id} className="h-full overflow-hidden group cursor-pointer hover:shadow-lg transition-all duration-300">
{/* Image with bookmark */}
<div className="relative overflow-hidden">
<img
src={article.image}
alt={article.title}
className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<button className="absolute top-3 right-3 w-10 h-10 bg-white rounded-full flex items-center justify-center text-primary hover:bg-primary hover:text-white transition-colors z-10">
<Bookmark className="w-4 h-4" />
</button>
<a href="/blog" className="absolute inset-0 z-5" aria-label="Read more"></a>
</div>
{/* Card Body */}
<CardContent className="p-6">
{/* Meta information */}
<div className="flex items-center gap-3 mb-3">
<span className="text-sm text-gray-500">{article.date}</span>
<span className="text-gray-300">|</span>
<a
href="/blog"
className="inline-block px-3 py-1 text-sm font-semibold text-primary bg-white border border-gray-200 rounded hover:bg-primary hover:text-white transition-colors"
>
{article.category}
</a>
</div>
{/* Title */}
<h3 className="text-lg font-semibold mb-0 line-clamp-2">
<a href="/blog" className="hover:text-primary transition-colors">
{article.title}
</a>
</h3>
</CardContent>
{/* Card Footer */}
<CardFooter className="py-4 px-6 border-t border-gray-100">
<div className="flex items-center w-full">
<div className="flex-shrink-0">
<img
src={article.author.avatar}
alt={article.author.name}
className="w-12 h-12 rounded-full object-cover"
/>
</div>
<div className="ml-3 flex-grow">
<a href="/author" className="block">
<span className="text-gray-500 italic text-sm">By</span>{" "}
<span className="font-medium text-gray-900 hover:text-primary transition-colors">
{article.author.name}
</span>
</a>
<div className="text-sm text-gray-500">{article.author.role}</div>
</div>
</div>
</CardFooter>
</Card>
))}
</div>
</div>
</section>
);
};
export default BlogSection;

View File

@@ -0,0 +1,313 @@
import React, { useState } from 'react';
import { Calendar as CalendarIcon, Clock, UserPlus, ShoppingCart, Phone, Mail, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Calendar } from '@/components/ui/calendar';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { es } from 'date-fns/locale';
interface Listing {
id: string;
title: string;
price: number;
category: string;
images: string[];
location: {
address: string;
};
}
interface BookingSidebarProps {
offer: Listing;
onBookNow: (date: Date | undefined, guests: number, timeSlot?: string) => void;
onAddToCart: (date: Date | undefined, guests: number, timeSlot?: string) => void;
}
interface AvailableDate {
date: Date;
available: boolean;
timeSlots?: string[];
}
const BookingSidebar: React.FC<BookingSidebarProps> = ({ offer, onBookNow, onAddToCart }) => {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [selectedTimeSlot, setSelectedTimeSlot] = useState<string>('');
const [guests, setGuests] = useState(1);
const [isOpen, setIsOpen] = useState(false);
const isMobile = useIsMobile();
// Mock available dates - in real app this would come from API
const availableDates: AvailableDate[] = [
{ date: new Date(2025, 8, 15), available: true, timeSlots: ['09:00', '12:00', '15:00', '18:00'] },
{ date: new Date(2025, 8, 16), available: true, timeSlots: ['09:00', '15:00', '18:00'] },
{ date: new Date(2025, 8, 17), available: false },
{ date: new Date(2025, 8, 18), available: true, timeSlots: ['12:00', '15:00', '18:00'] },
{ date: new Date(2025, 8, 20), available: true, timeSlots: ['09:00', '12:00', '15:00'] },
{ date: new Date(2025, 8, 22), available: true, timeSlots: ['09:00', '12:00', '15:00', '18:00'] },
{ date: new Date(2025, 8, 25), available: true, timeSlots: ['10:00', '14:00', '16:00', '19:00'] },
{ date: new Date(2025, 8, 28), available: true, timeSlots: ['09:00', '13:00', '17:00'] },
];
const isDateAvailable = (date: Date) => {
return availableDates.some(d =>
d.date.toDateString() === date.toDateString() && d.available
);
};
const getTimeSlots = (date: Date) => {
const availableDate = availableDates.find(d =>
d.date.toDateString() === date.toDateString()
);
return availableDate?.timeSlots || [];
};
const handleDateSelect = (date: Date | undefined) => {
setSelectedDate(date);
setSelectedTimeSlot('');
};
const handleBookNow = () => {
onBookNow(selectedDate, guests, selectedTimeSlot);
if (isMobile) setIsOpen(false);
};
const handleAddToCart = () => {
onAddToCart(selectedDate, guests, selectedTimeSlot);
if (isMobile) setIsOpen(false);
};
const isFormValid = selectedDate && (offer.category === 'tour' ? selectedTimeSlot : true);
const BookingForm = () => (
<div className="space-y-4">
<div className="text-center mb-6">
<div className="text-3xl font-bold text-primary mb-2">
${offer.price}
<span className="text-base font-normal text-muted-foreground">
{offer.category === 'hotel' ? '/noche' : offer.category === 'restaurant' ? '/persona' : '/tour'}
</span>
</div>
</div>
{/* Calendar */}
<div>
<Label>Selecciona una fecha</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? (
<span>
{format(selectedDate, "MMMM d, yyyy", { locale: es })}
{selectedTimeSlot && ` - ${selectedTimeSlot}`}
</span>
) : (
<span>Elige una fecha</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
disabled={(date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today || !isDateAvailable(date);
}}
modifiers={{
available: (date) => isDateAvailable(date),
booked: (date) => !isDateAvailable(date) && date >= new Date()
}}
modifiersStyles={{
available: {
backgroundColor: 'hsl(var(--foreground))',
color: 'hsl(var(--background))',
fontWeight: 'bold',
borderRadius: '6px',
border: '2px solid hsl(var(--primary))'
},
booked: {
backgroundColor: 'hsl(var(--muted))',
color: 'hsl(var(--muted-foreground))',
textDecoration: 'line-through',
opacity: '0.5'
}
}}
modifiersClassNames={{
available: 'hover:bg-primary hover:text-primary-foreground cursor-pointer transition-colors',
booked: 'cursor-not-allowed'
}}
initialFocus
className="pointer-events-auto"
/>
<div className="p-3 border-t">
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-foreground border border-primary"></div>
<span>Disponible</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-muted opacity-50"></div>
<span>No disponible</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Time Slots - only for tours and restaurants */}
{selectedDate && (offer.category === 'tour' || offer.category === 'restaurant') && (
<div>
<Label>Horarios disponibles</Label>
<div className="grid grid-cols-2 gap-2 mt-2">
{getTimeSlots(selectedDate).map((timeSlot) => (
<Button
key={timeSlot}
variant={selectedTimeSlot === timeSlot ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTimeSlot(timeSlot)}
className="h-8"
>
<Clock className="w-3 h-3 mr-1" />
{timeSlot}
</Button>
))}
</div>
</div>
)}
{/* Guests */}
{(offer.category === 'hotel' || offer.category === 'restaurant' || offer.category === 'tour') && (
<div>
<Label htmlFor="guests">
{offer.category === 'hotel' ? 'Huéspedes' :
offer.category === 'restaurant' ? 'Personas' : 'Participantes'}
</Label>
<Select value={guests.toString()} onValueChange={(value) => setGuests(parseInt(value))}>
<SelectTrigger>
<div className="flex items-center">
<UserPlus className="w-4 h-4 mr-2 text-muted-foreground" />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8].map(num => (
<SelectItem key={num} value={num.toString()}>
{num} {num === 1 ? 'persona' : 'personas'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Action Buttons */}
<div className="space-y-3 pt-4">
<Button
className="w-full"
size="lg"
onClick={handleBookNow}
disabled={!isFormValid}
>
Reservar Ahora
</Button>
<Button
variant="outline"
className="w-full"
size="lg"
onClick={handleAddToCart}
disabled={!isFormValid}
>
<ShoppingCart className="w-4 h-4 mr-2" />
Agregar al Carrito
</Button>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm">
<Phone className="w-4 h-4 mr-1" />
Llamar
</Button>
<Button variant="outline" size="sm">
<Mail className="w-4 h-4 mr-1" />
Contactar
</Button>
</div>
</div>
{/* Stats */}
<div className="border-t pt-4 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Tiempo de respuesta:</span>
<span className="font-medium">En 1 hora</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tasa de respuesta:</span>
<span className="font-medium">100%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Última vez activo:</span>
<span className="font-medium">Hace 2 horas</span>
</div>
</div>
</div>
);
if (isMobile) {
return (
<>
{/* Mobile Floating Button */}
<div className="fixed bottom-4 right-4 z-50 lg:hidden">
<Button
size="lg"
onClick={() => setIsOpen(true)}
className="rounded-full shadow-lg px-6 py-3 h-auto min-w-fit"
>
<CalendarIcon className="h-5 w-5 mr-2" />
<span className="font-semibold">Reservar</span>
</Button>
</div>
{/* Mobile Booking Sheet */}
{isOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/80" onClick={() => setIsOpen(false)} />
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-2xl">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">Reservar</h3>
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-4 max-h-[80vh] overflow-auto">
<BookingForm />
</div>
</div>
</div>
)}
</>
);
}
// Desktop Sidebar
return (
<Card className="p-6 sticky top-24">
<BookingForm />
</Card>
);
};
export default BookingSidebar;

View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { ShoppingCart, X, Plus, Minus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useCart } from '@/contexts/CartContext';
import { useNavigate } from 'react-router-dom';
const CartSidebar: React.FC = () => {
const { items, removeFromCart, updateQuantity, getTotalItems, getTotalPrice, clearCart } = useCart();
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const handleCheckout = () => {
setIsOpen(false);
navigate('/checkout');
};
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="relative">
<ShoppingCart className="w-4 h-4" />
{getTotalItems() > 0 && (
<Badge className="absolute -top-2 -right-2 h-5 w-5 p-0 flex items-center justify-center text-xs">
{getTotalItems()}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-lg">
<SheetHeader>
<SheetTitle className="flex items-center justify-between">
<span>Carrito de Compras</span>
{items.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearCart}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="w-4 h-4 mr-1" />
Limpiar
</Button>
)}
</SheetTitle>
</SheetHeader>
<div className="mt-6">
{items.length === 0 ? (
<div className="text-center py-8">
<ShoppingCart className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">Tu carrito está vacío</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setIsOpen(false)}
>
Continuar Explorando
</Button>
</div>
) : (
<>
{/* Cart Items */}
<div className="space-y-4 max-h-96 overflow-y-auto">
{items.map((item) => (
<div key={item.id} className="flex items-center space-x-4 bg-gray-50 p-3 rounded-lg">
<img
src={item.image}
alt={item.title}
className="w-16 h-16 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{item.title}</h4>
<p className="text-xs text-gray-500">{item.location}</p>
<p className="text-sm font-semibold text-primary">${item.price}</p>
{item.selectedDate && (
<p className="text-xs text-gray-600">Fecha: {item.selectedDate}</p>
)}
{item.guests && (
<p className="text-xs text-gray-600">Huéspedes: {item.guests}</p>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
>
<Minus className="w-3 h-3" />
</Button>
<span className="w-8 text-center text-sm">{item.quantity}</span>
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(item.id, item.quantity + 1)}
>
<Plus className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeFromCart(item.id)}
className="text-red-500 hover:text-red-700"
>
<X className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
{/* Cart Summary */}
<div className="mt-6 border-t pt-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">
Total de artículos: {getTotalItems()}
</span>
<span className="text-lg font-semibold">
${getTotalPrice().toFixed(2)}
</span>
</div>
<Button
className="w-full"
onClick={handleCheckout}
disabled={items.length === 0}
>
Proceder al Checkout
</Button>
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
};
export default CartSidebar;

View File

@@ -0,0 +1,484 @@
import React, { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { useLanguage } from '@/contexts/LanguageContext';
import DashboardStyles from '@/components/layouts/DashboardStyles';
import {
Home,
Plus,
Wallet,
List,
MessageSquare,
Star,
BookOpen,
Heart,
FileText,
User,
Users,
Settings,
LogOut,
Search,
Bell,
Menu,
Sun,
Moon,
Maximize,
CreditCard,
BarChart3,
MapPin,
DollarSign,
AlertTriangle,
RefreshCw,
Navigation,
Car,
Target,
Zap,
Eye,
Megaphone,
ChevronDown,
ChevronRight
} from 'lucide-react';
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
const { user, logout } = useAuth();
const { t } = useLanguage();
const location = useLocation();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
const currentTab = new URLSearchParams(location.search).get('tab') || 'overview';
const toggleExpanded = (itemTab: string) => {
setExpandedItems(prev => ({
...prev,
[itemTab]: !prev[itemTab]
}));
};
const menuItems = [
{ icon: Home, label: 'Dashboard', path: '/dashboard' },
{ icon: Settings, label: 'Admin Panel', path: '/dashboard/admin' },
{ icon: Plus, label: 'Add listing', path: '/dashboard/add-listing' },
{ icon: Wallet, label: 'Wallet', path: '/dashboard/wallet' },
{ icon: MessageSquare, label: 'Message', path: '/dashboard/messages', badge: '2' },
];
const adminSubmenu = [
{ icon: BarChart3, label: 'Resumen General', tab: 'overview' },
{ icon: Users, label: 'Gestión de Usuarios', tab: 'users' },
{ icon: MapPin, label: 'Proveedores de Servicios', tab: 'services' },
{ icon: DollarSign, label: 'Gestión Financiera', tab: 'financial' },
{
icon: FileText,
label: 'Contenido Turístico',
tab: 'content',
subItems: [
{ icon: MapPin, label: 'Destinos', tab: 'content-destinations' },
{ icon: Star, label: 'Lugares', tab: 'content-places' },
{ icon: Users, label: 'Guías', tab: 'content-guides' },
{ icon: Car, label: 'Taxis', tab: 'content-taxis' },
{
icon: Navigation,
label: 'Geolocalización',
tab: 'content-geolocation',
subItems: [
{ icon: MapPin, label: 'Geofences', tab: 'geofences' },
{ icon: BarChart3, label: 'Analíticas', tab: 'analytics' },
{ icon: Target, label: 'Pruebas', tab: 'testing' },
{ icon: AlertTriangle, label: 'Emergencias', tab: 'emergency-geo' },
{ icon: Navigation, label: 'Navegación', tab: 'navigation' }
]
},
{ icon: Megaphone, label: 'Promocional', tab: 'content-promotional' },
{ icon: Zap, label: 'Guías IA', tab: 'content-ai-guides' },
{ icon: Eye, label: 'Realidad AR', tab: 'content-ar' }
]
},
{ icon: AlertTriangle, label: 'Emergencias', tab: 'emergency' },
{ icon: MessageSquare, label: 'Soporte', tab: 'support' },
...(user?.role === 'super_admin' ? [{ icon: Settings, label: 'Configuración', tab: 'config' }] : []),
];
const listingItems = [
{ icon: List, label: 'My Listing', path: '/dashboard/my-listings', hasSubmenu: true },
{ icon: Star, label: 'Reviews', path: '/dashboard/reviews' },
{ icon: BookOpen, label: 'Bookings', path: '/dashboard/bookings' },
{ icon: Heart, label: 'Bookmark', path: '/dashboard/bookmarks' },
{ icon: FileText, label: 'Invoice', path: '/dashboard/invoices' },
];
const accountItems = [
{ icon: User, label: 'Edit Profile', path: '/dashboard/profile' },
{ icon: CreditCard, label: 'Wallet', path: '/dashboard/wallet' },
{ icon: Settings, label: 'Setting', path: '/dashboard/settings' },
];
return (
<>
<DashboardStyles />
<div className="min-h-screen" style={{ fontFamily: '"Wix Madefor Display", sans-serif', backgroundColor: '#f8f4f3' }}>
{/* Background decorations */}
<div className="decoration blur-2"></div>
<div className="decoration blur-3"></div>
{/* Sidebar */}
<nav className={`fixed top-0 left-0 z-50 h-screen bg-white border-r-4 border-white transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-80'}`} style={{ minWidth: sidebarCollapsed ? '64px' : '320px', maxWidth: sidebarCollapsed ? '64px' : '320px', backdropFilter: 'blur(15px)', backgroundColor: 'rgba(255, 255, 255, 0.9)' }}>
{/* Sidebar Header */}
<div className="flex items-center justify-between p-6 h-20 border-b border-gray-100">
<Link to="/dashboard" className={`flex items-center space-x-2 ${sidebarCollapsed ? 'justify-center' : ''}`}>
<div className="w-24 h-24 rounded-lg flex items-center justify-center">
<img
src="https://karibeo.com/desktop/assets/images/logo.png"
alt="Karibeo"
className="w-full h-full object-contain rounded-lg"
/>
</div>
</Link>
</div>
{/* Sidebar Navigation */}
<div className="p-4 h-full overflow-y-auto" style={{ height: 'calc(100vh - 80px)' }}>
{/* Main Menu */}
<div className="mb-6">
{!sidebarCollapsed && (
<div className="flex items-center justify-between mb-4 px-4 py-3">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wider">Main Menu</span>
<div className="flex space-x-1">
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
</div>
</div>
)}
<div className="space-y-1 pr-8">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
const isAdminPanel = item.path === '/dashboard/admin';
return (
<div key={item.path}>
<Link
to={item.path}
className={`flex items-center space-x-3 px-7 py-2.5 rounded-none transition-all border-l-4 ${
isActive
? 'text-white border-l-4 rounded-r-full'
: 'text-gray-700 hover:text-red-500 border-transparent rounded-r-full'
} ${sidebarCollapsed ? 'justify-center px-2' : ''}`}
style={{
backgroundColor: isActive ? 'rgba(248, 69, 37, 0.1)' : 'transparent',
borderLeftColor: isActive ? '#F84525' : 'transparent',
color: isActive ? '#F84525' : '#433c3a'
}}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && (
<div className="flex items-center justify-between w-full">
<span className="font-medium">{item.label}</span>
{item.badge && (
<span className="bg-green-500 text-white text-xs px-2 py-0.5 rounded-full">
{item.badge}
</span>
)}
</div>
)}
</Link>
{/* Admin submenus */}
{!sidebarCollapsed && isAdminPanel && location.pathname.startsWith('/dashboard/admin') && (
<div className="mt-1 ml-10 space-y-1">
{adminSubmenu.map((sub) => {
const SubIcon = sub.icon;
const activeSub = currentTab === sub.tab;
const hasSubItems = sub.subItems && sub.subItems.length > 0;
const isExpanded = expandedItems[sub.tab] || (hasSubItems && sub.subItems.some(item => currentTab === item.tab || (item.subItems && item.subItems.some(subItem => currentTab === subItem.tab))));
return (
<div key={sub.tab}>
<div className={`flex items-center justify-between px-3 py-2 rounded-md text-sm ${activeSub ? 'bg-orange-50 text-orange-600' : 'text-gray-600 hover:text-orange-600 hover:bg-gray-50'}`}>
<Link
to={`/dashboard/admin?tab=${sub.tab}`}
className="flex items-center space-x-2 flex-1"
>
<SubIcon className="w-4 h-4" />
<span>{sub.label}</span>
</Link>
{hasSubItems && (
<button
onClick={() => toggleExpanded(sub.tab)}
className="p-1 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
{/* Sub-submenus */}
{hasSubItems && isExpanded && (
<div className="ml-6 mt-1 space-y-1">
{sub.subItems.map((subItem) => {
const SubSubIcon = subItem.icon;
const activeSubSub = currentTab === subItem.tab;
const hasSubSubItems = subItem.subItems && subItem.subItems.length > 0;
const isSubExpanded = expandedItems[subItem.tab] || (hasSubSubItems && subItem.subItems.some(item => currentTab === item.tab));
return (
<div key={subItem.tab}>
<div className={`flex items-center justify-between px-3 py-1.5 rounded-md text-xs ${activeSubSub ? 'bg-orange-100 text-orange-700' : 'text-gray-500 hover:text-orange-600 hover:bg-gray-50'}`}>
<Link
to={`/dashboard/admin?tab=${subItem.tab}`}
className="flex items-center space-x-2 flex-1"
>
<SubSubIcon className="w-3 h-3" />
<span>{subItem.label}</span>
</Link>
{hasSubSubItems && (
<button
onClick={() => toggleExpanded(subItem.tab)}
className="p-1 hover:bg-gray-200 rounded"
>
{isSubExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
{/* Sub-sub-submenus */}
{hasSubSubItems && isSubExpanded && (
<div className="ml-4 mt-1 space-y-1">
{subItem.subItems.map((subSubItem) => {
const SubSubSubIcon = subSubItem.icon;
const activeSubSubSub = currentTab === subSubItem.tab;
return (
<Link
key={subSubItem.tab}
to={`/dashboard/admin?tab=${subSubItem.tab}`}
className={`flex items-center space-x-2 px-3 py-1 rounded-md text-xs ${activeSubSubSub ? 'bg-orange-200 text-orange-800' : 'text-gray-400 hover:text-orange-600 hover:bg-gray-50'}`}
>
<SubSubSubIcon className="w-3 h-3" />
<span>{subSubItem.label}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Listing Section */}
<div className="mb-6">
{!sidebarCollapsed && (
<div className="flex items-center justify-between mb-4 px-4 py-3">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wider">Listing</span>
<div className="flex space-x-1">
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
</div>
</div>
)}
<div className="space-y-1 pr-8">
{listingItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center space-x-3 px-7 py-2.5 rounded-none transition-all border-l-4 ${
isActive
? 'text-white border-l-4 rounded-r-full'
: 'text-gray-700 hover:text-red-500 border-transparent rounded-r-full'
} ${sidebarCollapsed ? 'justify-center px-2' : ''}`}
style={{
backgroundColor: isActive ? 'rgba(248, 69, 37, 0.1)' : 'transparent',
borderLeftColor: isActive ? '#F84525' : 'transparent',
color: isActive ? '#F84525' : '#433c3a'
}}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
</Link>
);
})}
</div>
</div>
{/* Account Section */}
<div className="mb-6">
{!sidebarCollapsed && (
<div className="flex items-center justify-between mb-4 px-4 py-3">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wider">Account</span>
<div className="flex space-x-1">
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
</div>
</div>
)}
<div className="space-y-1 pr-8">
{accountItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center space-x-3 px-7 py-2.5 rounded-none transition-all border-l-4 ${
isActive
? 'text-white border-l-4 rounded-r-full'
: 'text-gray-700 hover:text-red-500 border-transparent rounded-r-full'
} ${sidebarCollapsed ? 'justify-center px-2' : ''}`}
style={{
backgroundColor: isActive ? 'rgba(248, 69, 37, 0.1)' : 'transparent',
borderLeftColor: isActive ? '#F84525' : 'transparent',
color: isActive ? '#F84525' : '#433c3a'
}}
>
<Icon className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">{item.label}</span>}
</Link>
);
})}
<button
onClick={logout}
className={`flex items-center space-x-3 px-7 py-2.5 rounded-r-full transition-all text-gray-700 hover:text-red-600 w-full border-l-4 border-transparent ${sidebarCollapsed ? 'justify-center px-2' : ''}`}
>
<LogOut className="w-5 h-5 flex-shrink-0" />
{!sidebarCollapsed && <span className="font-medium">Logout</span>}
</button>
</div>
</div>
</div>
</nav>
{/* Main Content Area */}
<div className={`transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-80'}`}>
{/* Top Navigation */}
<nav className="h-20 px-6 py-4 z-10 relative" style={{ backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(15px)' }}>
<div className="flex items-center justify-between">
{/* Left Side */}
<div className="flex items-center space-x-4">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-2 rounded-full text-white transition-colors"
style={{ backgroundColor: '#F84525' }}
>
<Menu className="w-5 h-5" />
</button>
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4" style={{ color: '#69534f' }} />
</div>
<input
type="text"
placeholder="Search (Ctrl+/)"
className="block w-80 pl-12 pr-16 py-3 border border-white rounded-xl text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:border-red-500"
style={{
backgroundColor: '#fff',
borderColor: '#fff',
height: '48px',
borderRadius: '0.8rem'
}}
/>
<span className="absolute inset-y-0 right-0 pr-3 flex items-center text-xs font-bold px-2 py-1 rounded" style={{ backgroundColor: '#f8f4f3', borderColor: '#f8f4f3', top: '50%', transform: 'translateY(-50%)', right: '8px', fontSize: '12px' }}>
(Ctrl+/)
</span>
</div>
</div>
{/* Right Side */}
<div className="flex items-center space-x-4">
{/* Refresh (Admin) */}
<button
onClick={() => window.dispatchEvent(new CustomEvent('admin:refresh'))}
className="p-2 rounded-xl transition-colors"
style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}
title="Actualizar datos"
>
<RefreshCw className="w-5 h-5" />
</button>
{/* Notifications */}
<button className="p-2 rounded-xl transition-colors relative" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }} title="Notificaciones">
<Bell className="w-5 h-5" />
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] px-1 py-0.5 rounded-full">!</span>
</button>
{/* Theme Toggle */}
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
<Sun className="w-5 h-5" />
</button>
{/* Fullscreen */}
<button className="p-2 rounded-xl transition-colors" style={{ backgroundColor: '#21272f', borderColor: '#21272f', color: '#F84525' }}>
<Maximize className="w-5 h-5" />
</button>
{/* User Profile */}
<div className="flex items-center space-x-3 pl-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center relative">
<span className="text-white font-semibold text-sm">
{user?.name?.[0] || user?.email?.[0] || 'U'}
</span>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 border-2 border-white rounded-full"></div>
</div>
<div className="text-left">
<div className="font-semibold text-gray-800 text-sm flex items-center space-x-2">
<span>{user?.name || 'Usuario'}</span>
{user?.role === 'super_admin' && (
<span className="bg-gradient-to-r from-orange-400 to-red-500 text-white text-[10px] px-1.5 py-0.5 rounded">Super Admin</span>
)}
</div>
<div className="text-xs text-gray-500">
{user?.email || 'example@gmail.com'}
</div>
</div>
</div>
</div>
</div>
</div>
</nav>
{/* Page Content */}
<main className="p-6">
{children}
</main>
</div>
</div>
</>
);
};
export default DashboardLayout;

View File

@@ -0,0 +1,182 @@
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, MapPin, Star, Camera } from "lucide-react";
import santoDomingoImage from "@/assets/santo-domingo.jpg";
import sanJuanImage from "@/assets/san-juan.jpg";
import puntaCanaImage from "@/assets/punta-cana.jpg";
const DestinationsSection = () => {
const destinations = [
{
id: "santo-domingo",
name: "Santo Domingo",
region: "Zona Colonial",
image: santoDomingoImage,
listings: "89+",
rating: 4.8,
description: "Descubre el corazón histórico de América",
highlights: ["Patrimonio UNESCO", "Catedral Primada", "Fortaleza Ozama"],
isPopular: true
},
{
id: "san-juan",
name: "San Juan",
region: "Viejo San Juan",
image: sanJuanImage,
listings: "124+",
rating: 4.9,
description: "Colores vibrantes y historia viva",
highlights: ["El Morro", "Calles empedradas", "Gastronomía única"],
isPopular: true
},
{
id: "punta-cana",
name: "Punta Cana",
region: "Costa del Coco",
image: puntaCanaImage,
listings: "95+",
rating: 4.7,
description: "Paraíso tropical de arena blanca",
highlights: ["Playas pristinas", "Resorts de lujo", "Deportes acuáticos"],
isPopular: false
}
];
return (
<section className="py-20 bg-background">
<div className="max-w-7xl mx-auto px-4">
{/* Section Header */}
<div className="text-center mb-16">
<div className="inline-block px-4 py-2 bg-secondary/10 text-secondary rounded-full text-sm font-medium mb-4">
Principales Regiones
</div>
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Explora <span className="text-secondary-gradient">Ciudades</span>
</h2>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
Descubre destinos emocionantes. Encuentra exactamente lo que estás buscando.
</p>
</div>
{/* Destinations Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
{destinations.map((destination, index) => (
<Card key={destination.id} className="group overflow-hidden bg-card border-0 shadow-soft hover:shadow-medium transition-all duration-500 hover:-translate-y-2">
{/* Image Container */}
<div className="relative h-64 overflow-hidden">
<img
src={destination.image}
alt={destination.name}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{/* Popular Badge */}
{destination.isPopular && (
<div className="absolute top-4 left-4 bg-secondary text-secondary-foreground px-3 py-1 rounded-full text-sm font-medium">
🔥 Popular
</div>
)}
{/* Rating */}
<div className="absolute top-4 right-4 bg-white/90 text-gray-900 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
{destination.rating}
</div>
{/* Hover Actions */}
<div className="absolute bottom-4 left-4 right-4 transform translate-y-8 group-hover:translate-y-0 transition-transform duration-300 opacity-0 group-hover:opacity-100">
<div className="flex gap-2">
<Button size="sm" className="flex-1 bg-white/20 text-white border-white/30 hover:bg-white/30">
<Camera className="w-4 h-4 mr-2" />
Ver Galería
</Button>
<Button size="sm" className="bg-primary text-primary-foreground hover:bg-primary-dark">
<MapPin className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="p-6">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-2xl font-bold text-foreground mb-1">
{destination.name}
</h3>
<p className="text-lg font-medium text-primary">
{destination.region}
</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-secondary">
{destination.listings}
</div>
<div className="text-sm text-muted-foreground">
listings
</div>
</div>
</div>
<p className="text-muted-foreground mb-4 leading-relaxed">
{destination.description}
</p>
{/* Highlights */}
<div className="flex flex-wrap gap-2 mb-6">
{destination.highlights.map((highlight, idx) => (
<span
key={idx}
className="bg-primary/10 text-primary px-3 py-1 rounded-full text-sm font-medium"
>
{highlight}
</span>
))}
</div>
{/* CTA */}
<Button className="w-full group-hover:bg-primary-dark transition-colors">
Explorar más
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</div>
</Card>
))}
</div>
{/* Stats Section */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 py-12 border-t border-border">
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">2</div>
<div className="text-muted-foreground">Países</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-secondary mb-2">15+</div>
<div className="text-muted-foreground">Ciudades</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-success mb-2">500+</div>
<div className="text-muted-foreground">Negocios</div>
</div>
<div className="text-center">
<div className="text-4xl font-bold text-warning mb-2">50K+</div>
<div className="text-muted-foreground">Usuarios</div>
</div>
</div>
{/* Bottom CTA */}
<div className="text-center">
<Button size="lg" className="btn-hero text-lg px-8 py-4">
Ver Todos los Destinos
<ArrowRight className="w-5 h-5 ml-3" />
</Button>
</div>
</div>
</section>
);
};
export default DestinationsSection;

View File

@@ -0,0 +1,252 @@
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ArrowRight,
Smartphone,
Shield,
Car,
MapPin,
Utensils,
Building,
Users,
TrendingUp,
Zap
} from "lucide-react";
const EcosystemSection = () => {
const applications = [
{
id: "tourist",
title: "App Turistas",
description: "Explora, reserva y disfruta experiencias únicas con IA y realidad aumentada",
icon: "🏖️",
iconComponent: <Smartphone className="w-6 h-6" />,
features: ["Mapas con IA", "Realidad Aumentada", "Reservas integradas", "Guías virtuales"],
userType: "Viajeros",
color: "primary",
status: "live",
users: "15K+"
},
{
id: "politur",
title: "POLITUR",
description: "Sistema de seguridad turística con geolocalización y respuesta rápida",
icon: "👮‍♂️",
iconComponent: <Shield className="w-6 h-6" />,
features: ["Alertas en tiempo real", "Geolocalización", "Gestión de incidentes", "Comunicación directa"],
userType: "Policía Turística",
color: "destructive",
status: "live",
users: "500+"
},
{
id: "taxi",
title: "App Taxis",
description: "Plataforma de transporte seguro con tarifas dinámicas y rutas optimizadas",
icon: "🚕",
iconComponent: <Car className="w-6 h-6" />,
features: ["Rutas con IA", "Tarifas dinámicas", "Pagos integrados", "Calificaciones bidireccionales"],
userType: "Conductores",
color: "warning",
status: "live",
users: "2K+"
},
{
id: "guides",
title: "Guías Turísticos",
description: "Conecta con guías certificados para experiencias auténticas y personalizadas",
icon: "🗺️",
iconComponent: <MapPin className="w-6 h-6" />,
features: ["Itinerarios personalizados", "Audio-guías", "Contenido multimedia", "Gestión de reservas"],
userType: "Guías Profesionales",
color: "success",
status: "live",
users: "800+"
},
{
id: "restaurants",
title: "Restaurantes POS",
description: "Sistema completo de punto de venta con menús digitales e inventario",
icon: "🍽️",
iconComponent: <Utensils className="w-6 h-6" />,
features: ["POS completo", "Menús digitales", "Gestión de inventario", "Reservas online"],
userType: "Restaurantes",
color: "secondary",
status: "beta",
users: "150+"
},
{
id: "hotels",
title: "Gestión Hotelera",
description: "Plataforma integral para hoteles con check-in digital y channel manager",
icon: "🏨",
iconComponent: <Building className="w-6 h-6" />,
features: ["Check-in digital", "Channel manager", "Gestión de habitaciones", "CRM hotelero"],
userType: "Hoteles",
color: "primary",
status: "beta",
users: "75+"
}
];
const getStatusBadge = (status: string) => {
switch (status) {
case "live":
return <Badge className="bg-success text-success-foreground">En Vivo</Badge>;
case "beta":
return <Badge className="bg-warning text-warning-foreground">Beta</Badge>;
default:
return <Badge>Próximamente</Badge>;
}
};
const getColorClasses = (color: string) => {
switch (color) {
case "primary":
return "border-primary/20 hover:border-primary/40 bg-primary/5";
case "secondary":
return "border-secondary/20 hover:border-secondary/40 bg-secondary/5";
case "success":
return "border-success/20 hover:border-success/40 bg-success/5";
case "warning":
return "border-warning/20 hover:border-warning/40 bg-warning/5";
case "destructive":
return "border-destructive/20 hover:border-destructive/40 bg-destructive/5";
default:
return "border-border hover:border-border/60";
}
};
return (
<section style={{ backgroundColor: '#f8f4f3' }} className="py-20">{/* Karibeo original background */}
<div className="max-w-7xl mx-auto px-4">
{/* Section Header */}
<div className="text-center mb-16">
<div className="inline-block px-4 py-2 bg-primary/10 text-primary rounded-full text-sm font-medium mb-4">
Ecosistema Completo
</div>
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Aplicaciones Integradas{" "}
<span className="text-gradient">Para Todos</span>
</h2>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
Una plataforma única que conecta turistas, servicios y experiencias.
Descubre cómo cada aplicación trabaja en perfecta armonía.
</p>
</div>
{/* Applications Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
{applications.map((app, index) => (
<Card
key={app.id}
className={`group relative overflow-hidden border-2 transition-all duration-300 hover:-translate-y-2 hover:shadow-strong ${getColorClasses(app.color)}`}
>
<div className="p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="text-4xl">{app.icon}</div>
<div>
<h3 className="text-xl font-bold">{app.title}</h3>
<p className="text-sm text-muted-foreground">{app.userType}</p>
</div>
</div>
{getStatusBadge(app.status)}
</div>
{/* Description */}
<p className="text-muted-foreground mb-6 leading-relaxed">
{app.description}
</p>
{/* Features List */}
<div className="space-y-2 mb-6">
{app.features.map((feature, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<Zap className="w-3 h-3 text-primary" />
<span>{feature}</span>
</div>
))}
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-6 p-3 bg-card rounded-lg border">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Usuarios</span>
</div>
<div className="text-lg font-bold text-primary">{app.users}</div>
</div>
{/* CTA */}
<Button className="w-full group-hover:bg-primary-dark transition-colors">
Explorar App
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</div>
{/* Hover Effect Decoration */}
<div className="absolute top-0 right-0 w-20 h-20 bg-primary/10 rounded-full -translate-y-10 translate-x-10 group-hover:translate-y-0 group-hover:translate-x-0 transition-transform duration-500"></div>
</Card>
))}
</div>
{/* Integration Benefits */}
<div className="bg-card rounded-2xl p-8 md:p-12">
<div className="text-center mb-12">
<h3 className="text-3xl font-bold mb-4">
La Potencia de la{" "}
<span className="text-secondary-gradient">Integración</span>
</h3>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Todas nuestras aplicaciones trabajan juntas para crear una experiencia turística sin igual.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<TrendingUp className="w-8 h-8 text-primary" />
</div>
<h4 className="text-xl font-semibold mb-3">Datos Unificados</h4>
<p className="text-muted-foreground">
Toda la información de tu viaje en un solo lugar. Reservas, pagos y experiencias sincronizados.
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-secondary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-secondary" />
</div>
<h4 className="text-xl font-semibold mb-3">Seguridad Total</h4>
<p className="text-muted-foreground">
POLITUR integrado en tiempo real. Tu seguridad es nuestra prioridad en cada paso del viaje.
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-success/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-success" />
</div>
<h4 className="text-xl font-semibold mb-3">Experiencia Fluida</h4>
<p className="text-muted-foreground">
Sin fricciones entre servicios. Una experiencia continua desde la llegada hasta la partida.
</p>
</div>
</div>
<div className="text-center mt-12">
<Button size="lg" className="btn-hero text-lg px-8 py-4">
Comenzar con el Ecosistema Completo
<ArrowRight className="w-5 h-5 ml-3" />
</Button>
</div>
</div>
</div>
</section>
);
};
export default EcosystemSection;

View File

@@ -0,0 +1,45 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<React.PropsWithChildren, ErrorBoundaryState> {
constructor(props: React.PropsWithChildren) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Log detailed error to console so we can see stack instead of generic "Script error"
console.error('App crashed with error:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="max-w-md text-center">
<h1 className="text-2xl font-semibold mb-2">Algo salió mal</h1>
<p className="text-sm text-gray-500 mb-4">Se produjo un error inesperado. Intenta recargar la página.</p>
{this.state.error && (
<pre className="text-left text-xs bg-gray-100 p-3 rounded overflow-auto" style={{maxHeight: 200}}>
{this.state.error.message}\n{this.state.error.stack}
</pre>
)}
<button onClick={() => window.location.reload()} className="mt-4 px-4 py-2 rounded bg-orange-500 text-white">Recargar</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,215 @@
import { Button } from "@/components/ui/button";
import santoImage from "@/assets/santo-domingo.jpg";
import puntaImage from "@/assets/punta-cana.jpg";
import sanJuanImage from "@/assets/san-juan.jpg";
const ExploreSection = () => {
const destinations = [
{
image: santoImage,
city: "Santo Domingo",
area: "Zona Colonial",
listings: "89+ listings",
link: "/explore/santo-domingo"
},
{
image: puntaImage,
city: "Punta Cana",
area: "Resort Area",
listings: "124+ listings",
link: "/explore/punta-cana"
},
{
image: sanJuanImage,
city: "San Juan",
area: "Old San Juan",
listings: "67+ listings",
link: "/explore/san-juan"
}
];
return (
<section className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-16">
<p className="text-primary font-medium mb-4">Top Regions</p>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Explore Cities
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Discover exciting categories. Find what you're looking for.
</p>
</div>
{/* Carousel Container */}
<div className="owl-carousel-container relative">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 overflow-x-auto pb-4">
{/* Region Card 1 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://images.visitarepublicadominicana.org/Punta-Cana-Republica-Dominicana.jpg"
alt="Punta Cana"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">La Romana</h4>
<h3 className="text-3xl font-bold mb-2">Punta Cana</h3>
<span className="text-white/90">100+ listings</span>
</div>
<a href="/offer/punta-cana" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
{/* Region Card 2 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://static-resources-elementor.mirai.com/wp-content/uploads/sites/345/Playa-Bavaro-Punta-Cana-Garden-Hotels.jpg"
alt="Bavaro Beach"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">La Romana</h4>
<h3 className="text-3xl font-bold mb-2">Bavaro Beach</h3>
<span className="text-white/90">59+ listings</span>
</div>
<a href="/offer/bavaro-beach" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
{/* Region Card 3 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://www.soycaribepremium.es/wp-content/uploads/2018/06/Plaza-Espana_3-e1659698866331.jpg"
alt="Zona Colonial"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">Santo Domingo</h4>
<h3 className="text-3xl font-bold mb-2">Zona Colonial</h3>
<span className="text-white/90">89+ listings</span>
</div>
<a href="/offer/zona-colonial" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
{/* Region Card 4 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://assets.simpleviewinc.com/simpleview/image/upload/c_fill,f_jpg,h_749,q_65,w_639/v1/clients/dominicanrepublic/laromana_home_slide_2_1_b31a4780-1959-469d-93f1-ebd659183098.jpg"
alt="Santiago"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">Santiago</h4>
<h3 className="text-3xl font-bold mb-2">Santiago</h3>
<span className="text-white/90">65+ listings</span>
</div>
<a href="/offer/santiago" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
{/* Region Card 5 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://images.visitarepublicadominicana.org/catedral-de-san-pedro-de-macoris.jpg"
alt="San Pedro"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">Region Norte</h4>
<h3 className="text-3xl font-bold mb-2">San Pedro</h3>
<span className="text-white/90">65+ listings</span>
</div>
<a href="/offer/san-pedro" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
{/* Region Card 6 */}
<div className="region-card rounded-xl overflow-hidden relative text-white min-w-[300px] h-[458px] group cursor-pointer">
<div className="region-card-image h-full">
<img
src="https://encrypted-tbn2.gstatic.com/licensed-image?q=tbn:ANd9GcRizxrFtCGsxUo0DCHu0y-n1aw4G_2xddLVaxUQr-W8jF1EPCyhZTntrTlifAjmSXcVDdpECkPfge6LA0rqy7ope0tOSv5ZMA4QmNZpwQ"
alt="Barahona"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="region-card-content absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent flex flex-col justify-between p-6">
<div></div>
<div className="region-card-info">
<h4 className="font-caveat text-xl mb-0">Region Sur</h4>
<h3 className="text-3xl font-bold mb-2">Barahona</h3>
<span className="text-white/90">65+ listings</span>
</div>
<a href="/offer/barahona" className="region-card-link flex items-center justify-between mt-auto text-white hover:text-primary transition-colors">
<div className="text-xs uppercase font-semibold">Explore more</div>
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fillRule="evenodd" d="M14 2.5a.5.5 0 0 0-.5-.5h-6a.5.5 0 0 0 0 1h4.793L2.146 13.146a.5.5 0 0 0 .708.708L13 3.707V8.5a.5.5 0 0 0 1 0v-6z"></path>
</svg>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default ExploreSection;

View File

@@ -0,0 +1,124 @@
import { Card } from "@/components/ui/card";
import { ArrowRight, MapPin, Calendar, CreditCard } from "lucide-react";
const FeaturesSection = () => {
const steps = [
{
number: "1",
icon: <MapPin className="w-8 h-8" />,
title: "Ingresa tu ubicación para comenzar a buscar lugares emblemáticos",
description: "Nuestra IA detectará automáticamente puntos de interés, restaurantes, hoteles y actividades cerca de ti."
},
{
number: "2",
icon: <Calendar className="w-8 h-8" />,
title: "Haz una cita en el lugar que quieres visitar",
description: "Reserva directamente desde la plataforma con confirmación instantánea y pagos seguros."
},
{
number: "3",
icon: <CreditCard className="w-8 h-8" />,
title: "Visita el lugar y disfruta de la experiencia",
description: "Accede a guías con IA, realidad aumentada y soporte 24/7 durante tu visita."
}
];
return (
<section className="py-20" style={{ backgroundColor: '#f8f4f3' }}>
<div className="max-w-7xl mx-auto px-4">
{/* Section Header */}
<div className="text-center mb-16">
<div className="inline-block px-4 py-2 text-sm font-medium mb-4" style={{ backgroundColor: 'rgba(248, 69, 37, 0.1)', color: '#F84525', borderRadius: '9999px' }}>
La Mejor Manera
</div>
<h2 className="text-4xl md:text-5xl font-bold mb-6" style={{ color: '#433c3a' }}>
Encuentra Tu Lugar Soñado{" "}
<span style={{
background: 'linear-gradient(135deg, #F84525, #f7b733)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text'
}}>La Mejor Manera</span>
</h2>
<p className="text-xl max-w-3xl mx-auto" style={{ color: '#6b7280' }}>
Descubre categorías emocionantes. Encuentra exactamente lo que estás buscando.
</p>
</div>
{/* Steps Grid */}
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{steps.map((step, index) => (
<div key={index} className="relative">
<div className="p-8 text-center h-full" style={{
backgroundColor: 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(5px)',
borderRadius: '1rem',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
transition: 'all 0.3s ease-out'
}}>
{/* Step Number */}
<div className="w-16 h-16 rounded-full flex items-center justify-center text-white font-bold text-2xl mx-auto mb-6" style={{
background: 'linear-gradient(135deg, #F84525, #f7b733)'
}}>
{step.number}
</div>
{/* Icon */}
<div className="mb-6 flex justify-center" style={{ color: '#F84525' }}>
{step.icon}
</div>
{/* Content */}
<h3 className="text-xl font-semibold mb-4 leading-tight" style={{ color: '#433c3a' }}>
{step.title}
</h3>
<p className="leading-relaxed" style={{ color: '#6b7280' }}>
{step.description}
</p>
</div>
{/* Arrow Connector (hidden on mobile) */}
{index < steps.length - 1 && (
<div className="hidden md:block absolute top-24 -right-4 z-10">
<div className="w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: '#197db8' }}>
<ArrowRight className="w-4 h-4 text-white" />
</div>
</div>
)}
</div>
))}
</div>
{/* Bottom CTA */}
<div className="text-center mt-16">
<div className="max-w-2xl mx-auto">
<h3 className="text-2xl font-semibold mb-4" style={{ color: '#433c3a' }}>
¿Listo para comenzar tu aventura?
</h3>
<p className="mb-8" style={{ color: '#6b7280' }}>
Únete a miles de turistas que ya están disfrutando de experiencias únicas con nuestra plataforma.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button className="px-6 py-3 rounded-lg font-semibold text-white transition-all hover:transform hover:-translate-y-1" style={{
backgroundColor: '#F84525',
boxShadow: '0 4px 15px rgba(248, 69, 37, 0.3)'
}}>
Comenzar Ahora
</button>
<button className="px-6 py-3 rounded-lg font-medium border transition-all" style={{
color: '#F84525',
borderColor: 'rgba(248, 69, 37, 0.2)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(10px)'
}}>
Ver Demo
</button>
</div>
</div>
</div>
</div>
</section>
);
};
export default FeaturesSection;

210
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,210 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Phone, Mail, ChevronRight, Instagram, Twitter, Facebook, MessageCircle } from "lucide-react";
const Footer = () => {
return (
<footer className="bg-gray-900 text-white relative overflow-hidden pt-20">
<div className="max-w-7xl mx-auto px-4 pt-4">
{/* App Download Section */}
<div className="py-12">
<div className="bg-primary rounded-2xl">
<div className="max-w-5xl mx-auto px-0 flex flex-col-reverse md:flex-row items-center md:items-end gap-8">
<img
className="flex-shrink-0"
src="https://themes.easital.com/html/liston/v2.3/assets/images/phone-mpckup.png"
width="270"
alt="Mobile app"
/>
<div className="flex flex-col lg:flex-row items-center lg:items-center self-center px-4 pt-12 py-6 text-center md:text-left">
<div className="me-8">
<h4 className="text-white text-2xl font-bold mb-4">Download Our App</h4>
<p className="text-white/90 mb-6 lg:mb-0">
It is a long established fact that a reader will be distracted by the readable content.
</p>
</div>
<div className="flex flex-shrink-0 flex-wrap gap-3 justify-center">
{/* Apple Store Button */}
<a
href="#"
className="flex items-center px-4 py-3 rounded-xl text-white border border-white/20 hover:bg-white/10 transition-colors"
>
<i className="fab fa-apple text-3xl mr-3"></i>
<div>
<span className="text-sm block">Available on the</span>
<span className="text-lg capitalize">App Store</span>
</div>
</a>
{/* Google Play Button */}
<a
href="#"
className="flex items-center px-4 py-3 rounded-xl text-white border border-white/20 hover:bg-white/10 transition-colors"
>
<i className="fab fa-google-play text-2xl mr-3"></i>
<div>
<span className="text-sm block">Get it on</span>
<span className="text-lg capitalize">Google Play</span>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
{/* Main Footer Content */}
<div className="border-t border-gray-700 py-12">
<div className="grid lg:grid-cols-3 gap-8">
{/* Get In Touch Section */}
<div className="lg:border-r border-gray-700 lg:pr-8">
<h5 className="font-bold text-xl mb-6">Get In Touch</h5>
<div className="mb-6">
Join our newsletter and receive the best job<br className="hidden xxl:block" />
openings of the week, right on your inbox.
</div>
<div className="border border-gray-600 rounded-2xl p-6 mb-6">
<h6 className="text-gray-300 mb-4">Join our Whatsapp:</h6>
<a
href="#"
className="flex items-center text-white hover:text-primary transition-colors"
>
<MessageCircle className="w-8 h-8 mr-3" />
<span className="text-xl font-semibold underline">(123) 456-7890</span>
</a>
</div>
<h5 className="font-bold text-xl mb-4">
Want to join Karibeo?<br /> Write us !
</h5>
<span className="text-gray-300">support@karibeo.com</span>
</div>
{/* Stay Connect Section */}
<div className="lg:border-r border-gray-700 lg:pr-8">
<h5 className="font-bold text-xl mb-6">Stay Connect</h5>
<div className="text-gray-300 mb-6">
1123 Fictional St, San Francisco<br className="hidden xxl:block" />
, CA 94103
</div>
<div className="space-y-2">
<a
href="#"
className="flex items-center font-medium text-white hover:text-primary transition-colors"
>
<Phone className="w-4 h-4 mr-3" />
<span>(123) 456-7890</span>
</a>
<a
href="#"
className="flex items-center font-medium text-white hover:text-primary transition-colors"
>
<Mail className="w-4 h-4 mr-3" />
support@karibeo.com
</a>
</div>
</div>
{/* Newsletter and Social Section */}
<div>
<h5 className="font-bold text-xl mb-6">Get In Touch</h5>
<div className="relative mt-6">
<Input
type="email"
placeholder="name@example.com"
className="bg-gray-800 border-gray-600 text-white pr-12"
/>
<Button
size="sm"
className="absolute right-2 top-1/2 transform -translate-y-1/2 rounded-full w-8 h-8 p-0"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
<div className="border-t border-gray-700 my-6"></div>
<h5 className="font-bold text-xl mb-6">Follow the location</h5>
{/* Social Icons */}
<ul className="flex flex-wrap gap-3 list-none">
<li>
<a
href="#"
className="w-12 h-12 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center text-white hover:scale-110 transition-transform"
>
<Instagram className="w-5 h-5" />
</a>
</li>
<li>
<a
href="#"
className="w-12 h-12 rounded-lg bg-blue-400 flex items-center justify-center text-white hover:scale-110 transition-transform"
>
<Twitter className="w-5 h-5" />
</a>
</li>
<li>
<a
href="#"
className="w-12 h-12 rounded-lg bg-pink-500 flex items-center justify-center text-white hover:scale-110 transition-transform"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0C5.374 0 0 5.374 0 12s5.374 12 12 12 12-5.374 12-12S18.626 0 12 0zm6.568 5.341c.13.187.29.349.477.479.239.165.518.248.804.248.286 0 .565-.083.804-.248.187-.13.347-.292.477-.479C21.89 6.458 22.5 8.188 22.5 12c0 3.812-.61 5.542-1.37 6.659-.13.187-.29.349-.477.479-.239.165-.518.248-.804.248-.286 0-.565-.083-.804-.248-.187-.13-.347-.292-.477-.479C18.808 17.542 18.198 15.812 18.198 12c0-3.812.61-5.542 1.37-6.659z"/>
</svg>
</a>
</li>
<li>
<a
href="#"
className="w-12 h-12 rounded-lg bg-blue-600 flex items-center justify-center text-white hover:scale-110 transition-transform"
>
<Facebook className="w-5 h-5" />
</a>
</li>
<li>
<a
href="#"
className="w-12 h-12 rounded-lg bg-green-500 flex items-center justify-center text-white hover:scale-110 transition-transform"
>
<MessageCircle className="w-5 h-5" />
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{/* Bottom Footer */}
<div className="max-w-7xl mx-auto px-4 border-t border-gray-700">
<div className="flex flex-col lg:flex-row items-center gap-4 py-6">
<div className="lg:order-2">
{/* Footer Navigation */}
<ul className="flex space-x-6 text-sm">
<li><a href="#" className="text-gray-300 hover:text-white transition-colors">Privacy</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition-colors">Sitemap</a></li>
<li><a href="#" className="text-gray-300 hover:text-white transition-colors">Cookies</a></li>
</ul>
</div>
<div className="lg:order-1 lg:flex-1">
<div className="flex flex-col sm:flex-row items-center gap-4">
{/* Footer Logo */}
<a href="/" className="flex-shrink-0">
<img
src="https://themes.easital.com/html/liston/v2.3/assets/images/logo-white.png"
alt="Karibeo"
className="h-8"
/>
</a>
{/* Copyright */}
<div className="text-sm text-gray-400">
© 2025 Karibeo - All Rights Reserved
</div>
</div>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

190
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Menu, X, User, Plus, Sun, Moon, Globe } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { useCart } from "@/contexts/CartContext";
import CartSidebar from "@/components/CartSidebar";
const Header = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const { user, isAuthenticated, logout } = useAuth();
const { language, setLanguage, t } = useLanguage();
const { getTotalItems } = useCart();
const navigate = useNavigate();
const toggleTheme = () => {
setIsDark(!isDark);
document.documentElement.classList.toggle('dark');
};
const handleAuthAction = () => {
if (isAuthenticated) {
navigate('/dashboard');
} else {
navigate('/sign-in');
}
};
const handleLogout = () => {
logout();
navigate('/');
};
const languages = [
{ code: 'es', name: 'ES', flag: '🇪🇸' },
{ code: 'en', name: 'EN', flag: '🇺🇸' },
{ code: 'fr', name: 'FR', flag: '🇫🇷' }
];
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
<div className="container mx-auto px-6 py-4">
<div className="flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center">
<img
src="https://karibeo.com/desktop/assets/images/logo.png"
alt="Karibeo"
className="h-8 w-auto"
/>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-8">
<Link
to="/"
className="text-white hover:text-orange-400 font-medium transition-colors relative py-2"
style={{
borderBottom: '3px solid #F84525',
paddingBottom: '8px'
}}
>
Home
</Link>
<Link
to="/explore"
className="text-white hover:text-orange-400 font-medium transition-colors py-2"
>
Explore
</Link>
<Link
to="/"
className="text-white hover:text-orange-400 font-medium transition-colors py-2"
>
About
</Link>
</nav>
{/* Desktop Actions */}
<div className="hidden md:flex items-center space-x-4">
{/* Language/Theme Controls */}
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
className="text-white hover:text-orange-400 rounded-full w-10 h-10 p-0"
onClick={toggleTheme}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<CartSidebar />
<Button variant="ghost" size="sm" className="text-white hover:text-orange-400 rounded-full w-10 h-10 p-0">
<User className="w-4 h-4" />
</Button>
</div>
{isAuthenticated ? (
<div className="flex items-center space-x-3">
<Button
onClick={handleAuthAction}
className="text-white rounded-full px-6 py-2"
style={{backgroundColor: '#F84525'}}
>
Dashboard
</Button>
<Button
onClick={handleLogout}
variant="outline"
className="rounded-full text-white border-white hover:bg-white hover:text-gray-900"
>
Sign Out
</Button>
</div>
) : (
<Button
onClick={() => navigate('/sign-up')}
className="text-white rounded-full px-6 py-2"
style={{backgroundColor: '#F84525'}}
>
<Plus className="w-4 h-4 mr-2" />
Add Listing
</Button>
)}
</div>
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="sm"
className="md:hidden text-white"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
{isMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<div className="md:hidden mt-6 pb-6 bg-white/10 backdrop-blur-md rounded-lg">
<nav className="flex flex-col space-y-4 p-6">
<Link to="/" className="text-white hover:text-orange-400 font-medium transition-colors">
Home
</Link>
<Link to="/explore" className="text-white hover:text-orange-400 font-medium transition-colors">
Explore
</Link>
<Link to="/" className="text-white hover:text-orange-400 font-medium transition-colors">
About
</Link>
<div className="flex flex-col space-y-3 pt-4 border-t border-white/20">
{isAuthenticated ? (
<>
<Button
onClick={handleAuthAction}
style={{backgroundColor: '#F84525'}}
className="text-white"
>
Dashboard
</Button>
<Button
onClick={handleLogout}
variant="outline"
className="text-white border-white"
>
Sign Out
</Button>
</>
) : (
<Button
onClick={() => navigate('/sign-up')}
style={{backgroundColor: '#F84525'}}
className="text-white"
>
Add Listing
</Button>
)}
</div>
</nav>
</div>
)}
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, MapPin, Home, Utensils, Calendar, ShoppingBag, Building, Dumbbell } from "lucide-react";
import heroImage from "@/assets/hero-beach.jpg";
const HeroSection = () => {
const [searchQuery, setSearchQuery] = useState("");
const [location, setLocation] = useState("");
const categories = [
{
icon: <Home className="w-8 h-8" />,
title: "Appartment",
count: "99+",
description: "listings"
},
{
icon: <Utensils className="w-8 h-8" />,
title: "Restaurant",
count: "55+",
description: "listings"
},
{
icon: <Calendar className="w-8 h-8" />,
title: "Events/Arts",
count: "55+",
description: "listings"
},
{
icon: <ShoppingBag className="w-8 h-8" />,
title: "Shops",
count: "80+",
description: "listings"
},
{
icon: <Building className="w-8 h-8" />,
title: "Museum",
count: "96+",
description: "listings"
},
{
icon: <Dumbbell className="w-8 h-8" />,
title: "Gymnasiums",
count: "21+",
description: "listings"
}
];
return (
<section
className="relative min-h-screen flex items-center justify-center"
style={{
backgroundImage: `url(${heroImage})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Background Overlay */}
<div className="absolute inset-0 bg-black/40"></div>
{/* Content */}
<div className="relative z-10 w-full max-w-6xl mx-auto px-4 text-center">
{/* Header Badge */}
<div className="mb-8">
<div className="inline-flex items-center px-4 py-2 rounded-full text-white/90 text-sm font-medium mb-6 bg-white/10 backdrop-blur-sm">
WE ARE #1 ON THE MARKET
</div>
</div>
{/* Main Heading */}
<div className="mb-12">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight">
We're Here To Help You{" "}
<span className="italic underline decoration-4" style={{textDecorationColor: '#F84525'}}>Navigate</span>{" "}
While Traveling
</h1>
<p className="text-xl md:text-2xl text-white/90 max-w-3xl mx-auto leading-relaxed">
You'll get comprehensive results based on the provided location.
</p>
</div>
{/* Search Bar */}
<div className="max-w-4xl mx-auto mb-16">
<div className="flex flex-col md:flex-row gap-4 bg-white/10 backdrop-blur-md rounded-2xl p-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-600 w-5 h-5" />
<Input
placeholder="What are you looking for?"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 bg-white border-0 text-gray-700 placeholder:text-gray-500 h-14 text-lg rounded-xl"
/>
</div>
<div className="flex-1 relative">
<MapPin className="absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-600 w-5 h-5" />
<Input
placeholder="Location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="pl-12 bg-white border-0 text-gray-700 placeholder:text-gray-500 h-14 text-lg rounded-xl"
/>
</div>
<Button
className="h-14 px-8 text-lg font-semibold rounded-xl text-white hover:opacity-90"
style={{backgroundColor: '#F84525'}}
>
Search places
</Button>
</div>
</div>
{/* Category Cards Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 max-w-4xl mx-auto">
{categories.map((category, index) => (
<div
key={index}
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 text-center text-white cursor-pointer hover:bg-white/20 transition-all duration-300 group"
>
<div className="flex items-center justify-start gap-4">
<div
className="p-3 rounded-full group-hover:scale-110 transition-transform duration-300"
style={{backgroundColor: '#F84525'}}
>
{category.icon}
</div>
<div className="text-left">
<h3 className="font-semibold text-lg mb-1">{category.title}</h3>
<div className="font-bold text-sm" style={{color: '#F84525'}}>
{category.count} {category.description}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroSection;

164
src/components/MapView.tsx Normal file
View File

@@ -0,0 +1,164 @@
import React, { useEffect, useRef, useState } from 'react';
import { Loader } from '@googlemaps/js-api-loader';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { MapPin } from 'lucide-react';
// Declare global google type for window
declare global {
interface Window {
google: any;
}
}
interface Offer {
id: string;
title: string;
category: string;
price: number;
rating: number;
images: string[];
location: {
lat: number;
lng: number;
address: string;
};
}
interface MapViewProps {
offers: Offer[];
onOfferClick: (offer: Offer) => void;
}
const MapView: React.FC<MapViewProps> = ({ offers, onOfferClick }) => {
const mapRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<any | null>(null);
const [apiKey, setApiKey] = useState<string>('');
const [isMapLoaded, setIsMapLoaded] = useState(false);
const [markers, setMarkers] = useState<any[]>([]);
const loadMap = async (googleMapsApiKey: string) => {
if (!mapRef.current || !googleMapsApiKey) return;
try {
const loader = new Loader({
apiKey: googleMapsApiKey,
version: 'weekly',
libraries: ['places'],
id: '__googleMapsScriptIdKaribeo'
});
const google = await loader.load();
const { Map } = google.maps;
const mapInstance = new Map(mapRef.current, {
center: { lat: 18.4861, lng: -69.9312 }, // Santo Domingo, DR
zoom: 11,
styles: [
{
featureType: 'poi',
elementType: 'labels',
stylers: [{ visibility: 'off' }]
}
]
});
setMap(mapInstance);
setIsMapLoaded(true);
// Add markers for offers
const newMarkers = offers.map(offer => {
const marker = new google.maps.Marker({
position: { lat: offer.location.lat, lng: offer.location.lng },
map: mapInstance,
title: offer.title,
icon: {
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="20" r="18" fill="#fe8303" stroke="white" stroke-width="4"/>
<text x="20" y="26" text-anchor="middle" fill="white" font-size="12" font-weight="bold">$${offer.price}</text>
</svg>
`)}`,
scaledSize: new google.maps.Size(40, 40),
anchor: new google.maps.Point(20, 20)
}
});
// Create info window
const infoWindow = new google.maps.InfoWindow({
content: `
<div style="padding: 10px; max-width: 200px;">
<img src="${offer.images[0]}" alt="${offer.title}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />
<h3 style="margin: 0 0 4px 0; font-size: 14px; font-weight: bold;">${offer.title}</h3>
<p style="margin: 0 0 4px 0; font-size: 12px; color: #666;">${offer.location.address}</p>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: #fe8303;">⭐ ${offer.rating}</span>
<span style="font-size: 14px; font-weight: bold; color: #fe8303;">$${offer.price}</span>
</div>
</div>
`
});
marker.addListener('click', () => {
// Close other info windows
markers.forEach(m => m.infoWindow?.close());
infoWindow.open(mapInstance, marker);
onOfferClick(offer);
});
// Store info window reference
(marker as any).infoWindow = infoWindow;
return marker;
});
setMarkers(newMarkers);
} catch (error) {
console.error('Error loading Google Maps:', error);
}
};
useEffect(() => {
const savedApiKey = localStorage.getItem('googleMapsApiKey');
if (savedApiKey) {
setApiKey(savedApiKey);
loadMap(savedApiKey);
}
}, [offers]);
const handleApiKeySubmit = () => {
// Ya no se muestra el formulario de API key por seguridad.
};
if (!isMapLoaded && !apiKey) {
return (
<div className="h-96 bg-gray-100 rounded-lg flex items-center justify-center p-8">
<div className="text-center max-w-md">
<MapPin className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-semibold mb-2">Google Maps no disponible</h3>
<p className="text-gray-600 mb-4 text-sm">
Configura una Google Maps API key restringida por dominio y autoriza este dominio en Google Cloud Console.
</p>
</div>
</div>
);
}
return (
<div className="h-96 bg-gray-100 rounded-lg overflow-hidden">
<div ref={mapRef} className="w-full h-full" />
{!isMapLoaded && apiKey && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-gray-600">Cargando mapa...</p>
</div>
</div>
)}
</div>
);
};
export default MapView;

View File

@@ -0,0 +1,183 @@
import { Button } from "@/components/ui/button";
import { Star, Phone, Compass, Heart, Search } from "lucide-react";
const PlacesSection = () => {
const places = [
{
image: "https://themes.easital.com/html/liston/v2.3/assets/images/place/01.jpg",
title: "Green Mart Apartment",
description: "Amet minim mollit non deserunt ullamco est sit aliqua dolor.",
rating: 4.5,
reviews: "2,391 reviews",
badges: ["Featured", "$100 off $399: eblwc"],
phone: "(123) 456-7890",
verified: true
},
{
image: "https://themes.easital.com/html/liston/v2.3/assets/images/place/02.jpg",
title: "Chuijhal Hotel And Restaurant",
description: "Amet minim mollit non deserunt ullamco est sit aliqua dolor.",
rating: 4.5,
reviews: "2,391 reviews",
badges: ["10% OFF", "$100 off $399: eblwc"],
phone: "(123) 456-7890",
verified: false
},
{
image: "https://themes.easital.com/html/liston/v2.3/assets/images/place/03.jpg",
title: "The Barber's Lounge",
description: "Amet minim mollit non deserunt ullamco est sit aliqua dolor.",
rating: 4.5,
reviews: "2,391 reviews",
badges: ["10% OFF", "$100 off $399: eblwc"],
phone: "(123) 456-7890",
verified: false
},
{
image: "https://themes.easital.com/html/liston/v2.3/assets/images/place/04.jpg",
title: "Gaming Expo Spectacle",
description: "Amet minim mollit non deserunt ullamco est sit aliqua dolor.",
rating: 4.5,
reviews: "2,391 reviews",
badges: ["10% OFF", "$100 off $399: eblwc"],
phone: "(123) 456-7890",
verified: false
},
{
image: "https://themes.easital.com/html/liston/v2.3/assets/images/place/05.jpg",
title: "Fitness Petrol Gym and Health Club",
description: "Amet minim mollit non deserunt ullamco est sit aliqua dolor.",
rating: 4.5,
reviews: "2,391 reviews",
badges: ["10% OFF", "$100 off $399: eblwc"],
phone: "(123) 456-7890",
verified: false
}
];
return (
<section className="py-20 bg-gray-50 relative overflow-hidden">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="grid lg:grid-cols-12 gap-8">
{/* Sidebar */}
<div className="lg:col-span-4 sidebar">
<div className="text-center lg:text-left mb-12">
<div className="inline-block font-caveat text-5xl font-medium text-primary mb-4 capitalize">
Places
</div>
<h2 className="text-4xl md:text-5xl font-semibold mb-6 capitalize">
Discover Your Favourite Place
</h2>
<p className="text-gray-600 mb-6">
Our publications can provide quality and useful tips and advice for companies on how to evaluate SaaS providers and choose the best one for their needs, taking into account factors such as price, features and support.
</p>
<Button asChild className="mt-3">
<a href="/explore">View All Places</a>
</Button>
{/* Decorative SVG */}
<svg
className="text-primary mt-4 hidden lg:block"
width="200"
height="211"
viewBox="0 0 200 211"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M198.804 194.488C189.279 189.596 179.529 185.52 169.407 182.07L169.384 182.049C169.227 181.994 169.07 181.939 168.912 181.884C166.669 181.139 165.906 184.546 167.669 185.615C174.053 189.473 182.761 191.837 189.146 195.695C156.603 195.912 119.781 196.591 91.266 179.049C62.5221 161.368 48.1094 130.695 56.934 98.891C84.5539 98.7247 112.556 84.0176 129.508 62.667C136.396 53.9724 146.193 35.1448 129.773 30.2717C114.292 25.6624 93.7109 41.8875 83.1971 51.3147C70.1109 63.039 59.63 78.433 54.2039 95.0087C52.1221 94.9842 50.0776 94.8683 48.0703 94.6608C30.1803 92.8027 11.2197 83.6338 5.44902 65.1074C-1.88449 41.5699 14.4994 19.0183 27.9202 1.56641C28.6411 0.625793 27.2862 -0.561638 26.5419 0.358501C13.4588 16.4098 -0.221091 34.5242 0.896608 56.5659C1.8218 74.6941 14.221 87.9401 30.4121 94.2058C37.7076 97.0203 45.3454 98.5003 53.0334 98.8449C47.8679 117.532 49.2961 137.487 60.7729 155.283C87.7615 197.081 139.616 201.147 184.786 201.155L174.332 206.827C172.119 208.033 174.345 211.287 176.537 210.105C182.06 207.125 187.582 204.122 193.084 201.144C193.346 201.147 195.161 199.887 195.423 199.868C197.08 198.548 193.084 201.144 195.528 199.81C196.688 199.192 197.846 198.552 199.006 197.935C200.397 197.167 200.007 195.087 198.804 194.488ZM60.8213 88.0427C67.6894 72.648 78.8538 59.1566 92.1207 49.0388C98.8475 43.9065 106.334 39.2953 114.188 36.1439C117.295 34.8947 120.798 33.6609 124.168 33.635C134.365 33.5511 136.354 42.9911 132.638 51.031C120.47 77.4222 86.8639 93.9837 58.0983 94.9666C58.8971 92.6666 59.783 90.3603 60.8213 88.0427Z"
fill="currentColor"
/>
</svg>
</div>
</div>
{/* Content */}
<div className="lg:col-span-8 content lg:pl-8">
<div className="space-y-6">
{places.map((place, index) => (
<a href={`/offer/${index + 1}`} key={index} className="bg-white rounded-xl overflow-hidden border-0 shadow-soft hover:shadow-medium transition-all duration-300 group cursor-pointer block">
<div className="grid md:grid-cols-12 gap-0">
{/* Image */}
<div className="md:col-span-5 relative bg-white">
<div className="overflow-hidden relative h-64 md:h-full">
<img
src={place.image}
alt={place.title}
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{/* Badges */}
<div className="absolute top-4 left-4 space-y-2">
{place.badges.map((badge, badgeIndex) => (
<div key={badgeIndex} className="bg-primary px-3 py-1 text-xs font-semibold text-white uppercase rounded">
{badge.includes("Featured") && <Star className="inline w-3 h-3 mr-1" />}
{badge}
</div>
))}
</div>
</div>
</div>
{/* Content */}
<div className="md:col-span-7 p-6 flex flex-col">
{/* Action buttons */}
<div className="flex gap-2 justify-end mb-4">
<button className="w-10 h-10 bg-white shadow-sm rounded-full flex items-center justify-center text-primary hover:bg-primary hover:text-white transition-colors">
<Heart className="w-4 h-4" />
</button>
<button className="w-10 h-10 bg-white shadow-sm rounded-full flex items-center justify-content-center text-primary hover:bg-primary hover:text-white transition-colors">
<Search className="w-4 h-4" />
</button>
</div>
{/* Rating */}
<div className="flex items-center gap-2 text-primary mb-3">
<Star className="w-4 h-4 fill-current" />
<span className="font-medium">
<span className="text-lg font-semibold mr-1">({place.rating})</span>
{place.reviews}
</span>
</div>
{/* Title */}
<h3 className="text-xl font-semibold mb-0 flex items-center gap-2">
{place.title}
{place.verified && (
<div className="w-4 h-4 bg-green-500 rounded-full flex items-center justify-center">
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</h3>
{/* Description */}
<p className="text-gray-600 mt-3 mb-6">{place.description}</p>
{/* Contact */}
<div className="flex flex-wrap gap-6 mt-auto">
<a href={`tel:${place.phone}`} className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-primary transition-colors">
<Phone className="w-4 h-4" />
<span>{place.phone}</span>
</a>
<a href="#" className="flex items-center gap-2 text-sm font-semibold text-gray-600 hover:text-primary transition-colors">
<Compass className="w-4 h-4" />
<span>Directions</span>
</a>
</div>
</div>
</div>
</a>
))}
</div>
</div>
</div>
</div>
{/* Decorative blur */}
<div className="absolute bottom-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl opacity-30"></div>
</section>
);
};
export default PlacesSection;

View File

@@ -0,0 +1,55 @@
const ProcessSection = () => {
const steps = [
{
number: "1",
title: "Input your location to start looking for landmarks.",
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pharetra vitae quam integer semper."
},
{
number: "2",
title: "Make an appointment at the place you want to visit.",
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pharetra vitae quam integer."
},
{
number: "3",
title: "Visit the place and enjoy the experience.",
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pharetra vitae quam integer aenean."
}
];
return (
<section className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4">
<div className="text-center mb-16">
<p className="text-primary font-medium mb-4">Best Way</p>
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
Find Your Dream Place <span className="text-span">The Best Way</span>
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Discover exciting categories. Find what you're looking for.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{steps.map((step, index) => (
<div key={index} className="text-center group">
<div className="relative mb-6">
<div className="w-16 h-16 bg-primary text-white text-2xl font-bold rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform duration-300">
{step.number}
</div>
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-4 leading-tight">
{step.title}
</h4>
<p className="text-gray-600 leading-relaxed">
{step.description}
</p>
</div>
))}
</div>
</div>
</section>
);
};
export default ProcessSection;

View File

@@ -0,0 +1,203 @@
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { TrendingUp, Users, MapPin, Calendar, DollarSign } from "lucide-react";
const StatsSection = () => {
const [stats, setStats] = useState({
totalUsers: 50000,
totalReservations: 125000,
totalBusinesses: 2500,
totalRevenue: 15000000
});
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
// Simular animación de contadores al cargar
setIsAnimating(true);
// Simular actualizaciones en tiempo real (en producción vendría de la API)
const interval = setInterval(() => {
setStats(prev => ({
totalUsers: prev.totalUsers + Math.floor(Math.random() * 3),
totalReservations: prev.totalReservations + Math.floor(Math.random() * 5),
totalBusinesses: prev.totalBusinesses + Math.floor(Math.random() * 2),
totalRevenue: prev.totalRevenue + Math.floor(Math.random() * 10000)
}));
}, 10000); // Actualizar cada 10 segundos
return () => clearInterval(interval);
}, []);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('es-DO', {
style: 'currency',
currency: 'DOP',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const statItems = [
{
icon: <Users className="w-8 h-8" />,
label: "Usuarios Activos",
value: formatNumber(stats.totalUsers),
change: "+12.5%",
changeType: "positive" as const,
color: "primary"
},
{
icon: <Calendar className="w-8 h-8" />,
label: "Reservas Totales",
value: formatNumber(stats.totalReservations),
change: "+8.3%",
changeType: "positive" as const,
color: "secondary"
},
{
icon: <MapPin className="w-8 h-8" />,
label: "Negocios Registrados",
value: formatNumber(stats.totalBusinesses),
change: "+15.2%",
changeType: "positive" as const,
color: "success"
},
{
icon: <DollarSign className="w-8 h-8" />,
label: "Volumen de Transacciones",
value: formatCurrency(stats.totalRevenue),
change: "+22.1%",
changeType: "positive" as const,
color: "warning"
}
];
const getColorClasses = (color: string) => {
switch (color) {
case "primary":
return "text-primary bg-primary/10";
case "secondary":
return "text-secondary bg-secondary/10";
case "success":
return "text-success bg-success/10";
case "warning":
return "text-warning bg-warning/10";
default:
return "text-primary bg-primary/10";
}
};
return (
<section className="py-20 bg-background">
<div className="max-w-7xl mx-auto px-4">
{/* Section Header */}
<div className="text-center mb-16">
<div className="inline-block px-4 py-2 bg-success/10 text-success rounded-full text-sm font-medium mb-4">
Impacto en Tiempo Real
</div>
<h2 className="text-4xl md:text-5xl font-bold mb-6">
Transformando el{" "}
<span className="text-gradient">Turismo Caribeño</span>
</h2>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
Nuestras métricas en vivo muestran el crecimiento constante del ecosistema turístico más completo del Caribe.
</p>
</div>
{/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mb-16">
{statItems.map((stat, index) => (
<Card
key={index}
className={`card-tropical p-8 text-center group ${isAnimating ? 'animate-in fade-in slide-in-from-bottom-4' : ''}`}
style={{ animationDelay: `${index * 150}ms` }}
>
{/* Icon */}
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6 ${getColorClasses(stat.color)} group-hover:scale-110 transition-transform duration-300`}>
{stat.icon}
</div>
{/* Value */}
<div className="text-4xl font-bold mb-2 text-foreground">
{stat.value}
</div>
{/* Label */}
<div className="text-muted-foreground font-medium mb-4">
{stat.label}
</div>
{/* Change Indicator */}
<div className="flex items-center justify-center gap-1">
<TrendingUp className="w-4 h-4 text-success" />
<span className="text-sm font-medium text-success">
{stat.change}
</span>
<span className="text-xs text-muted-foreground">
vs mes anterior
</span>
</div>
</Card>
))}
</div>
{/* Live Activity Feed */}
<Card className="card-tropical p-8">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold">Actividad en Vivo</h3>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-success rounded-full animate-pulse"></div>
<span className="text-sm text-muted-foreground">En tiempo real</span>
</div>
</div>
<div className="space-y-4">
{[
{
text: "Nueva reserva de hotel en Santo Domingo",
time: "Hace 2 minutos",
type: "reservation"
},
{
text: "Guía turístico se unió en San Juan",
time: "Hace 5 minutos",
type: "guide"
},
{
text: "Restaurante activó menús digitales",
time: "Hace 8 minutos",
type: "restaurant"
},
{
text: "Nuevo usuario registrado desde Punta Cana",
time: "Hace 12 minutos",
type: "user"
}
].map((activity, index) => (
<div key={index} className="flex items-center gap-4 p-4 bg-muted/30 rounded-lg">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">{activity.text}</p>
<p className="text-xs text-muted-foreground">{activity.time}</p>
</div>
</div>
))}
</div>
</Card>
</div>
</section>
);
};
export default StatsSection;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Settings, Cog, Database, Wifi, Shield } from 'lucide-react';
interface ConfigTabProps {
isAdmin: boolean;
isSuperAdmin: boolean;
}
const ConfigTab: React.FC<ConfigTabProps> = ({ isSuperAdmin }) => {
if (!isSuperAdmin) {
return (
<div className="bg-white rounded-lg shadow p-8 text-center">
<Shield className="w-16 h-16 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Acceso Restringido</h3>
<p className="text-gray-600">Solo los Super Administradores pueden acceder a la configuración del sistema.</p>
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Configuración del Sistema</h2>
<div className="bg-white rounded-lg shadow p-8 text-center">
<Settings className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Configuración del Sistema
</h3>
<p className="text-gray-600 mb-4">
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
</p>
<div className="text-sm text-gray-500">
Funcionalidades pendientes:
<ul className="mt-2 space-y-1">
<li> Configuración de API</li>
<li> Parámetros del sistema</li>
<li> Gestión de integrations</li>
<li> Configuración de seguridad</li>
<li> Logs de auditoría</li>
</ul>
</div>
</div>
</div>
);
};
export default ConfigTab;

View File

@@ -0,0 +1,764 @@
import React, { useState } from 'react';
import {
FileText,
Globe,
Edit,
Plus,
MapPin,
Camera,
Bot,
Eye,
Trash2,
Star,
Users,
Calendar,
DollarSign,
Image,
Settings,
Car,
Clock
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { apiClient } from '@/services/adminApi';
import GeolocationTab from './GeolocationTab';
interface ContentTabProps {
isSuperAdmin: boolean;
activeSubTab?: string;
}
const ContentTab: React.FC<ContentTabProps> = ({ isSuperAdmin, activeSubTab = 'destinations' }) => {
const [activeTab, setActiveTab] = useState(activeSubTab || 'destinations');
React.useEffect(() => {
setActiveTab(activeSubTab || 'destinations');
}, [activeSubTab]);
const [destinations, setDestinations] = useState<any[]>([]);
const [places, setPlaces] = useState<any[]>([]);
const [guides, setGuides] = useState<any[]>([]);
const [taxis, setTaxis] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [newDestination, setNewDestination] = useState({
name: '',
description: '',
category: '',
countryId: 1,
coordinates: { x: 0, y: 0 },
images: ['']
});
const [newPlace, setNewPlace] = useState({
name: '',
description: '',
destinationId: '',
category: '',
address: '',
phone: '',
website: '',
coordinates: { x: 0, y: 0 },
entranceFee: 0,
openingHours: {
monday: '9:00-17:00',
tuesday: '9:00-17:00',
wednesday: '9:00-17:00',
thursday: '9:00-17:00',
friday: '9:00-17:00',
saturday: '9:00-17:00',
sunday: '9:00-17:00'
},
historicalInfo: '',
images: ['']
});
const [newGuide, setNewGuide] = useState({
licenseNumber: '',
specialties: [''],
languages: [''],
hourlyRate: 0,
dailyRate: 0,
bio: '',
certifications: {}
});
const [newTaxi, setNewTaxi] = useState({
licenseNumber: '',
vehiclePlate: '',
vehicleModel: '',
vehicleYear: new Date().getFullYear(),
vehicleColor: '',
vehicleCapacity: 4
});
// Promocional & IA forms
const [newCampaign, setNewCampaign] = useState({
title: '',
message: '',
segment: 'all'
});
const [aiGuideConfig, setAiGuideConfig] = useState({
name: '',
language: 'es',
personality: ''
});
// Load initial data
const loadData = async () => {
setLoading(true);
try {
const [destData, placesData, guidesData, taxisData] = await Promise.all([
apiClient.get('/tourism/destinations?page=1&limit=50'),
apiClient.get('/tourism/places?page=1&limit=50'),
apiClient.get('/tourism/guides?page=1&limit=50'),
apiClient.get('/tourism/taxis/available')
]);
setDestinations((destData as any)?.destinations || (Array.isArray(destData) ? (destData as any) : []) );
setPlaces((placesData as any)?.places || (Array.isArray(placesData) ? (placesData as any) : []) );
setGuides((guidesData as any)?.guides || (Array.isArray(guidesData) ? (guidesData as any) : []) );
const taxisArr = Array.isArray(taxisData) ? taxisData : (taxisData as any)?.taxis || (taxisData as any)?.drivers || (taxisData as any)?.data || [];
setTaxis(taxisArr as any[]);
} catch (error) {
console.error('Error loading data:', error);
} finally {
setLoading(false);
}
};
React.useEffect(() => {
loadData();
}, []);
const { toast } = useToast();
const handleCreateDestination = async () => {
try {
await apiClient.post('/tourism/destinations', {
name: newDestination.name,
description: newDestination.description,
category: newDestination.category,
countryId: newDestination.countryId,
coordinates: `(${newDestination.coordinates.x},${newDestination.coordinates.y})`,
images: newDestination.images.filter(img => img.trim() !== '')
});
setNewDestination({ name: '', description: '', category: '', countryId: 1, coordinates: { x: 0, y: 0 }, images: [''] });
loadData();
toast({ title: 'Destino creado', description: 'El destino se creó correctamente.' });
} catch (error: any) {
console.error('Error creating destination:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo crear el destino.' });
}
};
const handleCreatePlace = async () => {
try {
await apiClient.post('/tourism/places', {
name: newPlace.name,
description: newPlace.description,
destinationId: parseInt(newPlace.destinationId),
category: newPlace.category,
address: newPlace.address,
phone: newPlace.phone,
website: newPlace.website,
coordinates: `(${newPlace.coordinates.x},${newPlace.coordinates.y})`,
entranceFee: newPlace.entranceFee,
openingHours: newPlace.openingHours,
historicalInfo: newPlace.historicalInfo,
images: newPlace.images.filter(img => img.trim() !== '')
});
setNewPlace({
name: '', description: '', destinationId: '', category: '', address: '', phone: '', website: '',
coordinates: { x: 0, y: 0 }, entranceFee: 0, historicalInfo: '', images: [''],
openingHours: {
monday: '9:00-17:00', tuesday: '9:00-17:00', wednesday: '9:00-17:00', thursday: '9:00-17:00',
friday: '9:00-17:00', saturday: '9:00-17:00', sunday: '9:00-17:00'
}
});
loadData();
toast({ title: 'Lugar creado', description: 'El lugar se creó correctamente.' });
} catch (error: any) {
console.error('Error creating place:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo crear el lugar.' });
}
};
const handleDeleteDestination = async (id: string) => {
try {
await apiClient.delete(`/tourism/destinations/${id}`);
} catch (error: any) {
console.error('Error deleting destination:', error);
// Algunos endpoints devuelven 204 sin cuerpo
}
await loadData();
toast({ title: 'Destino eliminado', description: 'Se eliminó correctamente.' });
};
const handleDeletePlace = async (id: string) => {
try {
await apiClient.delete(`/tourism/places/${id}`);
} catch (error: any) {
console.error('Error deleting place:', error);
// Algunos endpoints devuelven 204 sin cuerpo
}
await loadData();
toast({ title: 'Lugar eliminado', description: 'Se eliminó correctamente.' });
};
const handleCreateCampaign = async () => {
try {
await apiClient.post('/notifications', {
title: newCampaign.title,
message: newCampaign.message,
segment: newCampaign.segment || 'all',
type: 'promotion'
});
toast({ title: 'Campaña creada', description: 'La campaña promocional fue creada.' });
setNewCampaign({ title: '', message: '', segment: 'all' });
} catch (error: any) {
console.error('Error creating campaign:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo crear la campaña.' });
}
};
const handleSetupAIGuide = async () => {
try {
await apiClient.post('/ai-generator/generate', {
template: 'virtual-guide',
inputs: {
name: aiGuideConfig.name,
language: aiGuideConfig.language,
personality: aiGuideConfig.personality
}
});
toast({ title: 'Guía IA creada', description: 'La guía virtual fue configurada.' });
setAiGuideConfig({ name: '', language: 'es', personality: '' });
} catch (error: any) {
console.error('Error setting up AI guide:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo configurar la guía IA.' });
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">Gestión de Contenido Turístico</h2>
<div className="flex gap-2">
<Badge variant="secondary" className="text-sm">
{destinations.length} Destinos
</Badge>
<Badge variant="secondary" className="text-sm">
{places.length} Lugares
</Badge>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsContent value="destinations" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gestión de Destinos Turísticos</h3>
{isSuperAdmin && (
<Dialog>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Nuevo Destino
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crear Nuevo Destino</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div>
<Label htmlFor="dest-name">Nombre del Destino</Label>
<Input
id="dest-name"
value={newDestination.name}
onChange={(e) => setNewDestination({ ...newDestination, name: e.target.value })}
placeholder="Ej: Punta Cana"
/>
</div>
<div>
<Label htmlFor="dest-category">Categoría</Label>
<Select value={newDestination.category} onValueChange={(value) => setNewDestination({ ...newDestination, category: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecciona una categoría" />
</SelectTrigger>
<SelectContent>
<SelectItem value="beach">Playa</SelectItem>
<SelectItem value="cultural">Cultural</SelectItem>
<SelectItem value="nature">Naturaleza</SelectItem>
<SelectItem value="luxury">Lujo</SelectItem>
<SelectItem value="adventure">Aventura</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="dest-description">Descripción</Label>
<Textarea
id="dest-description"
value={newDestination.description}
onChange={(e) => setNewDestination({ ...newDestination, description: e.target.value })}
placeholder="Describe el destino turístico..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dest-lat">Latitud</Label>
<Input
id="dest-lat"
type="number"
step="any"
value={newDestination.coordinates.y}
onChange={(e) => setNewDestination({
...newDestination,
coordinates: { ...newDestination.coordinates, y: parseFloat(e.target.value) || 0 }
})}
placeholder="18.4861"
/>
</div>
<div>
<Label htmlFor="dest-lng">Longitud</Label>
<Input
id="dest-lng"
type="number"
step="any"
value={newDestination.coordinates.x}
onChange={(e) => setNewDestination({
...newDestination,
coordinates: { ...newDestination.coordinates, x: parseFloat(e.target.value) || 0 }
})}
placeholder="-69.9312"
/>
</div>
</div>
<div>
<Label htmlFor="dest-image">URLs de Imágenes (separadas por coma)</Label>
<Textarea
id="dest-image"
value={newDestination.images.join(', ')}
onChange={(e) => setNewDestination({
...newDestination,
images: e.target.value.split(',').map(url => url.trim())
})}
placeholder="https://ejemplo.com/imagen1.jpg, https://ejemplo.com/imagen2.jpg"
rows={2}
/>
</div>
<Button onClick={handleCreateDestination} className="w-full">
Crear Destino
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="grid gap-4">
{destinations.map((destination) => (
<Card key={destination.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-4">
<img
src={destination.images?.[0] || '/placeholder.svg'}
alt={destination.name}
className="w-16 h-16 rounded-lg object-cover"
/>
<div>
<CardTitle className="text-lg">{destination.name}</CardTitle>
<p className="text-sm text-muted-foreground">{destination.category}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="flex items-center gap-1">
<Star className="w-3 h-3" />
{destination.rating || 4.5}
</Badge>
{isSuperAdmin && (
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => console.log('Edit destination:', destination.id)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteDestination(destination.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-3">{destination.description}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
{destination.active ? 'Activo' : 'Inactivo'}
</Badge>
{destination.country_id && (
<Badge variant="outline" className="text-xs">
País ID: {destination.country_id}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="places" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gestión de Lugares de Interés</h3>
<Dialog>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Nuevo Lugar
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crear Nuevo Lugar</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div>
<Label htmlFor="place-name">Nombre del Lugar</Label>
<Input
id="place-name"
value={newPlace.name}
onChange={(e) => setNewPlace({ ...newPlace, name: e.target.value })}
placeholder="Ej: Hoyo Azul"
/>
</div>
<div>
<Label htmlFor="place-destination">Destino</Label>
<Select value={newPlace.destinationId} onValueChange={(value) => setNewPlace({ ...newPlace, destinationId: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecciona un destino" />
</SelectTrigger>
<SelectContent>
{destinations.map((dest) => (
<SelectItem key={dest.id} value={dest.id.toString()}>
{dest.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="place-category">Categoría</Label>
<Select value={newPlace.category} onValueChange={(value) => setNewPlace({ ...newPlace, category: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecciona una categoría" />
</SelectTrigger>
<SelectContent>
<SelectItem value="attraction">Atracción</SelectItem>
<SelectItem value="restaurant">Restaurante</SelectItem>
<SelectItem value="hotel">Hotel</SelectItem>
<SelectItem value="beach">Playa</SelectItem>
<SelectItem value="adventure">Aventura</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleCreatePlace} className="w-full">
Crear Lugar
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{places.map((place) => (
<Card key={place.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-4">
<img
src={place.images?.[0] || '/placeholder.svg'}
alt={place.name}
className="w-16 h-16 rounded-lg object-cover"
/>
<div>
<CardTitle className="text-lg">{place.name}</CardTitle>
<p className="text-sm text-muted-foreground">{place.category}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="flex items-center gap-1">
<Star className="w-3 h-3" />
{place.rating || 4.5}
</Badge>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => console.log('Edit place:', place.id)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeletePlace(place.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{place.description}</p>
<p className="text-xs text-gray-500 mt-2">{place.address}</p>
{place.phone && (
<p className="text-xs text-gray-500 mt-1">📞 {place.phone}</p>
)}
{place.website && (
<p className="text-xs text-gray-500 mt-1">🌐 {place.website}</p>
)}
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="ai-guides" className="space-y-4">
<div className="text-center py-12">
<Bot className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Guías Virtuales con IA</h3>
<p className="text-gray-600 mb-4">Configura y entrena asistentes virtuales para guiar a los turistas</p>
<Button className="flex items-center gap-2 mx-auto">
<Settings className="w-4 h-4" />
Configurar IA
</Button>
</div>
</TabsContent>
<TabsContent value="guides" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gestión de Guías Turísticos</h3>
<Badge variant="secondary" className="text-sm">
{Array.isArray(guides) ? guides.length : 0} Guías Registrados
</Badge>
</div>
<div className="grid gap-4">
{(Array.isArray(guides) ? guides : []).map((guide) => (
<Card key={guide.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
<Users className="w-8 h-8 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{guide.user?.firstName} {guide.user?.lastName}</CardTitle>
<p className="text-sm text-muted-foreground">Licencia: {guide.licenseNumber}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="flex items-center gap-1">
<Star className="w-3 h-3" />
{guide.rating}
</Badge>
<Badge variant={guide.isVerified ? "default" : "secondary"}>
{guide.isVerified ? 'Verificado' : 'Pendiente'}
</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-2">{guide.bio}</p>
<div className="flex flex-wrap gap-2 mb-2">
{guide.specialties?.map((specialty: string, idx: number) => (
<Badge key={idx} variant="outline" className="text-xs">
{specialty}
</Badge>
))}
</div>
<div className="flex justify-between text-sm text-gray-500">
<span>Tarifa por hora: ${guide.hourlyRate}</span>
<span>Tours realizados: {guide.totalTours}</span>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="taxis" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gestión de Taxis</h3>
<Badge variant="secondary" className="text-sm">
{Array.isArray(taxis) ? taxis.length : 0} Taxis Disponibles
</Badge>
</div>
<div className="grid gap-4">
{(Array.isArray(taxis) ? taxis : []).map((taxi) => (
<Card key={taxi.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
<Car className="w-8 h-8 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{taxi.user?.firstName} {taxi.user?.lastName}</CardTitle>
<p className="text-sm text-muted-foreground">{taxi.vehicleModel} {taxi.vehicleYear}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="flex items-center gap-1">
<Star className="w-3 h-3" />
{taxi.rating}
</Badge>
<Badge variant={taxi.isAvailable ? "default" : "secondary"}>
{taxi.isAvailable ? 'Disponible' : 'Ocupado'}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Placa:</span> {taxi.vehiclePlate}
</div>
<div>
<span className="font-medium">Capacidad:</span> {taxi.vehicleCapacity} pasajeros
</div>
<div>
<span className="font-medium">Color:</span> {taxi.vehicleColor}
</div>
<div>
<span className="font-medium">Viajes:</span> {taxi.totalTrips}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="geolocation" className="space-y-4">
<GeolocationTab activeSubTab="geofences" />
</TabsContent>
<TabsContent value="promotional" className="space-y-4">
<div className="text-center py-12">
<Image className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Contenido Promocional</h3>
<p className="text-gray-600 mb-4">Gestiona banners, ofertas especiales y campañas promocionales</p>
<Dialog>
<DialogTrigger asChild>
<Button className="flex items-center gap-2 mx-auto">
<Plus className="w-4 h-4" />
Crear Campaña
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Crear Nueva Campaña Promocional</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div>
<Label>Título</Label>
<Input value={newCampaign.title} onChange={(e) => setNewCampaign({ ...newCampaign, title: e.target.value })} />
</div>
<div>
<Label>Mensaje</Label>
<Textarea rows={3} value={newCampaign.message} onChange={(e) => setNewCampaign({ ...newCampaign, message: e.target.value })} />
</div>
<div>
<Label>Segmento</Label>
<Select value={newCampaign.segment} onValueChange={(v) => setNewCampaign({ ...newCampaign, segment: v })}>
<SelectTrigger>
<SelectValue placeholder="Selecciona segmento" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="tourists">Turistas</SelectItem>
<SelectItem value="guides">Guías</SelectItem>
<SelectItem value="business">Comercios</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleCreateCampaign}>Crear Campaña</Button>
</div>
</DialogContent>
</Dialog>
</div>
</TabsContent>
<TabsContent value="ai-guides" className="space-y-4">
<div className="text-center py-12">
<Bot className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Guías Virtuales con IA</h3>
<p className="text-gray-600 mb-4">Configura y entrena asistentes virtuales para guiar a los turistas</p>
<Dialog>
<DialogTrigger asChild>
<Button className="flex items-center gap-2 mx-auto">
<Settings className="w-4 h-4" />
Configurar IA
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Configuración de Guías IA</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div>
<Label>Nombre</Label>
<Input value={aiGuideConfig.name} onChange={(e) => setAiGuideConfig({ ...aiGuideConfig, name: e.target.value })} />
</div>
<div>
<Label>Idioma</Label>
<Select value={aiGuideConfig.language} onValueChange={(v) => setAiGuideConfig({ ...aiGuideConfig, language: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="es">Español</SelectItem>
<SelectItem value="en">Inglés</SelectItem>
<SelectItem value="fr">Francés</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Personalidad</Label>
<Textarea rows={3} value={aiGuideConfig.personality} onChange={(e) => setAiGuideConfig({ ...aiGuideConfig, personality: e.target.value })} />
</div>
<Button onClick={handleSetupAIGuide}>Crear Guía IA</Button>
</div>
</DialogContent>
</Dialog>
</div>
</TabsContent>
<TabsContent value="ar-content" className="space-y-4">
<div className="text-center py-12">
<Camera className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Contenido de Realidad Aumentada</h3>
<p className="text-gray-600 mb-4">Crea experiencias inmersivas con realidad aumentada para destinos</p>
<Button className="flex items-center gap-2 mx-auto">
<Eye className="w-4 h-4" />
Crear Experiencia AR
</Button>
</div>
</TabsContent>
</Tabs>
</div>
);
};
export default ContentTab;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { AlertTriangle, Shield, Phone, MapPin } from 'lucide-react';
interface EmergencyTabProps {
incidents: any[];
stats: any;
isAdmin: boolean;
isSuperAdmin: boolean;
}
const EmergencyTab: React.FC<EmergencyTabProps> = ({ incidents, stats }) => {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Sistema de Emergencias y POLITUR</h2>
<div className="bg-white rounded-lg shadow p-8 text-center">
<AlertTriangle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Sistema de Emergencias y POLITUR
</h3>
<p className="text-gray-600 mb-4">
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
</p>
<div className="text-sm text-gray-500">
Funcionalidades pendientes:
<ul className="mt-2 space-y-1">
<li> Panel de emergencias en tiempo real</li>
<li> Gestión de incidentes</li>
<li> Comunicación con POLITUR</li>
<li> Geolocalización de emergencias</li>
<li> Botón de pánico integrado</li>
</ul>
</div>
</div>
</div>
);
};
export default EmergencyTab;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,959 @@
import React, { useState, useEffect } from 'react';
import {
MapPin,
Plus,
Settings,
Shield,
AlertTriangle,
Activity,
Navigation,
Target,
Eye,
Trash2,
Edit
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Loader } from '@googlemaps/js-api-loader';
import { useToast } from '@/hooks/use-toast';
import { Label } from '@/components/ui/label';
import { apiClient } from '@/services/adminApi';
import { useAuth } from '@/contexts/AuthContext';
import { ResponsiveContainer, AreaChart, Area, BarChart as RBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
interface GeolocationTabProps {
activeSubTab: string;
}
const GeolocationTab: React.FC<GeolocationTabProps> = ({ activeSubTab }) => {
const [activeTab, setActiveTab] = useState(activeSubTab);
useEffect(() => { setActiveTab(activeSubTab); }, [activeSubTab]);
const [geofences, setGeofences] = useState<any[]>([]);
const [analytics, setAnalytics] = useState<any>(null);
const [loading, setLoading] = useState(false);
const { isLoading: authLoading, isAuthenticated } = useAuth();
const [newGeofence, setNewGeofence] = useState({
name: '',
latitude: 0,
longitude: 0,
radius: 500,
type: 'tourist-zone',
description: '',
entryMessage: '',
exitMessage: ''
});
const [locationTest, setLocationTest] = useState({
latitude: 18.4735,
longitude: -69.8849,
activity: 'walking'
});
const [routeTest, setRouteTest] = useState({
startLat: 18.4735,
startLng: -69.8849,
endLat: 18.4620,
endLng: -69.9071,
includeAttractions: true
});
const [panicTest, setPanicTest] = useState({
latitude: 18.4740,
longitude: -69.8855,
message: 'Necesito ayuda'
});
// Maps & API key
const { toast } = useToast();
const [mapsApiKey, setMapsApiKey] = useState<string>(() => localStorage.getItem('googleMapsApiKey') || '');
const [showApiKeyInput, setShowApiKeyInput] = useState(!localStorage.getItem('googleMapsApiKey'));
const [googleObj, setGoogleObj] = useState<any>(null);
const testMapRef = React.useRef<HTMLDivElement>(null);
const navMapRef = React.useRef<HTMLDivElement>(null);
const [testMap, setTestMap] = useState<any>(null);
const [navMap, setNavMap] = useState<any>(null);
const [directionsRenderer, setDirectionsRenderer] = useState<any>(null);
const loadGoogle = async () => {
if (googleObj) return googleObj;
// Reusar instancia existente si ya está cargada
if (typeof window !== 'undefined' && (window as any).google?.maps) {
setGoogleObj((window as any).google);
return (window as any).google;
}
// Si el script ya existe (posiblemente con otra API key), esperar su carga y reutilizarlo
const existingScript = typeof document !== 'undefined'
? (document.getElementById('__googleMapsScriptId') as HTMLScriptElement | null)
: null;
if (existingScript) {
await new Promise<void>((resolve, reject) => {
if ((window as any).google?.maps) return resolve();
existingScript.addEventListener('load', () => resolve(), { once: true });
existingScript.addEventListener('error', () => reject(new Error('Fallo al cargar Google Maps script')), { once: true });
});
setGoogleObj((window as any).google);
return (window as any).google;
}
if (!mapsApiKey) {
toast({
title: 'Google Maps no configurado',
description: 'Necesitas configurar una API key de Google Maps. Ingresa tu clave abajo.'
});
setShowApiKeyInput(true);
return null;
}
try {
const loader = new Loader({ apiKey: mapsApiKey, version: 'weekly', libraries: ['places'], id: '__googleMapsScriptIdKaribeo' });
const g = await loader.load();
setGoogleObj(g);
return g;
} catch (err: any) {
console.error('Error cargando Google Maps:', err);
const msg = err?.message || '';
if (msg.includes('RefererNotAllowedMapError')) {
toast({
title: 'Dominio no autorizado',
description: `Autoriza el dominio ${window.location.origin} en Google Cloud Console (restricción por HTTP Referrer).`,
variant: 'destructive'
});
setShowApiKeyInput(true);
} else if (msg.includes('Loader must not be called again with different options')) {
// Si ya existe una instancia con otra configuración, reusar google si está disponible
if ((window as any).google?.maps) {
setGoogleObj((window as any).google);
return (window as any).google;
}
} else {
toast({
title: 'Error con Google Maps',
description: msg || 'Error desconocido con la API de Google Maps.'
});
}
return null;
}
};
const handleSaveApiKey = () => {
if (mapsApiKey.trim()) {
localStorage.setItem('googleMapsApiKey', mapsApiKey.trim());
setShowApiKeyInput(false);
toast({ title: 'API Key guardada', description: 'Intenta usar los mapas nuevamente.' });
}
};
// Espera hasta que el elemento esté en el DOM y tenga tamaño visible
const waitForVisible = (el: HTMLElement | null, maxTries = 60): Promise<void> => {
return new Promise((resolve) => {
let tries = 0;
const check = () => {
if (el && el.isConnected && el.clientHeight > 0 && el.clientWidth > 0) {
resolve();
return;
}
if (tries++ >= maxTries) {
resolve();
return;
}
requestAnimationFrame(check);
};
check();
});
};
const initTestMap = async () => {
const g = await loadGoogle();
if (!g) return;
if (testMapRef.current && testMapRef.current instanceof HTMLElement && !testMap) {
await waitForVisible(testMapRef.current);
try {
const m = new g.maps.Map(testMapRef.current as HTMLElement, {
center: { lat: locationTest.latitude, lng: locationTest.longitude },
zoom: 13,
});
setTestMap(m);
} catch (e) {
console.warn('Error inicializando Test Map:', e);
}
}
};
const initNavMap = async () => {
const g = await loadGoogle();
if (!g) return;
if (navMapRef.current && navMapRef.current instanceof HTMLElement && !navMap) {
await waitForVisible(navMapRef.current);
try {
const m = new g.maps.Map(navMapRef.current as HTMLElement, { center: { lat: routeTest.startLat, lng: routeTest.startLng }, zoom: 12 });
setNavMap(m);
const dr = new g.maps.DirectionsRenderer({ map: m });
setDirectionsRenderer(dr);
} catch (e) {
console.warn('Error inicializando Nav Map:', e);
}
}
};
useEffect(() => {
if (activeTab === 'testing') initTestMap();
if (activeTab === 'navigation') initNavMap();
// Reintentar si se guarda la API key
}, [activeTab, mapsApiKey]);
// Load geofences
const loadGeofences = async () => {
try {
const data = await apiClient.get('/geolocation/geofences');
setGeofences(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Error loading geofences:', error);
}
};
// Load analytics
const loadAnalytics = async () => {
try {
const data = await apiClient.get('/geolocation/analytics?timeframe=7d');
setAnalytics(data);
} catch (error) {
console.error('Error loading analytics:', error);
}
};
useEffect(() => {
const hasToken = !!(typeof window !== 'undefined' && (localStorage.getItem('karibeo-token') || localStorage.getItem('karibeo_token')));
if (!authLoading && (isAuthenticated || hasToken)) {
loadGeofences();
loadAnalytics();
}
}, [authLoading, isAuthenticated]);
// Create geofence
const handleCreateGeofence = async () => {
try {
await apiClient.post('/geolocation/geofences', {
name: newGeofence.name,
latitude: newGeofence.latitude,
longitude: newGeofence.longitude,
radius: newGeofence.radius,
type: newGeofence.type,
description: newGeofence.description,
entryMessage: newGeofence.entryMessage,
exitMessage: newGeofence.exitMessage
});
setNewGeofence({
name: '', latitude: 0, longitude: 0, radius: 500, type: 'tourist-zone',
description: '', entryMessage: '', exitMessage: ''
});
loadGeofences();
toast({ title: 'Geofence creado', description: 'Se creó correctamente.' });
} catch (error: any) {
console.error('Error creating geofence:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo crear el geofence.' });
}
};
const handleDeleteGeofence = async (id: string) => {
let success = false;
try {
await apiClient.delete(`/geolocation/geofences/${id}`);
success = true;
} catch (error: any) {
// Many backends don't implement DELETE; try to soft-deactivate
try {
await apiClient.patch(`/geolocation/geofences/${id}`, { isActive: false });
success = true;
} catch (e2) {
console.warn('Delete/patch not supported, removing locally:', id, e2);
// Final fallback: remove locally to avoid UX dead-ends
setGeofences((prev) => prev.filter((g) => g.id !== id));
toast({ title: 'Eliminado localmente', description: 'El backend no permite eliminar este geofence. Se ocultó en la interfaz.' });
return;
}
}
if (success) {
await loadGeofences();
toast({ title: 'Geofence eliminado', description: 'Se eliminó o desactivó correctamente.' });
}
};
// Test location update
const handleLocationUpdate = async () => {
try {
const result = await apiClient.post('/geolocation/location/update', {
latitude: locationTest.latitude,
longitude: locationTest.longitude,
accuracy: 10,
speed: 5,
activity: locationTest.activity
});
console.log('Location update result:', result);
toast({ title: 'Ubicación actualizada', description: 'Se envió la ubicación de prueba.' });
// Center marker on test map
if (testMap && googleObj) {
const marker = new googleObj.maps.Marker({ position: { lat: locationTest.latitude, lng: locationTest.longitude }, map: testMap });
testMap.setCenter(marker.getPosition());
}
} catch (error: any) {
console.error('Error updating location:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo actualizar la ubicación.' });
}
};
// Test geofence check
const handleGeofenceCheck = async () => {
try {
const result = await apiClient.post('/geolocation/geofences/check', {
latitude: locationTest.latitude,
longitude: locationTest.longitude
});
console.log('Geofence check result:', result);
const matched = (result as any)?.geofences?.length || 0;
toast({ title: 'Verificación de geofences', description: `Coincidencias: ${matched}` });
} catch (error: any) {
console.error('Error checking geofences:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo verificar geofences.' });
}
};
// Test smart suggestions
const handleSmartSuggestions = async () => {
try {
const result = await apiClient.post('/geolocation/suggestions/smart', {
latitude: locationTest.latitude,
longitude: locationTest.longitude,
activity: locationTest.activity
});
console.log('Smart suggestions result:', result);
toast({ title: 'Sugerencias IA', description: 'Sugerencias recibidas, revisa la consola.' });
} catch (error: any) {
console.error('Error getting smart suggestions:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo obtener sugerencias.' });
}
};
// Test panic button
const handlePanicButton = async () => {
try {
// Enviar alerta a POLITUR (backend de seguridad)
const result = await apiClient.post('/security/emergency-alerts', {
type: 'emergency',
channel: 'politur',
location: { latitude: panicTest.latitude, longitude: panicTest.longitude },
message: panicTest.message || 'Alerta de emergencia',
});
console.log('Panic button result:', result);
toast({ title: 'Emergencia', description: 'Alerta enviada a POLITUR.' });
} catch (error: any) {
console.error('Error triggering panic button:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo activar el botón de pánico.' });
}
};
// Test route planning
const handleRoutePlanning = async () => {
try {
const result = await apiClient.post('/geolocation/navigation/route', {
startLat: routeTest.startLat,
startLng: routeTest.startLng,
endLat: routeTest.endLat,
endLng: routeTest.endLng,
includeAttractions: routeTest.includeAttractions
});
console.log('Route planning result:', result);
toast({ title: 'Ruta planificada', description: 'Ruta generada correctamente.' });
// Draw route on Google Maps
if (navMap && googleObj) {
const ds = new googleObj.maps.DirectionsService();
const dr = directionsRenderer || new googleObj.maps.DirectionsRenderer({ map: navMap });
setDirectionsRenderer(dr);
const req = {
origin: { lat: routeTest.startLat, lng: routeTest.startLng },
destination: { lat: routeTest.endLat, lng: routeTest.endLng },
travelMode: googleObj.maps.TravelMode.DRIVING,
} as any;
// Usar callback para máxima compatibilidad
ds.route(req, (res: any, status: any) => {
if (status === 'OK' || status === 200) {
dr.setDirections(res);
} else {
console.warn('DirectionsService status:', status);
toast({ title: 'Ruta no disponible', description: `Google Directions status: ${status}` });
}
});
} else {
toast({ title: 'Mapa no inicializado', description: 'Abre la pestaña Navegación para inicializar el mapa antes de planificar.' });
}
} catch (error: any) {
console.error('Error planning route:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo planificar la ruta.' });
}
};
// Test nearby attractions
const handleNearbyAttractions = async () => {
try {
const result = await apiClient.get(`/geolocation/nearby/attractions?latitude=${locationTest.latitude}&longitude=${locationTest.longitude}&radius=500`);
console.log('Nearby attractions result:', result);
const count = Array.isArray(result) ? result.length : ((result as any)?.length || (result as any)?.data?.length || 0);
toast({ title: 'Atracciones cercanas', description: `Resultados: ${count}` });
} catch (error: any) {
console.error('Error getting nearby attractions:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo obtener atracciones.' });
}
};
// Test safety zones
const handleSafetyZones = async () => {
try {
const result = await apiClient.get(`/geolocation/safety/zones?latitude=${locationTest.latitude}&longitude=${locationTest.longitude}`);
console.log('Safety zones result:', result);
toast({ title: 'Zonas de seguridad', description: 'Consulta completada. Revisa la consola.' });
} catch (error: any) {
console.error('Error getting safety zones:', error);
toast({ title: 'Error', description: error?.message || 'No se pudo obtener zonas de seguridad.' });
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-900">Gestión de Geolocalización</h2>
<div className="flex gap-2">
<Badge variant="secondary" className="text-sm">
{geofences.length} Geofences
</Badge>
<Badge variant="secondary" className="text-sm">
{analytics?.totalUsers || 0} Usuarios Activos
</Badge>
</div>
</div>
{/* API Key Configuration */}
{showApiKeyInput && (
<Card className="border-orange-200 bg-orange-50">
<CardHeader>
<CardTitle className="text-orange-800">Configurar Google Maps API Key</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-orange-700">
Para usar los mapas, necesitas una API key de Google Maps.
<a href="https://console.cloud.google.com/google/maps-apis/credentials" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline ml-1">
Consigue una aquí
</a>
</p>
<div className="flex gap-2">
<Input
placeholder="Ingresa tu Google Maps API Key"
value={mapsApiKey}
onChange={(e) => setMapsApiKey(e.target.value)}
className="flex-1"
/>
<Button onClick={handleSaveApiKey} disabled={!mapsApiKey.trim()}>
Guardar
</Button>
</div>
<p className="text-xs text-orange-600">
Asegúrate de autorizar el dominio: <code className="bg-orange-100 px-1 rounded">{window.location.origin}</code> en tu Google Cloud Console
</p>
</CardContent>
</Card>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsContent value="geofences" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Gestión de Geofences</h3>
<Dialog>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Nuevo Geofence
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crear Nuevo Geofence</DialogTitle>
</DialogHeader>
<div className="grid gap-4">
<div>
<Label htmlFor="geo-name">Nombre</Label>
<Input
id="geo-name"
value={newGeofence.name}
onChange={(e) => setNewGeofence({ ...newGeofence, name: e.target.value })}
placeholder="Ej: Zona Colonial"
/>
</div>
<div>
<Label htmlFor="geo-type">Tipo</Label>
<Select value={newGeofence.type} onValueChange={(value) => setNewGeofence({ ...newGeofence, type: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecciona el tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tourist-zone">Zona Turística</SelectItem>
<SelectItem value="attraction">Atracción</SelectItem>
<SelectItem value="safety-alert">Alerta de Seguridad</SelectItem>
<SelectItem value="restricted">Zona Restringida</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geo-lat">Latitud</Label>
<Input
id="geo-lat"
type="number"
step="any"
value={newGeofence.latitude}
onChange={(e) => setNewGeofence({ ...newGeofence, latitude: parseFloat(e.target.value) || 0 })}
placeholder="18.4735"
/>
</div>
<div>
<Label htmlFor="geo-lng">Longitud</Label>
<Input
id="geo-lng"
type="number"
step="any"
value={newGeofence.longitude}
onChange={(e) => setNewGeofence({ ...newGeofence, longitude: parseFloat(e.target.value) || 0 })}
placeholder="-69.8849"
/>
</div>
</div>
<div>
<Label htmlFor="geo-radius">Radio (metros)</Label>
<Input
id="geo-radius"
type="number"
value={newGeofence.radius}
onChange={(e) => setNewGeofence({ ...newGeofence, radius: parseInt(e.target.value) || 500 })}
placeholder="500"
/>
</div>
<div>
<Label htmlFor="geo-description">Descripción</Label>
<Textarea
id="geo-description"
value={newGeofence.description}
onChange={(e) => setNewGeofence({ ...newGeofence, description: e.target.value })}
placeholder="Descripción del área..."
rows={2}
/>
</div>
<div>
<Label htmlFor="geo-entry">Mensaje de Entrada</Label>
<Input
id="geo-entry"
value={newGeofence.entryMessage}
onChange={(e) => setNewGeofence({ ...newGeofence, entryMessage: e.target.value })}
placeholder="Mensaje al entrar al área"
/>
</div>
<div>
<Label htmlFor="geo-exit">Mensaje de Salida</Label>
<Input
id="geo-exit"
value={newGeofence.exitMessage}
onChange={(e) => setNewGeofence({ ...newGeofence, exitMessage: e.target.value })}
placeholder="Mensaje al salir del área"
/>
</div>
<Button onClick={handleCreateGeofence} className="w-full">
Crear Geofence
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{geofences.map((geofence) => (
<Card key={geofence.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 rounded-lg bg-primary/10 flex items-center justify-center">
<Target className="w-8 h-8 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{geofence.name}</CardTitle>
<p className="text-sm text-muted-foreground">{geofence.type}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="flex items-center gap-1">
<Activity className="w-3 h-3" />
{geofence.entryCount} entradas
</Badge>
<div className="flex gap-1">
<Button variant="outline" size="sm">
<Edit className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteGeofence(geofence.id)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-3">{geofence.description}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
Radio: {geofence.radius}m
</Badge>
<Badge variant={geofence.isActive ? "default" : "secondary"} className="text-xs">
{geofence.isActive ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<h3 className="text-lg font-semibold">Analíticas de Geolocalización</h3>
{analytics && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Usuarios Totales</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.totalUsers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Usuarios Activos</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.activeUsers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Zonas Populares</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.popularZones?.length || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Geofences</CardTitle>
<Target className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.geofenceStats?.length || 0}</div>
</CardContent>
</Card>
</div>
)}
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Área: Usuarios activos últimos 7 días */}
<Card>
<CardHeader>
<CardTitle>Usuarios activos (últimos 7 días)</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
{(() => {
const labels = analytics?.dailyLabels || ['Lun','Mar','Mié','Jue','Vie','Sáb','Dom'];
const values = (analytics?.dailyActiveUsers || [12,18,14,22,17,25,21]).map((n: any) => Number(n) || 0);
const data = labels.map((name: string, i: number) => ({ name, value: values[i] ?? 0 }));
return (
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F84525" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#F84525" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Area type="monotone" dataKey="value" stroke="#F84525" fill="url(#colorValue)" name="Usuarios activos" />
</AreaChart>
);
})()}
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Barras: Entradas por geofence */}
<Card>
<CardHeader>
<CardTitle>Entradas por geofence</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
{(() => {
const stats = analytics?.geofenceStats || [];
const labels = (stats.length ? stats.map((s: any) => s.name) : ['A','B','C','D']);
const values = (stats.length ? stats.map((s: any) => Number(s.entries) || 0) : [10,8,6,12]);
const data = labels.map((name: string, i: number) => ({ name, entries: values[i] ?? 0 }));
return (
<RBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Bar dataKey="entries" fill="#F84525" name="Entradas" />
</RBarChart>
);
})()}
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{analytics?.geofenceStats && (
<Card>
<CardHeader>
<CardTitle>Estadísticas por Geofence</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{analytics.geofenceStats.map((stat: any, index: number) => (
<div key={index} className="flex justify-between items-center">
<span className="text-sm font-medium">{stat.name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline">{stat.type}</Badge>
<span className="text-sm text-muted-foreground">{stat.entries} entradas</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="testing" className="space-y-4">
<h3 className="text-lg font-semibold">Pruebas de Funcionalidad</h3>
<Card>
<CardHeader>
<CardTitle>Prueba de Ubicación (requiere Maps)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label>Latitud</Label>
<Input
type="number"
step="any"
value={locationTest.latitude}
onChange={(e) => setLocationTest({ ...locationTest, latitude: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Longitud</Label>
<Input
type="number"
step="any"
value={locationTest.longitude}
onChange={(e) => setLocationTest({ ...locationTest, longitude: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Actividad</Label>
<Select value={locationTest.activity} onValueChange={(value) => setLocationTest({ ...locationTest, activity: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="walking">Caminando</SelectItem>
<SelectItem value="driving">Conduciendo</SelectItem>
<SelectItem value="dining">Comiendo</SelectItem>
<SelectItem value="shopping">Comprando</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleLocationUpdate}>Actualizar Ubicación</Button>
<Button onClick={handleGeofenceCheck}>Verificar Geofences</Button>
<Button onClick={handleSmartSuggestions}>Sugerencias IA</Button>
<Button onClick={handleNearbyAttractions}>Atracciones Cercanas</Button>
<Button onClick={handleSafetyZones}>Zonas de Seguridad</Button>
</div>
<div className="mt-4">
<div ref={testMapRef} className="w-full h-64 rounded-lg border relative">
{!mapsApiKey && (
<div className="absolute inset-0 flex items-center justify-center text-center p-4">
<p className="text-sm text-muted-foreground">Mapa deshabilitado. Configura una Google Maps API key restringida por dominio y autoriza este dominio en Google Cloud Console.</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="emergency" className="space-y-4">
<h3 className="text-lg font-semibold">Sistema de Emergencias</h3>
<Card>
<CardHeader>
<CardTitle>Prueba de Botón de Pánico</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Latitud</Label>
<Input
type="number"
step="any"
value={panicTest.latitude}
onChange={(e) => setPanicTest({ ...panicTest, latitude: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Longitud</Label>
<Input
type="number"
step="any"
value={panicTest.longitude}
onChange={(e) => setPanicTest({ ...panicTest, longitude: parseFloat(e.target.value) })}
/>
</div>
</div>
<div>
<Label>Mensaje de Emergencia</Label>
<Textarea
value={panicTest.message}
onChange={(e) => setPanicTest({ ...panicTest, message: e.target.value })}
placeholder="Describe la emergencia..."
/>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
Activar Botón de Pánico
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar activación</AlertDialogTitle>
<AlertDialogDescription>
Se notificará a los servicios de emergencia con tu ubicación.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handlePanicButton}>Activar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="navigation" className="space-y-4">
<h3 className="text-lg font-semibold">Sistema de Navegación</h3>
<Card>
<CardHeader>
<CardTitle>Planificación de Rutas</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Latitud Origen</Label>
<Input
type="number"
step="any"
value={routeTest.startLat}
onChange={(e) => setRouteTest({ ...routeTest, startLat: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Longitud Origen</Label>
<Input
type="number"
step="any"
value={routeTest.startLng}
onChange={(e) => setRouteTest({ ...routeTest, startLng: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Latitud Destino</Label>
<Input
type="number"
step="any"
value={routeTest.endLat}
onChange={(e) => setRouteTest({ ...routeTest, endLat: parseFloat(e.target.value) })}
/>
</div>
<div>
<Label>Longitud Destino</Label>
<Input
type="number"
step="any"
value={routeTest.endLng}
onChange={(e) => setRouteTest({ ...routeTest, endLng: parseFloat(e.target.value) })}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={routeTest.includeAttractions}
onChange={(e) => setRouteTest({ ...routeTest, includeAttractions: e.target.checked })}
/>
<Label>Incluir atracciones en la ruta</Label>
</div>
<Button onClick={handleRoutePlanning} className="flex items-center gap-2">
<Navigation className="w-4 h-4" />
Planificar Ruta
</Button>
<div className="mt-4">
<div ref={navMapRef} className="w-full h-72 rounded-lg border relative">
{!mapsApiKey && (
<div className="absolute inset-0 flex items-center justify-center text-center p-4">
<p className="text-sm text-muted-foreground">Mapa deshabilitado. Configura una Google Maps API key restringida por dominio y autoriza este dominio en Google Cloud Console.</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default GeolocationTab;

View File

@@ -0,0 +1,135 @@
import React from 'react';
import {
Users,
DollarSign,
Calendar,
Activity,
TrendingUp,
AlertTriangle,
Bell,
CheckCircle,
Clock,
BarChart3
} from 'lucide-react';
interface OverviewTabProps {
stats: any;
users: any[];
incidents: any[];
isAdmin: boolean;
isSuperAdmin: boolean;
refreshData: () => void;
}
const OverviewTab: React.FC<OverviewTabProps> = ({ stats, users, incidents, refreshData }) => {
return (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="w-6 h-6 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Usuarios</p>
<p className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg">
<DollarSign className="w-6 h-6 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
<p className="text-2xl font-semibold text-gray-900">${stats?.totalRevenue || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-purple-100 rounded-lg">
<Calendar className="w-6 h-6 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Reservas Totales</p>
<p className="text-2xl font-semibold text-gray-900">{stats?.totalBookings || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-orange-100 rounded-lg">
<Activity className="w-6 h-6 text-orange-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Servicios Activos</p>
<p className="text-2xl font-semibold text-gray-900">{stats?.activeServices || 0}</p>
</div>
</div>
</div>
</div>
{/* Alert Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Verificaciones Pendientes</h3>
<span className="bg-yellow-100 text-yellow-800 text-xs font-medium px-2.5 py-0.5 rounded">
{stats?.pendingVerifications || 0} pendientes
</span>
</div>
<p className="text-gray-600">Proveedores de servicios esperando aprobación</p>
<button className="mt-4 bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600">
Revisar Verificaciones
</button>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Alertas de Emergencia</h3>
<span className="bg-red-100 text-red-800 text-xs font-medium px-2.5 py-0.5 rounded">
{stats?.emergencyAlerts || 0} activas
</span>
</div>
<p className="text-gray-600">Situaciones que requieren atención inmediata</p>
<button className="mt-4 bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600">
Ver Emergencias
</button>
</div>
</div>
{/* Recent Activity */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b">
<h3 className="text-lg font-semibold text-gray-900">Actividad Reciente</h3>
</div>
<div className="p-6">
<div className="space-y-4">
{users.slice(0, 5).map((user, index) => (
<div key={user.id || index} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
<Users className="w-4 h-4 text-gray-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500">Nuevo usuario {(typeof user.role === 'string' ? user.role : user.role?.name) || user.type}</p>
</div>
</div>
<span className="text-xs text-gray-500">Hace 2 horas</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default OverviewTab;

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import {
Building,
Hotel,
UtensilsCrossed,
Car,
Store,
Users,
MapPin,
Star,
Phone,
Mail,
Plus,
Search,
Edit,
Trash2,
Eye,
CheckCircle,
XCircle,
Clock,
Map,
Shield
} from 'lucide-react';
import { useAdminData } from '@/hooks/useAdminData';
interface ServicesTabProps {
establishments: any[];
isAdmin: boolean;
isSuperAdmin: boolean;
loadEstablishments: (type?: string) => void;
}
const ServicesTab: React.FC<ServicesTabProps> = ({
establishments,
isAdmin,
loadEstablishments
}) => {
const { updateEstablishment, deleteEstablishment } = useAdminData();
const [activeFilter, setActiveFilter] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
}, [activeFilter]);
const serviceTypes = [
{ id: 'all', label: 'Todos', icon: Building, count: establishments.length },
{ id: 'hotel', label: 'Hoteles', icon: Hotel, count: establishments.filter(e => e.type === 'hotel').length },
{ id: 'restaurant', label: 'Restaurantes', icon: UtensilsCrossed, count: establishments.filter(e => e.type === 'restaurant').length },
{ id: 'taxi', label: 'Taxis', icon: Car, count: establishments.filter(e => e.type === 'taxi').length },
{ id: 'shop', label: 'Tiendas', icon: Store, count: establishments.filter(e => e.type === 'shop').length },
{ id: 'guide', label: 'Guías Turísticos', icon: Map, count: establishments.filter(e => e.type === 'guide').length },
{ id: 'security', label: 'Politur', icon: Shield, count: establishments.filter(e => e.type === 'security').length },
];
const filteredEstablishments = establishments.filter(establishment =>
establishment.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
establishment.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleUpdateStatus = async (id: string, status: 'active' | 'suspended' | 'pending') => {
const result = await updateEstablishment(id, { status });
if (result.success) {
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
}
};
const handleDelete = async (id: string) => {
if (window.confirm('¿Estás seguro de que quieres eliminar este establecimiento?')) {
const result = await deleteEstablishment(id);
if (result.success) {
loadEstablishments(activeFilter === 'all' ? undefined : activeFilter);
}
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-900">Proveedores de Servicios</h2>
<div className="flex space-x-2">
<button className="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 flex items-center space-x-2">
<Plus className="w-4 h-4" />
<span>Aprobar Pendientes</span>
</button>
</div>
</div>
{/* Service Type Filters */}
<div className="grid services-grid-mobile md:grid-cols-4 lg:grid-cols-7 gap-4">
{serviceTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.id}
onClick={() => setActiveFilter(type.id)}
className={`p-4 rounded-lg border-2 transition-colors ${
activeFilter === type.id
? 'border-orange-500 bg-orange-50 text-orange-700'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-center mb-2">
<Icon className="w-6 h-6" />
</div>
<div className="text-sm font-medium">{type.label}</div>
<div className="text-xs text-gray-500">{type.count} activos</div>
</button>
);
})}
</div>
{/* Search */}
<div className="bg-white rounded-lg shadow p-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Buscar establecimientos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
</div>
</div>
{/* Establishments Grid */}
<div className="grid mobile-grid md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{filteredEstablishments.map((establishment) => (
<div key={establishment.id} className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Image */}
<div className="h-48 bg-gray-200 relative">
<img
src={establishment.imageUrl || '/api/placeholder/400/300'}
alt={establishment.name}
className="w-full h-full object-cover"
/>
<div className="absolute top-2 right-2">
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
establishment.status === 'active' ? 'bg-green-100 text-green-800' :
establishment.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{establishment.status}
</span>
</div>
</div>
{/* Content */}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-gray-900 truncate">
{establishment.name}
</h3>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-400 fill-current" />
<span className="text-sm text-gray-600">{establishment.rating || 0}</span>
</div>
</div>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{establishment.description}
</p>
<div className="space-y-2 mb-4">
<div className="flex items-center space-x-2 text-sm text-gray-500">
<MapPin className="w-4 h-4" />
<span className="truncate">{establishment.location?.address}</span>
</div>
{establishment.owner && (
<div className="flex items-center space-x-2 text-sm text-gray-500">
<Users className="w-4 h-4" />
<span>{establishment.owner.name}</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex space-x-2">
<button
className="text-blue-600 hover:text-blue-900"
title="Ver detalles"
>
<Eye className="w-4 h-4" />
</button>
<button
className="text-green-600 hover:text-green-900"
title="Editar"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(establishment.id)}
className="text-red-600 hover:text-red-900"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{establishment.status === 'pending' && (
<div className="flex space-x-1">
<button
onClick={() => handleUpdateStatus(establishment.id, 'active')}
className="p-1 text-green-600 hover:text-green-900"
title="Aprobar"
>
<CheckCircle className="w-4 h-4" />
</button>
<button
onClick={() => handleUpdateStatus(establishment.id, 'suspended')}
className="p-1 text-red-600 hover:text-red-900"
title="Rechazar"
>
<XCircle className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
))}
</div>
{filteredEstablishments.length === 0 && (
<div className="text-center py-12">
<Building className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay establecimientos</h3>
<p className="text-gray-500">No se encontraron establecimientos que coincidan con los filtros aplicados.</p>
</div>
)}
</div>
);
};
export default ServicesTab;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Phone, MessageSquare, HeadphonesIcon } from 'lucide-react';
interface SupportTabProps {
isAdmin: boolean;
isSuperAdmin: boolean;
}
const SupportTab: React.FC<SupportTabProps> = ({ }) => {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">Centro de Soporte y Tickets</h2>
<div className="bg-white rounded-lg shadow p-8 text-center">
<HeadphonesIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Centro de Soporte y Tickets
</h3>
<p className="text-gray-600 mb-4">
Esta sección está en desarrollo y se implementará según las especificaciones del informe.
</p>
<div className="text-sm text-gray-500">
Funcionalidades pendientes:
<ul className="mt-2 space-y-1">
<li> Sistema de tickets de soporte</li>
<li> Chat en vivo</li>
<li> Base de conocimientos</li>
<li> Métricas de soporte</li>
<li> Escalación automática</li>
</ul>
</div>
</div>
</div>
);
};
export default SupportTab;

View File

@@ -0,0 +1,408 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Search,
Plus,
Edit,
Trash2,
Eye,
UserPlus,
Shield,
Users,
Car,
UtensilsCrossed,
Hotel,
MapPin,
CheckCircle,
XCircle,
AlertTriangle
} from 'lucide-react';
import { toast } from 'sonner';
interface UsersTabProps {
users: any[];
isAdmin: boolean;
isSuperAdmin: boolean;
loadUsers: (page?: number, limit?: number, role?: string) => void;
createUser?: (userData: any) => Promise<{ success: boolean; error?: string }>;
updateUser?: (id: string, userData: any) => Promise<{ success: boolean; error?: string }>;
deleteUser?: (id: string) => Promise<{ success: boolean; error?: string }>;
}
const UsersTab: React.FC<UsersTabProps> = ({
users,
isAdmin,
isSuperAdmin,
loadUsers
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [showCreateModal, setShowCreateModal] = useState(false);
const [mockUsers, setMockUsers] = useState([
{
id: '1',
name: 'Super Admin',
email: 'superadmin@karibeo.com',
role: 'super_admin',
status: 'active',
verified: true,
lastLogin: '2024-01-15 10:30:00',
avatar: '/api/placeholder/40/40',
createdAt: '2023-01-01'
},
{
id: '2',
name: 'Admin User',
email: 'admin@karibeo.com',
role: 'admin',
status: 'active',
verified: true,
lastLogin: '2024-01-14 15:45:00',
avatar: '/api/placeholder/40/40',
createdAt: '2023-02-01'
},
{
id: '3',
name: 'Juan Pérez',
email: 'juan.perez@karibeo.com',
role: 'tourist',
status: 'active',
verified: true,
lastLogin: '2024-01-13 09:15:00',
avatar: '/api/placeholder/40/40',
createdAt: '2023-03-15'
}
]);
useEffect(() => {
loadUsers(1, 50, roleFilter === 'all' ? undefined : roleFilter);
}, [roleFilter]);
const handleCreateUser = async (formData: any) => {
const newUser = {
id: (mockUsers.length + 1).toString(),
name: formData.name,
email: formData.email,
role: formData.role,
status: 'active',
verified: false,
lastLogin: 'Nunca',
avatar: '/api/placeholder/40/40',
createdAt: new Date().toISOString().split('T')[0]
};
setMockUsers([...mockUsers, newUser]);
setShowCreateModal(false);
toast.success('Usuario creado exitosamente');
};
const handleDeleteUser = async (userId: string) => {
setMockUsers(mockUsers.filter(user => user.id !== userId));
toast.success('Usuario eliminado exitosamente');
};
const getRoleName = (role: any): string => (typeof role === 'string' ? role : role?.name || '');
const filteredUsers = (users.length > 0 ? users : mockUsers).filter(user => {
const matchesSearch = user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
const normalizedRole = getRoleName(user.role);
const matchesRole = roleFilter === 'all' || normalizedRole === roleFilter;
const matchesStatus = statusFilter === 'all' || user.status === statusFilter;
return matchesSearch && matchesRole && matchesStatus;
});
const getRoleIcon = (role: string) => {
const iconMap = {
'super_admin': Shield,
'admin': Shield,
'tourist': Users,
'guide': MapPin,
'hotel': Hotel,
'taxi': Car,
'restaurant': UtensilsCrossed
};
const Icon = iconMap[role as keyof typeof iconMap] || Users;
return <Icon className="w-4 h-4" />;
};
const getRoleBadgeColor = (role: string) => {
const colorMap = {
'super_admin': 'bg-purple-100 text-purple-800',
'admin': 'bg-blue-100 text-blue-800',
'tourist': 'bg-green-100 text-green-800',
'guide': 'bg-yellow-100 text-yellow-800',
'hotel': 'bg-indigo-100 text-indigo-800',
'taxi': 'bg-red-100 text-red-800',
'restaurant': 'bg-orange-100 text-orange-800'
};
return colorMap[role as keyof typeof colorMap] || 'bg-gray-100 text-gray-800';
};
const getStatusIcon = (status: string, verified: boolean) => {
if (status === 'active' && verified) return <CheckCircle className="w-4 h-4 text-green-500" />;
if (status === 'active' && !verified) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <XCircle className="w-4 h-4 text-red-500" />;
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h2>
<p className="text-gray-600">Administra usuarios, roles y permisos del sistema</p>
</div>
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogTrigger asChild>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
Nuevo Usuario
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Crear Nuevo Usuario</DialogTitle>
</DialogHeader>
<UserForm
onSubmit={handleCreateUser}
onCancel={() => setShowCreateModal(false)}
isSuperAdmin={isSuperAdmin}
/>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-64">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Buscar por nombre o email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filtrar por rol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los roles</SelectItem>
<SelectItem value="super_admin">Super Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="tourist">Turista</SelectItem>
<SelectItem value="guide">Guía</SelectItem>
<SelectItem value="hotel">Hotel</SelectItem>
<SelectItem value="taxi">Taxi</SelectItem>
<SelectItem value="restaurant">Restaurante</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
<SelectItem value="active">Activo</SelectItem>
<SelectItem value="pending">Pendiente</SelectItem>
<SelectItem value="suspended">Suspendido</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Lista de Usuarios ({filteredUsers.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3 px-4 font-medium text-gray-700">Usuario</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Rol</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Estado</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Última Conexión</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Registrado</th>
<th className="text-right py-3 px-4 font-medium text-gray-700">Acciones</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="border-b hover:bg-gray-50">
<td className="py-3 px-4">
<div className="flex items-center space-x-3">
<Avatar>
<AvatarImage src={user.avatar} />
<AvatarFallback>
{user.name?.split(' ').map((n: string) => n[0]).join('').toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-600">{user.email}</div>
</div>
</div>
</td>
<td className="py-3 px-4">
<Badge className={getRoleBadgeColor(getRoleName(user.role))}>
{getRoleIcon(getRoleName(user.role))}
<span className="ml-1 capitalize">{getRoleName(user.role).replace('_', ' ')}</span>
</Badge>
</td>
<td className="py-3 px-4">
<div className="flex items-center space-x-2">
{getStatusIcon(user.status, user.verified)}
<span className="capitalize">{user.status}</span>
{!user.verified && <span className="text-xs text-yellow-600">(No verificado)</span>}
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{user.lastLogin === 'Nunca' ? 'Nunca' : new Date(user.lastLogin).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-sm text-gray-600">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end space-x-2">
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm">
<Edit className="w-4 h-4" />
</Button>
{isSuperAdmin && getRoleName(user.role) !== 'super_admin' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.id)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
};
const UserForm = ({
onSubmit,
onCancel,
user = null,
isSuperAdmin = false
}: {
onSubmit: (data: any) => void;
onCancel: () => void;
user?: any;
isSuperAdmin: boolean;
}) => {
const [formData, setFormData] = useState({
name: user?.name || '',
email: user?.email || '',
role: (typeof user?.role === 'string' ? user?.role : user?.role?.name) || 'tourist',
phone: user?.profile?.phone || '',
address: user?.profile?.address || '',
status: user?.status || 'active'
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Nombre Completo</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="role">Rol</Label>
<Select value={formData.role} onValueChange={(value) => setFormData({ ...formData, role: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tourist">Turista</SelectItem>
<SelectItem value="guide">Guía Turístico</SelectItem>
<SelectItem value="hotel">Hotel</SelectItem>
<SelectItem value="taxi">Taxista</SelectItem>
<SelectItem value="restaurant">Restaurante</SelectItem>
{isSuperAdmin && (
<>
<SelectItem value="admin">Administrador</SelectItem>
<SelectItem value="super_admin">Super Administrador</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="phone">Teléfono</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
<div className="md:col-span-2">
<Label htmlFor="address">Dirección</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
/>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">
{user ? 'Actualizar' : 'Crear'} Usuario
</Button>
</div>
</form>
);
};
export default UsersTab;

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react';
import type { ApexOptions } from 'apexcharts';
// Chart module is imported dynamically inside the component to avoid SSR/hot-reload issues.
interface ApexChartProps {
type?: 'area' | 'line' | 'bar' | 'pie' | 'donut';
data?: number[];
labels?: string[];
title?: string;
height?: number;
color?: string;
}
const ApexChart: React.FC<ApexChartProps> = ({
type = 'area',
data = [],
labels = [],
title = 'Chart',
height = 350,
color = '#F84525',
}) => {
const isClient = typeof window !== 'undefined';
const [Chart, setChart] = useState<any>(null);
useEffect(() => {
if (isClient) {
import('react-apexcharts')
.then((m) => setChart(m.default))
.catch((e) => console.error('Failed to load react-apexcharts', e));
}
}, [isClient]);
const safeData = Array.isArray(data) ? data.map((n) => (Number.isFinite(Number(n)) ? Number(n) : 0)) : [];
const isDarkMode = typeof document !== 'undefined' && document.documentElement.classList.contains('dark');
// Show loading/no data state if chart not ready or no data
if (!isClient || !Chart || safeData.length === 0) {
return (
<div className="card-enhanced">
<div className="card-body p-4">
<h5 className="card-title mb-3">{title || 'Chart'}</h5>
<div className="text-sm text-muted">
{!Chart ? 'Cargando gráfico...' : 'No hay datos disponibles.'}
</div>
</div>
</div>
);
}
// Ensure options are valid and chart configuration is safe
const options: ApexOptions = {
chart: {
type: type || 'area',
height: height || 350,
zoom: { enabled: false },
toolbar: { show: false },
foreColor: isDarkMode ? '#91989e' : '#433c3a',
animations: { enabled: false } // Disable animations for stability
},
colors: [color || '#F84525'],
dataLabels: { enabled: false },
stroke: {
curve: 'smooth',
width: 3,
},
fill: type === 'area'
? {
type: 'gradient',
gradient: {
shadeIntensity: 1,
type: 'vertical',
opacityFrom: 0.4,
opacityTo: 0,
stops: [0, 70, 97],
gradientToColors: ['#f7b733'],
},
}
: undefined,
markers: {
size: type === 'area' ? 3 : 0,
strokeWidth: 0,
hover: { sizeOffset: 2 },
colors: type === 'area' ? ['#FFFFFF'] : [color || '#F84525'],
},
xaxis: {
categories: labels && labels.length > 0 ? labels : ['Sin datos'],
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: isDarkMode ? '#91989e' : '#aaa' } },
},
yaxis: {
labels: { style: { colors: isDarkMode ? '#91989e' : '#aaa' } },
min: 0
},
grid: { borderColor: isDarkMode ? '#26292d' : '#eff2f7' },
legend: {
horizontalAlign: 'left',
labels: { colors: isDarkMode ? '#ffffff' : '#433c3a' },
},
title: {
text: title || 'Chart',
style: { color: isDarkMode ? '#ffffff' : '#433c3a' }
},
theme: { mode: isDarkMode ? 'dark' : 'light' },
noData: {
text: 'No hay datos disponibles',
align: 'center',
verticalAlign: 'middle',
style: {
color: isDarkMode ? '#ffffff' : '#433c3a',
fontSize: '14px'
}
}
};
// Ensure series data is valid
const series = [{
name: title || 'Data',
data: safeData && safeData.length > 0 ? safeData : [0]
}];
return (
<div className="card-enhanced">
<div className="card-body p-4">
<h5 className="card-title mb-3">{title || 'Chart'}</h5>
{Chart && (
<Chart
options={options}
series={series}
type={type || 'area'}
height={height || 350}
key={`chart-${title}-${safeData.length}`}
/>
)}
</div>
</div>
);
};
export default ApexChart;

View File

@@ -0,0 +1,318 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
// CreateReservationDto validation schema
const bookingSchema = z.object({
establishmentId: z.string().min(1, 'Establishment ID is required'),
userId: z.string().min(1, 'User ID is required'),
type: z.enum(['hotel', 'restaurant', 'tour', 'activity'], {
required_error: 'Type is required',
}),
referenceId: z.string().optional(),
checkInDate: z.string().min(1, 'Check-in date is required'),
checkOutDate: z.string().optional(),
checkInTime: z.string().optional(),
guestsCount: z.number().min(1, 'At least 1 guest is required'),
specialRequests: z.string().optional(),
totalAmount: z.number().min(0, 'Total amount must be positive'),
});
type BookingFormData = z.infer<typeof bookingSchema>;
interface Booking {
id: string;
establishmentId: string;
userId: string;
type: 'hotel' | 'restaurant' | 'tour' | 'activity';
referenceId?: string;
checkInDate: string;
checkOutDate?: string;
checkInTime?: string;
guestsCount: number;
specialRequests?: string;
totalAmount: number;
}
interface BookingFormProps {
booking?: Booking | null;
onSubmit: (data: BookingFormData) => void;
onCancel: () => void;
}
export function BookingForm({ booking, onSubmit, onCancel }: BookingFormProps) {
const form = useForm<BookingFormData>({
resolver: zodResolver(bookingSchema),
defaultValues: {
establishmentId: booking?.establishmentId || '',
userId: booking?.userId || '',
type: booking?.type || 'hotel',
referenceId: booking?.referenceId || '',
checkInDate: booking?.checkInDate || '',
checkOutDate: booking?.checkOutDate || '',
checkInTime: booking?.checkInTime || '',
guestsCount: booking?.guestsCount || 1,
specialRequests: booking?.specialRequests || '',
totalAmount: booking?.totalAmount || 0,
},
});
const handleSubmit = (data: BookingFormData) => {
onSubmit(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="establishmentId"
render={({ field }) => (
<FormItem>
<FormLabel>Establishment ID *</FormLabel>
<FormControl>
<Input placeholder="Enter establishment ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="userId"
render={({ field }) => (
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input placeholder="Enter user ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type *</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select booking type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="hotel">Hotel</SelectItem>
<SelectItem value="restaurant">Restaurant</SelectItem>
<SelectItem value="tour">Tour</SelectItem>
<SelectItem value="activity">Activity</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceId"
render={({ field }) => (
<FormItem>
<FormLabel>Reference ID</FormLabel>
<FormControl>
<Input placeholder="Enter reference ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="checkInDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Check-in Date *</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(new Date(field.value), "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
disabled={(date) => date < new Date()}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="checkOutDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Check-out Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(new Date(field.value), "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
disabled={(date) => date < new Date()}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="checkInTime"
render={({ field }) => (
<FormItem>
<FormLabel>Check-in Time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="guestsCount"
render={({ field }) => (
<FormItem>
<FormLabel>Number of Guests *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Enter number of guests"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="totalAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Total Amount *</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="Enter total amount"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="specialRequests"
render={({ field }) => (
<FormItem>
<FormLabel>Special Requests</FormLabel>
<FormControl>
<Textarea
placeholder="Enter any special requests or notes"
className="resize-none"
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" className="bg-primary hover:bg-primary/90">
{booking ? 'Update Booking' : 'Create Booking'}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useRef } from 'react';
import CountUp from 'react-countup';
interface CounterCardProps {
icon: React.ReactNode;
title: string;
value: number;
suffix?: string;
trend?: number;
color?: string;
}
const CounterCard: React.FC<CounterCardProps> = ({
icon,
title,
value,
suffix = '',
trend,
color = '#F84525'
}) => {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!('IntersectionObserver' in window)) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
(entry.target as Element).classList.add('animate-fade-in-up');
}
});
},
{ threshold: 0.1 }
);
try {
if (cardRef.current && cardRef.current instanceof Element) {
observer.observe(cardRef.current);
}
} catch (e) {
console.warn('IntersectionObserver.observe failed:', e);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={cardRef}
className="card-enhanced widget-card position-relative overflow-hidden"
style={{ minHeight: '140px' }}
>
<div className="card-body p-4">
<div className="d-flex align-items-center justify-content-between">
<div className="flex-grow-1">
<div className="d-flex align-items-center mb-2">
<div
className="rounded-circle p-2 me-3"
style={{ backgroundColor: `${color}20`, color: color }}
>
{icon}
</div>
<h6 className="text-muted mb-0 small">{title}</h6>
</div>
<div className="d-flex align-items-center">
<h3 className="mb-0 fw-bold me-2" style={{ color: color }}>
<CountUp
start={0}
end={value}
duration={2.5}
separator=","
suffix={suffix}
/>
</h3>
{trend && (
<span
className={`badge ${trend > 0 ? 'bg-success' : 'bg-danger'} d-flex align-items-center`}
style={{ fontSize: '10px' }}
>
<i className={`bi bi-arrow-${trend > 0 ? 'up' : 'down'} me-1`}></i>
{Math.abs(trend)}%
</span>
)}
</div>
</div>
<div
className="rounded-circle d-flex align-items-center justify-content-center"
style={{
width: '60px',
height: '60px',
backgroundColor: `${color}10`,
color: color
}}
>
{icon}
</div>
</div>
</div>
{/* Animated background element */}
<div
className="position-absolute"
style={{
bottom: '-20px',
right: '-20px',
width: '80px',
height: '80px',
borderRadius: '50%',
backgroundColor: `${color}08`,
opacity: 0.3
}}
></div>
</div>
);
};
export default CounterCard;

View File

@@ -0,0 +1,273 @@
import { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight, Search, Filter, Download } from 'lucide-react';
interface Column {
key: string;
label: string;
sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode;
}
interface EnhancedDataTableProps {
data: any[];
columns: Column[];
itemsPerPage?: number;
searchable?: boolean;
exportable?: boolean;
className?: string;
}
const EnhancedDataTable: React.FC<EnhancedDataTableProps> = ({
data,
columns,
itemsPerPage = 10,
searchable = true,
exportable = true,
className = ''
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: 'asc' | 'desc';
} | null>(null);
// Filter and search data
const filteredData = useMemo(() => {
return data.filter(item =>
columns.some(column =>
String(item[column.key] || '')
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
);
}, [data, searchTerm, columns]);
// Sort data
const sortedData = useMemo(() => {
if (!sortConfig) return filteredData;
return [...filteredData].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue < bValue) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
}, [filteredData, sortConfig]);
// Paginate data
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return sortedData.slice(startIndex, startIndex + itemsPerPage);
}, [sortedData, currentPage, itemsPerPage]);
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
const handleSort = (columnKey: string) => {
const column = columns.find(col => col.key === columnKey);
if (!column?.sortable) return;
setSortConfig(current => ({
key: columnKey,
direction:
current?.key === columnKey && current.direction === 'asc'
? 'desc'
: 'asc'
}));
};
const exportToCSV = () => {
const headers = columns.map(col => col.label).join(',');
const rows = sortedData.map(row =>
columns.map(col => `"${row[col.key] || ''}"`).join(',')
).join('\n');
const csvContent = `${headers}\n${rows}`;
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'table-data.csv';
a.click();
window.URL.revokeObjectURL(url);
};
const goToPage = (page: number) => {
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
};
const renderPaginationButtons = () => {
const buttons = [];
const maxVisible = 5;
const start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
const end = Math.min(totalPages, start + maxVisible - 1);
for (let i = start; i <= end; i++) {
buttons.push(
<button
key={i}
onClick={() => goToPage(i)}
className={`px-3 py-2 text-sm rounded-lg ${
i === currentPage
? 'bg-primary text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
{i}
</button>
);
}
return buttons;
};
return (
<div className={`card-enhanced ${className}`}>
{/* Table Header */}
<div className="card-header border-bottom-0 pb-0" style={{ position: 'relative' }}>
<div className="d-flex justify-content-between align-items-center" style={{ paddingLeft: '16px' }}>
<h5 className="card-title mb-0">Data Table</h5>
<div className="d-flex gap-2">
{searchable && (
<div className="position-relative">
<Search className="position-absolute start-0 top-50 translate-middle-y ms-3" size={16} />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="form-control-enhanced ps-5"
style={{ width: '200px' }}
/>
</div>
)}
{exportable && (
<button
onClick={exportToCSV}
className="btn btn-outline-primary btn-sm d-flex align-items-center gap-2"
data-tooltip="Export to CSV"
>
<Download size={16} />
Export
</button>
)}
</div>
</div>
</div>
{/* Table */}
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-hover mb-0">
<thead>
<tr style={{ backgroundColor: '#f8e8e5' }}>
{columns.map((column) => (
<th
key={column.key}
className={`text-uppercase ${column.sortable ? 'cursor-pointer user-select-none' : ''}`}
style={{
fontSize: '11px',
fontWeight: 600,
letterSpacing: '1px',
padding: '0.8rem 1rem'
}}
onClick={() => handleSort(column.key)}
>
<div className="d-flex align-items-center gap-2">
{column.label}
{column.sortable && (
<div className="d-flex flex-column">
<i
className={`bi bi-caret-up-fill ${
sortConfig?.key === column.key && sortConfig.direction === 'asc'
? 'text-primary'
: 'text-muted'
}`}
style={{ fontSize: '8px', lineHeight: '1' }}
></i>
<i
className={`bi bi-caret-down-fill ${
sortConfig?.key === column.key && sortConfig.direction === 'desc'
? 'text-primary'
: 'text-muted'
}`}
style={{ fontSize: '8px', lineHeight: '1' }}
></i>
</div>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{paginatedData.map((row, index) => (
<tr key={index} className="border-bottom">
{columns.map((column) => (
<td key={column.key} style={{ padding: '0.8rem 1rem' }}>
{column.render
? column.render(row[column.key], row)
: row[column.key]
}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{paginatedData.length === 0 && (
<div className="text-center py-5 text-muted">
<p>No data found</p>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="card-footer bg-transparent border-top">
<div className="d-flex justify-content-between align-items-center">
<div className="text-muted small">
Showing {((currentPage - 1) * itemsPerPage) + 1} to{' '}
{Math.min(currentPage * itemsPerPage, sortedData.length)} of{' '}
{sortedData.length} entries
</div>
<div className="d-flex gap-1">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
>
<ChevronLeft size={16} />
Previous
</button>
{renderPaginationButtons()}
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1"
>
Next
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default EnhancedDataTable;

View File

@@ -0,0 +1,163 @@
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, Image, File } from 'lucide-react';
interface EnhancedFileUploadProps {
onFilesChange: (files: File[]) => void;
maxFiles?: number;
maxSize?: number; // in bytes
accept?: { [key: string]: string[] };
className?: string;
}
const EnhancedFileUpload: React.FC<EnhancedFileUploadProps> = ({
onFilesChange,
maxFiles = 5,
maxSize = 10 * 1024 * 1024, // 10MB
accept = {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/*': ['.txt', '.doc', '.docx']
},
className = ''
}) => {
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setUploading(true);
const newFiles = [...uploadedFiles, ...acceptedFiles].slice(0, maxFiles);
setUploadedFiles(newFiles);
onFilesChange(newFiles);
// Simulate upload progress
for (const file of acceptedFiles) {
for (let progress = 0; progress <= 100; progress += 10) {
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
await new Promise(resolve => setTimeout(resolve, 100));
}
}
setUploading(false);
setUploadProgress({});
}, [uploadedFiles, maxFiles, onFilesChange]);
const removeFile = (fileToRemove: File) => {
const newFiles = uploadedFiles.filter(file => file !== fileToRemove);
setUploadedFiles(newFiles);
onFilesChange(newFiles);
};
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept,
maxSize,
maxFiles: maxFiles - uploadedFiles.length,
disabled: uploading
});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (file: File) => {
if (file.type.startsWith('image/')) {
return <Image className="w-5 h-5" />;
}
return <File className="w-5 h-5" />;
};
return (
<div className={`file-upload-enhanced ${className}`}>
{/* Dropzone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center transition-all cursor-pointer ${
isDragActive
? 'border-primary bg-primary/5'
: 'border-gray-300 hover:border-primary hover:bg-primary/5'
} ${uploading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<input {...getInputProps()} />
<div className="space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Upload className="w-8 h-8 text-primary" />
</div>
<div>
<h6 className="font-semibold text-gray-800 mb-2">
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
</h6>
<p className="text-gray-600 text-sm mb-3">
or <span className="text-primary font-medium">browse files</span>
</p>
<p className="text-xs text-gray-500">
Max {maxFiles} files, up to {formatFileSize(maxSize)} each
</p>
</div>
</div>
</div>
{/* File Rejections */}
{fileRejections.length > 0 && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<h6 className="text-red-800 font-medium mb-1">Upload Errors:</h6>
{fileRejections.map(({ file, errors }) => (
<div key={file.name} className="text-sm text-red-600">
{file.name}: {errors.map(e => e.message).join(', ')}
</div>
))}
</div>
)}
{/* Uploaded Files List */}
{uploadedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<h6 className="font-medium text-gray-800">Uploaded Files ({uploadedFiles.length})</h6>
{uploadedFiles.map((file, index) => (
<div key={`${file.name}-${index}`} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<div className="text-primary">
{getFileIcon(file)}
</div>
<div>
<p className="font-medium text-gray-800 text-sm">{file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<div className="flex items-center space-x-2">
{uploadProgress[file.name] !== undefined && (
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[file.name]}%` }}
></div>
</div>
)}
<button
onClick={() => removeFile(file)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 rounded"
disabled={uploading}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default EnhancedFileUpload;

View File

@@ -0,0 +1,125 @@
import { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useDropzone } from 'react-dropzone';
import { Upload, X, Image as ImageIcon } from 'lucide-react';
interface ReviewPhotoUploadProps {
onUpload: (images: string[]) => void;
onClose: () => void;
}
export function ReviewPhotoUpload({ onUpload, onClose }: ReviewPhotoUploadProps) {
const [selectedImages, setSelectedImages] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
const imagePromises = acceptedFiles.map(file => {
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
});
Promise.all(imagePromises).then(images => {
setSelectedImages(prev => [...prev, ...images]);
});
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp']
},
multiple: true,
maxFiles: 5,
onDragEnter: () => setIsDragging(true),
onDragLeave: () => setIsDragging(false),
});
const removeImage = (index: number) => {
setSelectedImages(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = () => {
onUpload(selectedImages);
setSelectedImages([]);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Photos to Reply</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Upload Area */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive || isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary hover:bg-primary/5'
}`}
>
<input {...getInputProps()} />
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{isDragActive ? 'Drop photos here' : 'Click to upload photos'}
</p>
<p className="text-xs text-muted-foreground">
Drag & drop or click to select (Max 5 photos)
</p>
</div>
</div>
</div>
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-medium">Selected Photos ({selectedImages.length})</h4>
<div className="grid grid-cols-3 gap-2">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
<img
src={image}
alt={`Preview ${index + 1}`}
className="w-full h-20 rounded-lg object-cover"
/>
<Button
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-4">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleUpload}
disabled={selectedImages.length === 0}
className="bg-primary hover:bg-primary/90"
>
<ImageIcon className="h-4 w-4 mr-1" />
Add {selectedImages.length} Photo{selectedImages.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Camera, Send, X, Star } from 'lucide-react';
import { ReviewPhotoUpload } from './ReviewPhotoUpload';
interface Review {
id: string;
userName: string;
userAvatar: string;
rating: number;
comment: string;
createdAt: string;
}
interface ReviewReplyDialogProps {
review: Review;
onReply: (reviewId: string, content: string, images?: string[]) => void;
onClose: () => void;
}
export function ReviewReplyDialog({ review, onReply, onClose }: ReviewReplyDialogProps) {
const [replyContent, setReplyContent] = useState('');
const [images, setImages] = useState<string[]>([]);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const handleSubmit = () => {
if (replyContent.trim()) {
onReply(review.id, replyContent, images);
setReplyContent('');
setImages([]);
onClose();
}
};
const handleImageUpload = (newImages: string[]) => {
setImages([...images, ...newImages]);
setShowPhotoUpload(false);
};
const removeImage = (index: number) => {
setImages(images.filter((_, i) => i !== index));
};
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={14}
className={i < rating ? "text-yellow-400 fill-yellow-400" : "text-muted-foreground"}
/>
));
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Reply to Review</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Original Review */}
<div className="bg-muted p-4 rounded-lg">
<div className="flex gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={review.userAvatar} alt={review.userName} />
<AvatarFallback>{review.userName.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div>
<h4 className="font-medium text-sm">{review.userName}</h4>
<p className="text-xs text-muted-foreground">{review.createdAt}</p>
</div>
<div className="flex items-center gap-1">
{renderStars(review.rating)}
<span className="text-xs ml-1">{review.rating}/5</span>
</div>
</div>
<p className="text-sm text-muted-foreground">{review.comment}</p>
</div>
</div>
</div>
{/* Reply Form */}
<div className="space-y-3">
<Textarea
placeholder="Write your reply..."
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
rows={4}
className="resize-none"
/>
{/* Image Previews */}
{images.length > 0 && (
<div className="flex gap-2 flex-wrap">
{images.map((image, index) => (
<div key={index} className="relative">
<img
src={image}
alt={`Upload ${index + 1}`}
className="w-20 h-20 rounded-lg object-cover"
/>
<Button
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0 rounded-full"
onClick={() => removeImage(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowPhotoUpload(true)}
className="h-8"
>
<Camera className="h-4 w-4 mr-1" />
Add Photos
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!replyContent.trim()}
className="bg-primary hover:bg-primary/90"
>
<Send className="h-4 w-4 mr-1" />
Send Reply
</Button>
</div>
</div>
</div>
</div>
{/* Photo Upload Dialog */}
{showPhotoUpload && (
<ReviewPhotoUpload
onUpload={handleImageUpload}
onClose={() => setShowPhotoUpload(false)}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,7 @@
import React from 'react';
// Safe DashboardStyles: avoid loading external scripts/styles that can cause cross-origin "Script error" crashes.
// Styling is handled via Tailwind and src/index.css design tokens.
const DashboardStyles: React.FC = () => null;
export default DashboardStyles;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import FrontendStyles from './FrontendStyles';
interface FrontendLayoutProps {
children: React.ReactNode;
}
const FrontendLayout: React.FC<FrontendLayoutProps> = ({ children }) => {
return (
<>
<FrontendStyles />
{children}
</>
);
};
export default FrontendLayout;

View File

@@ -0,0 +1,7 @@
import React from 'react';
// FrontendStyles (Safe): Remove all external script/style injections to avoid jQuery-related runtime errors and CORS issues.
// Tailwind + index.css handle styling. If you need a specific library, add it via npm and import it properly.
const FrontendStyles: React.FC = () => null;
export default FrontendStyles;

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary-dark shadow-soft hover:shadow-medium transition-all duration-300 rounded-xl",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-soft hover:shadow-medium transition-all duration-300 rounded-xl",
outline:
"border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-300 rounded-xl",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary-dark shadow-soft hover:shadow-medium transition-all duration-300 rounded-xl",
ghost: "hover:bg-accent hover:text-accent-foreground transition-all duration-300 rounded-xl",
link: "text-primary underline-offset-4 hover:underline transition-all duration-300",
hero: "bg-secondary text-secondary-foreground hover:bg-secondary-dark font-semibold shadow-soft hover:shadow-medium hover:-translate-y-1 transition-all duration-300 rounded-full",
glass: "bg-white/10 text-white border border-white/20 hover:bg-white/20 backdrop-blur-lg transition-all duration-300 rounded-full",
success: "bg-success text-success-foreground hover:bg-success/90 shadow-soft hover:shadow-medium transition-all duration-300 rounded-xl",
warning: "bg-warning text-warning-foreground hover:bg-warning/90 shadow-soft hover:shadow-medium transition-all duration-300 rounded-xl",
},
size: {
default: "h-11 px-6 py-3",
sm: "h-9 px-4 py-2",
lg: "h-14 px-8 py-4 text-base",
icon: "h-11 w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,153 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

176
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,69 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,234 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

131
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,131 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}

View File

@@ -0,0 +1,761 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster, toast }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

127
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast";
export { useToast, toast };