mas cambios

This commit is contained in:
2026-03-12 10:54:46 -04:00
parent 6b1e9a25af
commit 951a1f64ac
25 changed files with 5788 additions and 284 deletions

View File

@@ -29,6 +29,10 @@ import Messages from "./pages/dashboard/Messages";
import Reviews from "./pages/dashboard/Reviews"; import Reviews from "./pages/dashboard/Reviews";
import Bookings from "./pages/dashboard/Bookings"; import Bookings from "./pages/dashboard/Bookings";
import Bookmarks from "./pages/dashboard/Bookmarks"; import Bookmarks from "./pages/dashboard/Bookmarks";
import Favorites from "./pages/dashboard/Favorites";
import Collections from "./pages/dashboard/Collections";
import TripsPage from "./pages/dashboard/Trips";
import QuizPage from "./pages/dashboard/Quiz";
import Profile from "./pages/dashboard/Profile"; import Profile from "./pages/dashboard/Profile";
import Settings from "./pages/dashboard/Settings"; import Settings from "./pages/dashboard/Settings";
import Invoices from "./pages/dashboard/Invoices"; import Invoices from "./pages/dashboard/Invoices";
@@ -65,6 +69,8 @@ import CRMDashboard from "./pages/dashboard/crm/CRMDashboard";
import CRMContacts from "./pages/dashboard/crm/Contacts"; import CRMContacts from "./pages/dashboard/crm/Contacts";
import CRMCampaigns from "./pages/dashboard/crm/Campaigns"; import CRMCampaigns from "./pages/dashboard/crm/Campaigns";
import CRMAnalytics from "./pages/dashboard/crm/Analytics"; import CRMAnalytics from "./pages/dashboard/crm/Analytics";
// Influencer pages
import InfluencerDashboard from "./pages/dashboard/influencer/InfluencerDashboard";
// Roles & Permissions // Roles & Permissions
import RolesPermissions from "./pages/dashboard/RolesPermissions"; import RolesPermissions from "./pages/dashboard/RolesPermissions";
// Tourist App // Tourist App
@@ -270,6 +276,39 @@ const AppRouter = () => (
</ProtectedRoute> </ProtectedRoute>
} /> } />
{/* Phase 1 Routes - Favorites, Collections, Trips, Quiz */}
<Route path="/dashboard/favorites" element={
<ProtectedRoute>
<DashboardLayout>
<Favorites />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/collections" element={
<ProtectedRoute>
<DashboardLayout>
<Collections />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/trips" element={
<ProtectedRoute>
<DashboardLayout>
<TripsPage />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/quiz" element={
<ProtectedRoute>
<DashboardLayout>
<QuizPage />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/profile" element={ <Route path="/dashboard/profile" element={
<ProtectedRoute> <ProtectedRoute>
<DashboardLayout> <DashboardLayout>
@@ -701,6 +740,47 @@ const AppRouter = () => (
</ProtectedRoute> </ProtectedRoute>
} /> } />
{/* Influencer Dashboard Routes */}
<Route path="/dashboard/influencer" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/stats" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/campaigns" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/earnings" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
<Route path="/dashboard/influencer/profile" element={
<ProtectedRoute>
<DashboardLayout>
<InfluencerDashboard />
</DashboardLayout>
</ProtectedRoute>
} />
{/* Catch-all route */} {/* Catch-all route */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -60,7 +60,9 @@ import {
Server, Server,
ShieldAlert, ShieldAlert,
UserCircle, UserCircle,
Mail Mail,
TrendingUp,
Instagram
} from 'lucide-react'; } from 'lucide-react';
const DashboardLayout = ({ children }: { children: React.ReactNode }) => { const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
@@ -227,6 +229,18 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
{ icon: BookOpen, label: 'Biblioteca', path: '/dashboard/guides/library' } { icon: BookOpen, label: 'Biblioteca', path: '/dashboard/guides/library' }
] ]
}, },
{
icon: Instagram,
label: 'Influencer Dashboard',
path: '/dashboard/influencer',
subItems: [
{ icon: Home, label: 'Mi Dashboard', path: '/dashboard/influencer' },
{ icon: BarChart3, label: 'Mis Estadísticas', path: '/dashboard/influencer/stats' },
{ icon: Megaphone, label: 'Campañas', path: '/dashboard/influencer/campaigns' },
{ icon: TrendingUp, label: 'Earnings', path: '/dashboard/influencer/earnings' },
{ icon: Users, label: 'Mi Perfil Público', path: '/dashboard/influencer/profile' }
]
},
{ {
icon: Store, icon: Store,
label: t('commerce'), label: t('commerce'),

View File

@@ -0,0 +1,409 @@
import { useState, useEffect } from "react";
import {
Users,
Star,
Instagram,
Youtube,
Twitter,
MapPin,
TrendingUp,
Heart,
MessageCircle,
Filter,
Search,
Loader2,
BadgeCheck,
Globe,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
interface Influencer {
id: string;
userId: string;
username: string;
displayName: string;
avatar?: string;
bio?: string;
followers: number;
engagement: number;
categories: string[];
platforms: string[];
location?: string;
verified: boolean;
rating: number;
campaignsCompleted: number;
pricePerPost?: number;
}
const categoryFilters = [
{ id: "all", label: "Todos" },
{ id: "travel", label: "Viajes" },
{ id: "food", label: "Gastronomía" },
{ id: "lifestyle", label: "Lifestyle" },
{ id: "adventure", label: "Aventura" },
{ id: "luxury", label: "Lujo" },
];
const InfluencerMarketplace = () => {
const [influencers, setInfluencers] = useState<Influencer[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const [favorites, setFavorites] = useState<string[]>([]);
useEffect(() => {
fetchInfluencers();
}, [selectedCategory]);
const fetchInfluencers = async () => {
setLoading(true);
try {
// TODO: Conectar con API real
// const params: Record<string, unknown> = { page: 1, limit: 20 };
// if (selectedCategory !== "all") {
// params.category = selectedCategory;
// }
// const result = await api.getInfluencerMarketplace(params);
// setInfluencers(result.influencers || result || []);
// Mock data para demo
await new Promise(resolve => setTimeout(resolve, 500));
setInfluencers([
{
id: "1",
userId: "u1",
username: "maria_viajera",
displayName: "Maria Rodriguez",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150",
bio: "Exploradora del Caribe. Comparto los mejores destinos y experiencias de viaje.",
followers: 125000,
engagement: 4.8,
categories: ["travel", "lifestyle"],
platforms: ["instagram", "youtube"],
location: "Santo Domingo, RD",
verified: true,
rating: 4.9,
campaignsCompleted: 45,
pricePerPost: 500,
},
{
id: "2",
userId: "u2",
username: "chef_carlos",
displayName: "Carlos Mendez",
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150",
bio: "Chef y food blogger. Descubriendo la gastronomía dominicana.",
followers: 89000,
engagement: 5.2,
categories: ["food", "travel"],
platforms: ["instagram", "twitter"],
location: "Santiago, RD",
verified: true,
rating: 4.7,
campaignsCompleted: 32,
pricePerPost: 350,
},
{
id: "3",
userId: "u3",
username: "adventure_rd",
displayName: "Pedro Adventures",
avatar: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150",
bio: "Aventuras extremas en el Caribe. Rafting, parapente, buceo y más.",
followers: 67000,
engagement: 6.1,
categories: ["adventure", "travel"],
platforms: ["youtube", "instagram"],
location: "Jarabacoa, RD",
verified: false,
rating: 4.8,
campaignsCompleted: 18,
pricePerPost: 280,
},
{
id: "4",
userId: "u4",
username: "luxury_caribbean",
displayName: "Ana Luxury Travel",
avatar: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150",
bio: "Experiencias de lujo en el Caribe. Resorts, spas y gastronomía premium.",
followers: 210000,
engagement: 3.9,
categories: ["luxury", "lifestyle", "travel"],
platforms: ["instagram"],
location: "Punta Cana, RD",
verified: true,
rating: 4.6,
campaignsCompleted: 78,
pricePerPost: 1200,
},
{
id: "5",
userId: "u5",
username: "rd_foodie",
displayName: "Laura Foodie",
avatar: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150",
bio: "Descubriendo la mejor comida callejera y restaurantes de RD.",
followers: 45000,
engagement: 7.2,
categories: ["food", "lifestyle"],
platforms: ["instagram", "twitter"],
location: "Santo Domingo, RD",
verified: false,
rating: 4.5,
campaignsCompleted: 12,
pricePerPost: 200,
},
{
id: "6",
userId: "u6",
username: "caribbean_surfer",
displayName: "Diego Surf",
avatar: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=150",
bio: "Surf, kitesurf y deportes acuáticos en las mejores playas del Caribe.",
followers: 156000,
engagement: 5.5,
categories: ["adventure", "lifestyle"],
platforms: ["youtube", "instagram"],
location: "Cabarete, RD",
verified: true,
rating: 4.8,
campaignsCompleted: 56,
pricePerPost: 650,
},
]);
} catch (error) {
console.error("Error fetching influencers:", error);
} finally {
setLoading(false);
}
};
const toggleFavorite = (id: string) => {
setFavorites((prev) =>
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id]
);
};
const formatFollowers = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
return count.toString();
};
const getPlatformIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case "instagram":
return Instagram;
case "youtube":
return Youtube;
case "twitter":
return Twitter;
default:
return Globe;
}
};
const filteredInfluencers = influencers.filter((inf) => {
const matchesSearch =
inf.displayName.toLowerCase().includes(searchQuery.toLowerCase()) ||
inf.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
inf.bio?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory =
selectedCategory === "all" ||
inf.categories.includes(selectedCategory);
return matchesSearch && matchesCategory;
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h2 className="text-xl font-bold flex items-center gap-2 text-gray-900">
<Users className="w-5 h-5 text-orange-500" />
Marketplace de Influencers
</h2>
<p className="text-sm text-gray-600">
Conecta con creadores de contenido para promocionar tu destino
</p>
</div>
<Button className="bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600">
<TrendingUp className="w-4 h-4 mr-2" />
Crear Campaña
</Button>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Buscar influencers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* Category Filters */}
<div className="flex gap-2 overflow-x-auto pb-2">
{categoryFilters.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors ${
selectedCategory === cat.id
? "bg-orange-500 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{cat.label}
</button>
))}
</div>
{/* Loading */}
{loading && (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
)}
{/* Influencer Grid */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredInfluencers.map((influencer) => (
<div
key={influencer.id}
className="bg-white border rounded-xl p-4 hover:shadow-lg transition-shadow"
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div className="relative">
<img
src={influencer.avatar || "https://via.placeholder.com/60"}
alt={influencer.displayName}
className="w-14 h-14 rounded-full object-cover"
/>
{influencer.verified && (
<BadgeCheck className="absolute -bottom-1 -right-1 w-5 h-5 text-blue-500 bg-white rounded-full" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<h3 className="font-semibold truncate text-gray-900">{influencer.displayName}</h3>
</div>
<p className="text-sm text-gray-500">@{influencer.username}</p>
{influencer.location && (
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
<MapPin className="w-3 h-3" />
{influencer.location}
</div>
)}
</div>
<button
onClick={() => toggleFavorite(influencer.id)}
className="p-2 rounded-full hover:bg-gray-100 transition-colors"
>
<Heart
className={`w-5 h-5 ${
favorites.includes(influencer.id)
? "fill-red-500 text-red-500"
: "text-gray-400"
}`}
/>
</button>
</div>
{/* Bio */}
{influencer.bio && (
<p className="text-sm text-gray-600 line-clamp-2 mb-3">
{influencer.bio}
</p>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center p-2 bg-gray-50 rounded-lg">
<p className="text-lg font-bold text-gray-900">{formatFollowers(influencer.followers)}</p>
<p className="text-xs text-gray-500">Seguidores</p>
</div>
<div className="text-center p-2 bg-gray-50 rounded-lg">
<p className="text-lg font-bold text-gray-900">{influencer.engagement}%</p>
<p className="text-xs text-gray-500">Engagement</p>
</div>
<div className="text-center p-2 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center gap-1">
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
<span className="text-lg font-bold text-gray-900">{influencer.rating}</span>
</div>
<p className="text-xs text-gray-500">Rating</p>
</div>
</div>
{/* Platforms */}
<div className="flex items-center gap-2 mb-3">
{influencer.platforms.map((platform) => {
const Icon = getPlatformIcon(platform);
return (
<div
key={platform}
className="p-1.5 bg-gray-100 rounded-lg"
title={platform}
>
<Icon className="w-4 h-4 text-gray-600" />
</div>
);
})}
<div className="flex-1" />
{influencer.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="outline" className="text-xs capitalize">
{cat}
</Badge>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t">
<div>
{influencer.pricePerPost && (
<p className="text-lg font-bold text-orange-500">
${influencer.pricePerPost}
<span className="text-xs font-normal text-gray-500">/post</span>
</p>
)}
<p className="text-xs text-gray-500">
{influencer.campaignsCompleted} campañas
</p>
</div>
<Button size="sm" className="bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600">
<MessageCircle className="w-4 h-4 mr-1" />
Contactar
</Button>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{!loading && filteredInfluencers.length === 0 && (
<div className="text-center py-12">
<Users className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-gray-500">No se encontraron influencers</p>
</div>
)}
</div>
);
};
export default InfluencerMarketplace;

View File

@@ -1,6 +1,6 @@
// API Configuration and Constants // API Configuration and Constants
export const API_CONFIG = { export const API_CONFIG = {
BASE_URL: 'https://karibeo.lesoluciones.net:8443/api/v1', BASE_URL: 'https://api.karibeo.ai:8443/api/v1',
ENDPOINTS: { ENDPOINTS: {
// Authentication // Authentication
LOGIN: '/auth/login', LOGIN: '/auth/login',
@@ -43,6 +43,37 @@ export const API_CONFIG = {
HOTEL_ROOM_SERVICE: '/hotel/room-service', HOTEL_ROOM_SERVICE: '/hotel/room-service',
HOTEL_STATS: '/hotel/establishments/:id/stats', HOTEL_STATS: '/hotel/establishments/:id/stats',
HOTEL_HOUSEKEEPING: '/hotel/establishments/:id/housekeeping', HOTEL_HOUSEKEEPING: '/hotel/establishments/:id/housekeeping',
// Favorites (Fase 1)
FAVORITES: '/favorites',
FAVORITES_MY: '/favorites/my',
FAVORITES_COUNTS: '/favorites/my/counts',
FAVORITES_CHECK: '/favorites/check/:itemType/:itemId',
FAVORITES_TOGGLE: '/favorites/toggle',
// Collections (Fase 1)
COLLECTIONS: '/collections',
COLLECTIONS_MY: '/collections/my',
COLLECTIONS_STATS: '/collections/my/stats',
COLLECTION_ITEMS: '/collections/:id/items',
COLLECTION_ITEMS_ORDER: '/collections/:id/items/order',
COLLECTIONS_ORDER: '/collections/order',
// Trips (Fase 1)
TRIPS: '/trips',
TRIPS_MY: '/trips/my',
TRIPS_STATS: '/trips/my/stats',
TRIP_DAYS: '/trips/:tripId/days',
TRIP_DAY: '/trips/:tripId/days/:dayId',
TRIP_ACTIVITIES: '/trips/:tripId/days/:dayId/activities',
TRIP_ACTIVITY: '/trips/:tripId/days/:dayId/activities/:activityId',
TRIP_ACTIVITIES_ORDER: '/trips/:tripId/days/:dayId/activities/order',
// Quiz (Fase 1)
QUIZ_QUESTIONS: '/quiz/questions',
QUIZ_MY: '/quiz/my',
QUIZ_SUBMIT: '/quiz/submit',
QUIZ_RESET: '/quiz/reset',
}, },
// External Assets // External Assets

224
src/hooks/useCollections.ts Normal file
View File

@@ -0,0 +1,224 @@
import { useState, useEffect, useCallback } from 'react';
import { collectionsApi, Collection, CollectionStats, CreateCollectionDto, UpdateCollectionDto, AddCollectionItemDto } from '@/services/collectionsApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useCollections = () => {
const { user } = useAuth();
const [collections, setCollections] = useState<Collection[]>([]);
const [stats, setStats] = useState<CollectionStats | null>(null);
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cargar colecciones
const loadCollections = useCallback(async () => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await collectionsApi.getMyCollections();
setCollections(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar colecciones';
setError(errorMessage);
console.error('Error loading collections:', err);
} finally {
setLoading(false);
}
}, [user?.id]);
// Cargar estadísticas
const loadStats = useCallback(async () => {
if (!user?.id) return;
try {
const data = await collectionsApi.getCollectionsStats();
setStats(data);
} catch (err) {
console.error('Error loading collections stats:', err);
}
}, [user?.id]);
// Crear colección
const createCollection = useCallback(async (data: CreateCollectionDto): Promise<Collection | null> => {
if (!user?.id) {
toast.error('Inicia sesión para crear colecciones');
return null;
}
try {
const newCollection = await collectionsApi.createCollection(data);
toast.success('Colección creada');
await loadCollections();
await loadStats();
return newCollection;
} catch (err) {
console.error('Error creating collection:', err);
toast.error('Error al crear colección');
return null;
}
}, [user?.id, loadCollections, loadStats]);
// Obtener colección por ID
const getCollectionById = useCallback(async (id: string): Promise<Collection | null> => {
try {
const collection = await collectionsApi.getCollectionById(id);
setSelectedCollection(collection);
return collection;
} catch (err) {
console.error('Error fetching collection:', err);
return null;
}
}, []);
// Actualizar colección
const updateCollection = useCallback(async (id: string, data: UpdateCollectionDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para actualizar colecciones');
return false;
}
try {
await collectionsApi.updateCollection(id, data);
toast.success('Colección actualizada');
await loadCollections();
return true;
} catch (err) {
console.error('Error updating collection:', err);
toast.error('Error al actualizar colección');
return false;
}
}, [user?.id, loadCollections]);
// Eliminar colección
const deleteCollection = useCallback(async (id: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para eliminar colecciones');
return false;
}
try {
await collectionsApi.deleteCollection(id);
toast.success('Colección eliminada');
setCollections(prev => prev.filter(c => c.id !== id));
await loadStats();
return true;
} catch (err) {
console.error('Error deleting collection:', err);
toast.error('Error al eliminar colección');
return false;
}
}, [user?.id, loadStats]);
// Agregar item a colección
const addItemToCollection = useCallback(async (collectionId: string, data: AddCollectionItemDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para agregar items');
return false;
}
try {
await collectionsApi.addItemToCollection(collectionId, data);
toast.success('Item agregado a la colección');
// Recargar la colección si está seleccionada
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
await loadCollections();
return true;
} catch (err) {
console.error('Error adding item to collection:', err);
toast.error('Error al agregar item');
return false;
}
}, [user?.id, selectedCollection?.id, getCollectionById, loadCollections]);
// Quitar item de colección
const removeItemFromCollection = useCallback(async (collectionId: string, itemId: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para quitar items');
return false;
}
try {
await collectionsApi.removeItemFromCollection(collectionId, itemId);
toast.success('Item eliminado de la colección');
// Actualizar colección seleccionada
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
await loadCollections();
return true;
} catch (err) {
console.error('Error removing item from collection:', err);
toast.error('Error al eliminar item');
return false;
}
}, [user?.id, selectedCollection?.id, getCollectionById, loadCollections]);
// Reordenar items
const reorderItems = useCallback(async (collectionId: string, itemIds: string[]): Promise<boolean> => {
try {
await collectionsApi.reorderCollectionItems(collectionId, itemIds);
if (selectedCollection?.id === collectionId) {
await getCollectionById(collectionId);
}
return true;
} catch (err) {
console.error('Error reordering items:', err);
toast.error('Error al reordenar items');
return false;
}
}, [selectedCollection?.id, getCollectionById]);
// Reordenar colecciones
const reorderCollections = useCallback(async (collectionIds: string[]): Promise<boolean> => {
try {
await collectionsApi.reorderCollections(collectionIds);
await loadCollections();
return true;
} catch (err) {
console.error('Error reordering collections:', err);
toast.error('Error al reordenar colecciones');
return false;
}
}, [loadCollections]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadCollections();
loadStats();
}
}, [user?.id, loadCollections, loadStats]);
return {
collections,
stats,
selectedCollection,
loading,
error,
loadCollections,
loadStats,
createCollection,
getCollectionById,
updateCollection,
deleteCollection,
addItemToCollection,
removeItemFromCollection,
reorderItems,
reorderCollections,
setSelectedCollection,
clearError,
getCollectionsCount: () => collections.length,
};
};
export default useCollections;

176
src/hooks/useFavorites.ts Normal file
View File

@@ -0,0 +1,176 @@
import { useState, useEffect, useCallback } from 'react';
import { favoritesApi, Favorite, FavoriteItemType, CreateFavoriteDto, FavoritesCounts } from '@/services/favoritesApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useFavorites = (initialItemType?: FavoriteItemType) => {
const { user } = useAuth();
const [favorites, setFavorites] = useState<Favorite[]>([]);
const [counts, setCounts] = useState<FavoritesCounts | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<FavoriteItemType | undefined>(initialItemType);
// Cargar favoritos
const loadFavorites = useCallback(async (itemType?: FavoriteItemType) => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await favoritesApi.getMyFavorites(itemType || selectedType);
setFavorites(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar favoritos';
setError(errorMessage);
console.error('Error loading favorites:', err);
} finally {
setLoading(false);
}
}, [user?.id, selectedType]);
// Cargar conteos
const loadCounts = useCallback(async () => {
if (!user?.id) return;
try {
const data = await favoritesApi.getFavoritesCounts();
setCounts(data);
} catch (err) {
console.error('Error loading favorites counts:', err);
}
}, [user?.id]);
// Agregar a favoritos
const addFavorite = useCallback(async (data: CreateFavoriteDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para agregar favoritos');
return false;
}
try {
await favoritesApi.addFavorite(data);
toast.success('Agregado a favoritos');
await loadFavorites();
await loadCounts();
return true;
} catch (err) {
console.error('Error adding favorite:', err);
toast.error('Error al agregar favorito');
return false;
}
}, [user?.id, loadFavorites, loadCounts]);
// Toggle favorito
const toggleFavorite = useCallback(async (data: CreateFavoriteDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para gestionar favoritos');
return false;
}
try {
const result = await favoritesApi.toggleFavorite(data);
if (result.action === 'added') {
toast.success('Agregado a favoritos');
} else {
toast.success('Eliminado de favoritos');
}
await loadFavorites();
await loadCounts();
return true;
} catch (err) {
console.error('Error toggling favorite:', err);
toast.error('Error al actualizar favorito');
return false;
}
}, [user?.id, loadFavorites, loadCounts]);
// Eliminar favorito
const removeFavorite = useCallback(async (favoriteId: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para gestionar favoritos');
return false;
}
try {
await favoritesApi.removeFavorite(favoriteId);
toast.success('Eliminado de favoritos');
setFavorites(prev => prev.filter(f => f.id !== favoriteId));
await loadCounts();
return true;
} catch (err) {
console.error('Error removing favorite:', err);
toast.error('Error al eliminar favorito');
return false;
}
}, [user?.id, loadCounts]);
// Verificar si es favorito
const checkFavorite = useCallback(async (itemType: FavoriteItemType, itemId: string): Promise<boolean> => {
if (!user?.id) return false;
try {
const result = await favoritesApi.checkFavorite(itemType, itemId);
return result.isFavorite;
} catch (err) {
console.error('Error checking favorite:', err);
return false;
}
}, [user?.id]);
// Verificar si un item está en los favoritos cargados
const isFavorite = useCallback((itemId: string, itemType?: FavoriteItemType): boolean => {
return favorites.some(f => f.itemId === itemId && (!itemType || f.itemType === itemType));
}, [favorites]);
// Obtener favorito por ID
const getFavoriteById = useCallback((favoriteId: string): Favorite | undefined => {
return favorites.find(f => f.id === favoriteId);
}, [favorites]);
// Filtrar por tipo
const filterByType = useCallback((type: FavoriteItemType): Favorite[] => {
return favorites.filter(f => f.itemType === type);
}, [favorites]);
// Cambiar tipo seleccionado
const changeType = useCallback((type?: FavoriteItemType) => {
setSelectedType(type);
loadFavorites(type);
}, [loadFavorites]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadFavorites();
loadCounts();
}
}, [user?.id, loadFavorites, loadCounts]);
return {
favorites,
counts,
loading,
error,
selectedType,
loadFavorites,
loadCounts,
addFavorite,
toggleFavorite,
removeFavorite,
checkFavorite,
isFavorite,
getFavoriteById,
filterByType,
changeType,
clearError,
getFavoritesCount: () => favorites.length,
};
};
export default useFavorites;

View File

@@ -0,0 +1,177 @@
/**
* useNotifications Hook
* Hook para gestionar notificaciones en el Dashboard - Fase 2
*/
import { useState, useEffect, useCallback } from 'react';
import { notificationsApi, Notification, NotificationType, NotificationStats } from '@/services/notificationsApi';
import { useToast } from '@/hooks/useToast';
interface UseNotificationsOptions {
autoFetch?: boolean;
pollingInterval?: number; // en milisegundos, 0 = sin polling
}
interface UseNotificationsReturn {
notifications: Notification[];
unreadCount: number;
stats: NotificationStats | null;
loading: boolean;
error: string | null;
// Actions
fetchNotifications: (type?: NotificationType, isRead?: boolean) => Promise<void>;
markAsRead: (notificationId: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
deleteNotification: (notificationId: string) => Promise<void>;
deleteAllRead: () => Promise<void>;
refresh: () => Promise<void>;
}
export function useNotifications(options: UseNotificationsOptions = {}): UseNotificationsReturn {
const { autoFetch = true, pollingInterval = 0 } = options;
const { toast } = useToast();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [stats, setStats] = useState<NotificationStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchNotifications = useCallback(async (type?: NotificationType, isRead?: boolean) => {
setLoading(true);
setError(null);
try {
const result = await notificationsApi.getMyNotifications({ type, isRead, limit: 50 });
setNotifications(result.notifications);
setUnreadCount(result.unreadCount);
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al cargar notificaciones';
setError(message);
console.error('Error fetching notifications:', err);
} finally {
setLoading(false);
}
}, []);
const fetchUnreadCount = useCallback(async () => {
try {
const count = await notificationsApi.getUnreadCount();
setUnreadCount(count);
} catch (err) {
console.error('Error fetching unread count:', err);
}
}, []);
const markAsRead = useCallback(async (notificationId: string) => {
try {
await notificationsApi.markAsRead(notificationId);
setNotifications(prev =>
prev.map(n => n.id === notificationId ? { ...n, isRead: true, readAt: new Date().toISOString() } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (err) {
toast({
title: 'Error',
description: 'No se pudo marcar la notificacion como leida',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const markAllAsRead = useCallback(async () => {
try {
const result = await notificationsApi.markAllAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
);
setUnreadCount(0);
toast({
title: 'Listo',
description: `${result.count} notificaciones marcadas como leidas`,
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudieron marcar las notificaciones como leidas',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const deleteNotification = useCallback(async (notificationId: string) => {
try {
const notification = notifications.find(n => n.id === notificationId);
await notificationsApi.deleteNotification(notificationId);
setNotifications(prev => prev.filter(n => n.id !== notificationId));
if (notification && !notification.isRead) {
setUnreadCount(prev => Math.max(0, prev - 1));
}
toast({
title: 'Eliminada',
description: 'Notificacion eliminada correctamente',
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudo eliminar la notificacion',
variant: 'destructive',
});
throw err;
}
}, [notifications, toast]);
const deleteAllRead = useCallback(async () => {
try {
const result = await notificationsApi.deleteAllRead();
setNotifications(prev => prev.filter(n => !n.isRead));
toast({
title: 'Listo',
description: `${result.count} notificaciones eliminadas`,
});
} catch (err) {
toast({
title: 'Error',
description: 'No se pudieron eliminar las notificaciones',
variant: 'destructive',
});
throw err;
}
}, [toast]);
const refresh = useCallback(async () => {
await fetchNotifications();
}, [fetchNotifications]);
// Auto fetch on mount
useEffect(() => {
if (autoFetch) {
fetchNotifications();
}
}, [autoFetch, fetchNotifications]);
// Polling for unread count
useEffect(() => {
if (pollingInterval > 0) {
const interval = setInterval(fetchUnreadCount, pollingInterval);
return () => clearInterval(interval);
}
}, [pollingInterval, fetchUnreadCount]);
return {
notifications,
unreadCount,
stats,
loading,
error,
fetchNotifications,
markAsRead,
markAllAsRead,
deleteNotification,
deleteAllRead,
refresh,
};
}
export default useNotifications;

211
src/hooks/useQuiz.ts Normal file
View File

@@ -0,0 +1,211 @@
import { useState, useEffect, useCallback } from 'react';
import { quizApi, QuizQuestion, QuizResponse, SubmitQuizDto, QuizAnswer } from '@/services/quizApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useQuiz = () => {
const { user } = useAuth();
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
const [quizResponse, setQuizResponse] = useState<QuizResponse | null>(null);
const [currentAnswers, setCurrentAnswers] = useState<QuizAnswer[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cargar preguntas
const loadQuestions = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await quizApi.getQuestions();
setQuestions(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar preguntas';
setError(errorMessage);
console.error('Error loading questions:', err);
} finally {
setLoading(false);
}
}, []);
// Cargar respuesta del usuario
const loadMyResponse = useCallback(async () => {
if (!user?.id) return;
try {
const data = await quizApi.getMyQuizResponse();
setQuizResponse(data);
} catch (err) {
console.error('Error loading quiz response:', err);
}
}, [user?.id]);
// Responder pregunta actual
const answerQuestion = useCallback((questionId: string, selectedOptions: string[]) => {
setCurrentAnswers(prev => {
const existing = prev.findIndex(a => a.questionId === questionId);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = { questionId, selectedOptions };
return updated;
}
return [...prev, { questionId, selectedOptions }];
});
}, []);
// Ir a siguiente pregunta
const nextQuestion = useCallback(() => {
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(prev => prev + 1);
}
}, [currentQuestionIndex, questions.length]);
// Ir a pregunta anterior
const prevQuestion = useCallback(() => {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(prev => prev - 1);
}
}, [currentQuestionIndex]);
// Ir a pregunta específica
const goToQuestion = useCallback((index: number) => {
if (index >= 0 && index < questions.length) {
setCurrentQuestionIndex(index);
}
}, [questions.length]);
// Enviar quiz
const submitQuiz = useCallback(async (): Promise<QuizResponse | null> => {
if (!user?.id) {
toast.error('Inicia sesión para completar el quiz');
return null;
}
// Verificar que todas las preguntas estén respondidas
const unanswered = questions.filter(
q => !currentAnswers.find(a => a.questionId === q.id)
);
if (unanswered.length > 0) {
toast.error(`Faltan ${unanswered.length} preguntas por responder`);
return null;
}
try {
setSubmitting(true);
const response = await quizApi.submitQuiz({ answers: currentAnswers });
setQuizResponse(response);
toast.success(`¡Quiz completado! Tu Travel Persona es: ${response.travelPersona}`);
return response;
} catch (err) {
console.error('Error submitting quiz:', err);
toast.error('Error al enviar quiz');
return null;
} finally {
setSubmitting(false);
}
}, [user?.id, questions, currentAnswers]);
// Reiniciar quiz
const resetQuiz = useCallback(async (): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para reiniciar el quiz');
return false;
}
try {
await quizApi.resetQuiz();
setQuizResponse(null);
setCurrentAnswers([]);
setCurrentQuestionIndex(0);
toast.success('Quiz reiniciado');
return true;
} catch (err) {
console.error('Error resetting quiz:', err);
toast.error('Error al reiniciar quiz');
return false;
}
}, [user?.id]);
// Verificar si el quiz está completado
const isCompleted = useCallback((): boolean => {
return quizResponse?.isCompleted ?? false;
}, [quizResponse]);
// Obtener la Travel Persona
const getTravelPersona = useCallback(() => {
if (quizResponse?.isCompleted && quizResponse.travelPersona) {
return {
persona: quizResponse.travelPersona,
description: quizResponse.personaDescription,
};
}
return null;
}, [quizResponse]);
// Obtener respuesta de una pregunta
const getAnswer = useCallback((questionId: string): string[] | undefined => {
return currentAnswers.find(a => a.questionId === questionId)?.selectedOptions;
}, [currentAnswers]);
// Verificar si una pregunta está respondida
const isAnswered = useCallback((questionId: string): boolean => {
return currentAnswers.some(a => a.questionId === questionId && a.selectedOptions.length > 0);
}, [currentAnswers]);
// Obtener progreso del quiz
const getProgress = useCallback(() => {
const answered = currentAnswers.filter(a => a.selectedOptions.length > 0).length;
return {
answered,
total: questions.length,
percentage: questions.length > 0 ? Math.round((answered / questions.length) * 100) : 0,
};
}, [currentAnswers, questions.length]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Pregunta actual
const currentQuestion = questions[currentQuestionIndex] || null;
// Carga inicial
useEffect(() => {
loadQuestions();
if (user?.id) {
loadMyResponse();
}
}, [loadQuestions, user?.id, loadMyResponse]);
return {
questions,
quizResponse,
currentQuestion,
currentQuestionIndex,
currentAnswers,
loading,
submitting,
error,
loadQuestions,
loadMyResponse,
answerQuestion,
nextQuestion,
prevQuestion,
goToQuestion,
submitQuiz,
resetQuiz,
isCompleted,
getTravelPersona,
getAnswer,
isAnswered,
getProgress,
clearError,
isFirstQuestion: currentQuestionIndex === 0,
isLastQuestion: currentQuestionIndex === questions.length - 1,
};
};
export default useQuiz;

289
src/hooks/useTrips.ts Normal file
View File

@@ -0,0 +1,289 @@
import { useState, useEffect, useCallback } from 'react';
import { tripsApi, Trip, TripStatus, TripStats, TripDay, TripActivity, CreateTripDto, UpdateTripDto, CreateTripDayDto, UpdateTripDayDto, CreateTripActivityDto, UpdateTripActivityDto } from '@/services/tripsApi';
import { useAuth } from '@/contexts/AuthContext';
import { toast } from 'sonner';
export const useTrips = (initialStatus?: TripStatus) => {
const { user } = useAuth();
const [trips, setTrips] = useState<Trip[]>([]);
const [stats, setStats] = useState<TripStats | null>(null);
const [selectedTrip, setSelectedTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<TripStatus | undefined>(initialStatus);
// Cargar viajes
const loadTrips = useCallback(async (status?: TripStatus) => {
if (!user?.id) return;
try {
setLoading(true);
setError(null);
const data = await tripsApi.getMyTrips(status || selectedStatus);
setTrips(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error al cargar viajes';
setError(errorMessage);
console.error('Error loading trips:', err);
} finally {
setLoading(false);
}
}, [user?.id, selectedStatus]);
// Cargar estadísticas
const loadStats = useCallback(async () => {
if (!user?.id) return;
try {
const data = await tripsApi.getTripsStats();
setStats(data);
} catch (err) {
console.error('Error loading trips stats:', err);
}
}, [user?.id]);
// Crear viaje
const createTrip = useCallback(async (data: CreateTripDto): Promise<Trip | null> => {
if (!user?.id) {
toast.error('Inicia sesión para crear viajes');
return null;
}
try {
const newTrip = await tripsApi.createTrip(data);
toast.success('Viaje creado');
await loadTrips();
await loadStats();
return newTrip;
} catch (err) {
console.error('Error creating trip:', err);
toast.error('Error al crear viaje');
return null;
}
}, [user?.id, loadTrips, loadStats]);
// Obtener viaje por ID
const getTripById = useCallback(async (id: string): Promise<Trip | null> => {
try {
const trip = await tripsApi.getTripById(id);
setSelectedTrip(trip);
return trip;
} catch (err) {
console.error('Error fetching trip:', err);
return null;
}
}, []);
// Actualizar viaje
const updateTrip = useCallback(async (id: string, data: UpdateTripDto): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para actualizar viajes');
return false;
}
try {
const updated = await tripsApi.updateTrip(id, data);
toast.success('Viaje actualizado');
if (selectedTrip?.id === id) {
setSelectedTrip(updated);
}
await loadTrips();
return true;
} catch (err) {
console.error('Error updating trip:', err);
toast.error('Error al actualizar viaje');
return false;
}
}, [user?.id, selectedTrip?.id, loadTrips]);
// Eliminar viaje
const deleteTrip = useCallback(async (id: string): Promise<boolean> => {
if (!user?.id) {
toast.error('Inicia sesión para eliminar viajes');
return false;
}
try {
await tripsApi.deleteTrip(id);
toast.success('Viaje eliminado');
setTrips(prev => prev.filter(t => t.id !== id));
if (selectedTrip?.id === id) {
setSelectedTrip(null);
}
await loadStats();
return true;
} catch (err) {
console.error('Error deleting trip:', err);
toast.error('Error al eliminar viaje');
return false;
}
}, [user?.id, selectedTrip?.id, loadStats]);
// ============ DAYS ============
// Agregar día
const addDay = useCallback(async (tripId: string, data: CreateTripDayDto): Promise<TripDay | null> => {
try {
const newDay = await tripsApi.addDay(tripId, data);
toast.success('Día agregado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return newDay;
} catch (err) {
console.error('Error adding day:', err);
toast.error('Error al agregar día');
return null;
}
}, [selectedTrip?.id, getTripById]);
// Actualizar día
const updateDay = useCallback(async (tripId: string, dayId: string, data: UpdateTripDayDto): Promise<boolean> => {
try {
await tripsApi.updateDay(tripId, dayId, data);
toast.success('Día actualizado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error updating day:', err);
toast.error('Error al actualizar día');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Eliminar día
const deleteDay = useCallback(async (tripId: string, dayId: string): Promise<boolean> => {
try {
await tripsApi.deleteDay(tripId, dayId);
toast.success('Día eliminado');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error deleting day:', err);
toast.error('Error al eliminar día');
return false;
}
}, [selectedTrip?.id, getTripById]);
// ============ ACTIVITIES ============
// Agregar actividad
const addActivity = useCallback(async (tripId: string, dayId: string, data: CreateTripActivityDto): Promise<TripActivity | null> => {
try {
const newActivity = await tripsApi.addActivity(tripId, dayId, data);
toast.success('Actividad agregada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return newActivity;
} catch (err) {
console.error('Error adding activity:', err);
toast.error('Error al agregar actividad');
return null;
}
}, [selectedTrip?.id, getTripById]);
// Actualizar actividad
const updateActivity = useCallback(async (tripId: string, dayId: string, activityId: string, data: UpdateTripActivityDto): Promise<boolean> => {
try {
await tripsApi.updateActivity(tripId, dayId, activityId, data);
toast.success('Actividad actualizada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error updating activity:', err);
toast.error('Error al actualizar actividad');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Eliminar actividad
const deleteActivity = useCallback(async (tripId: string, dayId: string, activityId: string): Promise<boolean> => {
try {
await tripsApi.deleteActivity(tripId, dayId, activityId);
toast.success('Actividad eliminada');
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error deleting activity:', err);
toast.error('Error al eliminar actividad');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Reordenar actividades
const reorderActivities = useCallback(async (tripId: string, dayId: string, activityIds: string[]): Promise<boolean> => {
try {
await tripsApi.reorderActivities(tripId, dayId, activityIds);
if (selectedTrip?.id === tripId) {
await getTripById(tripId);
}
return true;
} catch (err) {
console.error('Error reordering activities:', err);
toast.error('Error al reordenar actividades');
return false;
}
}, [selectedTrip?.id, getTripById]);
// Cambiar estado seleccionado
const changeStatus = useCallback((status?: TripStatus) => {
setSelectedStatus(status);
loadTrips(status);
}, [loadTrips]);
// Filtrar por estado
const filterByStatus = useCallback((status: TripStatus): Trip[] => {
return trips.filter(t => t.status === status);
}, [trips]);
// Limpiar error
const clearError = useCallback(() => {
setError(null);
}, []);
// Carga inicial
useEffect(() => {
if (user?.id) {
loadTrips();
loadStats();
}
}, [user?.id, loadTrips, loadStats]);
return {
trips,
stats,
selectedTrip,
loading,
error,
selectedStatus,
loadTrips,
loadStats,
createTrip,
getTripById,
updateTrip,
deleteTrip,
addDay,
updateDay,
deleteDay,
addActivity,
updateActivity,
deleteActivity,
reorderActivities,
changeStatus,
filterByStatus,
setSelectedTrip,
clearError,
getTripsCount: () => trips.length,
};
};
export default useTrips;

View File

@@ -0,0 +1,518 @@
import { useState } from 'react';
import { useCollections } from '@/hooks/useCollections';
import {
FolderHeart,
Plus,
Trash2,
Edit2,
Eye,
Globe,
Lock,
RefreshCw,
Search,
MoreVertical,
Image as ImageIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
const Collections = () => {
const {
collections,
stats,
loading,
error,
loadCollections,
createCollection,
updateCollection,
deleteCollection,
getCollectionById,
selectedCollection,
setSelectedCollection,
} = useCollections();
const [searchTerm, setSearchTerm] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
coverImageUrl: '',
color: '#3b82f6',
isPublic: false,
});
const filteredCollections = collections.filter(col =>
col.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
col.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadCollections();
};
const handleCreate = async () => {
const result = await createCollection(formData);
if (result) {
setIsCreateDialogOpen(false);
resetForm();
}
};
const handleEdit = async () => {
if (!selectedCollection) return;
const success = await updateCollection(selectedCollection.id, formData);
if (success) {
setIsEditDialogOpen(false);
resetForm();
}
};
const handleDelete = async () => {
if (!collectionToDelete) return;
const success = await deleteCollection(collectionToDelete);
if (success) {
setIsDeleteDialogOpen(false);
setCollectionToDelete(null);
}
};
const openEditDialog = async (collectionId: string) => {
const collection = await getCollectionById(collectionId);
if (collection) {
setFormData({
name: collection.name,
description: collection.description || '',
coverImageUrl: collection.coverImageUrl || '',
color: collection.color || '#3b82f6',
isPublic: collection.isPublic,
});
setIsEditDialogOpen(true);
}
};
const openDeleteDialog = (collectionId: string) => {
setCollectionToDelete(collectionId);
setIsDeleteDialogOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
description: '',
coverImageUrl: '',
color: '#3b82f6',
isPublic: false,
});
setSelectedCollection(null);
};
if (loading && collections.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando colecciones...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<FolderHeart className="h-6 w-6 text-primary" />
Gestión de Colecciones
</h1>
<p className="text-muted-foreground mt-1">
Administra las colecciones de lugares favoritos
</p>
</div>
<div className="flex gap-2 mt-4 md:mt-0">
<Button onClick={handleRefresh} variant="outline">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nueva Colección
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Crear Colección</DialogTitle>
<DialogDescription>
Crea una nueva colección para organizar lugares.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Nombre</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Mi colección"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe tu colección..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="coverImage">URL de imagen de portada</Label>
<Input
id="coverImage"
value={formData.coverImageUrl}
onChange={(e) => setFormData({ ...formData, coverImageUrl: e.target.value })}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-20"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="isPublic">Colección pública</Label>
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!formData.name}>
Crear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Colecciones
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalCollections}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalItems}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Globe className="h-3 w-3" />
Públicas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.publicCollections}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Lock className="h-3 w-3" />
Privadas
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.privateCollections}</div>
</CardContent>
</Card>
</div>
)}
{/* Search */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar colecciones..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredCollections.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<FolderHeart className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay colecciones</h3>
<p className="text-muted-foreground mb-4">
{searchTerm
? 'No se encontraron colecciones con ese término de búsqueda.'
: 'Crea tu primera colección para organizar lugares.'}
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Crear Colección
</Button>
</div>
</CardContent>
</Card>
)}
{/* Collections Grid */}
{filteredCollections.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredCollections.map((collection) => (
<Card key={collection.id} className="overflow-hidden">
<div
className="h-32 relative"
style={{
backgroundColor: collection.color || '#3b82f6',
backgroundImage: collection.coverImageUrl
? `url(${collection.coverImageUrl})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{!collection.coverImageUrl && (
<div className="absolute inset-0 flex items-center justify-center">
<ImageIcon className="h-12 w-12 text-white/50" />
</div>
)}
<div className="absolute top-2 right-2">
<Badge variant={collection.isPublic ? 'default' : 'secondary'}>
{collection.isPublic ? (
<><Globe className="h-3 w-3 mr-1" /> Pública</>
) : (
<><Lock className="h-3 w-3 mr-1" /> Privada</>
)}
</Badge>
</div>
</div>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{collection.name}</CardTitle>
<CardDescription className="line-clamp-2">
{collection.description || 'Sin descripción'}
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => getCollectionById(collection.id)}>
<Eye className="h-4 w-4 mr-2" />
Ver detalles
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(collection.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => openDeleteDialog(collection.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{collection.itemCount || 0} items</span>
<span>{new Date(collection.createdAt).toLocaleDateString('es-ES')}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar Colección</DialogTitle>
<DialogDescription>
Modifica los detalles de la colección.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Nombre</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Descripción</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-coverImage">URL de imagen de portada</Label>
<Input
id="edit-coverImage"
value={formData.coverImageUrl}
onChange={(e) => setFormData({ ...formData, coverImageUrl: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-color">Color</Label>
<Input
id="edit-color"
type="color"
value={formData.color}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
className="h-10 w-20"
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-isPublic">Colección pública</Label>
<Switch
id="edit-isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEdit} disabled={!formData.name}>
Guardar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar colección?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. La colección y todos sus items serán eliminados permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredCollections.length} de {collections.length} colecciones
</div>
</div>
</div>
);
};
export default Collections;

View File

@@ -0,0 +1,340 @@
import { useState } from 'react';
import { useFavorites } from '@/hooks/useFavorites';
import { FavoriteItemType } from '@/services/favoritesApi';
import {
Heart,
Star,
MapPin,
Trash2,
Filter,
Search,
Building2,
Utensils,
Hotel,
Compass,
ShoppingBag,
RefreshCw,
Eye,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
const typeIcons: Record<FavoriteItemType, any> = {
place: MapPin,
restaurant: Utensils,
hotel: Hotel,
attraction: Compass,
tour: Compass,
experience: Star,
product: ShoppingBag,
};
const typeLabels: Record<FavoriteItemType, string> = {
place: 'Lugares',
restaurant: 'Restaurantes',
hotel: 'Hoteles',
attraction: 'Atracciones',
tour: 'Tours',
experience: 'Experiencias',
product: 'Productos',
};
const Favorites = () => {
const {
favorites,
counts,
loading,
error,
selectedType,
loadFavorites,
removeFavorite,
changeType,
loadCounts,
} = useFavorites();
const [searchTerm, setSearchTerm] = useState('');
const filteredFavorites = favorites.filter(fav =>
fav.itemName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
fav.notes?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadFavorites();
await loadCounts();
};
const handleDelete = async (id: string) => {
await removeFavorite(id);
};
if (loading && favorites.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando favoritos...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Heart className="h-6 w-6 text-red-500" />
Gestión de Favoritos
</h1>
<p className="text-muted-foreground mt-1">
Administra los favoritos de los usuarios
</p>
</div>
<Button onClick={handleRefresh} variant="outline" className="mt-4 md:mt-0">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
{/* Stats Cards */}
{counts && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
<Card className="col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Favoritos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{counts.total}</div>
</CardContent>
</Card>
{Object.entries(counts.byType || {}).map(([type, count]) => {
const Icon = typeIcons[type as FavoriteItemType] || Heart;
return (
<Card key={type}>
<CardHeader className="pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Icon className="h-3 w-3" />
{typeLabels[type as FavoriteItemType] || type}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{count}</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por nombre o notas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-full md:w-48">
<Select
value={selectedType || 'all'}
onValueChange={(value) => changeType(value === 'all' ? undefined : value as FavoriteItemType)}
>
<SelectTrigger>
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Filtrar por tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los tipos</SelectItem>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredFavorites.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<Heart className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay favoritos</h3>
<p className="text-muted-foreground">
{searchTerm
? 'No se encontraron favoritos con ese término de búsqueda.'
: selectedType
? `No hay favoritos de tipo "${typeLabels[selectedType]}".`
: 'Los usuarios aún no han agregado favoritos.'}
</p>
</div>
</CardContent>
</Card>
)}
{/* Favorites Table */}
{filteredFavorites.length > 0 && (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Notas</TableHead>
<TableHead>Fecha</TableHead>
<TableHead className="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFavorites.map((favorite) => {
const Icon = typeIcons[favorite.itemType] || Heart;
return (
<TableRow key={favorite.id}>
<TableCell>
<div className="flex items-center gap-3">
{favorite.itemImageUrl ? (
<img
src={favorite.itemImageUrl}
alt={favorite.itemName || 'Favorite'}
className="h-10 w-10 rounded object-cover"
/>
) : (
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{favorite.itemName || 'Sin nombre'}</p>
<p className="text-xs text-muted-foreground">
ID: {favorite.itemId.slice(0, 8)}...
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
<Icon className="h-3 w-3" />
{typeLabels[favorite.itemType] || favorite.itemType}
</Badge>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground max-w-xs truncate">
{favorite.notes || '-'}
</p>
</TableCell>
<TableCell>
<p className="text-sm text-muted-foreground">
{new Date(favorite.createdAt).toLocaleDateString('es-ES')}
</p>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar favorito?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. El favorito será eliminado permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(favorite.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredFavorites.length} de {favorites.length} favoritos
</div>
</div>
</div>
);
};
export default Favorites;

View File

@@ -0,0 +1,445 @@
import { useState } from 'react';
import { useQuiz } from '@/hooks/useQuiz';
import {
ClipboardList,
RefreshCw,
CheckCircle,
XCircle,
ChevronLeft,
ChevronRight,
Send,
RotateCcw,
User,
Sparkles,
Target,
Award,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
const Quiz = () => {
const {
questions,
quizResponse,
currentQuestion,
currentQuestionIndex,
loading,
submitting,
error,
loadQuestions,
answerQuestion,
nextQuestion,
prevQuestion,
submitQuiz,
resetQuiz,
isCompleted,
getTravelPersona,
getAnswer,
isAnswered,
getProgress,
isFirstQuestion,
isLastQuestion,
} = useQuiz();
const [showQuiz, setShowQuiz] = useState(false);
const progress = getProgress();
const travelPersona = getTravelPersona();
const completed = isCompleted();
const handleRefresh = async () => {
await loadQuestions();
};
const handleStartQuiz = () => {
setShowQuiz(true);
};
const handleSubmit = async () => {
const result = await submitQuiz();
if (result) {
setShowQuiz(false);
}
};
const handleReset = async () => {
const success = await resetQuiz();
if (success) {
setShowQuiz(false);
}
};
if (loading && questions.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando quiz...</p>
</div>
</div>
</div>
</div>
);
}
// Vista del resultado si ya completó el quiz
if (completed && travelPersona && !showQuiz) {
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ClipboardList className="h-6 w-6 text-primary" />
Quiz de Estilo de Viaje
</h1>
<p className="text-muted-foreground mt-1">
Descubre tu personalidad viajera
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="mt-4 md:mt-0">
<RotateCcw className="h-4 w-4 mr-2" />
Reiniciar Quiz
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Reiniciar quiz?</AlertDialogTitle>
<AlertDialogDescription>
Esto borrará tu Travel Persona actual y podrás volver a tomar el quiz.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleReset}>
Reiniciar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Result Card */}
<div className="max-w-2xl mx-auto">
<Card className="overflow-hidden">
<div className="h-32 bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
<Award className="h-20 w-20 text-white" />
</div>
<CardHeader className="text-center">
<Badge className="w-fit mx-auto mb-2" variant="secondary">
<CheckCircle className="h-3 w-3 mr-1" />
Quiz Completado
</Badge>
<CardTitle className="text-2xl">Tu Travel Persona</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-6">
<div className="py-6">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-primary/10 mb-4">
<Sparkles className="h-12 w-12 text-primary" />
</div>
<h2 className="text-3xl font-bold text-primary mb-2">
{travelPersona.persona}
</h2>
<p className="text-muted-foreground max-w-md mx-auto">
{travelPersona.description}
</p>
</div>
{quizResponse && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
{quizResponse.travelStyles && quizResponse.travelStyles.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
Estilos de Viaje
</h4>
<div className="flex flex-wrap gap-2">
{quizResponse.travelStyles.map((style, i) => (
<Badge key={i} variant="outline">{style}</Badge>
))}
</div>
</div>
)}
{quizResponse.preferredActivities && quizResponse.preferredActivities.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Actividades Preferidas
</h4>
<div className="flex flex-wrap gap-2">
{quizResponse.preferredActivities.slice(0, 5).map((activity, i) => (
<Badge key={i} variant="outline">{activity}</Badge>
))}
</div>
</div>
)}
{quizResponse.budgetRange && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2">Rango de Presupuesto</h4>
<Badge>{quizResponse.budgetRange}</Badge>
</div>
)}
{quizResponse.groupType && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-semibold mb-2">Tipo de Grupo</h4>
<Badge>{quizResponse.groupType}</Badge>
</div>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
Completado el {quizResponse?.completedAt
? new Date(quizResponse.completedAt).toLocaleDateString('es-ES')
: '-'}
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Vista del quiz
if (showQuiz && currentQuestion) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="max-w-2xl mx-auto">
{/* Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">
Pregunta {currentQuestionIndex + 1} de {questions.length}
</span>
<span className="text-sm font-medium">
{progress.percentage}% completado
</span>
</div>
<Progress value={progress.percentage} className="h-2" />
</div>
{/* Question Card */}
<Card>
<CardHeader>
<Badge variant="outline" className="w-fit mb-2">
{currentQuestion.category}
</Badge>
<CardTitle className="text-xl">
{currentQuestion.question}
</CardTitle>
{currentQuestion.type === 'multiple' && (
<CardDescription>
Puedes seleccionar múltiples opciones
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{currentQuestion.options.map((option) => {
const currentAnswer = getAnswer(currentQuestion.id) || [];
const isSelected = currentAnswer.includes(option.value);
return (
<button
key={option.id}
onClick={() => {
if (currentQuestion.type === 'multiple') {
const newAnswer = isSelected
? currentAnswer.filter(v => v !== option.value)
: [...currentAnswer, option.value];
answerQuestion(currentQuestion.id, newAnswer);
} else {
answerQuestion(currentQuestion.id, [option.value]);
}
}}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 mt-0.5 ${
isSelected ? 'border-primary bg-primary' : 'border-muted-foreground'
}`}>
{isSelected && <CheckCircle className="h-3 w-3 text-white" />}
</div>
<div>
<p className="font-medium">{option.label}</p>
{option.description && (
<p className="text-sm text-muted-foreground mt-1">
{option.description}
</p>
)}
</div>
</div>
</button>
);
})}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between mt-6">
<Button
variant="outline"
onClick={prevQuestion}
disabled={isFirstQuestion}
>
<ChevronLeft className="h-4 w-4 mr-2" />
Anterior
</Button>
{isLastQuestion ? (
<Button
onClick={handleSubmit}
disabled={submitting || progress.answered < questions.length}
>
{submitting ? (
<>
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
Enviando...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Enviar Quiz
</>
)}
</Button>
) : (
<Button onClick={nextQuestion}>
Siguiente
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
{/* Question indicators */}
<div className="flex justify-center gap-2 mt-6">
{questions.map((q, index) => (
<button
key={q.id}
onClick={() => {
// goToQuestion not exposed, using prev/next
}}
className={`w-3 h-3 rounded-full transition-all ${
index === currentQuestionIndex
? 'bg-primary scale-125'
: isAnswered(q.id)
? 'bg-primary/50'
: 'bg-muted'
}`}
/>
))}
</div>
</div>
</div>
</div>
);
}
// Vista inicial - Invitación a tomar el quiz
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ClipboardList className="h-6 w-6 text-primary" />
Quiz de Estilo de Viaje
</h1>
<p className="text-muted-foreground mt-1">
Descubre tu personalidad viajera y recibe recomendaciones personalizadas
</p>
</div>
<Button onClick={handleRefresh} variant="outline" className="mt-4 md:mt-0">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Welcome Card */}
<div className="max-w-2xl mx-auto">
<Card className="overflow-hidden">
<div className="h-48 bg-gradient-to-br from-primary via-primary/80 to-primary/60 flex items-center justify-center relative">
<div className="absolute inset-0 bg-[url('/src/assets/hero-beach.jpg')] bg-cover bg-center opacity-20" />
<div className="relative text-center text-white p-6">
<User className="h-16 w-16 mx-auto mb-4" />
<h2 className="text-2xl font-bold">Descubre tu Travel Persona</h2>
</div>
</div>
<CardContent className="p-6 space-y-6">
<div className="text-center">
<p className="text-lg text-muted-foreground mb-6">
Responde unas preguntas rápidas sobre tus preferencias de viaje y descubriremos
qué tipo de viajero eres. Esto nos ayudará a darte mejores recomendaciones.
</p>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<ClipboardList className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">{questions.length} Preguntas</p>
</div>
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<Target className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">~3 Minutos</p>
</div>
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-2">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-medium">Personalizado</p>
</div>
</div>
<Button size="lg" onClick={handleStartQuiz} disabled={questions.length === 0}>
<Sparkles className="h-5 w-5 mr-2" />
Comenzar Quiz
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default Quiz;

View File

@@ -0,0 +1,636 @@
import { useState } from 'react';
import { useTrips } from '@/hooks/useTrips';
import { TripStatus } from '@/services/tripsApi';
import {
Plane,
Plus,
Trash2,
Edit2,
Eye,
Calendar,
Users,
DollarSign,
MapPin,
RefreshCw,
Search,
MoreVertical,
Clock,
CheckCircle,
XCircle,
PlayCircle,
PauseCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
const statusConfig: Record<TripStatus, { label: string; icon: any; color: string }> = {
planning: { label: 'Planificando', icon: Clock, color: 'bg-yellow-500' },
upcoming: { label: 'Próximo', icon: Calendar, color: 'bg-blue-500' },
in_progress: { label: 'En progreso', icon: PlayCircle, color: 'bg-green-500' },
completed: { label: 'Completado', icon: CheckCircle, color: 'bg-gray-500' },
cancelled: { label: 'Cancelado', icon: XCircle, color: 'bg-red-500' },
};
const Trips = () => {
const {
trips,
stats,
loading,
error,
selectedStatus,
loadTrips,
createTrip,
updateTrip,
deleteTrip,
getTripById,
selectedTrip,
setSelectedTrip,
changeStatus,
} = useTrips();
const [searchTerm, setSearchTerm] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [tripToDelete, setTripToDelete] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
name: '',
description: '',
destination: '',
startDate: '',
endDate: '',
travelersCount: 1,
estimatedBudget: 0,
currency: 'USD',
isPublic: false,
});
const filteredTrips = trips.filter(trip =>
trip.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
trip.destination?.toLowerCase().includes(searchTerm.toLowerCase()) ||
trip.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleRefresh = async () => {
await loadTrips();
};
const handleCreate = async () => {
const result = await createTrip(formData);
if (result) {
setIsCreateDialogOpen(false);
resetForm();
}
};
const handleEdit = async () => {
if (!selectedTrip) return;
const success = await updateTrip(selectedTrip.id, formData);
if (success) {
setIsEditDialogOpen(false);
resetForm();
}
};
const handleDelete = async () => {
if (!tripToDelete) return;
const success = await deleteTrip(tripToDelete);
if (success) {
setIsDeleteDialogOpen(false);
setTripToDelete(null);
}
};
const openEditDialog = async (tripId: string) => {
const trip = await getTripById(tripId);
if (trip) {
setFormData({
name: trip.name,
description: trip.description || '',
destination: trip.destination || '',
startDate: trip.startDate || '',
endDate: trip.endDate || '',
travelersCount: trip.travelersCount || 1,
estimatedBudget: trip.estimatedBudget || 0,
currency: trip.currency || 'USD',
isPublic: trip.isPublic,
});
setIsEditDialogOpen(true);
}
};
const openDeleteDialog = (tripId: string) => {
setTripToDelete(tripId);
setIsDeleteDialogOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
description: '',
destination: '',
startDate: '',
endDate: '',
travelersCount: 1,
estimatedBudget: 0,
currency: 'USD',
isPublic: false,
});
setSelectedTrip(null);
};
const formatDate = (date: string | undefined) => {
if (!date) return '-';
return new Date(date).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
if (loading && trips.length === 0) {
return (
<div className="body-content">
<div className="container-xxl">
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="mt-4 text-muted-foreground">Cargando viajes...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="body-content">
<div className="container-xxl">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Plane className="h-6 w-6 text-primary" />
Gestión de Viajes
</h1>
<p className="text-muted-foreground mt-1">
Administra los itinerarios de viaje de los usuarios
</p>
</div>
<div className="flex gap-2 mt-4 md:mt-0">
<Button onClick={handleRefresh} variant="outline">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Nuevo Viaje
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Crear Viaje</DialogTitle>
<DialogDescription>
Crea un nuevo itinerario de viaje.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="name">Nombre del viaje</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Mi viaje a..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="destination">Destino</Label>
<Input
id="destination"
value={formData.destination}
onChange={(e) => setFormData({ ...formData, destination: e.target.value })}
placeholder="Punta Cana, RD"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe tu viaje..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Fecha inicio</Label>
<Input
id="startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate">Fecha fin</Label>
<Input
id="endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="travelers">Viajeros</Label>
<Input
id="travelers"
type="number"
min="1"
value={formData.travelersCount}
onChange={(e) => setFormData({ ...formData, travelersCount: parseInt(e.target.value) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="budget">Presupuesto</Label>
<Input
id="budget"
type="number"
min="0"
value={formData.estimatedBudget}
onChange={(e) => setFormData({ ...formData, estimatedBudget: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!formData.name}>
Crear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Viajes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalTrips}</div>
</CardContent>
</Card>
{Object.entries(stats.byStatus || {}).map(([status, count]) => {
const config = statusConfig[status as TripStatus];
const Icon = config?.icon || Clock;
return (
<Card key={status}>
<CardHeader className="pb-2">
<CardTitle className="text-xs font-medium text-muted-foreground flex items-center gap-1">
<Icon className="h-3 w-3" />
{config?.label || status}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xl font-bold">{count}</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Filters */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar viajes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-full md:w-48">
<Select
value={selectedStatus || 'all'}
onValueChange={(value) => changeStatus(value === 'all' ? undefined : value as TripStatus)}
>
<SelectTrigger>
<SelectValue placeholder="Filtrar por estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
{Object.entries(statusConfig).map(([value, config]) => (
<SelectItem key={value} value={value}>
{config.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Error State */}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
<Button onClick={handleRefresh} variant="outline" className="mt-2">
Reintentar
</Button>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && filteredTrips.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<Plane className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-xl font-semibold mb-2">No hay viajes</h3>
<p className="text-muted-foreground mb-4">
{searchTerm
? 'No se encontraron viajes con ese término de búsqueda.'
: selectedStatus
? `No hay viajes con estado "${statusConfig[selectedStatus]?.label}".`
: 'Crea tu primer itinerario de viaje.'}
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Crear Viaje
</Button>
</div>
</CardContent>
</Card>
)}
{/* Trips Grid */}
{filteredTrips.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTrips.map((trip) => {
const statusInfo = statusConfig[trip.status];
const StatusIcon = statusInfo?.icon || Clock;
return (
<Card key={trip.id} className="overflow-hidden">
<div
className="h-32 relative bg-gradient-to-br from-primary/20 to-primary/5"
style={{
backgroundImage: trip.coverImageUrl
? `url(${trip.coverImageUrl})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-3 left-3 right-3">
<h3 className="text-white font-bold text-lg truncate">{trip.name}</h3>
{trip.destination && (
<p className="text-white/80 text-sm flex items-center gap-1">
<MapPin className="h-3 w-3" />
{trip.destination}
</p>
)}
</div>
<div className="absolute top-2 right-2">
<Badge className={`${statusInfo?.color} text-white`}>
<StatusIcon className="h-3 w-3 mr-1" />
{statusInfo?.label}
</Badge>
</div>
</div>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(trip.startDate)}
</span>
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trip.travelersCount}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => getTripById(trip.id)}>
<Eye className="h-4 w-4 mr-2" />
Ver detalles
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openEditDialog(trip.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => openDeleteDialog(trip.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Eliminar
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{trip.estimatedBudget && trip.estimatedBudget > 0 && (
<div className="flex items-center gap-1 text-sm">
<DollarSign className="h-4 w-4 text-green-500" />
<span className="font-medium">
{trip.estimatedBudget.toLocaleString('es-ES')} {trip.currency}
</span>
</div>
)}
{trip.days && trip.days.length > 0 && (
<p className="text-sm text-muted-foreground mt-2">
{trip.days.length} días planificados
</p>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Editar Viaje</DialogTitle>
<DialogDescription>
Modifica los detalles del viaje.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="edit-name">Nombre del viaje</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-destination">Destino</Label>
<Input
id="edit-destination"
value={formData.destination}
onChange={(e) => setFormData({ ...formData, destination: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Descripción</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">Fecha inicio</Label>
<Input
id="edit-startDate"
type="date"
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">Fecha fin</Label>
<Input
id="edit-endDate"
type="date"
value={formData.endDate}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-travelers">Viajeros</Label>
<Input
id="edit-travelers"
type="number"
min="1"
value={formData.travelersCount}
onChange={(e) => setFormData({ ...formData, travelersCount: parseInt(e.target.value) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-budget">Presupuesto</Label>
<Input
id="edit-budget"
type="number"
min="0"
value={formData.estimatedBudget}
onChange={(e) => setFormData({ ...formData, estimatedBudget: parseFloat(e.target.value) || 0 })}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEdit} disabled={!formData.name}>
Guardar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar viaje?</AlertDialogTitle>
<AlertDialogDescription>
Esta acción no se puede deshacer. El viaje y todos sus días y actividades serán eliminados permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Footer */}
<div className="mt-6 text-center text-sm text-muted-foreground">
Mostrando {filteredTrips.length} de {trips.length} viajes
</div>
</div>
</div>
);
};
export default Trips;

View File

@@ -20,6 +20,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Mail, Mail,
Plus, Plus,
@@ -32,10 +33,12 @@ import {
Eye, Eye,
MousePointer, MousePointer,
ShoppingCart, ShoppingCart,
Calendar Calendar,
Megaphone
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { z } from 'zod'; import { z } from 'zod';
import InfluencerMarketplace from '@/components/InfluencerMarketplace';
const campaignSchema = z.object({ const campaignSchema = z.object({
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'), name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
@@ -152,9 +155,28 @@ const Campaigns = () => {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1> <h1 className="text-3xl font-bold text-gray-900">Campañas de Marketing</h1>
<p className="text-gray-600 mt-1">Gestiona tus campañas de marketing</p> <p className="text-gray-600 mt-1">Gestiona tus campañas de email e influencer marketing</p>
</div> </div>
</div>
{/* Tabs */}
<Tabs defaultValue="email" className="space-y-6">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="email" className="flex items-center gap-2">
<Mail className="h-4 w-4" />
Email Marketing
</TabsTrigger>
<TabsTrigger value="influencers" className="flex items-center gap-2">
<Users className="h-4 w-4" />
Influencers
</TabsTrigger>
</TabsList>
{/* Email Marketing Tab */}
<TabsContent value="email" className="space-y-6">
{/* Create Button */}
<div className="flex justify-end">
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
@@ -412,6 +434,13 @@ const Campaigns = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Influencers Tab */}
<TabsContent value="influencers">
<InfluencerMarketplace />
</TabsContent>
</Tabs>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,562 @@
import React, { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Users,
TrendingUp,
DollarSign,
Eye,
Heart,
MessageCircle,
Instagram,
Youtube,
Twitter,
Star,
Calendar,
ArrowUpRight,
ArrowDownRight,
CheckCircle,
Clock,
Megaphone,
BarChart3,
Globe,
Loader2,
Home,
User,
Wallet,
MapPin,
Link as LinkIcon,
Camera,
Edit,
Download,
Filter,
Search,
CreditCard,
BanknoteIcon,
PieChart
} from 'lucide-react';
const InfluencerDashboard = () => {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
// Determinar tab activo basado en la ruta
const getActiveTab = () => {
if (location.pathname.includes('/stats')) return 'stats';
if (location.pathname.includes('/campaigns')) return 'campaigns';
if (location.pathname.includes('/earnings')) return 'earnings';
if (location.pathname.includes('/profile')) return 'profile';
return 'dashboard';
};
const [activeTab, setActiveTab] = useState(getActiveTab());
useEffect(() => {
setActiveTab(getActiveTab());
}, [location.pathname]);
const handleTabChange = (value: string) => {
setActiveTab(value);
const routes: Record<string, string> = {
dashboard: '/dashboard/influencer',
stats: '/dashboard/influencer/stats',
campaigns: '/dashboard/influencer/campaigns',
earnings: '/dashboard/influencer/earnings',
profile: '/dashboard/influencer/profile'
};
navigate(routes[value]);
};
const [stats] = useState({
followers: 125000,
engagement: 4.8,
totalEarnings: 15750,
pendingPayments: 2500,
activeCampaigns: 3,
completedCampaigns: 45,
avgRating: 4.9,
totalViews: 2500000
});
const [campaigns] = useState([
{ id: 1, brand: 'Resort Paradise', type: 'Hotel Review', status: 'active', payment: 800, deadline: '2024-03-25', progress: 60, description: 'Review del resort con contenido en Instagram y YouTube' },
{ id: 2, brand: 'Caribbean Tours', type: 'Tour Promotion', status: 'pending', payment: 500, deadline: '2024-03-30', progress: 0, description: 'Promocion de tours por el Caribe' },
{ id: 3, brand: 'Local Restaurant', type: 'Food Review', status: 'completed', payment: 350, deadline: '2024-03-10', progress: 100, description: 'Review de restaurante local' },
{ id: 4, brand: 'Beach Hotel', type: 'Hotel Stay', status: 'available', payment: 650, deadline: '2024-04-15', progress: 0, description: 'Estadia de 3 noches con contenido' },
{ id: 5, brand: 'Adventure Co', type: 'Activity Review', status: 'available', payment: 400, deadline: '2024-04-20', progress: 0, description: 'Review de actividades de aventura' }
]);
const [platformStats] = useState([
{ platform: 'Instagram', followers: 85000, engagement: 5.2, posts: 342, icon: Instagram, growth: 3.2 },
{ platform: 'YouTube', followers: 32000, engagement: 4.1, posts: 87, icon: Youtube, growth: 5.1 },
{ platform: 'Twitter', followers: 8000, engagement: 3.8, posts: 1205, icon: Twitter, growth: 1.8 }
]);
const [earnings] = useState([
{ id: 1, brand: 'Resort Paradise', amount: 800, status: 'paid', date: '2024-03-15', method: 'PayPal' },
{ id: 2, brand: 'Caribbean Tours', amount: 500, status: 'pending', date: '2024-03-20', method: 'Bank Transfer' },
{ id: 3, brand: 'Local Restaurant', amount: 350, status: 'paid', date: '2024-03-10', method: 'PayPal' },
{ id: 4, brand: 'Hotel XYZ', amount: 1200, status: 'paid', date: '2024-02-28', method: 'Bank Transfer' },
{ id: 5, brand: 'Tour Company', amount: 450, status: 'processing', date: '2024-03-18', method: 'PayPal' }
]);
const [profileData] = useState({
displayName: 'Maria Rodriguez',
username: 'maria_viajera',
bio: 'Exploradora del Caribe. Comparto los mejores destinos y experiencias de viaje.',
location: 'Santo Domingo, RD',
website: 'https://mariaviajera.com',
categories: ['travel', 'lifestyle', 'food'],
pricePerPost: 500
});
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 300);
return () => clearTimeout(timer);
}, []);
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
return num.toString();
};
const getStatusBadge = (status: string) => {
const config: Record<string, { label: string; color: string }> = {
active: { label: 'Activa', color: 'bg-green-100 text-green-700' },
pending: { label: 'Pendiente', color: 'bg-yellow-100 text-yellow-700' },
completed: { label: 'Completada', color: 'bg-gray-100 text-gray-700' },
available: { label: 'Disponible', color: 'bg-blue-100 text-blue-700' },
paid: { label: 'Pagado', color: 'bg-green-100 text-green-700' },
processing: { label: 'Procesando', color: 'bg-orange-100 text-orange-700' }
};
return config[status] || config.pending;
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Influencer Dashboard</h1>
<p className="text-gray-600 mt-1">Gestiona tu perfil, campanas y ganancias</p>
</div>
</div>
{/* Tabs Navigation */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-5">
<TabsTrigger value="dashboard" className="flex items-center gap-2">
<Home className="h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span>
</TabsTrigger>
<TabsTrigger value="stats" className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Estadisticas</span>
</TabsTrigger>
<TabsTrigger value="campaigns" className="flex items-center gap-2">
<Megaphone className="h-4 w-4" />
<span className="hidden sm:inline">Campanas</span>
</TabsTrigger>
<TabsTrigger value="earnings" className="flex items-center gap-2">
<Wallet className="h-4 w-4" />
<span className="hidden sm:inline">Earnings</span>
</TabsTrigger>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">Perfil</span>
</TabsTrigger>
</TabsList>
{/* Dashboard Tab */}
<TabsContent value="dashboard" className="space-y-6">
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Seguidores</CardTitle>
<Users className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{formatNumber(stats.followers)}</div>
<p className="text-xs text-green-600 flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 mr-1" />+2.5% este mes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Engagement</CardTitle>
<Heart className="h-5 w-5 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">{stats.engagement}%</div>
<p className="text-xs text-green-600 flex items-center mt-1">
<ArrowUpRight className="h-3 w-3 mr-1" />+0.3% vs promedio
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Ganancias</CardTitle>
<DollarSign className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">${stats.totalEarnings.toLocaleString()}</div>
<p className="text-xs text-gray-500 mt-1">${stats.pendingPayments.toLocaleString()} pendiente</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Rating</CardTitle>
<Star className="h-5 w-5 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900 flex items-center">
{stats.avgRating}
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400 ml-1" />
</div>
<p className="text-xs text-gray-500 mt-1">De {stats.completedCampaigns} campanas</p>
</CardContent>
</Card>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
Plataformas
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{platformStats.map((platform) => {
const Icon = platform.icon;
return (
<div key={platform.platform} className="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<Icon className="h-5 w-5 text-gray-700" />
<div className="flex-1">
<span className="font-medium text-gray-900">{platform.platform}</span>
</div>
<span className="text-sm text-gray-500">{formatNumber(platform.followers)}</span>
</div>
);
})}
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-orange-500" />
Campanas Activas
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{campaigns.filter(c => c.status === 'active').map((campaign) => (
<div key={campaign.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<h4 className="font-semibold text-gray-900">{campaign.brand}</h4>
<p className="text-sm text-gray-500">{campaign.type}</p>
</div>
<div className="flex items-center gap-3">
<Progress value={campaign.progress} className="w-20 h-2" />
<span className="text-sm text-gray-500">{campaign.progress}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Stats Tab */}
<TabsContent value="stats" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{platformStats.map((platform) => {
const Icon = platform.icon;
return (
<Card key={platform.platform}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
{platform.platform}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Seguidores</p>
<p className="text-xl font-bold">{formatNumber(platform.followers)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Engagement</p>
<p className="text-xl font-bold">{platform.engagement}%</p>
</div>
<div>
<p className="text-sm text-gray-500">Posts</p>
<p className="text-xl font-bold">{platform.posts}</p>
</div>
<div>
<p className="text-sm text-gray-500">Crecimiento</p>
<p className="text-xl font-bold text-green-600">+{platform.growth}%</p>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5 text-orange-500" />
Resumen de Rendimiento
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{formatNumber(stats.totalViews)}</p>
<p className="text-sm text-gray-500">Vistas Totales</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.completedCampaigns}</p>
<p className="text-sm text-gray-500">Campanas Completadas</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.avgRating}</p>
<p className="text-sm text-gray-500">Rating Promedio</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">{stats.engagement}%</p>
<p className="text-sm text-gray-500">Engagement Global</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Campaigns Tab */}
<TabsContent value="campaigns" className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Input placeholder="Buscar campanas..." className="w-64" />
<Button variant="outline" size="icon"><Filter className="h-4 w-4" /></Button>
</div>
<Button className="bg-gradient-to-r from-orange-500 to-red-500">
<Search className="h-4 w-4 mr-2" />
Explorar Campanas
</Button>
</div>
<div className="grid gap-4">
{campaigns.map((campaign) => {
const statusConfig = getStatusBadge(campaign.status);
return (
<Card key={campaign.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900">{campaign.brand}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{campaign.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-green-600 font-medium">
<DollarSign className="h-4 w-4" />${campaign.payment}
</span>
<span className="flex items-center gap-1 text-gray-500">
<Calendar className="h-4 w-4" />{campaign.deadline}
</span>
</div>
</div>
{campaign.status === 'active' && (
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">Progreso</p>
<p className="font-bold">{campaign.progress}%</p>
</div>
<Progress value={campaign.progress} className="w-24 h-2" />
</div>
)}
{campaign.status === 'available' && (
<Button size="sm">Aplicar</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
</TabsContent>
{/* Earnings Tab */}
<TabsContent value="earnings" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Total Ganado</CardTitle>
<DollarSign className="h-5 w-5 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">${stats.totalEarnings.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Pendiente</CardTitle>
<Clock className="h-5 w-5 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">${stats.pendingPayments.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-gray-600">Este Mes</CardTitle>
<TrendingUp className="h-5 w-5 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-900">$1,650</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Historial de Pagos</CardTitle>
<Button variant="outline" size="sm">
<Download className="h-4 w-4 mr-2" />
Exportar
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{earnings.map((earning) => {
const statusConfig = getStatusBadge(earning.status);
return (
<div key={earning.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
{earning.method === 'PayPal' ? <CreditCard className="h-5 w-5" /> : <BanknoteIcon className="h-5 w-5" />}
</div>
<div>
<p className="font-medium text-gray-900">{earning.brand}</p>
<p className="text-sm text-gray-500">{earning.date} - {earning.method}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
<span className="font-bold text-gray-900">${earning.amount}</span>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Profile Tab */}
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Mi Perfil Publico</CardTitle>
<Button variant="outline">
<Edit className="h-4 w-4 mr-2" />
Editar Perfil
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-start gap-6">
<div className="relative">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-red-500 rounded-full flex items-center justify-center">
<span className="text-white text-3xl font-bold">{profileData.displayName[0]}</span>
</div>
<button className="absolute bottom-0 right-0 p-2 bg-white rounded-full shadow-lg">
<Camera className="h-4 w-4 text-gray-600" />
</button>
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900">{profileData.displayName}</h2>
<p className="text-gray-500">@{profileData.username}</p>
<p className="text-gray-600 mt-2">{profileData.bio}</p>
<div className="flex items-center gap-4 mt-3 text-sm text-gray-500">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />{profileData.location}
</span>
<span className="flex items-center gap-1">
<LinkIcon className="h-4 w-4" />{profileData.website}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t">
<div>
<Label>Nombre</Label>
<Input value={profileData.displayName} className="mt-1" />
</div>
<div>
<Label>Username</Label>
<Input value={profileData.username} className="mt-1" />
</div>
<div className="md:col-span-2">
<Label>Bio</Label>
<Textarea value={profileData.bio} className="mt-1" rows={3} />
</div>
<div>
<Label>Ubicacion</Label>
<Input value={profileData.location} className="mt-1" />
</div>
<div>
<Label>Precio por Post ($)</Label>
<Input type="number" value={profileData.pricePerPost} className="mt-1" />
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button variant="outline">Cancelar</Button>
<Button className="bg-gradient-to-r from-orange-500 to-red-500">
Guardar Cambios
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
export default InfluencerDashboard;

View File

@@ -1,5 +1,5 @@
// API Configuration // API Configuration
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1'; const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';
// Get auth token from localStorage (support both keys) // Get auth token from localStorage (support both keys)
const getAuthToken = () => { const getAuthToken = () => {

View File

@@ -3,7 +3,7 @@
* Integración con la API del sistema de aplicaciones turísticas * Integración con la API del sistema de aplicaciones turísticas
*/ */
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1'; const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';
// Tipos de datos principales // Tipos de datos principales
export interface User { export interface User {

View File

@@ -0,0 +1,279 @@
/**
* Collections API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export interface CollectionItem {
id: string;
collectionId: string;
itemId: string;
itemType: string;
itemName?: string;
itemImageUrl?: string;
notes?: string;
sortOrder: number;
createdAt: string;
}
export interface Collection {
id: string;
userId: string;
name: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic: boolean;
sortOrder: number;
itemCount?: number;
items?: CollectionItem[];
createdAt: string;
updatedAt: string;
}
export interface CollectionStats {
totalCollections: number;
totalItems: number;
publicCollections: number;
privateCollections: number;
}
export interface CreateCollectionDto {
name: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic?: boolean;
}
export interface UpdateCollectionDto {
name?: string;
description?: string;
coverImageUrl?: string;
color?: string;
icon?: string;
isPublic?: boolean;
}
export interface AddCollectionItemDto {
itemId: string;
itemType: string;
itemName?: string;
itemImageUrl?: string;
notes?: string;
}
class CollectionsApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis colecciones
async getMyCollections(): Promise<Collection[]> {
try {
const response = await fetch(`${this.baseUrl}/collections/my`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collections: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collections:', error);
throw error;
}
}
// Obtener estadísticas de colecciones
async getCollectionsStats(): Promise<CollectionStats> {
try {
const response = await fetch(`${this.baseUrl}/collections/my/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collections stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collections stats:', error);
throw error;
}
}
// Crear colección
async createCollection(data: CreateCollectionDto): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating collection:', error);
throw error;
}
}
// Obtener colección por ID
async getCollectionById(id: string): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching collection:', error);
throw error;
}
}
// Actualizar colección
async updateCollection(id: string, data: UpdateCollectionDto): Promise<Collection> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating collection:', error);
throw error;
}
}
// Eliminar colección
async deleteCollection(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting collection: ${response.status}`);
}
} catch (error) {
console.error('Error deleting collection:', error);
throw error;
}
}
// Agregar item a colección
async addItemToCollection(collectionId: string, data: AddCollectionItemDto): Promise<CollectionItem> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding item to collection: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding item to collection:', error);
throw error;
}
}
// Quitar item de colección
async removeItemFromCollection(collectionId: string, itemId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items/${itemId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing item from collection: ${response.status}`);
}
} catch (error) {
console.error('Error removing item from collection:', error);
throw error;
}
}
// Reordenar items en colección
async reorderCollectionItems(collectionId: string, itemIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/${collectionId}/items/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ itemIds }),
});
if (!response.ok) {
throw new Error(`Error reordering collection items: ${response.status}`);
}
} catch (error) {
console.error('Error reordering collection items:', error);
throw error;
}
}
// Reordenar colecciones
async reorderCollections(collectionIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/collections/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ collectionIds }),
});
if (!response.ok) {
throw new Error(`Error reordering collections: ${response.status}`);
}
} catch (error) {
console.error('Error reordering collections:', error);
throw error;
}
}
}
export const collectionsApi = new CollectionsApiService();
export default collectionsApi;

View File

@@ -1 +1 @@
export const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api/v1'; export const API_BASE_URL = 'https://api.karibeo.ai:8443/api/v1';

View File

@@ -0,0 +1,241 @@
/**
* Favorites API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
// Tipos de items que pueden ser favoritos
export type FavoriteItemType = 'place' | 'restaurant' | 'hotel' | 'attraction' | 'tour' | 'experience' | 'product';
export interface Favorite {
id: string;
userId: string;
itemId: string;
itemType: FavoriteItemType;
itemName?: string;
itemImageUrl?: string;
itemMetadata?: Record<string, any>;
notes?: string;
createdAt: string;
}
export interface FavoritesCounts {
total: number;
byType: Record<FavoriteItemType, number>;
}
export interface CreateFavoriteDto {
itemId: string;
itemType: FavoriteItemType;
itemName?: string;
itemImageUrl?: string;
itemMetadata?: Record<string, any>;
notes?: string;
}
export interface UpdateFavoriteDto {
notes?: string;
itemName?: string;
itemImageUrl?: string;
}
class FavoritesApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis favoritos
async getMyFavorites(itemType?: FavoriteItemType): Promise<Favorite[]> {
try {
const url = new URL(`${this.baseUrl}/favorites/my`);
if (itemType) {
url.searchParams.append('itemType', itemType);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorites: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorites:', error);
throw error;
}
}
// Obtener conteo de favoritos por tipo
async getFavoritesCounts(): Promise<FavoritesCounts> {
try {
const response = await fetch(`${this.baseUrl}/favorites/my/counts`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorites counts: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorites counts:', error);
throw error;
}
}
// Verificar si un item es favorito
async checkFavorite(itemType: FavoriteItemType, itemId: string): Promise<{ isFavorite: boolean; favoriteId?: string }> {
try {
const response = await fetch(`${this.baseUrl}/favorites/check/${itemType}/${itemId}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error checking favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error checking favorite:', error);
throw error;
}
}
// Agregar a favoritos
async addFavorite(data: CreateFavoriteDto): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding favorite:', error);
throw error;
}
}
// Toggle favorito (agregar/quitar)
async toggleFavorite(data: CreateFavoriteDto): Promise<{ action: 'added' | 'removed'; favorite?: Favorite }> {
try {
const response = await fetch(`${this.baseUrl}/favorites/toggle`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error toggling favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error toggling favorite:', error);
throw error;
}
}
// Obtener un favorito por ID
async getFavoriteById(id: string): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching favorite:', error);
throw error;
}
}
// Actualizar notas de un favorito
async updateFavorite(id: string, data: UpdateFavoriteDto): Promise<Favorite> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating favorite: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating favorite:', error);
throw error;
}
}
// Eliminar favorito por ID
async removeFavorite(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/favorites/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing favorite: ${response.status}`);
}
} catch (error) {
console.error('Error removing favorite:', error);
throw error;
}
}
// Eliminar favorito por item (itemType + itemId)
async removeFavoriteByItem(itemType: FavoriteItemType, itemId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/favorites/item/${itemType}/${itemId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error removing favorite: ${response.status}`);
}
} catch (error) {
console.error('Error removing favorite:', error);
throw error;
}
}
}
export const favoritesApi = new FavoritesApiService();
export default favoritesApi;

View File

@@ -0,0 +1,244 @@
/**
* Notifications API Service
* Conectado a la API real de Karibeo - Fase 2
*/
import { API_BASE_URL } from './config';
export type NotificationType = 'reservation' | 'review' | 'payment' | 'system' | 'promotion' | 'alert' | 'message';
export interface Notification {
id: string;
userId: string;
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
isRead: boolean;
readAt?: string;
createdAt: string;
}
export interface NotificationStats {
total: number;
unread: number;
byType: Record<NotificationType, number>;
}
export interface CreateNotificationDto {
type: NotificationType;
title: string;
message: string;
data?: Record<string, any>;
targetUserId?: string;
targetRole?: string;
}
class NotificationsApiService {
private getToken(): string | null {
return localStorage.getItem('karibeo_token') || localStorage.getItem('karibeo-token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener mis notificaciones
async getMyNotifications(options?: {
type?: NotificationType;
isRead?: boolean;
page?: number;
limit?: number;
}): Promise<{ notifications: Notification[]; total: number; unreadCount: number }> {
try {
const params = new URLSearchParams();
if (options?.type) params.append('type', options.type);
if (options?.isRead !== undefined) params.append('isRead', options.isRead.toString());
if (options?.page) params.append('page', options.page.toString());
if (options?.limit) params.append('limit', options.limit.toString());
const url = `${API_BASE_URL}/notifications/my?${params}`;
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching notifications: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching notifications:', error);
throw error;
}
}
// Obtener conteo de notificaciones no leidas
async getUnreadCount(): Promise<number> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/unread-count`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching unread count: ${response.status}`);
}
const data = await response.json();
return data.count || 0;
} catch (error) {
console.error('Error fetching unread count:', error);
return 0;
}
}
// Marcar notificacion como leida
async markAsRead(notificationId: string): Promise<Notification> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}/read`, {
method: 'PATCH',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error marking notification as read: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error marking notification as read:', error);
throw error;
}
}
// Marcar todas como leidas
async markAllAsRead(): Promise<{ count: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/read-all`, {
method: 'PATCH',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error marking all notifications as read: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error marking all notifications as read:', error);
throw error;
}
}
// Eliminar notificacion
async deleteNotification(notificationId: string): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/${notificationId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting notification: ${response.status}`);
}
} catch (error) {
console.error('Error deleting notification:', error);
throw error;
}
}
// Eliminar todas las notificaciones leidas
async deleteAllRead(): Promise<{ count: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/clear-read`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting read notifications: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error deleting read notifications:', error);
throw error;
}
}
// === ADMIN METHODS ===
// Crear notificacion (admin)
async createNotification(data: CreateNotificationDto): Promise<Notification> {
try {
const response = await fetch(`${API_BASE_URL}/notifications`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating notification: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating notification:', error);
throw error;
}
}
// Enviar notificacion masiva (admin)
async sendBulkNotification(data: {
type: NotificationType;
title: string;
message: string;
targetRoles?: string[];
targetUserIds?: string[];
}): Promise<{ sent: number }> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/bulk`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error sending bulk notification: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error sending bulk notification:', error);
throw error;
}
}
// Obtener estadisticas de notificaciones (admin)
async getNotificationStats(): Promise<NotificationStats> {
try {
const response = await fetch(`${API_BASE_URL}/notifications/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching notification stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching notification stats:', error);
throw error;
}
}
}
export const notificationsApi = new NotificationsApiService();
export default notificationsApi;

191
src/services/quizApi.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Quiz API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export interface QuizQuestion {
id: string;
question: string;
type: 'single' | 'multiple' | 'scale';
options: QuizOption[];
category: string;
order: number;
}
export interface QuizOption {
id: string;
label: string;
value: string;
icon?: string;
description?: string;
}
export interface QuizResponse {
id: string;
userId: string;
travelStyles: string[];
preferredActivities: string[];
accommodationPreferences: string[];
budgetRange: string;
groupType: string;
cuisinePreferences: string[];
travelPersona: string;
personaDescription: string;
isCompleted: boolean;
completedAt?: string;
createdAt: string;
}
export interface SubmitQuizDto {
answers: QuizAnswer[];
}
export interface QuizAnswer {
questionId: string;
selectedOptions: string[];
}
// Travel Personas que puede generar el quiz
export type TravelPersona =
| 'Adventure Explorer'
| 'Luxury Connoisseur'
| 'Cultural Enthusiast'
| 'Beach Relaxer'
| 'Foodie Traveler'
| 'Nature Lover'
| 'Urban Explorer'
| 'Budget Backpacker'
| 'Family Vacationer'
| 'Romantic Getaway';
class QuizApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Obtener preguntas del quiz
async getQuestions(): Promise<QuizQuestion[]> {
try {
const response = await fetch(`${this.baseUrl}/quiz/questions`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching quiz questions: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching quiz questions:', error);
throw error;
}
}
// Obtener mi respuesta del quiz
async getMyQuizResponse(): Promise<QuizResponse | null> {
try {
const response = await fetch(`${this.baseUrl}/quiz/my`, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Error fetching quiz response: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching quiz response:', error);
throw error;
}
}
// Enviar respuestas del quiz
async submitQuiz(data: SubmitQuizDto): Promise<QuizResponse> {
try {
const response = await fetch(`${this.baseUrl}/quiz/submit`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error submitting quiz: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error submitting quiz:', error);
throw error;
}
}
// Reiniciar quiz (para volver a tomarlo)
async resetQuiz(): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/quiz/reset`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error resetting quiz: ${response.status}`);
}
} catch (error) {
console.error('Error resetting quiz:', error);
throw error;
}
}
// Verificar si el usuario ya completó el quiz
async hasCompletedQuiz(): Promise<boolean> {
try {
const response = await this.getMyQuizResponse();
return response?.isCompleted ?? false;
} catch (error) {
return false;
}
}
// Obtener la Travel Persona del usuario
async getTravelPersona(): Promise<{ persona: string; description: string } | null> {
try {
const response = await this.getMyQuizResponse();
if (response?.isCompleted && response.travelPersona) {
return {
persona: response.travelPersona,
description: response.personaDescription,
};
}
return null;
} catch (error) {
return null;
}
}
}
export const quizApi = new QuizApiService();
export default quizApi;

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api'; import { API_BASE_URL } from './config';
export interface Review { export interface Review {
id: string; id: string;

View File

@@ -1,4 +1,4 @@
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api'; import { API_BASE_URL } from './config';
export interface TourismOffer { export interface TourismOffer {
id: string; id: string;

408
src/services/tripsApi.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* Trips API Service
* Conectado a la API real de Karibeo - Fase 1
*/
import { API_CONFIG } from '@/config/api';
const API_BASE_URL = API_CONFIG.BASE_URL;
export type TripStatus = 'planning' | 'upcoming' | 'in_progress' | 'completed' | 'cancelled';
export interface TripActivity {
id: string;
dayId: string;
title: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
sortOrder: number;
createdAt: string;
}
export interface TripDay {
id: string;
tripId: string;
dayNumber: number;
date?: string;
title?: string;
notes?: string;
activities?: TripActivity[];
createdAt: string;
}
export interface Trip {
id: string;
userId: string;
name: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount: number;
estimatedBudget?: number;
currency: string;
status: TripStatus;
isPublic: boolean;
days?: TripDay[];
createdAt: string;
updatedAt: string;
}
export interface TripStats {
totalTrips: number;
byStatus: Record<TripStatus, number>;
upcomingTrips: number;
completedTrips: number;
}
export interface CreateTripDto {
name: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount?: number;
estimatedBudget?: number;
currency?: string;
isPublic?: boolean;
}
export interface UpdateTripDto {
name?: string;
description?: string;
coverImageUrl?: string;
startDate?: string;
endDate?: string;
destination?: string;
travelersCount?: number;
estimatedBudget?: number;
currency?: string;
status?: TripStatus;
isPublic?: boolean;
}
export interface CreateTripDayDto {
dayNumber: number;
date?: string;
title?: string;
notes?: string;
}
export interface UpdateTripDayDto {
dayNumber?: number;
date?: string;
title?: string;
notes?: string;
}
export interface CreateTripActivityDto {
title: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
}
export interface UpdateTripActivityDto {
title?: string;
description?: string;
startTime?: string;
endTime?: string;
location?: string;
locationCoords?: { lat: number; lng: number };
itemId?: string;
itemType?: string;
estimatedCost?: number;
}
class TripsApiService {
private baseUrl: string;
constructor() {
this.baseUrl = API_BASE_URL;
}
private getToken(): string | null {
return localStorage.getItem('karibeo_token');
}
private getHeaders(): HeadersInit {
const token = this.getToken();
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// ============ TRIPS ============
// Obtener mis viajes
async getMyTrips(status?: TripStatus): Promise<Trip[]> {
try {
const url = new URL(`${this.baseUrl}/trips/my`);
if (status) {
url.searchParams.append('status', status);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trips: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trips:', error);
throw error;
}
}
// Obtener estadísticas de viajes
async getTripsStats(): Promise<TripStats> {
try {
const response = await fetch(`${this.baseUrl}/trips/my/stats`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trips stats: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trips stats:', error);
throw error;
}
}
// Crear viaje
async createTrip(data: CreateTripDto): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error creating trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error creating trip:', error);
throw error;
}
}
// Obtener viaje por ID
async getTripById(id: string): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error fetching trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching trip:', error);
throw error;
}
}
// Actualizar viaje
async updateTrip(id: string, data: UpdateTripDto): Promise<Trip> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating trip: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating trip:', error);
throw error;
}
}
// Eliminar viaje
async deleteTrip(id: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting trip: ${response.status}`);
}
} catch (error) {
console.error('Error deleting trip:', error);
throw error;
}
}
// ============ DAYS ============
// Agregar día al viaje
async addDay(tripId: string, data: CreateTripDayDto): Promise<TripDay> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding day: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding day:', error);
throw error;
}
}
// Actualizar día
async updateDay(tripId: string, dayId: string, data: UpdateTripDayDto): Promise<TripDay> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating day: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating day:', error);
throw error;
}
}
// Eliminar día
async deleteDay(tripId: string, dayId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting day: ${response.status}`);
}
} catch (error) {
console.error('Error deleting day:', error);
throw error;
}
}
// ============ ACTIVITIES ============
// Agregar actividad
async addActivity(tripId: string, dayId: string, data: CreateTripActivityDto): Promise<TripActivity> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error adding activity: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error adding activity:', error);
throw error;
}
}
// Actualizar actividad
async updateActivity(tripId: string, dayId: string, activityId: string, data: UpdateTripActivityDto): Promise<TripActivity> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/${activityId}`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Error updating activity: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Error updating activity:', error);
throw error;
}
}
// Eliminar actividad
async deleteActivity(tripId: string, dayId: string, activityId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/${activityId}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Error deleting activity: ${response.status}`);
}
} catch (error) {
console.error('Error deleting activity:', error);
throw error;
}
}
// Reordenar actividades
async reorderActivities(tripId: string, dayId: string, activityIds: string[]): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/trips/${tripId}/days/${dayId}/activities/order`, {
method: 'PATCH',
headers: this.getHeaders(),
body: JSON.stringify({ activityIds }),
});
if (!response.ok) {
throw new Error(`Error reordering activities: ${response.status}`);
}
} catch (error) {
console.error('Error reordering activities:', error);
throw error;
}
}
}
export const tripsApi = new TripsApiService();
export default tripsApi;