Skip to main content

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>
)}

FavoriteButton.tsx

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>

ShareButtons.tsx

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.

TurismoHeader.astro

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>

Performance Tips

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

Build docs developers (and LLMs) love