-
-
React Boilerplate
-
-
-
-
-
+ <>
+
-
+
+ >
);
};
\ No newline at end of file
diff --git a/src/front/index.css b/src/front/index.css
index e69de29bb2..4388a23e73 100644
--- a/src/front/index.css
+++ b/src/front/index.css
@@ -0,0 +1,105 @@
+
+:root {
+ --color-sg-green: #00473C;
+ --color-sg-light-green: #f4f3e7;
+}
+.map-wrapper {
+ flex-grow: 1;
+ position: relative; /* Clave para que el absolute del mapa funcione */
+ height: 100%;
+}
+#map-container {
+ position: absolute; /* Para que rellene todo el wrapper */
+ top: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+}
+/* Así imitamos a Tailwind sin que rompa el resto de la web */
+.flex { display: flex; }
+.w-full { width: 100%; }
+.h-full { height: 100%; }
+.w-1-4 { width: 25%; } /* Equivalente a w-1/4 */
+.w-3-4 { width: 75%; } /* Equivalente a w-3/4 */
+.p-4 { padding: 1.5rem; }
+.bg-sg-light-green { background-color: var(--color-sg-light-green); }
+.text-sg-green { color: var(--color-sg-green); }
+.font-bold { font-weight: bold; }
+.text-xl { font-size: 1.25rem; }
+
+/* Para que el mapa no tape el Navbar */
+nav {
+ z-index: 10;
+ position: relative;
+}
+.button-container {
+ display: flex;
+ position: absolute; /* Cambiado a absolute para que se pegue al mapa y no a la pantalla */
+ left: 0.75rem;
+ z-index: 50;
+ top: 0.75rem;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+/* Estilos de los botones del tutorial del mapa*/
+.category-button {
+ color: #1F2937;
+ background-color: #ffffff;
+ border-radius: 9999px;
+ padding: 0.5rem 1rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ transition: all 0.2s ease-in-out;
+ border: 1px solid #e5e7eb;
+ cursor: pointer;
+}
+
+.category-button:hover {
+ background-color: #f3f4f6;
+}
+
+.category-button.active {
+ background-color: #e5e7eb;
+ font-weight: bold;
+}
+
+.popup-title {
+ font-weight: bold;
+}
+
+.popup-address {
+ color: #444;
+ font-size: 12px;
+}
+
+.marker-emoji {
+ position: absolute;
+ top: 6px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 15px;
+ pointer-events: none;
+}
+/*------Boton de busqueda mapa ----*/
+.search-area-button {
+ position: absolute; /* Usamos absolute porque el padre es relative */
+ top: 15px;
+ left: 50%;
+ transform: translateX(-50%); /* Centrado exacto */
+ z-index: 10;
+ background-color: #00473C; /* Tu color verde oscuro */
+ color: white;
+ padding: 8px 16px;
+ border-radius: 20px;
+ border: none;
+ font-weight: bold;
+ cursor: pointer;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+}
+
+.search-area-button:hover {
+ background-color: #005f51;
+}
\ No newline at end of file
diff --git a/src/front/pages/Map.jsx b/src/front/pages/Map.jsx
new file mode 100644
index 0000000000..8b5e070763
--- /dev/null
+++ b/src/front/pages/Map.jsx
@@ -0,0 +1,243 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import mapboxgl from 'mapbox-gl';
+import { SearchBoxCore } from '@mapbox/search-js-core';
+import { Sidebar } from '../components/Map-components/Sidebar';
+import { POIMarker } from '../components/Map-components/POIMarker';
+import { Marker } from '../components/Map-components/Marker';
+import { getAllSpots } from '../services/spotServices';
+import '../../front/index.css';
+import 'mapbox-gl/dist/mapbox-gl.css';
+
+const MAPBOX_ACCESS_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN;
+
+export const Map = () => {
+ // --- REFERENCIAS ---
+ const mapRef = useRef(null);
+ const mapContainerRef = useRef(null);
+ const searchRef = useRef(null);
+
+ // --- ESTADOS ---
+ const [isMapReady, setIsMapReady] = useState(false);
+ const [searchCategory, setSearchCategory] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [mapBounds, setMapBounds] = useState();
+ const [searchBounds, setSearchBounds] = useState();
+ const [showSearchAreaButton, setShowSearchAreaButton] = useState(false);
+ const [stores, setStores] = useState([]);
+ const [selectedStore, setSelectedStore] = useState(null);
+
+ // Estado para los filtros coordinados con el equipo
+ const [filters, setFilters] = useState({
+ water: false,
+ sleep: false,
+ waste: false,
+ electricity: false
+ });
+
+ // 1. CARGA INICIAL: Sincronización con la Base de Datos
+ useEffect(() => {
+ const loadSpots = async () => {
+ const data = await getAllSpots();
+ if (data) setStores(data);
+ };
+ loadSpots();
+ }, []);
+
+ // --- LÓGICA DE FILTRADO UNIFICADA ---
+ const filteredStores = useMemo(() => {
+ return (stores || []).filter(store => {
+ // Filtros de servicios
+ const matchWater = !filters.water || store.has_water === true;
+ const matchSleep = !filters.sleep || store.is_sleepable === true;
+ const matchWaste = !filters.waste || store.has_waste_dump === true;
+ const matchElectric = !filters.electricity || store.has_electricity === true;
+
+ // Filtro de categoría (mantiene todos visibles si no hay selección)
+ const matchesCategory = !searchCategory ||
+ (searchCategory === "water_waste" && (store.has_water || store.has_waste_dump)) ||
+ (searchCategory === "parking" && (store.is_sleepable || store.category === "parking")) ||
+ (searchCategory === "campground" && (store.category === "campground" || store.category === "area")) ||
+ (store.category === searchCategory);
+
+ return matchWater && matchSleep && matchWaste && matchElectric && matchesCategory;
+ });
+ }, [stores, filters, searchCategory]);
+
+ // 2. INICIALIZAR EL MAPA
+ useEffect(() => {
+ if (mapRef.current) return;
+
+ const timer = setTimeout(() => {
+ mapRef.current = new mapboxgl.Map({
+ accessToken: MAPBOX_ACCESS_TOKEN,
+ container: mapContainerRef.current,
+ style: 'mapbox://styles/mapbox/streets-v12',
+ center: [-3.70379, 40.41678], // Madrid
+ zoom: 13,
+ minZoom: 6
+ });
+
+ mapRef.current.on('load', () => {
+ setMapBounds(mapRef.current.getBounds().toArray());
+ setIsMapReady(true);
+ });
+
+ mapRef.current.on('moveend', () => {
+ setMapBounds(mapRef.current.getBounds().toArray());
+ });
+
+ searchRef.current = new SearchBoxCore({
+ accessToken: MAPBOX_ACCESS_TOKEN,
+ language: 'es'
+ });
+ }, 100);
+
+ return () => {
+ clearTimeout(timer);
+ if (mapRef.current) mapRef.current.remove();
+ };
+ }, []);
+
+ // 3. NAVEGACIÓN Y VUELO
+ useEffect(() => {
+ if (selectedStore && mapRef.current) {
+ const lng = selectedStore.longitude || selectedStore.geometry?.coordinates[0];
+ const lat = selectedStore.latitude || selectedStore.geometry?.coordinates[1];
+ if (lng && lat) {
+ mapRef.current.flyTo({ center: [lng, lat], zoom: 14, essential: true });
+ }
+ }
+ }, [selectedStore]);
+
+ // 4. LÓGICA DE BÚSQUEDA EXTERNA (MAPBOX)
+ const performCategorySearch = async () => {
+ if (!searchCategory || !mapBounds || !searchRef.current) return;
+ if (searchCategory === "water_waste") {
+ setSearchResults([]);
+ setShowSearchAreaButton(false);
+ return;
+ }
+
+ const flatBbox = [mapBounds[0][0], mapBounds[0][1], mapBounds[1][0], mapBounds[1][1]];
+
+ try {
+ const { features } = await searchRef.current.category(searchCategory, {
+ bbox: flatBbox,
+ limit: 15
+ });
+
+ let cleanFeatures = features;
+ if (searchCategory === "campground") {
+ const forbiddenWords = ["infantil", "scout", "niños", "youth", "school", "campamento"];
+ cleanFeatures = features.filter(feature => {
+ const name = (feature.properties.name || "").toLowerCase();
+ return !forbiddenWords.some(word => name.includes(word));
+ });
+ }
+ setSearchResults(cleanFeatures);
+ setSearchBounds(mapBounds);
+ setShowSearchAreaButton(false);
+ } catch (error) {
+ setSearchResults([]);
+ }
+ };
+
+ useEffect(() => { if (searchCategory) performCategorySearch(); }, [searchCategory]);
+
+ useEffect(() => {
+ if (searchCategory && searchBounds) {
+ const boundsChanged = JSON.stringify(mapBounds) !== JSON.stringify(searchBounds);
+ setShowSearchAreaButton(boundsChanged);
+ }
+ }, [mapBounds, searchCategory, searchBounds]);
+
+ // --- 5. UNIFICACIÓN DE DATOS (SIDEBAR) ---
+ const unifiedListForSidebar = useMemo(() => {
+ if (!mapBounds) return [];
+
+ // Extraemos los límites actuales del mapa
+ const [[swLng, swLat], [neLng, neLat]] = mapBounds;
+
+ // 1. Filtramos los puntos de nuestra DB para que SOLO aparezcan los que se ven en el mapa
+ const visibleDbSpots = filteredStores.filter(s => {
+ return s.longitude >= swLng && s.longitude <= neLng &&
+ s.latitude >= swLat && s.latitude <= neLat;
+ }).map(s => ({ ...s, id: `db-${s.spot_id}`, isCustom: true }));
+
+ // 2. Los resultados de Mapbox ya vienen filtrados por área desde la API
+ const mapboxSpots = searchResults.map(f => ({
+ ...f,
+ id: f.properties.mapbox_id || f.id,
+ name: f.properties.name,
+ address: f.properties.full_address || f.properties.address,
+ isCustom: false
+ }));
+
+ // 3. Unimos ambos y ORDENAMOS por estrellas
+ return [...visibleDbSpots, ...mapboxSpots].sort((a, b) => {
+ const ratingA = a.rating || 0;
+ const ratingB = b.rating || 0;
+ return ratingB - ratingA;
+ });
+ }, [filteredStores, searchResults, mapBounds]);
+
+ const categoryButtons = [
+ { label: "🏕️ Áreas y Campings", value: "campground" },
+ { label: "🅿️ Parkings (Pernocta)", value: "parking" },
+ { label: "💧 Vaciado y Agua", value: "water_waste" },
+ { label: "⛽ Gasolineras", value: "gas_station" },
+ { label: "🛒 Supermercados", value: "supermarket" }
+ ];
+
+ return (
+
+
+
+
+
+ {categoryButtons.map(({ label, value }) => (
+
+ ))}
+
+
+ {showSearchAreaButton && (
+
+ )}
+
+ {/* Marcadores de la Comunidad (Verdes) */}
+ {isMapReady && filteredStores.map((store) => (
+
+ ))}
+
+ {/* Marcadores Externos (Azules) */}
+ {isMapReady && searchResults.map((feature) => (
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/pages/Signup.jsx b/src/front/pages/Signup.jsx
new file mode 100644
index 0000000000..176736ff58
--- /dev/null
+++ b/src/front/pages/Signup.jsx
@@ -0,0 +1,161 @@
+import { useEffect, useState } from "react"
+import useGlobalReducer from "../hooks/useGlobalReducer.jsx";
+import { signUp } from "../services/BackEndService.js";
+import { useNavigate } from "react-router-dom";
+
+export const Signup = () => {
+
+ const { store, dispatch } = useGlobalReducer()
+ const navigate = useNavigate();
+
+ const [user, setUser] = useState({
+ email: "",
+ password: "",
+ confirmPassword: "",
+ })
+
+ const handleChange = (e) => {
+ setUser({
+ ...user,
+ [e.target.name]: e.target.value
+ })
+ }
+
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError("");
+
+ if (!user.email.trim() || !user.password.trim() || !user.confirmPassword.trim()) {
+ setError("Por favor, completa todos los campos.");
+ return;
+ }
+ if (user.password.length < 6) {
+ setError("La contraseña debe tener al menos 6 caracteres.");
+ return;
+ }
+
+ if (user.password !== user.confirmPassword) {
+ setError("Las contraseñas no coinciden");
+ return;
+ }
+ setLoading(true);
+ const response = await signUp(user)
+ if (response.error) {
+ setError(response.error)
+ return;
+ }
+ navigate("/")
+
+ }
+
+ useEffect(() => {
+ console.log(user)
+ }, [user])
+
+
+ return (
+
+
+
+
Crea tu perfil
+
+ {error &&
{error}
}
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/front/pages/Spots.jsx b/src/front/pages/Spots.jsx
new file mode 100644
index 0000000000..0001372ae3
--- /dev/null
+++ b/src/front/pages/Spots.jsx
@@ -0,0 +1,9 @@
+import React from "react";
+
+export const Spots = () => {
+ return (
+
+
POR HACER
+
+ );
+};
\ No newline at end of file
diff --git a/src/front/routes.jsx b/src/front/routes.jsx
index 0557df6141..3e40761ee4 100644
--- a/src/front/routes.jsx
+++ b/src/front/routes.jsx
@@ -9,6 +9,9 @@ import { Layout } from "./pages/Layout";
import { Home } from "./pages/Home";
import { Single } from "./pages/Single";
import { Demo } from "./pages/Demo";
+import { Signup } from "./pages/Signup";
+import { Map } from "./pages/Map";
+import { Spots } from "./pages/Spots";
export const router = createBrowserRouter(
createRoutesFromElements(
@@ -25,6 +28,10 @@ export const router = createBrowserRouter(
} />
} /> {/* Dynamic route for single items */}
} />
+
} />
+
+
} />
+
} />
)
);
\ No newline at end of file
diff --git a/src/front/services/BackEndService.js b/src/front/services/BackEndService.js
new file mode 100644
index 0000000000..48761b4199
--- /dev/null
+++ b/src/front/services/BackEndService.js
@@ -0,0 +1,14 @@
+export const signUp = async (user) => {
+ const response = await fetch(`${import.meta.env.VITE_BACKEND_URL}/auth/signup`,
+ {
+ method: "POST",
+ body: JSON.stringify(user),
+ headers: { "Content-Type": "application/json" },
+ });
+const data = await response.json();
+ if(!response.ok){
+ alert(data.error)
+ return
+ }
+ return data;
+}
\ No newline at end of file
diff --git a/src/front/services/no_usar.js b/src/front/services/no_usar.js
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/front/services/spotServices.js b/src/front/services/spotServices.js
new file mode 100644
index 0000000000..da9b3c3e61
--- /dev/null
+++ b/src/front/services/spotServices.js
@@ -0,0 +1,26 @@
+const API_URL = import.meta.env.VITE_BACKEND_URL;
+
+export const getAllSpots = async () => {
+ try {
+ // Limpiamos la URL por si hay dobles barras
+ const cleanUrl = `${API_URL}/api/spots`.replace(/([^:]\/)\/+/g, "$1");
+ console.log("🔍 Intentando conectar a:", cleanUrl);
+
+ const response = await fetch(cleanUrl);
+
+ // Verificamos el tipo de contenido antes de procesar
+ const contentType = response.headers.get("content-type");
+
+ if (contentType && contentType.includes("application/json")) {
+ return await response.json();
+ } else {
+ const errorText = await response.text();
+ console.error("❌ El servidor respondió con HTML en lugar de JSON. Probablemente un error 404 o 500 del Backend.");
+ return [];
+ }
+
+ } catch (error) {
+ console.error("❌ Error de red o conexión:", error);
+ return [];
+ }
+};
\ No newline at end of file
diff --git a/src/seed_campers.py b/src/seed_campers.py
new file mode 100644
index 0000000000..5b8d7f6b06
--- /dev/null
+++ b/src/seed_campers.py
@@ -0,0 +1,270 @@
+from app import app
+from api.models import db, User, Post_spot
+
+CAMPER_SPOTS = [
+ {
+ "name": "Área de Autocaravanas de Vitoria-Gasteiz",
+ "category": "campground",
+ "description": "Área municipal gratuita, muy amplia y asfaltada. Una de las mejores de España para hacer parada técnica. Conectada por carril bici con el centro.",
+ "address": "Portal de Foronda, 46",
+ "city": "Vitoria-Gasteiz",
+ "latitude": 42.864700,
+ "longitude": -2.685300,
+ "rating": 4.8,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Parking Faro de Finisterre",
+ "category": "parking",
+ "description": "Parking en el fin del mundo. Vistas espectaculares al océano y atardeceres increíbles. Muy expuesto al viento. Solo aparcar y dormir, prohibido sacar toldos.",
+ "address": "Carretera del Faro",
+ "city": "Fisterra (A Coruña)",
+ "latitude": 42.882400,
+ "longitude": -9.271800,
+ "rating": 4.9,
+ "is_sleepable": True,
+ "has_water": False,
+ "has_waste_dump": False
+ },
+ {
+ "name": "Área de Servicio Ugaldebieta",
+ "category": "water_waste",
+ "description": "Punto estratégico de vaciado y llenado en la autovía A-8. Servicios gratuitos si echas combustible. Perfecto para hacer ruta por el norte.",
+ "address": "Autovía A-8, Km 131",
+ "city": "Abanto Zierbena (Bizkaia)",
+ "latitude": 43.321400,
+ "longitude": -3.072800,
+ "rating": 4.0,
+ "is_sleepable": False,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Camper Park Costa Blanca",
+ "category": "campground",
+ "description": "Área privada de pago (Camper Park). Instalaciones de primera calidad, electricidad, duchas calientes y lavandería. Ideal para estancias largas en invierno.",
+ "address": "Camí del Romeral",
+ "city": "L'Alfàs del Pi (Alicante)",
+ "latitude": 38.583300,
+ "longitude": -0.091100,
+ "rating": 4.7,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Parking Playa de Valdevaqueros",
+ "category": "parking",
+ "description": "Explanada de tierra frente a la playa. Mítico para furgonetas y amantes del kitesurf. En verano suele ser de pago por el día. Cero servicios.",
+ "address": "N-340, Km 75.5",
+ "city": "Tarifa (Cádiz)",
+ "latitude": 36.071850,
+ "longitude": -5.663180,
+ "rating": 4.5,
+ "is_sleepable": True,
+ "has_water": False,
+ "has_waste_dump": False
+ },
+ {
+ "name": "Área Municipal de Astorga",
+ "category": "campground",
+ "description": "Área pública junto a la plaza de toros. Muy tranquila, a 10 minutos andando de la Catedral y el Palacio de Gaudí. Parada clásica de la Ruta de la Plata.",
+ "address": "Calle de los Derechos Humanos",
+ "city": "Astorga (León)",
+ "latitude": 42.455200,
+ "longitude": -6.065500,
+ "rating": 4.6,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Punto de Vaciado La Jonquera",
+ "category": "water_waste",
+ "description": "Último/primer punto de vaciado seguro antes de cruzar la frontera con Francia. Muy concurrido.",
+ "address": "N-II, Polígono Industrial",
+ "city": "La Jonquera (Girona)",
+ "latitude": 42.404200,
+ "longitude": 2.877600,
+ "rating": 3.8,
+ "is_sleepable": False,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Área Camper Peñíscola Los Pinos",
+ "category": "campground",
+ "description": "Camper park privado a 15 min de la playa. Suelo de gravilla, muy llano. Permiten sacar mesas y sillas (comportamiento de camping).",
+ "address": "Camí de la Volta",
+ "city": "Peñíscola (Castellón)",
+ "latitude": 40.383500,
+ "longitude": 0.395100,
+ "rating": 4.4,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Área de Autocaravanas de Cáceres (Valhondo)",
+ "category": "campground",
+ "description": "Área municipal gratuita muy famosa. A 15 minutos andando del casco histórico. Suele llenarse rápido en puentes. Servicios de agua y vaciado gratuitos.",
+ "address": "Avenida del Brocense",
+ "city": "Cáceres",
+ "latitude": 39.481600,
+ "longitude": -6.368600,
+ "rating": 4.5,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Área del Parque de Cabárceno",
+ "category": "campground",
+ "description": "Un lugar espectacular junto a un lago en el exterior del parque de animales. Entorno natural increíble. Muy tranquilo por la noche.",
+ "address": "Lago de Acebo",
+ "city": "Obregón (Cantabria)",
+ "latitude": 43.350300,
+ "longitude": -3.854400,
+ "rating": 4.8,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Parking Los Peñones (Sierra Nevada)",
+ "category": "parking",
+ "description": "El parking para autocaravanas más alto de España (2.500m). De pago en temporada de esquí (unos 15€). Perfecto para salir esquiando desde la furgo. Frío extremo en invierno.",
+ "address": "Carretera de la Sierra",
+ "city": "Monachil (Granada)",
+ "latitude": 37.098400,
+ "longitude": -3.390800,
+ "rating": 4.6,
+ "is_sleepable": True,
+ "has_water": False,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Área Camper Pinto (Madrid)",
+ "category": "campground",
+ "description": "Punto estratégico para visitar Madrid capital. Muy cerca de la estación de tren de cercanías. Zona asfaltada, videovigilada y con todos los servicios. De pago.",
+ "address": "Calle Pablo Picasso",
+ "city": "Pinto (Madrid)",
+ "latitude": 40.244700,
+ "longitude": -3.695300,
+ "rating": 4.3,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Estación de Vaciado CEPSA - Meco",
+ "category": "water_waste",
+ "description": "Gasolinera en el corredor del Henares con borne específico Euro-Relais. Muy útil para los que entran o salen de Madrid por la A-2.",
+ "address": "Autovía A-2, Km 35",
+ "city": "Meco (Madrid)",
+ "latitude": 40.521500,
+ "longitude": -3.328300,
+ "rating": 4.0,
+ "is_sleepable": False,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Parking Dinópolis",
+ "category": "parking",
+ "description": "Parking gigantesco y gratuito frente al parque temático. El ayuntamiento y el parque permiten la pernocta sin problema. Muchísimo espacio y muy seguro.",
+ "address": "Polígono Los Planos",
+ "city": "Teruel",
+ "latitude": 40.329400,
+ "longitude": -1.085800,
+ "rating": 4.2,
+ "is_sleepable": True,
+ "has_water": False,
+ "has_waste_dump": False
+ },
+ {
+ "name": "Área Camper Riumar (Delta de l'Ebre)",
+ "category": "campground",
+ "description": "Área privada muy bien cuidada en el corazón del Delta. Ideal para rutas en bici y avistamiento de aves. Suelo de tierra compactada.",
+ "address": "Passeig Marítim",
+ "city": "Deltebre (Tarragona)",
+ "latitude": 40.727800,
+ "longitude": 0.835300,
+ "rating": 4.6,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Parking de la Selva de Irati",
+ "category": "parking",
+ "description": "Aparcamiento en plena naturaleza para visitar el bosque (acceso por Orbaizeta). Hay que pagar una pequeña tasa de conservación ambiental en temporada alta. Cero contaminación lumínica.",
+ "address": "Carretera a Irati",
+ "city": "Orbaizeta (Navarra)",
+ "latitude": 43.003300,
+ "longitude": -1.229400,
+ "rating": 4.7,
+ "is_sleepable": True,
+ "has_water": False,
+ "has_waste_dump": False
+ },
+ {
+ "name": "Área Municipal de Muxía",
+ "category": "campground",
+ "description": "Frente al mar y muy cerca del santuario. Suele hacer bastante viento pero las vistas son inmejorables. Servicios básicos gratuitos.",
+ "address": "Rúa Virxe da Barca",
+ "city": "Muxía (A Coruña)",
+ "latitude": 43.107500,
+ "longitude": -9.215500,
+ "rating": 4.4,
+ "is_sleepable": True,
+ "has_water": True,
+ "has_waste_dump": True
+ },
+ {
+ "name": "Gasolinera Repsol - Área de Servicio del Desierto",
+ "category": "water_waste",
+ "description": "En plena ruta por el desierto de Tabernas. Tienen una plataforma de vaciado muy amplia para autocaravanas grandes.",
+ "address": "N-340a, Km 464",
+ "city": "Tabernas (Almería)",
+ "latitude": 37.050600,
+ "longitude": -2.392500,
+ "rating": 3.9,
+ "is_sleepable": False,
+ "has_water": True,
+ "has_waste_dump": True
+ }
+]
+
+def seed_database():
+ with app.app_context():
+ # 1. Crear un usuario "Admin" para asociar estos lugares
+ admin = User.query.filter_by(email="camperbot@app.com").first()
+ if not admin:
+ admin = User(
+ email="camperbot@app.com",
+ password_hash="password_segura",
+ name="CamperBot Oficial"
+ )
+ db.session.add(admin)
+ db.session.commit()
+ print("👤 Usuario CamperBot creado.")
+
+ # 2. Insertar los lugares evitando duplicados
+ spots_added = 0
+ for spot_data in CAMPER_SPOTS:
+ exists = Post_spot.query.filter_by(name=spot_data["name"]).first()
+ if not exists:
+ new_spot = Post_spot(user_id=admin.id, **spot_data)
+ db.session.add(new_spot)
+ spots_added += 1
+
+ db.session.commit()
+ print(f"🚐 ¡Éxito! Se han insertado {spots_added} nuevos spots camper en tu base de datos local.")
+
+
+
+if __name__ == '__main__':
+ seed_database()
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000000..20fcf2cba9
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,16 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ 'sg-green': '#00473C',
+ 'sg-light-green': '#f4f3e7',
+ },
+ },
+ },
+ plugins: [],
+}
\ No newline at end of file