mas cambios
This commit is contained in:
80
src/App.tsx
80
src/App.tsx
@@ -29,6 +29,10 @@ import Messages from "./pages/dashboard/Messages";
|
||||
import Reviews from "./pages/dashboard/Reviews";
|
||||
import Bookings from "./pages/dashboard/Bookings";
|
||||
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 Settings from "./pages/dashboard/Settings";
|
||||
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 CRMCampaigns from "./pages/dashboard/crm/Campaigns";
|
||||
import CRMAnalytics from "./pages/dashboard/crm/Analytics";
|
||||
// Influencer pages
|
||||
import InfluencerDashboard from "./pages/dashboard/influencer/InfluencerDashboard";
|
||||
// Roles & Permissions
|
||||
import RolesPermissions from "./pages/dashboard/RolesPermissions";
|
||||
// Tourist App
|
||||
@@ -270,6 +276,39 @@ const AppRouter = () => (
|
||||
</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={
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout>
|
||||
@@ -701,6 +740,47 @@ const AppRouter = () => (
|
||||
</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 */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -60,7 +60,9 @@ import {
|
||||
Server,
|
||||
ShieldAlert,
|
||||
UserCircle,
|
||||
Mail
|
||||
Mail,
|
||||
TrendingUp,
|
||||
Instagram
|
||||
} from 'lucide-react';
|
||||
|
||||
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: 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,
|
||||
label: t('commerce'),
|
||||
|
||||
409
src/components/InfluencerMarketplace.tsx
Normal file
409
src/components/InfluencerMarketplace.tsx
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
// API Configuration and Constants
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: 'https://karibeo.lesoluciones.net:8443/api/v1',
|
||||
BASE_URL: 'https://api.karibeo.ai:8443/api/v1',
|
||||
ENDPOINTS: {
|
||||
// Authentication
|
||||
LOGIN: '/auth/login',
|
||||
@@ -43,6 +43,37 @@ export const API_CONFIG = {
|
||||
HOTEL_ROOM_SERVICE: '/hotel/room-service',
|
||||
HOTEL_STATS: '/hotel/establishments/:id/stats',
|
||||
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
|
||||
|
||||
224
src/hooks/useCollections.ts
Normal file
224
src/hooks/useCollections.ts
Normal 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
176
src/hooks/useFavorites.ts
Normal 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;
|
||||
177
src/hooks/useNotifications.ts
Normal file
177
src/hooks/useNotifications.ts
Normal 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
211
src/hooks/useQuiz.ts
Normal 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
289
src/hooks/useTrips.ts
Normal 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;
|
||||
518
src/pages/dashboard/Collections.tsx
Normal file
518
src/pages/dashboard/Collections.tsx
Normal 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;
|
||||
340
src/pages/dashboard/Favorites.tsx
Normal file
340
src/pages/dashboard/Favorites.tsx
Normal 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;
|
||||
445
src/pages/dashboard/Quiz.tsx
Normal file
445
src/pages/dashboard/Quiz.tsx
Normal 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;
|
||||
636
src/pages/dashboard/Trips.tsx
Normal file
636
src/pages/dashboard/Trips.tsx
Normal 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;
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Mail,
|
||||
Plus,
|
||||
@@ -32,10 +33,12 @@ import {
|
||||
Eye,
|
||||
MousePointer,
|
||||
ShoppingCart,
|
||||
Calendar
|
||||
Calendar,
|
||||
Megaphone
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { z } from 'zod';
|
||||
import InfluencerMarketplace from '@/components/InfluencerMarketplace';
|
||||
|
||||
const campaignSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Nombre requerido').max(100, 'Nombre muy largo'),
|
||||
@@ -152,9 +155,28 @@ const Campaigns = () => {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Campañas</h1>
|
||||
<p className="text-gray-600 mt-1">Gestiona tus campañas de marketing</p>
|
||||
<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 email e influencer marketing</p>
|
||||
</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}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
@@ -412,6 +434,13 @@ const Campaigns = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Influencers Tab */}
|
||||
<TabsContent value="influencers">
|
||||
<InfluencerMarketplace />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
562
src/pages/dashboard/influencer/InfluencerDashboard.tsx
Normal file
562
src/pages/dashboard/influencer/InfluencerDashboard.tsx
Normal 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;
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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)
|
||||
const getAuthToken = () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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
|
||||
export interface User {
|
||||
|
||||
279
src/services/collectionsApi.ts
Normal file
279
src/services/collectionsApi.ts
Normal 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;
|
||||
@@ -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';
|
||||
241
src/services/favoritesApi.ts
Normal file
241
src/services/favoritesApi.ts
Normal 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;
|
||||
244
src/services/notificationsApi.ts
Normal file
244
src/services/notificationsApi.ts
Normal 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
191
src/services/quizApi.ts
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
|
||||
import { API_BASE_URL } from './config';
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const API_BASE_URL = 'https://karibeo.lesoluciones.net:8443/api';
|
||||
import { API_BASE_URL } from './config';
|
||||
|
||||
export interface TourismOffer {
|
||||
id: string;
|
||||
|
||||
408
src/services/tripsApi.ts
Normal file
408
src/services/tripsApi.ts
Normal 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;
|
||||
Reference in New Issue
Block a user