Documentation Index
Fetch the complete documentation index at: https://mintlify.com/Jesus-Puertos/h-ayuntamiento/llms.txt
Use this file to discover all available pages before exploring further.
Overview
Tourism components power the personalized travel experience on the platform. They handle:
- User onboarding with preference collection
- Route generation based on user interests
- Social sharing of personalized itineraries
- Favorites and saved attractions
- Real-time user interactions
All tourism components are located in /src/components/turismo/.
TurismoOnboarding.tsx
Interactive multi-step wizard for collecting user travel preferences.
Features
- 5-step onboarding flow with visual choices
- Full-screen immersive experience
- Google OAuth authentication
- Saves preferences to Supabase
- Generates personalized route
- Unlocks achievement badges
- Confetti celebration on completion
Component Structure
// src/components/turismo/TurismoOnboarding.tsx
import { useEffect, useState, useCallback } from 'react';
import {
supabase,
signInWithGoogle,
saveUserPreferences,
saveUserRoute,
unlockBadge,
markOnboardingCompleted,
type UserPreferences,
} from '@/lib/supabase';
import { generateRouteName } from '@/lib/recommendations';
import { generateShareCode } from '@/lib/generateTicket';
import { nanoid } from 'nanoid';
export default function TurismoOnboarding() {
const [step, setStep] = useState(0);
const [user, setUser] = useState(null);
const [preferences, setPreferences] = useState<Partial<UserPreferences>>({});
// ...
}
Onboarding Steps
Step 0: Authentication
Sign in with Google before starting onboarding.
const handleGoogleSignIn = async () => {
const { error } = await signInWithGoogle('/turismo?onboarding=1');
if (error) {
console.error('OAuth error:', error);
}
};
Step 1: Experience Type
Choose preferred experiences (multi-select):
const EXPERIENCIAS: Choice[] = [
{
id: 'aventura',
label: 'Aventura',
icon: '⛰️',
description: 'Cuevas, rappel, descensos',
image: '/turismo/gallery/img-5.webp'
},
{
id: 'cultura',
label: 'Cultura',
icon: '🎭',
description: 'Tradición y artesanía',
image: '/turismo/gallery/img-2.webp'
},
{
id: 'naturaleza',
label: 'Naturaleza',
icon: '🌿',
description: 'Cascadas y miradores',
image: '/turismo/gallery/img-4.webp'
},
{
id: 'gastronomia',
label: 'Gastronomía',
icon: '🍽️',
description: 'Sabores serranos',
image: '/turismo/gallery/img-3.webp'
},
{
id: 'relax',
label: 'Descanso',
icon: '✨',
description: 'Paz y contemplación',
image: '/turismo/gallery/img-1.webp'
}
];
Step 2: Trip Duration
Select how much time available (single-select):
const DURACIONES: Choice[] = [
{ id: 'medio-dia', label: 'Medio día', icon: '🌅', description: 'Escapada rápida' },
{ id: 'dia-completo', label: 'Día completo', icon: '☀️', description: 'Sin prisas' },
{ id: 'fin-semana', label: 'Fin de semana', icon: '🗓️', description: 'Planea con calma' },
{ id: 'semana', label: 'Más días', icon: '🌙', description: 'Inmersión total' }
];
Step 3: Difficulty Level
Physical intensity preference:
const DIFICULTADES: Choice[] = [
{ id: 'facil', label: 'Suave', icon: '🚶', description: 'Paseos tranquilos' },
{ id: 'moderado', label: 'Moderado', icon: '🥾', description: 'Algo de caminata' },
{ id: 'dificil', label: 'Intenso', icon: '⛰️', description: 'Rutas largas' },
{ id: 'extremo', label: 'Extremo', icon: '💪', description: 'Solo expertos' }
];
Step 4: Travel Group
Who they’re traveling with:
const GRUPOS: Choice[] = [
{ id: 'solo', label: 'Solo', icon: '🧭', description: 'Mi propio ritmo' },
{ id: 'pareja', label: 'Pareja', icon: '💑', description: 'Escapada romántica' },
{ id: 'familia', label: 'Familia', icon: '👨👩👧', description: 'Con niños' },
{ id: 'amigos', label: 'Amigos', icon: '👥', description: 'Grupo de aventura' }
];
Step 5: Specific Interests
Fine-tune attraction types (multi-select):
const INTERESES: Choice[] = [
{ id: 'cascadas', label: 'Cascadas', icon: '💧' },
{ id: 'miradores', label: 'Miradores', icon: '🌄' },
{ id: 'artesanias', label: 'Artesanías', icon: '🎨' },
{ id: 'campamento', label: 'Campamento', icon: '⛺' },
{ id: 'cuevas', label: 'Cuevas', icon: '🕳️' },
{ id: 'rios', label: 'Ríos', icon: '🏞️' }
];
Completing Onboarding
const handleComplete = async () => {
if (!user) return;
try {
// 1. Save preferences
await saveUserPreferences({
user_id: user.id,
experiencia: preferences.experiencia || [],
duracion: preferences.duracion || 'dia-completo',
dificultad: preferences.dificultad || 'moderado',
grupo: preferences.grupo || 'solo',
intereses: preferences.intereses || []
});
// 2. Generate route
const routeName = generateRouteName(preferences);
const shareCode = nanoid(10);
const atractivos = selectAtractivos(preferences); // Your recommendation logic
await saveUserRoute({
user_id: user.id,
user_name: user.user_metadata.name,
route_name: routeName,
atractivos,
ticket_url: `/ruta/${shareCode}`,
share_code: shareCode,
badges: ['explorador']
});
// 3. Unlock badge
await unlockBadge(user.id, 'explorador');
// 4. Mark onboarding complete
await markOnboardingCompleted(user.id);
// 5. Show celebration
showConfetti();
// 6. Redirect to route
window.location.href = `/ruta/${shareCode}`;
} catch (error) {
console.error('Error completing onboarding:', error);
}
};
Usage in Pages
---
// pages/turismo.astro
import TurismoOnboarding from '@/components/turismo/TurismoOnboarding.tsx';
import { getCurrentUser, isOnboardingCompleted } from '@/lib/supabase';
const user = await getCurrentUser();
let showOnboarding = false;
if (user) {
showOnboarding = !(await isOnboardingCompleted(user.id));
}
---
{showOnboarding ? (
<TurismoOnboarding client:load />
) : (
<p>Bienvenido de vuelta!</p>
)}
Interactive button to save/unsave attractions.
Features
- Real-time favorite toggle
- Optimistic UI updates
- Authentication check
- Toast notifications
- Supabase integration
Implementation
// src/components/turismo/FavoriteButton.tsx
import { useState, useEffect } from 'react';
import { getCurrentUser, isFavorite, toggleFavorite } from '@/lib/supabase';
import { toast } from 'sonner';
import { Heart } from 'lucide-react';
interface Props {
atractivoSlug: string;
}
export default function FavoriteButton({ atractivoSlug }: Props) {
const [user, setUser] = useState(null);
const [isFav, setIsFav] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
async function init() {
const currentUser = await getCurrentUser();
setUser(currentUser);
if (currentUser) {
const favStatus = await isFavorite(currentUser.id, atractivoSlug);
setIsFav(favStatus);
}
}
init();
}, [atractivoSlug]);
const handleToggle = async () => {
if (!user) {
toast.error('Inicia sesión para guardar favoritos');
return;
}
setLoading(true);
// Optimistic update
setIsFav(!isFav);
try {
const newStatus = await toggleFavorite(user.id, atractivoSlug);
toast.success(newStatus ? '¡Guardado en favoritos!' : 'Eliminado de favoritos');
} catch (error) {
// Revert on error
setIsFav(isFav);
toast.error('Error al guardar favorito');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleToggle}
disabled={loading}
className="p-2 rounded-full hover:bg-gray-100 transition"
aria-label={isFav ? 'Quitar de favoritos' : 'Guardar en favoritos'}
>
<Heart
className={`w-6 h-6 transition ${
isFav ? 'fill-red-500 text-red-500' : 'text-gray-400'
}`}
/>
</button>
);
}
Usage
---
import FavoriteButton from '@/components/turismo/FavoriteButton.tsx';
---
<div class="atractivo-header">
<h1>Cascada de Texpico</h1>
<FavoriteButton client:load atractivoSlug="cascada-texpico" />
</div>
Social sharing component for routes and attractions.
Features
- Share to Facebook, Twitter, WhatsApp
- Copy link to clipboard
- Native share API (mobile)
- Download ticket image
Implementation
// src/components/ShareButtons.tsx
import { useState, useEffect } from 'react';
interface ShareButtonsProps {
shareCode?: string;
routeName?: string;
ticketUrl?: string;
url?: string;
title?: string;
text?: string;
}
export default function ShareButtons({
shareCode,
routeName,
ticketUrl,
url,
title,
text
}: ShareButtonsProps) {
const [copied, setCopied] = useState(false);
const [shareUrl, setShareUrl] = useState('');
const [shareText, setShareText] = useState('');
useEffect(() => {
if (url) {
setShareUrl(url);
} else if (shareCode) {
setShareUrl(`${window.location.origin}/ruta/${shareCode}`);
}
if (text) {
setShareText(text);
} else if (routeName) {
setShareText(`🎫 ¡Mira mi ruta personalizada en Zongolica! ${routeName}`);
}
}, [shareCode, routeName, url, text]);
const shareOnFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
};
const shareOnTwitter = () => {
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
};
const shareOnWhatsApp = () => {
const url = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${shareUrl}`)}`;
window.open(url, '_blank');
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Error al copiar:', error);
}
};
const shareNative = async () => {
if (navigator.share) {
try {
await navigator.share({
title: routeName,
text: shareText,
url: shareUrl
});
} catch (error) {
console.log('Error al compartir:', error);
}
} else {
copyToClipboard();
}
};
return (
<div className="space-y-4">
<h3 className="text-lg font-bold text-gray-900">Comparte tu ruta</h3>
<div className="grid grid-cols-2 gap-3">
<button
onClick={shareOnFacebook}
className="flex items-center justify-center gap-2 px-4 py-3 bg-[#1877F2] text-white rounded-xl font-semibold hover:bg-[#166FE5] transition"
>
{/* Facebook icon SVG */}
Facebook
</button>
<button
onClick={shareOnTwitter}
className="flex items-center justify-center gap-2 px-4 py-3 bg-[#1DA1F2] text-white rounded-xl font-semibold hover:bg-[#1a8cd8] transition"
>
{/* Twitter icon SVG */}
Twitter
</button>
<button
onClick={shareOnWhatsApp}
className="flex items-center justify-center gap-2 px-4 py-3 bg-[#25D366] text-white rounded-xl font-semibold hover:bg-[#20BD5A] transition"
>
{/* WhatsApp icon SVG */}
WhatsApp
</button>
<button
onClick={copyToClipboard}
className="flex items-center justify-center gap-2 px-4 py-3 bg-gray-700 text-white rounded-xl font-semibold hover:bg-gray-600 transition"
>
{copied ? '¡Copiado!' : 'Copiar link'}
</button>
</div>
{navigator.share && (
<button
onClick={shareNative}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-orange-600 to-orange-500 text-white rounded-xl font-bold hover:shadow-lg transition"
>
Compartir más...
</button>
)}
</div>
);
}
Usage
---
import ShareButtons from '@/components/ShareButtons.tsx';
import { getUserRoute } from '@/lib/supabase';
const { shareCode } = Astro.params;
const { data: route } = await getUserRoute(shareCode);
---
<ShareButtons
client:load
shareCode={route.share_code}
routeName={route.route_name}
ticketUrl={route.ticket_url}
/>
MiRutaView.tsx
Display personalized route with map and attractions.
Features
- Shows selected attractions
- Interactive map (if integrated)
- Share buttons
- Download ticket
- Edit preferences
Implementation
// src/components/turismo/MiRutaView.tsx
import { useEffect, useState } from 'react';
import { getCurrentUser, getLatestUserRoute } from '@/lib/supabase';
import ShareButtons from '@/components/ShareButtons.tsx';
export default function MiRutaView() {
const [route, setRoute] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadRoute() {
const user = await getCurrentUser();
if (!user) {
window.location.href = '/turismo?login=1';
return;
}
const latestRoute = await getLatestUserRoute(user.id);
setRoute(latestRoute);
setLoading(false);
}
loadRoute();
}, []);
if (loading) {
return <div className="text-center py-20">Cargando tu ruta...</div>;
}
if (!route) {
return (
<div className="text-center py-20">
<p>No tienes rutas aún.</p>
<a href="/turismo?onboarding=1">Crear mi primera ruta</a>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-4">{route.route_name}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{route.atractivos.map(slug => (
<AtractivoCard key={slug} slug={slug} />
))}
</div>
<ShareButtons
shareCode={route.share_code}
routeName={route.route_name}
ticketUrl={route.ticket_url}
/>
</div>
);
}
PreferencesEditor.tsx
Edit saved preferences without full onboarding flow.
export default function PreferencesEditor() {
const [preferences, setPreferences] = useState(null);
const [loading, setLoading] = useState(false);
const handleSave = async () => {
setLoading(true);
const user = await getCurrentUser();
if (!user) return;
await saveUserPreferences({
user_id: user.id,
...preferences
});
toast.success('Preferencias actualizadas');
setLoading(false);
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
{/* Preference inputs */}
<button type="submit" disabled={loading}>
Guardar cambios
</button>
</form>
);
}
Astro Tourism Components
Static components for tourism pages.
Page header with background image:
---
const { title, subtitle, image } = Astro.props;
---
<header class="relative h-96 overflow-hidden">
<img src={image} alt={title} class="absolute inset-0 w-full h-full object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent"></div>
<div class="relative z-10 flex items-end h-full p-8">
<div>
<h1 class="text-5xl font-black text-white mb-2">{title}</h1>
<p class="text-xl text-white/90">{subtitle}</p>
</div>
</div>
</header>
RouteCard.astro
Preview card for tourism routes:
---
interface Props {
route: {
name: string;
description: string;
duration: string;
difficulty: string;
image: string;
};
}
const { route } = Astro.props;
---
<a href={`/turismo/rutas/${route.slug}`} class="group block">
<div class="relative overflow-hidden rounded-2xl">
<img src={route.image} alt={route.name} class="w-full h-64 object-cover group-hover:scale-110 transition duration-500" />
<div class="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black/80 to-transparent">
<h3 class="text-2xl font-bold text-white mb-2">{route.name}</h3>
<div class="flex gap-4 text-sm text-white/90">
<span>🕒 {route.duration}</span>
<span>⛰️ {route.difficulty}</span>
</div>
</div>
</div>
</a>
1. Lazy Load Below Fold
<TurismoOnboarding client:visible />
<MiRutaView client:visible />
2. Minimize Initial Props
<!-- ✅ Good -->
<FavoriteButton client:load atractivoSlug={slug} />
<!-- ❌ Avoid -->
<FavoriteButton client:load atractivo={fullObject} />
3. Use Optimistic UI
Update UI immediately, sync with server:
const handleToggle = async () => {
setIsFav(!isFav); // Immediate update
try {
await toggleFavorite(userId, slug);
} catch {
setIsFav(isFav); // Revert on error
}
};
Next Steps
UI Components
Header, Footer, and navigation
Supabase Integration
Database functions used by tourism components
Authentication
OAuth flow in TurismoOnboarding
Component Overview
Component architecture patterns