diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 3a736237..8a904673 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -124,28 +124,6 @@ class WeatherViewTests(APITestCase): self.assertEqual(response.json()["results"][0]["temperature_c"], 15.0) -class RecommendationPhotoProxyValidationTests(APITestCase): - def setUp(self): - self.user = User.objects.create_user( - username="reco-user", - email="reco@example.com", - password="password123", - ) - self.client.force_authenticate(user=self.user) - - def test_google_photo_rejects_invalid_photo_name(self): - response = self.client.get( - "/api/recommendations/google-photo/?photo_name=invalid-photo-name" - ) - self.assertEqual(response.status_code, 400) - - def test_google_photo_rejects_trailing_newline_photo_name(self): - response = self.client.get( - "/api/recommendations/google-photo/?photo_name=places/abc/photos/def%0A" - ) - self.assertEqual(response.status_code, 400) - - class MCPAuthTests(APITestCase): def test_mcp_unauthenticated_access_is_rejected(self): unauthenticated_client = APIClient() diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 0a751b60..8936923b 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -18,11 +18,6 @@ router.register(r"ics-calendar", IcsCalendarGeneratorViewSet, basename="ics-cale router.register(r"search", GlobalSearchView, basename="search") router.register(r"attachments", AttachmentViewSet, basename="attachments") router.register(r"lodging", LodgingViewSet, basename="lodging") -( - router.register( - r"recommendations", RecommendationsViewSet, basename="recommendations" - ), -) router.register(r"backup", BackupViewSet, basename="backup") router.register(r"trails", TrailViewSet, basename="trails") router.register(r"activities", ActivityViewSet, basename="activities") diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 0244be8a..a0f04a5d 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -13,7 +13,6 @@ from .transportation_view import * from .global_search_view import * from .attachment_view import * from .lodging_view import * -from .recommendations_view import * from .import_export_view import * from .trail_view import * from .activity_view import * diff --git a/backend/server/adventures/views/recommendations_view.py b/backend/server/adventures/views/recommendations_view.py deleted file mode 100644 index 53917b58..00000000 --- a/backend/server/adventures/views/recommendations_view.py +++ /dev/null @@ -1,910 +0,0 @@ -from urllib.parse import urlencode -import re - -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from django.conf import settings -import requests -from geopy.distance import geodesic -import logging -from ..geocoding import search_osm -from integrations.models import EncryptionConfigurationError, UserAPIKey - -logger = logging.getLogger(__name__) - - -class RecommendationsViewSet(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - OVERPASS_URL = "https://overpass-api.de/api/interpreter" - NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" - HEADERS = {"User-Agent": "Voyage Server"} - - # Quality thresholds - MIN_GOOGLE_RATING = 3.0 # Minimum rating to include - MIN_GOOGLE_REVIEWS = 5 # Minimum number of reviews - MAX_RESULTS = 50 # Maximum results to return - - def _get_google_api_key(self, request): - user_key = UserAPIKey.objects.filter( - user=request.user, provider="google_maps" - ).first() - if user_key: - try: - decrypted = user_key.get_api_key() - except EncryptionConfigurationError: - decrypted = None - if decrypted: - return decrypted - return getattr(settings, "GOOGLE_MAPS_API_KEY", None) - - def _search_google_text(self, query, api_key): - if not api_key: - return None - - url = "https://places.googleapis.com/v1/places:searchText" - headers = { - "Content-Type": "application/json", - "X-Goog-Api-Key": api_key, - "X-Goog-FieldMask": "places.displayName.text,places.formattedAddress,places.location", - } - payload = { - "textQuery": query, - "maxResultCount": 5, - } - - try: - response = requests.post(url, json=payload, headers=headers, timeout=10) - response.raise_for_status() - data = response.json() - except Exception: - return None - - places = data.get("places", []) or [] - if not places: - return None - - normalized = [] - for place in places: - loc = place.get("location") or {} - normalized.append( - { - "lat": loc.get("latitude"), - "lon": loc.get("longitude"), - "name": (place.get("displayName") or {}).get("text"), - "display_name": place.get("formattedAddress"), - } - ) - return normalized - - def calculate_quality_score(self, place_data): - """ - Calculate a quality score based on multiple factors. - Higher score = better quality recommendation. - """ - import math - - score = 0.0 - - # Rating contribution (0-50 points) - rating = place_data.get("rating") - if rating is not None and rating > 0: - score += (rating / 5.0) * 50 - - # Review count contribution (0-30 points, logarithmic scale) - review_count = place_data.get("review_count") - if review_count is not None and review_count > 0: - # Logarithmic scale: 10 reviews = ~10 pts, 100 = ~20 pts, 1000 = ~30 pts - score += min(30, math.log10(review_count) * 10) - - # Distance penalty (0-20 points, closer is better) - distance_km = place_data.get("distance_km") - if distance_km is not None: - if distance_km < 1: - score += 20 - elif distance_km < 5: - score += 15 - elif distance_km < 10: - score += 10 - elif distance_km < 20: - score += 5 - - # Verified/business status bonus (0-10 points) - if ( - place_data.get("is_verified") - or place_data.get("business_status") == "OPERATIONAL" - ): - score += 10 - - # Has photos bonus (0-5 points) - photos = place_data.get("photos") - if photos and len(photos) > 0: - score += 5 - - # Has opening hours bonus (0-5 points) - opening_hours = place_data.get("opening_hours") - if opening_hours and len(opening_hours) > 0: - score += 5 - - return round(score, 2) - - def parse_google_places(self, places, origin): - """ - Parse Google Places API results into unified format. - Enhanced with quality filtering and comprehensive data extraction. - """ - locations = [] - - for place in places: - location = place.get("location", {}) - types = place.get("types", []) - - # Extract display name - display_name = place.get("displayName", {}) - name = ( - display_name.get("text") - if isinstance(display_name, dict) - else display_name - ) - - # Extract coordinates - lat = location.get("latitude") - lon = location.get("longitude") - - if not name or not lat or not lon: - continue - - # Extract rating information - rating = place.get("rating") - review_count = place.get("userRatingCount", 0) - - # Quality filter: Skip low-rated or unreviewed places - if rating and rating < self.MIN_GOOGLE_RATING: - continue - if review_count < self.MIN_GOOGLE_REVIEWS: - continue - - # Calculate distance - distance_km = geodesic(origin, (lat, lon)).km - - # Extract address information - formatted_address = place.get("formattedAddress") or place.get( - "shortFormattedAddress" - ) - - # Extract business status - business_status = place.get("businessStatus") - is_operational = business_status == "OPERATIONAL" - - # Extract opening hours - opening_hours = place.get("regularOpeningHours", {}) - current_opening_hours = place.get("currentOpeningHours", {}) - is_open_now = current_opening_hours.get("openNow") - - # Extract photos and construct URLs - photos = place.get("photos", []) - photo_urls = [] - if photos: - # Get first 5 photos and construct full URLs - for photo in photos[:5]: - photo_name = photo.get("name", "") - if photo_name: - query = urlencode( - { - "photo_name": photo_name, - "max_height": 800, - "max_width": 800, - } - ) - photo_url = f"/api/recommendations/google-photo/?{query}" - photo_urls.append(photo_url) - - # Extract contact information - phone_number = place.get("nationalPhoneNumber") or place.get( - "internationalPhoneNumber" - ) - website = place.get("websiteUri") - google_maps_uri = place.get("googleMapsUri") - - # Extract price level - price_level = place.get("priceLevel") - - # Extract editorial summary/description - editorial_summary = place.get("editorialSummary", {}) - description = ( - editorial_summary.get("text") - if isinstance(editorial_summary, dict) - else None - ) - - # Filter out unwanted types (generic categories) - filtered_types = [ - t for t in types if t not in ["point_of_interest", "establishment"] - ] - - # Build unified response - place_data = { - "id": f"google:{place.get('id')}", - "external_id": place.get("id"), - "source": "google", - "name": name, - "description": description, - "latitude": lat, - "longitude": lon, - "address": formatted_address, - "distance_km": round(distance_km, 2), - "rating": rating, - "review_count": review_count, - "price_level": price_level, - "types": filtered_types, - "primary_type": filtered_types[0] if filtered_types else None, - "business_status": business_status, - "is_open_now": is_open_now, - "opening_hours": opening_hours.get("weekdayDescriptions", []) - if opening_hours - else None, - "phone_number": phone_number, - "website": website, - "google_maps_url": google_maps_uri, - "photos": photo_urls, - "is_verified": is_operational, - } - - # Calculate quality score - place_data["quality_score"] = self.calculate_quality_score(place_data) - - locations.append(place_data) - - return locations - - def parse_overpass_response(self, data, request, origin): - """ - Parse Overpass API (OSM) results into unified format. - Enhanced with quality filtering and comprehensive data extraction. - """ - nodes = data.get("elements", []) - locations = [] - - for node in nodes: - if node.get("type") not in ["node", "way", "relation"]: - continue - - tags = node.get("tags", {}) - - # Get coordinates (for ways/relations, use center) - lat = node.get("lat") or node.get("center", {}).get("lat") - lon = node.get("lon") or node.get("center", {}).get("lon") - - # Extract name (with fallbacks) - name = tags.get("name") or tags.get("official_name") or tags.get("alt_name") - - if not name or lat is None or lon is None: - continue - - # Calculate distance - distance_km = round(geodesic(origin, (lat, lon)).km, 2) if origin else None - - # Extract address information - address_parts = [ - tags.get("addr:housenumber"), - tags.get("addr:street"), - tags.get("addr:suburb") or tags.get("addr:neighbourhood"), - tags.get("addr:city"), - tags.get("addr:state"), - tags.get("addr:postcode"), - tags.get("addr:country"), - ] - formatted_address = ", ".join(filter(None, address_parts)) or None - - # Extract contact information - phone = tags.get("phone") or tags.get("contact:phone") - website = ( - tags.get("website") or tags.get("contact:website") or tags.get("url") - ) - - # Extract opening hours - opening_hours = tags.get("opening_hours") - - # Extract rating/stars (if available) - stars = tags.get("stars") - - # Determine category/type hierarchy - category_keys = [ - "tourism", - "leisure", - "amenity", - "natural", - "historic", - "attraction", - "shop", - "sport", - ] - types = [tags.get(key) for key in category_keys if key in tags] - primary_type = types[0] if types else None - - # Extract description and additional info - description = tags.get("description") or tags.get("note") - wikipedia = tags.get("wikipedia") or tags.get("wikidata") - - # Extract image if available - image = tags.get("image") or tags.get("wikimedia_commons") - - # Quality filters for OSM data - # Skip if it's just a generic POI without specific category - if not primary_type: - continue - - # Skip construction or disused places - if tags.get("disused") or tags.get("construction"): - continue - - # Build unified response - place_data = { - "id": f"osm:{node.get('type')}:{node.get('id')}", - "external_id": str(node.get("id")), - "source": "osm", - "name": name, - "description": description, - "latitude": lat, - "longitude": lon, - "address": formatted_address, - "distance_km": distance_km, - "rating": None, # OSM doesn't have ratings - "review_count": None, - "price_level": None, - "types": types, - "primary_type": primary_type, - "business_status": None, - "is_open_now": None, - "opening_hours": [opening_hours] if opening_hours else None, - "phone_number": phone, - "website": website, - "google_maps_url": None, - "photos": [image] if image else [], - "is_verified": bool(wikipedia), # Has Wikipedia = more verified - "osm_type": node.get("type"), - "wikipedia": wikipedia, - "stars": stars, - } - - # Calculate quality score (will be lower without ratings) - place_data["quality_score"] = self.calculate_quality_score(place_data) - - locations.append(place_data) - - return locations - - def query_overpass(self, lat, lon, radius, category, request): - """ - Query Overpass API (OpenStreetMap) for nearby places. - Enhanced with better queries and error handling. - """ - # Limit radius for OSM to prevent timeouts (max 5km for OSM due to server limits) - osm_radius = min(radius, 5000) - - # Build optimized query - use simpler queries and limit results - # Reduced timeout and simplified queries to prevent 504 errors - if category == "tourism": - query = f""" - [out:json][timeout:25]; - ( - nwr["tourism"~"attraction|viewpoint|museum|gallery|zoo|aquarium"](around:{osm_radius},{lat},{lon}); - nwr["historic"~"monument|castle|memorial"](around:{osm_radius},{lat},{lon}); - nwr["leisure"~"park|garden|nature_reserve"](around:{osm_radius},{lat},{lon}); - ); - out center tags 50; - """ - elif category == "lodging": - query = f""" - [out:json][timeout:25]; - nwr["tourism"~"hotel|motel|guest_house|hostel"](around:{osm_radius},{lat},{lon}); - out center tags 50; - """ - elif category == "food": - query = f""" - [out:json][timeout:25]; - nwr["amenity"~"restaurant|cafe|bar|pub"](around:{osm_radius},{lat},{lon}); - out center tags 50; - """ - else: - logger.error(f"Invalid category requested: {category}") - return {"error": "Invalid category.", "results": []} - - try: - response = requests.post( - self.OVERPASS_URL, data=query, headers=self.HEADERS, timeout=30 - ) - response.raise_for_status() - data = response.json() - except requests.exceptions.Timeout: - logger.warning( - f"Overpass API timeout for {category} at ({lat}, {lon}) with radius {osm_radius}m" - ) - return { - "error": f"OpenStreetMap query timed out. The service is overloaded. Radius limited to {int(osm_radius)}m.", - "results": [], - } - except requests.exceptions.HTTPError as e: - if e.response.status_code == 504: - logger.warning(f"Overpass API 504 Gateway Timeout for {category}") - return { - "error": "OpenStreetMap server is overloaded. Try again later or use Google source.", - "results": [], - } - logger.warning(f"Overpass API HTTP error: {e}") - return { - "error": f"OpenStreetMap error: please try again later.", - "results": [], - } - except requests.exceptions.RequestException as e: - logger.warning(f"Overpass API error: {e}") - return { - "error": f"OpenStreetMap temporarily unavailable: please try again later.", - "results": [], - } - except ValueError as e: - logger.error(f"Invalid JSON response from Overpass: {e}") - return {"error": "Invalid response from OpenStreetMap.", "results": []} - - origin = (float(lat), float(lon)) - locations = self.parse_overpass_response(data, request, origin) - - logger.info(f"Overpass returned {len(locations)} results") - return {"error": None, "results": locations} - - def query_google_nearby(self, lat, lon, radius, category, request): - """ - Query Google Places API (New) for nearby places. - Enhanced with comprehensive field masks and better error handling. - """ - api_key = self._get_google_api_key(request) - - url = "https://places.googleapis.com/v1/places:searchNearby" - - # Comprehensive field mask to get all useful information - headers = { - "Content-Type": "application/json", - "X-Goog-Api-Key": api_key, - "X-Goog-FieldMask": ( - "places.id," - "places.displayName," - "places.formattedAddress," - "places.shortFormattedAddress," - "places.location," - "places.types," - "places.rating," - "places.userRatingCount," - "places.businessStatus," - "places.priceLevel," - "places.websiteUri," - "places.googleMapsUri," - "places.nationalPhoneNumber," - "places.internationalPhoneNumber," - "places.editorialSummary," - "places.photos," - "places.currentOpeningHours," - "places.regularOpeningHours" - ), - } - - # Map categories to place types - use multiple types for better coverage - type_mapping = { - "lodging": [ - "lodging", - "hotel", - "hostel", - "resort_hotel", - "extended_stay_hotel", - ], - "food": [ - "restaurant", - "cafe", - "bar", - "bakery", - "meal_takeaway", - "meal_delivery", - ], - "tourism": [ - "tourist_attraction", - "museum", - "art_gallery", - "aquarium", - "zoo", - "amusement_park", - "park", - "natural_feature", - ], - } - - payload = { - "includedTypes": type_mapping.get(category, ["tourist_attraction"]), - "maxResultCount": 20, - "rankPreference": "DISTANCE", # Sort by distance first - "locationRestriction": { - "circle": { - "center": {"latitude": float(lat), "longitude": float(lon)}, - "radius": float(radius), - } - }, - } - - try: - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() - data = response.json() - - places = data.get("places", []) - origin = (float(lat), float(lon)) - locations = self.parse_google_places(places, origin) - - logger.info( - f"Google Places returned {len(locations)} quality results for category '{category}'" - ) - - return Response(self._prepare_final_results(locations)) - - except requests.exceptions.Timeout: - logger.warning("Google Places API timeout, falling back to OSM") - return self.query_overpass(lat, lon, radius, category, request) - except requests.exceptions.RequestException as e: - logger.warning(f"Google Places API error: {e}, falling back to OSM") - return self.query_overpass(lat, lon, radius, category, request) - except Exception as e: - logger.error(f"Unexpected error with Google Places API: {e}") - return self.query_overpass(lat, lon, radius, category, request) - - def _prepare_final_results(self, locations): - """ - Prepare final results: sort by quality score and limit results. - """ - # Sort by quality score (highest first) - locations.sort(key=lambda x: x.get("quality_score", 0), reverse=True) - - # Limit to MAX_RESULTS - locations = locations[: self.MAX_RESULTS] - - return locations - - def _deduplicate_results(self, google_results, osm_results): - """ - Deduplicate results from both sources based on name and proximity. - Prioritize Google results when duplicates are found. - """ - from difflib import SequenceMatcher - - def is_similar(name1, name2, threshold=0.85): - """Check if two names are similar using fuzzy matching.""" - return ( - SequenceMatcher(None, name1.lower(), name2.lower()).ratio() > threshold - ) - - def is_nearby(loc1, loc2, max_distance_m=50): - """Check if two locations are within max_distance_m meters.""" - dist = geodesic( - (loc1["latitude"], loc1["longitude"]), - (loc2["latitude"], loc2["longitude"]), - ).meters - return dist < max_distance_m - - # Start with all Google results (higher quality) - deduplicated = list(google_results) - - # Add OSM results that don't match Google results - for osm_loc in osm_results: - is_duplicate = False - for google_loc in google_results: - if is_similar(osm_loc["name"], google_loc["name"]) and is_nearby( - osm_loc, google_loc - ): - is_duplicate = True - break - - if not is_duplicate: - deduplicated.append(osm_loc) - - return deduplicated - - @action(detail=False, methods=["get"]) - def query(self, request): - """ - Query both Google Places and OSM for recommendations. - Returns unified, high-quality results sorted by quality score. - - Query Parameters: - - lat (required): Latitude - - lon (required): Longitude - - radius (optional): Search radius in meters (default: 5000, max: 50000) - - category (required): Category - 'tourism', 'food', or 'lodging' - - sources (optional): Comma-separated sources - 'google', 'osm', or 'both' (default: 'both') - """ - lat = request.query_params.get("lat") - lon = request.query_params.get("lon") - # Allow a free-text `location` parameter which will be geocoded - location_param = request.query_params.get("location") - radius = request.query_params.get("radius", "5000") - category = request.query_params.get("category") - sources = request.query_params.get("sources", "both").lower() - - # If lat/lon not supplied, try geocoding the free-text location param - if (not lat or not lon) and location_param: - geocode_results = None - request_google_api_key = self._get_google_api_key(request) - # Try Google first if API key configured - if request_google_api_key: - try: - geocode_results = self._search_google_text( - location_param, request_google_api_key - ) - except Exception: - logger.warning("Google geocoding failed; falling back to OSM") - geocode_results = None - - # Fallback to OSM Nominatim - if not geocode_results: - try: - geocode_results = search_osm(location_param) - except Exception: - logger.warning("OSM geocoding failed") - geocode_results = None - - # Validate geocode results - if isinstance(geocode_results, dict) and geocode_results.get("error"): - # Log internal geocoding error but avoid exposing sensitive details - logger.warning("Geocoding helper returned an internal error") - return Response( - { - "error": "Geocoding failed. Please try a different location or contact support." - }, - status=400, - ) - - if not geocode_results: - return Response( - {"error": "Could not geocode provided location."}, status=400 - ) - - # geocode_results expected to be a list of results; pick the best (first) - best = None - if isinstance(geocode_results, list) and len(geocode_results) > 0: - best = geocode_results[0] - elif isinstance(geocode_results, dict): - # Some helpers might return a dict when only one result found - best = geocode_results - - if not best: - return Response({"error": "No geocoding results found."}, status=400) - - try: - best_lat = best.get("lat") or best.get("latitude") - best_lon = best.get("lon") or best.get("longitude") - if best_lat is None or best_lon is None: - raise ValueError("missing_coordinates") - lat = float(best_lat) - lon = float(best_lon) - except Exception: - return Response( - {"error": "Geocoding result missing coordinates."}, status=400 - ) - - # Replace location_param with display name when available for logging/debug - location_param = ( - best.get("display_name") or best.get("name") or location_param - ) - - # Validation: require lat and lon at this point - if not lat or not lon: - return Response( - { - "error": "Latitude and longitude parameters are required (or provide a 'location' parameter to geocode)." - }, - status=400, - ) - - try: - lat = float(lat) - lon = float(lon) - radius = min(float(radius), 50000) # Max 50km radius - except ValueError: - return Response( - {"error": "Invalid latitude, longitude, or radius value."}, status=400 - ) - - valid_categories = ["lodging", "food", "tourism"] - if category not in valid_categories: - return Response( - { - "error": f"Invalid category. Valid categories: {', '.join(valid_categories)}" - }, - status=400, - ) - - valid_sources = ["google", "osm", "both"] - if sources not in valid_sources: - return Response( - { - "error": f"Invalid sources. Valid options: {', '.join(valid_sources)}" - }, - status=400, - ) - - api_key = self._get_google_api_key(request) - - google_results = [] - osm_results = [] - - # Query Google Places if available and requested - if api_key and sources in ["google", "both"]: - try: - url = "https://places.googleapis.com/v1/places:searchNearby" - headers = { - "Content-Type": "application/json", - "X-Goog-Api-Key": api_key, - "X-Goog-FieldMask": ( - "places.id,places.displayName,places.formattedAddress," - "places.shortFormattedAddress,places.location,places.types," - "places.rating,places.userRatingCount,places.businessStatus," - "places.priceLevel,places.websiteUri,places.googleMapsUri," - "places.nationalPhoneNumber,places.internationalPhoneNumber," - "places.editorialSummary,places.photos," - "places.currentOpeningHours,places.regularOpeningHours" - ), - } - - type_mapping = { - "lodging": ["lodging", "hotel", "hostel", "resort_hotel"], - "food": ["restaurant", "cafe", "bar", "bakery"], - "tourism": [ - "tourist_attraction", - "museum", - "art_gallery", - "aquarium", - "zoo", - "park", - ], - } - - payload = { - "includedTypes": type_mapping.get(category, ["tourist_attraction"]), - "maxResultCount": 20, - "rankPreference": "DISTANCE", - "locationRestriction": { - "circle": { - "center": {"latitude": lat, "longitude": lon}, - "radius": radius, - } - }, - } - - response = requests.post(url, json=payload, headers=headers, timeout=15) - response.raise_for_status() - data = response.json() - places = data.get("places", []) - origin = (lat, lon) - google_results = self.parse_google_places(places, origin) - logger.info(f"Google Places: {len(google_results)} quality results") - - except Exception as e: - logger.warning(f"Google Places failed: {e}") - - # Query OSM if requested or as fallback - osm_error = None - if sources in ["osm", "both"] or (sources == "google" and not google_results): - osm_response = self.query_overpass(lat, lon, radius, category, request) - osm_results = osm_response.get("results", []) - osm_error = osm_response.get("error") - - if osm_error: - logger.warning(f"OSM query had issues: {osm_error}") - - # Combine and deduplicate if using both sources - if sources == "both" and google_results and osm_results: - all_results = self._deduplicate_results(google_results, osm_results) - else: - all_results = google_results + osm_results - - # Prepare final results - final_results = self._prepare_final_results(all_results) - - logger.info(f"Returning {len(final_results)} total recommendations") - - # Build response with metadata - response_data = { - "count": len(final_results), - "results": final_results, - "sources_used": { - "google": len(google_results), - "osm": len(osm_results), - "total_before_dedup": len(google_results) + len(osm_results), - }, - } - - # Add warnings if there were errors but we still have some results - warnings = [] - if osm_error and len(osm_results) == 0: - warnings.append(osm_error) - - if warnings: - response_data["warnings"] = warnings - - # If no results at all and user requested only OSM, return error status - if len(final_results) == 0 and sources == "osm" and osm_error: - # Log internal error notice for investigation but do not expose details to clients - logger.debug("OSM query error (internal)") - return Response( - { - "error": "OpenStreetMap service temporarily unavailable. Please try again later.", - "count": 0, - "results": [], - "sources_used": response_data["sources_used"], - }, - status=503, - ) - - return Response(response_data) - - @action(detail=False, methods=["get"], url_path="google-photo") - def google_photo(self, request): - photo_name = request.query_params.get("photo_name") - if not photo_name: - return Response( - {"error": "photo_name is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if not re.fullmatch(r"places/[A-Za-z0-9_-]+/photos/[A-Za-z0-9_-]+", photo_name): - return Response( - { - "error": "photo_name must match pattern: places/{place_id}/photos/{photo_id}" - }, - status=status.HTTP_400_BAD_REQUEST, - ) - - api_key = self._get_google_api_key(request) - if not api_key: - return Response( - {"error": "Google API key is not configured for this account."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - max_height = min( - max(int(request.query_params.get("max_height", "800")), 1), 1600 - ) - max_width = min( - max(int(request.query_params.get("max_width", "800")), 1), 1600 - ) - except ValueError: - return Response( - {"error": "max_height and max_width must be integers."}, - status=status.HTTP_400_BAD_REQUEST, - ) - - photo_url = f"https://places.googleapis.com/v1/{photo_name}/media" - try: - upstream = requests.get( - photo_url, - params={ - "key": api_key, - "maxHeightPx": max_height, - "maxWidthPx": max_width, - }, - timeout=15, - ) - except requests.RequestException: - return Response( - {"error": "Unable to fetch Google photo right now."}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - if upstream.status_code >= 400: - return Response( - {"error": "Google photo unavailable."}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - response = Response(upstream.content, status=status.HTTP_200_OK) - response["Content-Type"] = upstream.headers.get("Content-Type", "image/jpeg") - cache_control = upstream.headers.get("Cache-Control") - if cache_control: - response["Cache-Control"] = cache_control - return response diff --git a/backend/server/integrations/tests.py b/backend/server/integrations/tests.py index e4cbdda9..16abb80b 100644 --- a/backend/server/integrations/tests.py +++ b/backend/server/integrations/tests.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - from django.contrib.auth import get_user_model from django.test import override_settings from rest_framework.test import APITestCase @@ -29,29 +27,6 @@ class UserAPIKeyConfigurationTests(APITestCase): self.assertEqual(response.status_code, 503) self.assertIn("invalid", response.json().get("detail", "").lower()) - @override_settings(FIELD_ENCRYPTION_KEY="") - @patch("adventures.views.recommendations_view.requests.get") - def test_google_photo_uses_graceful_fallback_when_user_key_unreadable( - self, mock_requests_get - ): - from integrations.models import UserAPIKey - - # Legacy/bad row exists but cannot be decrypted due to missing key. - UserAPIKey.objects.create( - user=self.user, - provider="google_maps", - encrypted_api_key="not-a-valid-fernet-token", - ) - - response = self.client.get( - "/api/recommendations/google-photo/?photo_name=places/abc/photos/def" - ) - - # Should fail gracefully as misconfigured key path, not crash (500). - self.assertEqual(response.status_code, 400) - self.assertIn("not configured", response.json().get("error", "").lower()) - mock_requests_get.assert_not_called() - class UserAPIKeyCreateBehaviorTests(APITestCase): @override_settings( diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py index 002a03f8..716c9627 100644 --- a/backend/server/worldtravel/urls.py +++ b/backend/server/worldtravel/urls.py @@ -2,18 +2,31 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region, globespin +from .views import ( + CountryViewSet, + RegionViewSet, + VisitedRegionViewSet, + regions_by_country, + visits_by_country, + cities_by_region, + VisitedCityViewSet, + visits_by_region, +) + router = DefaultRouter() -router.register(r'countries', CountryViewSet, basename='countries') -router.register(r'regions', RegionViewSet, basename='regions') -router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion') -router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity') +router.register(r"countries", CountryViewSet, basename="countries") +router.register(r"regions", RegionViewSet, basename="regions") +router.register(r"visitedregion", VisitedRegionViewSet, basename="visitedregion") +router.register(r"visitedcity", VisitedCityViewSet, basename="visitedcity") urlpatterns = [ - path('', include(router.urls)), - path('/regions/', regions_by_country, name='regions-by-country'), - path('/visits/', visits_by_country, name='visits-by-country'), - path('regions//cities/', cities_by_region, name='cities-by-region'), - path('regions//cities/visits/', visits_by_region, name='visits-by-region'), - path('globespin/', globespin, name='globespin'), + path("", include(router.urls)), + path("/regions/", regions_by_country, name="regions-by-country"), + path("/visits/", visits_by_country, name="visits-by-country"), + path("regions//cities/", cities_by_region, name="cities-by-region"), + path( + "regions//cities/visits/", + visits_by_region, + name="visits-by-region", + ), ] diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py index baa604c7..89bec7de 100644 --- a/backend/server/worldtravel/views.py +++ b/backend/server/worldtravel/views.py @@ -1,6 +1,12 @@ from django.shortcuts import get_object_or_404 from .models import Country, Region, VisitedRegion, City, VisitedCity -from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer +from .serializers import ( + CitySerializer, + CountrySerializer, + RegionSerializer, + VisitedRegionSerializer, + VisitedCitySerializer, +) from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -8,15 +14,17 @@ from rest_framework.decorators import api_view, permission_classes, action from django.contrib.gis.geos import Point from adventures.models import Location -@api_view(['GET']) + +@api_view(["GET"]) @permission_classes([IsAuthenticated]) def regions_by_country(request, country_code): country = get_object_or_404(Country, country_code=country_code) - regions = Region.objects.filter(country=country).order_by('name') + regions = Region.objects.filter(country=country).order_by("name") serializer = RegionSerializer(regions, many=True) return Response(serializer.data) -@api_view(['GET']) + +@api_view(["GET"]) @permission_classes([IsAuthenticated]) def visits_by_country(request, country_code): country = get_object_or_404(Country, country_code=country_code) @@ -24,15 +32,17 @@ def visits_by_country(request, country_code): serializer = VisitedRegionSerializer(visits, many=True) return Response(serializer.data) -@api_view(['GET']) + +@api_view(["GET"]) @permission_classes([IsAuthenticated]) def cities_by_region(request, region_id): region = get_object_or_404(Region, id=region_id) - cities = City.objects.filter(region=region).order_by('name') + cities = City.objects.filter(region=region).order_by("name") serializer = CitySerializer(cities, many=True) return Response(serializer.data) -@api_view(['GET']) + +@api_view(["GET"]) @permission_classes([IsAuthenticated]) def visits_by_region(request, region_id): region = get_object_or_404(Region, id=region_id) @@ -40,122 +50,127 @@ def visits_by_region(request, region_id): serializer = VisitedCitySerializer(visits, many=True) return Response(serializer.data) -# view called spin the globe that return a random country, a random region in that country and a random city in that region -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def globespin(request): - country = Country.objects.order_by('?').first() - data = { - "country": CountrySerializer(country).data, - } - - regions = Region.objects.filter(country=country) - if regions.exists(): - region = regions.order_by('?').first() - data["region"] = RegionSerializer(region).data - - cities = City.objects.filter(region=region) - if cities.exists(): - city = cities.order_by('?').first() - data["city"] = CitySerializer(city).data - - return Response(data) class CountryViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Country.objects.all().order_by('name') + queryset = Country.objects.all().order_by("name") serializer_class = CountrySerializer permission_classes = [IsAuthenticated] - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def check_point_in_region(self, request): - lat = float(request.query_params.get('lat')) - lon = float(request.query_params.get('lon')) + lat = float(request.query_params.get("lat")) + lon = float(request.query_params.get("lon")) point = Point(lon, lat, srid=4326) region = Region.objects.filter(geometry__contains=point).first() if region: - return Response({'in_region': True, 'region_name': region.name, 'region_id': region.id}) + return Response( + {"in_region": True, "region_name": region.name, "region_id": region.id} + ) else: - return Response({'in_region': False}) + return Response({"in_region": False}) - @action(detail=False, methods=['post']) + @action(detail=False, methods=["post"]) def region_check_all_adventures(self, request): - adventures = Location.objects.filter(user=request.user.id, type='visited') + adventures = Location.objects.filter(user=request.user.id, type="visited") count = 0 for adventure in adventures: if adventure.latitude is not None and adventure.longitude is not None: try: - point = Point(float(adventure.longitude), float(adventure.latitude), srid=4326) + point = Point( + float(adventure.longitude), float(adventure.latitude), srid=4326 + ) region = Region.objects.filter(geometry__contains=point).first() if region: - if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists(): - VisitedRegion.objects.create(user=request.user, region=region) + if not VisitedRegion.objects.filter( + user=request.user.id, region=region + ).exists(): + VisitedRegion.objects.create( + user=request.user, region=region + ) count += 1 except Exception as e: print(f"Error processing adventure {adventure.id}: {e}") continue - return Response({'regions_visited': count}) + return Response({"regions_visited": count}) + class RegionViewSet(viewsets.ReadOnlyModelViewSet): queryset = Region.objects.all() serializer_class = RegionSerializer permission_classes = [IsAuthenticated] + class VisitedRegionViewSet(viewsets.ModelViewSet): serializer_class = VisitedRegionSerializer permission_classes = [IsAuthenticated] def get_queryset(self): return VisitedRegion.objects.filter(user=self.request.user.id) - + def perform_create(self, serializer): serializer.save(user=self.request.user) def create(self, request, *args, **kwargs): - request.data['user'] = request.user - if VisitedRegion.objects.filter(user=request.user.id, region=request.data['region']).exists(): + request.data["user"] = request.user + if VisitedRegion.objects.filter( + user=request.user.id, region=request.data["region"] + ).exists(): return Response({"error": "Region already visited by user."}, status=400) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + def destroy(self, request, **kwargs): - region = get_object_or_404(Region, id=kwargs['pk']) - visited_region = VisitedRegion.objects.filter(user=request.user.id, region=region) + region = get_object_or_404(Region, id=kwargs["pk"]) + visited_region = VisitedRegion.objects.filter( + user=request.user.id, region=region + ) if visited_region.exists(): visited_region.delete() return Response(status=status.HTTP_204_NO_CONTENT) else: - return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND) - + return Response( + {"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND + ) + + class VisitedCityViewSet(viewsets.ModelViewSet): serializer_class = VisitedCitySerializer permission_classes = [IsAuthenticated] def get_queryset(self): return VisitedCity.objects.filter(user=self.request.user.id) - + def perform_create(self, serializer): serializer.save(user=self.request.user) def create(self, request, *args, **kwargs): - request.data['user'] = request.user + request.data["user"] = request.user serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) # Ensure a VisitedRegion exists for the city - region = serializer.validated_data['city'].region - if not VisitedRegion.objects.filter(user=request.user.id, region=region).exists(): + region = serializer.validated_data["city"].region + if not VisitedRegion.objects.filter( + user=request.user.id, region=region + ).exists(): VisitedRegion.objects.create(user=request.user, region=region) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + def destroy(self, request, **kwargs): - city = get_object_or_404(City, id=kwargs['pk']) + city = get_object_or_404(City, id=kwargs["pk"]) visited_city = VisitedCity.objects.filter(user=request.user.id, city=city) if visited_city.exists(): visited_city.delete() return Response(status=status.HTTP_204_NO_CONTENT) else: - return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND + ) diff --git a/frontend/src/lib/components/CollectionRecommendationView.svelte b/frontend/src/lib/components/CollectionRecommendationView.svelte deleted file mode 100644 index ceafc707..00000000 --- a/frontend/src/lib/components/CollectionRecommendationView.svelte +++ /dev/null @@ -1,800 +0,0 @@ - - - -{#if photoModalOpen} - -{/if} - -{#if showLocationModal} - -{/if} - -{#if showLodgingModal} - -{/if} - -
- -
-
-

- - {$t('recomendations.discover_places')} -

- - -
- - {#if locationsWithCoords.length > 0} -
- - -
- {/if} - - -
- - e.key === 'Enter' && searchRecommendations()} - /> -
- - -
- - -
- - -
- - -
-
- - -
- - -
- - - {#if showFilters} -
{$t('adventures.filter')}
-
-
- - -
- -
- - - -
- -
- -
-
- {/if} - - - {#if error} -
- - {error} -
- {/if} -
-
- - - {#if loading} -
- -
- {:else if filteredResults.length > 0} - -
-
-
{$t('recomendations.total_results')}
-
{filteredResults.length}
-
-
-
{$t('recomendations.average_rating')}
-
- {( - filteredResults.filter((r) => r.rating).reduce((sum, r) => sum + (r.rating || 0), 0) / - filteredResults.filter((r) => r.rating).length - ).toFixed(1)} - ⭐ -
-
-
-
{$t('recomendations.search_radius_label')}
-
{radiusDisplay}
-
-
- - -
-
-

📍 {$t('recomendations.map_view')}

-
- - - {#each collection.locations as location} - {#if location.latitude && location.longitude} - - -
- - {location.name} - -

- {$t('recomendations.your_location')} -

-
-
-
- {/if} - {/each} - - - {#each filteredResults as result} - - -
-

{result.name}

- {#if result.rating} -
-
- {#each renderStars(result.rating) as star} - {#if star.type === 'full'} - - {:else if star.type === 'half'} - - {:else} - - {/if} - {/each} -
- {result.rating.toFixed(1)} - {#if result.review_count} - - ({result.review_count}) - - {/if} -
- {/if} - {#if result.address} -

📍 {result.address}

- {/if} -

- 🚶 {formatDistance(result.distance_km)} - {$t('recomendations.away')} -

-
-
-
- {/each} -
-
-
-
- - -
- {#each filteredResults as result} -
- - {#if result.photos && result.photos.length > 0} -
- - {#if result.photos.length > 1} -
- 📷 {result.photos.length} -
- {/if} -
- {:else} -
- -
- {/if} - -
- -

- {result.name} - {#if result.is_open_now} - {$t('recomendations.open')} - {/if} -

- - - {#if result.rating} -
-
- {#each renderStars(result.rating) as star} - {#if star.type === 'full'} - - {:else if star.type === 'half'} - - {:else} - - {/if} - {/each} -
- {result.rating.toFixed(1)} - {#if result.review_count} - - - {result.review_count} - - {/if} - {#if result.quality_score} -
- Score: {result.quality_score} -
- {/if} -
- {/if} - - - {#if result.address} -

- - {result.address} -

- {/if} - - -
-
- 🚶 {formatDistance(result.distance_km)} -
- {#if result.price_level} -
- - {getPriceLevelDisplay(result.price_level)} -
- {/if} -
- {result.source === 'google' ? '🔍 Google' : '🗺️ OSM'} -
-
- - - {#if result.description} -

- {result.description} -

- {/if} - - - {#if result.opening_hours && result.opening_hours.length > 0} -
- -
- - {$t('recomendations.hours')} -
-
- {#each result.opening_hours as hours} -

{hours}

- {/each} -
-
- {/if} - - -
- {#if result.phone_number} - - - - {/if} - {#if result.website} - - - - {/if} - {#if result.google_maps_uri} - - View on Maps - - - {/if} - - - - -
-
-
- {/each} -
- {:else if !loading && results.length === 0 && !error} -
-
- -

{$t('recomendations.no_results_yet')}

-

{$t('recomendations.select_location_or_query')}

-
-
- {/if} -
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 60105d9a..483aa72c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -526,46 +526,6 @@ export type Pin = { category: Category | null; }; -export type Recommendation = { - id: string; - external_id: string; - source: 'google' | 'osm'; - name: string; - description: string | null; - latitude: number; - longitude: number; - address: string | null; - distance_km: number; - rating: number | null; - review_count: number | null; - price_level: string | null; - types: string[]; - primary_type: string | null; - business_status: string | null; - is_open_now: boolean | null; - opening_hours: string[] | null; - phone_number: string | null; - website: string | null; - google_maps_url: string | null; - photos: string[]; - is_verified: boolean; - quality_score: number; - // OSM-specific fields - osm_type?: string; - wikipedia?: string; - stars?: string; -}; - -export type RecommendationResponse = { - count: number; - results: Recommendation[]; - sources_used: { - google: number; - osm: number; - total_before_dedup: number; - }; -}; - export type ChatProviderCatalogEntry = { id: string; label: string; diff --git a/frontend/src/locales/ar.json b/frontend/src/locales/ar.json index 1b15972a..731c458d 100644 --- a/frontend/src/locales/ar.json +++ b/frontend/src/locales/ar.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "طعام", - "recommendations": "التوصيات", "tourism": "السياحة", "any": "أي", "average_rating": "متوسط ​​التقييم", @@ -1049,21 +1048,9 @@ "visit_remove_failed": "فشل في إزالة الزيارة", "visit_to": "زيارة", "getting_location_details": "الحصول على تفاصيل الموقع", - "cities_available": "المدن المتاحة", - "destination_revealed": "كشفت الوجهة!", - "dive_deeper": "الغوص أعمق", - "exploration_progress": "تقدم الاستكشاف", "explore_country": "استكشف البلد", - "globe_spin_error_desc": "خطأ جلب بيانات الدوران العالمي", - "hide_globe_spin": "إخفاء الدوران العالمي", "in": "في", - "loading_globe_spin": "تحميل الكرة الأرضية", - "no_globe_spin_data": "لا توجد بيانات تدور حول العالم", - "show_globe_spin": "عرض Globe Spin", - "spin_again": "تدور مرة أخرى", - "spinning_globe": "كرة الغزل", "try_again": "حاول ثانية", - "your_random_adventure_awaits": "مغامرتك العشوائية تنتظر!", "about_country": "حول البلد", "about_region": "حول المنطقة", "show_less": "عرض أقل", diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 0f9b2df1..47694e84 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -573,21 +573,9 @@ "total_countries": "Länder gesamt", "total_regions": "Regionen gesamt", "getting_location_details": "Erhalten von Standortdetails", - "cities_available": "verfügbare Städte", - "destination_revealed": "Ziel enthüllt!", - "dive_deeper": "tiefer Tauchen", - "exploration_progress": "Explorationsfortschritt", "explore_country": "Land erkunden", - "globe_spin_error_desc": "Fehler beim Abrufen von Globus-Drehung-Daten", - "hide_globe_spin": "Globusdrehung verstecken", "in": "in", - "loading_globe_spin": "Globusdrehung wird geladen", - "no_globe_spin_data": "Keine Globus-Drehung-Daten", - "show_globe_spin": "Globus Drehung anzeigen", - "spin_again": "Nochmal drehen", - "spinning_globe": "Drehender Globus", "try_again": "Versuchen Sie es erneut", - "your_random_adventure_awaits": "Ihr zufälliges Abenteuer wartet!", "about_country": "Über Land", "about_region": "Über die Region", "show_less": "Weniger anzeigen", @@ -1000,7 +988,6 @@ "try_different_date": "Versuchen Sie ein anderes Datum" }, "recomendations": { - "recommendations": "Empfehlungen", "food": "Essen", "tourism": "Tourismus", "any": "Beliebig", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d444dd8e..8a7cc11d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -595,21 +595,9 @@ "total_cities": "Total Cities", "region_completed": "Region completed", "getting_location_details": "Getting location details", - "hide_globe_spin": "Hide Globe Spin", - "show_globe_spin": "Show Globe Spin", - "loading_globe_spin": "Loading Globe Spin", - "spinning_globe": "Spinning Globe", - "destination_revealed": "Destination Revealed!", - "your_random_adventure_awaits": "Your Random Adventure Awaits!", - "exploration_progress": "Exploration Progress", - "dive_deeper": "Dive Deeper", - "cities_available": "Cities Available", "in": "in", "explore_country": "Explore Country", - "spin_again": "Spin Again", - "globe_spin_error_desc": "Error fetching globe spin data", "try_again": "Try Again", - "no_globe_spin_data": "No Globe Spin Data", "show_less": "Show Less", "show_more": "Show More", "about_country": "About Country", @@ -1098,7 +1086,6 @@ "google_maps_integration_desc_no_staff": "This integration must first be enabled by the admin on this server." }, "recomendations": { - "recommendations": "Recommendations", "food": "Food", "tourism": "Tourism", "discover_places": "Discover Places", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 7d3eb704..3582f94e 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -544,21 +544,9 @@ "region_completed": "Región completada", "total_cities": "Ciudades totales", "getting_location_details": "Obtener detalles de ubicación", - "cities_available": "Ciudades disponibles", - "destination_revealed": "¡Destino revelado!", - "dive_deeper": "Sumergirse", - "exploration_progress": "Progreso de exploración", "explore_country": "Explorar el país", - "globe_spin_error_desc": "Error al obtener datos de giro global", - "hide_globe_spin": "Ocultar giro global", "in": "en", - "loading_globe_spin": "Cargando giro global", - "no_globe_spin_data": "Sin datos de giro de globo", - "show_globe_spin": "Show Globe Spin", - "spin_again": "Girar de nuevo", - "spinning_globe": "Globo hilado", "try_again": "Intentar otra vez", - "your_random_adventure_awaits": "¡Tu aventura aleatoria te espera!", "about_country": "Acerca del país", "about_region": "Acerca de la región", "show_less": "Mostrar menos", @@ -968,7 +956,6 @@ "try_different_date": "Prueba una fecha diferente" }, "recomendations": { - "recommendations": "Recomendaciones", "food": "Comida", "tourism": "Turismo", "any": "Cualquier", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index ebe35a81..2d2a03a5 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -573,21 +573,9 @@ "total_countries": "Total des pays", "total_regions": "Régions totales", "getting_location_details": "Obtenir les détails de l'emplacement", - "cities_available": "Villes disponibles", - "destination_revealed": "Destination révélée!", - "dive_deeper": "Plonger plus profondément", - "exploration_progress": "Progrès de l'exploration", "explore_country": "Explorer le pays", - "globe_spin_error_desc": "Erreur pour récupérer les données de spin globe", - "hide_globe_spin": "Hide Globe Spin", "in": "dans", - "loading_globe_spin": "Chargement du globe Spin", - "no_globe_spin_data": "Pas de données de spin globe", - "show_globe_spin": "Montrer le spin au globe", - "spin_again": "Remonter", - "spinning_globe": "Globe de rotation", "try_again": "Essayer à nouveau", - "your_random_adventure_awaits": "Votre aventure aléatoire vous attend!", "about_country": "À propos du pays", "about_region": "À propos de la région", "show_less": "Afficher moins", @@ -968,7 +956,6 @@ "try_different_date": "Essayez une date différente" }, "recomendations": { - "recommendations": "Recommandations", "food": "Nourriture", "tourism": "Tourisme", "any": "N'importe lequel", diff --git a/frontend/src/locales/hu.json b/frontend/src/locales/hu.json index 3d94c2a4..4d1856bd 100644 --- a/frontend/src/locales/hu.json +++ b/frontend/src/locales/hu.json @@ -544,21 +544,9 @@ "total_cities": "Összes város", "region_completed": "Régió teljesítve", "getting_location_details": "Helyadatok lekérése", - "hide_globe_spin": "Földgömb forgás elrejtése", - "show_globe_spin": "Földgömb forgás megjelenítése", - "loading_globe_spin": "Földgömb forgás betöltése", - "spinning_globe": "Forgó földgömb", - "destination_revealed": "Úticél felfedve!", - "your_random_adventure_awaits": "A véletlenszerű kalandod vár rád!", - "exploration_progress": "Felfedezés előrehaladása", - "dive_deeper": "Merülj mélyebbre", - "cities_available": "Elérhető városok", "in": "itt:", "explore_country": "Ország felfedezése", - "spin_again": "Forgatás újra", - "globe_spin_error_desc": "Hiba történt a földgömb adatainak lekérésekor", "try_again": "Próbáld újra", - "no_globe_spin_data": "Nincsenek földgömb adatok", "about_country": "Országról", "about_region": "A régióról", "show_less": "Mutass kevesebbet", @@ -995,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Ezt az integrációt először a szerver adminisztrátorának kell engedélyeznie." }, "recomendations": { - "recommendations": "Ajánlások", "food": "Étel", "tourism": "Turizmus", "any": "Bármilyen", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 9ed41b37..730f08d0 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -573,21 +573,9 @@ "total_countries": "Paesi totali", "total_regions": "Regioni totali", "getting_location_details": "Ottenere dettagli sulla posizione", - "cities_available": "Città disponibili", - "destination_revealed": "Destinazione rivelata!", - "dive_deeper": "Immergersi più in profondità", - "exploration_progress": "Progressi di esplorazione", "explore_country": "Esplora il paese", - "globe_spin_error_desc": "Errore che recupera i dati di spin Globe", - "hide_globe_spin": "Nascondi lo spin di globo", "in": "In", - "loading_globe_spin": "Caricamento di rotazione del globo", - "no_globe_spin_data": "Nessun dati di spin Globe", - "show_globe_spin": "Mostra lo spin globo", - "spin_again": "Girare di nuovo", - "spinning_globe": "Globe rotante", "try_again": "Riprova", - "your_random_adventure_awaits": "La tua avventura casuale ti aspetta!", "about_country": "Informazioni sul paese", "about_region": "A proposito di Regione", "show_less": "Mostra meno", @@ -968,7 +956,6 @@ "try_different_date": "Prova una data diversa" }, "recomendations": { - "recommendations": "Raccomandazioni", "food": "Cibo", "tourism": "Turismo", "any": "Qualunque", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 68ff8ac4..ef02d8cb 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "食べ物", - "recommendations": "推奨事項", "tourism": "観光", "any": "どれでも", "average_rating": "平均評価", @@ -1049,21 +1048,9 @@ "visit_remove_failed": "訪問を削除できませんでした", "visit_to": "訪問", "getting_location_details": "場所の詳細を取得します", - "cities_available": "利用可能な都市", - "destination_revealed": "目的地が明らかに!", - "dive_deeper": "より深く潜ります", - "exploration_progress": "探索の進行", "explore_country": "国を探索します", - "globe_spin_error_desc": "グローブスピンデータの取得エラー", - "hide_globe_spin": "グローブスピンを隠します", "in": "で", - "loading_globe_spin": "グローブスピンのロード", - "no_globe_spin_data": "グローブスピンデータはありません", - "show_globe_spin": "グローブスピンを表示します", - "spin_again": "もう一度スピンします", - "spinning_globe": "スピニンググローブ", "try_again": "もう一度やり直してください", - "your_random_adventure_awaits": "あなたのランダムな冒険が待っています!", "about_country": "国について", "about_region": "地域について", "show_less": "表示を減らす", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index a2549a6d..ea423a12 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -692,7 +692,6 @@ "public_location_experiences": "공개 위치 경험" }, "recomendations": { - "recommendations": "권장 사항", "food": "음식", "tourism": "관광 여행", "any": "어느", @@ -973,21 +972,9 @@ "total_countries": "총 국가", "total_regions": "총 지역", "getting_location_details": "위치 세부 정보 얻기", - "dive_deeper": "더 깊이 다이빙하십시오", - "exploration_progress": "탐사 진행", "explore_country": "국가를 탐험하십시오", - "globe_spin_error_desc": "오류 페치 글로브 스핀 데이터", - "hide_globe_spin": "글로브 스핀을 숨기십시오", "in": "~에", - "loading_globe_spin": "로드 글로브 스핀", - "no_globe_spin_data": "글로브 스핀 데이터가 없습니다", - "show_globe_spin": "글로브 스핀을 보여주십시오", - "spin_again": "다시 회전하십시오", - "spinning_globe": "회전하는 글로브", "try_again": "다시 시도하십시오", - "your_random_adventure_awaits": "당신의 임의의 모험이 기다리고 있습니다!", - "cities_available": "이용 가능", - "destination_revealed": "목적지 공개!", "about_country": "국가 소개", "about_region": "지역정보", "show_less": "간략히 표시", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index b26d43e7..c6e1255e 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -573,21 +573,9 @@ "total_countries": "Totale landen", "total_regions": "Totaal aantal regio's", "getting_location_details": "Locatiegegevens krijgen", - "cities_available": "Steden beschikbaar", - "destination_revealed": "Bestemming onthuld!", - "dive_deeper": "Duik dieper", - "exploration_progress": "Verkennings voortgang", "explore_country": "Verken het land", - "globe_spin_error_desc": "Fout bij het ophalen van globe spin -gegevens", - "hide_globe_spin": "Globe spin verbergen", "in": "in", - "loading_globe_spin": "Globe spin laden", - "no_globe_spin_data": "Geen Globe spin -gegevens", - "show_globe_spin": "Toon Globe Spin", - "spin_again": "Weer spinnen", - "spinning_globe": "Spinnende bol", "try_again": "Probeer het opnieuw", - "your_random_adventure_awaits": "Je willekeurige avontuur wacht!", "about_country": "Over land", "about_region": "Over Regio", "show_less": "Toon minder", @@ -968,7 +956,6 @@ "try_different_date": "Probeer een andere datum" }, "recomendations": { - "recommendations": "Aanbevelingen", "food": "Voedsel", "tourism": "Toerisme", "any": "Elk", diff --git a/frontend/src/locales/no.json b/frontend/src/locales/no.json index f84daa49..ff0ad64a 100644 --- a/frontend/src/locales/no.json +++ b/frontend/src/locales/no.json @@ -544,21 +544,9 @@ "total_countries": "Totalt land", "total_regions": "Totale regioner", "getting_location_details": "Få stedsdetaljer", - "cities_available": "Byer tilgjengelig", - "destination_revealed": "Destinasjon avslørt!", - "dive_deeper": "Dykk dypere", - "exploration_progress": "Utforskningsfremgang", "explore_country": "Utforsk landet", - "globe_spin_error_desc": "Feilhåndtering av klode -spinndata", - "hide_globe_spin": "Skjul klode spinn", "in": "i", - "loading_globe_spin": "Laster klode spinn", - "no_globe_spin_data": "Ingen klode spinndata", - "show_globe_spin": "Vis Globe Spin", - "spin_again": "Spinn igjen", - "spinning_globe": "Spinnende klode", "try_again": "Prøv igjen", - "your_random_adventure_awaits": "Ditt tilfeldige eventyr venter!", "about_country": "Om landet", "about_region": "Om regionen", "show_less": "Vis mindre", @@ -991,7 +979,6 @@ "try_different_date": "Prøv en annen dato" }, "recomendations": { - "recommendations": "Anbefalinger", "food": "Mat", "tourism": "Turisme", "any": "Noen", diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 55b80809..c6393a88 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -544,21 +544,9 @@ "all_regions": "Wszystkie regiony", "cities_in": "Miasta w", "getting_location_details": "Uzyskanie szczegółów lokalizacji", - "cities_available": "Dostępne miasta", - "destination_revealed": "Ujawnione miejsce docelowe!", - "dive_deeper": "Nurkuj głębiej", - "exploration_progress": "Postęp eksploracyjny", "explore_country": "Poznaj kraj", - "globe_spin_error_desc": "Błąd przyciąganie danych spinowych globe", - "hide_globe_spin": "Ukryj globe spin", "in": "W", - "loading_globe_spin": "Ładowanie globowego spinu", - "no_globe_spin_data": "Brak danych spinowych globe", - "show_globe_spin": "Pokaż globe spin", - "spin_again": "Obrócić ponownie", - "spinning_globe": "Spinning Globe", "try_again": "Spróbuj ponownie", - "your_random_adventure_awaits": "Twoja przypadkowa przygoda czeka!", "about_country": "O kraju", "about_region": "O Regionie", "show_less": "Pokaż mniej", @@ -968,7 +956,6 @@ "try_different_date": "Wypróbuj inną datę" }, "recomendations": { - "recommendations": "Zalecenia", "food": "Żywność", "tourism": "Turystyka", "any": "Każdy", diff --git a/frontend/src/locales/pt-br.json b/frontend/src/locales/pt-br.json index 4b4fbdb0..0840d06d 100644 --- a/frontend/src/locales/pt-br.json +++ b/frontend/src/locales/pt-br.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "Comida", - "recommendations": "Recomendações", "tourism": "Turismo", "any": "Qualquer", "average_rating": "Avaliação média", @@ -1051,23 +1050,11 @@ "visit_to": "Visita a", "about_country": "Sobre o país", "about_region": "Sobre a região", - "cities_available": "Cidades disponíveis", - "destination_revealed": "Destino revelado!", - "dive_deeper": "Mergulhe mais fundo", - "exploration_progress": "Progresso da Exploração", "explore_country": "Explorar o país", - "globe_spin_error_desc": "Erro ao buscar dados de rotação do globo", - "hide_globe_spin": "Ocultar rotação do globo", "in": "em", - "loading_globe_spin": "Carregando Globo Spin", - "no_globe_spin_data": "Sem dados de rotação do globo", - "show_globe_spin": "Mostrar rotação do globo", "show_less": "Mostrar menos", "show_more": "Mostrar mais", - "spin_again": "Gire novamente", - "spinning_globe": "Globo giratório", "try_again": "Tente novamente", - "your_random_adventure_awaits": "Sua aventura aleatória o aguarda!", "all_locations_visited": "Todos os locais visitados!" }, "collections": { diff --git a/frontend/src/locales/ro.json b/frontend/src/locales/ro.json index 3c0cf7e7..af07ab82 100644 --- a/frontend/src/locales/ro.json +++ b/frontend/src/locales/ro.json @@ -845,7 +845,6 @@ "no_results_yet": "Încă nu există rezultate", "open": "Deschide", "open_now_only": "Deschide numai acum", - "recommendations": "Recomandări", "search_around_location": "Căutați în jurul locației", "search_by_address": "Căutați după adresă", "search_radius_label": "Raza de căutare:", @@ -1090,7 +1089,6 @@ "all_regions": "Toate Regiunile", "available_to_explore": "Disponibil pentru a explora", "cities": "orase", - "cities_available": "Orașe disponibile", "cities_in": "Orașe în", "clear_all": "Ștergeți tot", "clear_all_filters": "Ștergeți toate filtrele", @@ -1099,29 +1097,22 @@ "countries": "ţări", "country_completed": "Țara finalizată", "country_list": "Lista țărilor", - "destination_revealed": "Destinația dezvăluită!", - "dive_deeper": "Scufundați mai adânc", - "exploration_progress": "Progresul de explorare", "explore_country": "Explorează țara", "failed_to_mark_visit": "Nu s-a marcat vizita la", "failed_to_remove_visit": "Nu s-a putut elimina vizita la", "filter_by": "Filtrați după", "filter_by_region": "Filtrați după regiune", "getting_location_details": "Obținerea detaliilor locației", - "globe_spin_error_desc": "Eroare la preluarea datelor de rotație a globului", - "hide_globe_spin": "Ascunde Globe Spin", "hide_map": "Ascunde harta", "hide_map_labels": "Ascundeți etichetele hărții", "in": "în", "interactive_map": "Hartă interactivă", - "loading_globe_spin": "Se încarcă Globe Spin", "marked_visited": "marcat ca vizitat", "no_cities_found": "Nu s-au găsit orașe", "no_countries_found": "Nu au fost găsite țări", "no_countries_found_desc": "Încercați să ajustați termenii sau filtrele de căutare pentru a găsi țările pe care le căutați.", "no_country_data_available": "Nu sunt disponibile date despre țară", "no_country_data_available_desc": "Vă rugăm să verificați documentația pentru actualizarea datelor din regiune.", - "no_globe_spin_data": "Fără date de rotație a globului", "no_regions_found": "Nu au fost găsite regiuni", "of": "de", "partial": "Parţial", @@ -1132,20 +1123,16 @@ "regions_in": "Regiunile din", "remaining": "Rămânând", "removed": "îndepărtat", - "show_globe_spin": "Arată Globe Spin", "show_less": "Arată mai puțin", "show_map": "Afișați harta", "show_map_labels": "Afișați etichetele hărții", "show_more": "Arată mai mult", - "spin_again": "Învârte din nou", - "spinning_globe": "Globul care se învârte", "total_cities": "Total orașe", "total_countries": "Total Țări", "total_regions": "Total regiuni", "try_again": "Încearcă din nou", "view_cities": "Vedeți orașele", "visit_remove_failed": "Nu s-a eliminat vizita", - "visit_to": "Vizită la", - "your_random_adventure_awaits": "Aventura ta aleatorie vă așteaptă!" + "visit_to": "Vizită la" } } diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 66b7a5ec..d24333a2 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -544,21 +544,9 @@ "total_countries": "Всего стран", "total_regions": "Всего регионов", "getting_location_details": "Получение деталей локации", - "cities_available": "Города доступны", - "destination_revealed": "Открыто место!", - "dive_deeper": "Погрузитесь глубже", - "exploration_progress": "Прогресс исследования", "explore_country": "Исследуйте страну", - "globe_spin_error_desc": "Ошибка извлечения данных спиновых глобусов", - "hide_globe_spin": "Скрыть глобус спин", "in": "в", - "loading_globe_spin": "Загрузка глобуса спина", - "no_globe_spin_data": "Нет данных о вращении Globe", - "show_globe_spin": "Показать Globe Spin", - "spin_again": "Снова спите", - "spinning_globe": "Вращающийся глобус", "try_again": "Попробуйте еще раз", - "your_random_adventure_awaits": "Ваше случайное приключение ждет!", "about_country": "О стране", "about_region": "О регионе", "show_less": "Показать меньше", @@ -995,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Эта интеграция должна сначала быть включена администратором на этом сервере." }, "recomendations": { - "recommendations": "Рекомендации", "food": "Еда", "tourism": "Туризм", "any": "Любой", diff --git a/frontend/src/locales/sk.json b/frontend/src/locales/sk.json index 08d5a2d6..a3fc2a0d 100644 --- a/frontend/src/locales/sk.json +++ b/frontend/src/locales/sk.json @@ -544,20 +544,8 @@ "total_cities": "Celkový počet miest", "region_completed": "Región dokončený", "getting_location_details": "Získavajú sa detaily miesta", - "cities_available": "Mestá k dispozícii", - "destination_revealed": "Destinácia odhalená!", - "dive_deeper": "Ponorte sa hlbšie", - "exploration_progress": "Pokrok v preskúmavaní", "explore_country": "Preskúmať krajinu", - "globe_spin_error_desc": "Chyba načítania náhodnej destinácie", - "loading_globe_spin": "Načítavanie náhodnej destinácie", - "no_globe_spin_data": "Žiadne údaje náhodnej destinácie", - "show_globe_spin": "Zobraziť náhodnú destináciu", - "spin_again": "Iná náhodná destinácia", - "spinning_globe": "Glóbus sa točí", "try_again": "Skúsiť znova", - "your_random_adventure_awaits": "Čaká vaše náhodné dobrodružstvo!", - "hide_globe_spin": "Skryť náhodnú destináciu", "in": "v", "about_country": "O krajine", "about_region": "O regióne", @@ -995,7 +983,6 @@ "google_maps_integration_desc_no_staff": "Túto integráciu musí najprv povoliť administrátor na tomto serveri." }, "recomendations": { - "recommendations": "Odporúčania", "food": "Jedlo", "tourism": "Turizmus", "any": "Akékoľvek", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 475847a4..0d00406d 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -544,21 +544,9 @@ "total_countries": "Totala länder", "total_regions": "Totala regioner", "getting_location_details": "Få platsinformation", - "cities_available": "Städer tillgängliga", - "destination_revealed": "Destination avslöjad!", - "dive_deeper": "Dyk djupare", - "exploration_progress": "Undersökningens framsteg", "explore_country": "Utforska land", - "globe_spin_error_desc": "Fel som hämtar Globe Spin Data", - "hide_globe_spin": "Dölj jordklot", "in": "i", - "loading_globe_spin": "Loading Globe Spin", - "no_globe_spin_data": "Inga Globe Spin -data", - "show_globe_spin": "Show Globe Spin", - "spin_again": "Snurra igen", - "spinning_globe": "Snurrande jordklot", "try_again": "Försök igen", - "your_random_adventure_awaits": "Ditt slumpmässiga äventyr väntar!", "about_country": "Om Country", "about_region": "Om regionen", "show_less": "Visa mindre", @@ -968,7 +956,6 @@ "try_different_date": "Prova ett annat datum" }, "recomendations": { - "recommendations": "Rekommendationer", "food": "Mat", "tourism": "Turism", "any": "Några", diff --git a/frontend/src/locales/tr.json b/frontend/src/locales/tr.json index c59aad53..4f5fc5d7 100644 --- a/frontend/src/locales/tr.json +++ b/frontend/src/locales/tr.json @@ -544,21 +544,9 @@ "total_cities": "Toplam Şehir", "region_completed": "Bölge Tamamlandı", "getting_location_details": "Konum detayları alınıyor", - "hide_globe_spin": "Küre Dönüşünü Gizle", - "show_globe_spin": "Küre Dönüşünü Göster", - "loading_globe_spin": "Küre Dönüşü Yükleniyor", - "spinning_globe": "Dönen Küre", - "destination_revealed": "Hedef Keşfedildi!", - "your_random_adventure_awaits": "Rastgele Maceran Seni Bekliyor!", - "exploration_progress": "Keşif İlerlemesi", - "dive_deeper": "Daha Fazlasını Keşfet", - "cities_available": "Mevcut Şehirler", "in": "içinde", "explore_country": "Ülkeyi Keşfet", - "spin_again": "Tekrar Döndür", - "globe_spin_error_desc": "Küre dönüşü verisi alınamadı", "try_again": "Tekrar Deneyin", - "no_globe_spin_data": "Küre Dönüşü Verisi Yok", "about_country": "Ülke Hakkında", "about_region": "Bölge Hakkında", "show_less": "Daha Az Göster", @@ -998,7 +986,6 @@ "google_maps_integration_desc_no_staff": "Bu entegrasyon öncelikle bu sunucudaki yönetici tarafından etkinleştirilmelidir." }, "recomendations": { - "recommendations": "Önerilenler", "food": "Yemek", "tourism": "Turizm", "any": "Herhangi", diff --git a/frontend/src/locales/uk.json b/frontend/src/locales/uk.json index 784fe253..736f0135 100644 --- a/frontend/src/locales/uk.json +++ b/frontend/src/locales/uk.json @@ -753,7 +753,6 @@ }, "recomendations": { "food": "харчування", - "recommendations": "Рекомендації", "tourism": "Туризм", "any": "Будь-який", "average_rating": "Середній рейтинг", @@ -1011,7 +1010,6 @@ "all_regions": "Всі регіони", "available_to_explore": "Доступний для дослідження", "cities": "міст", - "cities_available": "Доступні міста", "cities_in": "Міста в", "clear_all": "Очистити все", "clear_all_filters": "Очистити всі фільтри", @@ -1020,29 +1018,22 @@ "countries": "країни", "country_completed": "Країна завершена", "country_list": "Список країн", - "destination_revealed": "Пункт призначення відомий!", - "dive_deeper": "Пірни глибше", - "exploration_progress": "Хід розвідки", "explore_country": "Досліджуйте країну", "failed_to_mark_visit": "Не вдалося позначити відвідування", "failed_to_remove_visit": "Не вдалося видалити відвідування", "filter_by": "Фільтрувати за", "filter_by_region": "Фільтрувати за регіоном", "getting_location_details": "Отримання інформації про місцезнаходження", - "globe_spin_error_desc": "Помилка отримання даних обертання глобуса", - "hide_globe_spin": "Приховати обертання глобуса", "hide_map": "Приховати карту", "hide_map_labels": "Приховати мітки на карті", "in": "в", "interactive_map": "Інтерактивна карта", - "loading_globe_spin": "Обертання глобуса завантаження", "marked_visited": "позначено як відвідане", "no_cities_found": "Міста не знайдено", "no_countries_found": "Країни не знайдено", "no_countries_found_desc": "Спробуйте налаштувати пошукові терміни або фільтри, щоб знайти країни, які ви шукаєте.", "no_country_data_available": "Немає даних по країні", "no_country_data_available_desc": "Будь ласка, перевірте документацію щодо оновлення даних регіону.", - "no_globe_spin_data": "Немає даних обертання глобуса", "no_regions_found": "Регіонів не знайдено", "of": "з", "partial": "Частковий", @@ -1053,12 +1044,9 @@ "regions_in": "Регіони в", "remaining": "Залишилося", "removed": "видалено", - "show_globe_spin": "Показати обертання глобуса", "show_less": "Показати менше", "show_map": "Показати карту", "show_map_labels": "Показати мітки на карті", - "spin_again": "Знову обертання", - "spinning_globe": "Обертовий глобус", "total_cities": "Всього міст", "total_countries": "Всього країн", "total_regions": "Всього регіонів", @@ -1066,7 +1054,6 @@ "view_cities": "Переглянути міста", "visit_remove_failed": "Не вдалося видалити відвідування", "visit_to": "Візит до", - "your_random_adventure_awaits": "Ваша випадкова пригода чекає!", "show_more": "Показати Більше", "all_locations_visited": "Всі відвідані локації!" }, diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 3cfe79d7..94cd7ccf 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -570,21 +570,9 @@ "total_cities": "总城市", "total_countries": "总国家", "getting_location_details": "获取地点详细信息", - "cities_available": "可用的城市", - "destination_revealed": "目的地揭示了!", - "dive_deeper": "深入潜水", - "exploration_progress": "勘探进度", "explore_country": "探索国家", - "globe_spin_error_desc": "错误获取地球旋转数据", - "hide_globe_spin": "隐藏环球旋转", "in": "在", - "loading_globe_spin": "加载地球旋转", - "no_globe_spin_data": "没有地球旋转数据", - "show_globe_spin": "显示环球旋转", - "spin_again": "再次旋转", - "spinning_globe": "旋转地球", "try_again": "再试一次", - "your_random_adventure_awaits": "您的随机冒险在等待!", "about_country": "关于国家", "about_region": "关于地区", "show_less": "显示较少", @@ -968,7 +956,6 @@ "try_different_date": "尝试其他日期" }, "recomendations": { - "recommendations": "建议", "food": "食物", "tourism": "旅游", "average_rating": "平均评分", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index aa4e4d95..300c14cc 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -25,19 +25,18 @@ import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte'; import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte'; - import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte'; import AITravelChat from '$lib/components/AITravelChat.svelte'; import CollectionMap from '$lib/components/collections/CollectionMap.svelte'; import CollectionStats from '$lib/components/collections/CollectionStats.svelte'; import LocationLink from '$lib/components/LocationLink.svelte'; - import { MessageCircle, X } from 'lucide-svelte'; + import MessageCircle from '~icons/mdi/message-text-outline'; + import X from '~icons/mdi/close'; import { getBasemapUrl } from '$lib'; import { formatMoney, toMoneyValue, DEFAULT_CURRENCY } from '$lib/money'; import FolderMultiple from '~icons/mdi/folder-multiple'; import FormatListBulleted from '~icons/mdi/format-list-bulleted'; import Timeline from '~icons/mdi/timeline'; import MapIcon from '~icons/mdi/map'; - import Lightbulb from '~icons/mdi/lightbulb'; import ChartBar from '~icons/mdi/chart-bar'; import Plus from '~icons/mdi/plus'; import { addToast } from '$lib/toasts'; @@ -206,7 +205,7 @@ } // View state from URL params - type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'recommendations' | 'stats'; + type ViewType = 'all' | 'itinerary' | 'map' | 'calendar' | 'stats'; let currentView: ViewType = 'itinerary'; let chatPanelOpen = false; let innerWidth = 1024; @@ -243,7 +242,6 @@ ) || false, calendar: !isFolderView, - recommendations: true, // may be overridden by permission check below stats: true }; @@ -256,7 +254,7 @@ const view = $page.url.searchParams.get('view') as ViewType; if ( view && - ['all', 'itinerary', 'map', 'calendar', 'recommendations', 'stats'].includes(view) && + ['all', 'itinerary', 'map', 'calendar', 'stats'].includes(view) && availableViews[view] ) { currentView = view; @@ -290,9 +288,6 @@ return false; })(); - // Enforce recommendations visibility only for owner/shared users - $: availableViews.recommendations = !!canModifyCollection; - $: if (!canModifyCollection && chatPanelOpen) { chatPanelOpen = false; } @@ -1225,16 +1220,6 @@ {/if} - {#if availableViews.recommendations} - - {/if} {#if availableViews.stats} - - @@ -449,257 +397,6 @@ {/if} - - {#if showGlobeSpin} -
-
-
- {#if isLoadingGlobeSpin} - -
-
- -
-
- -
- -
-
-
- -
-
-
-
-
-

- {$t('worldtravel.spinning_globe') + '...'} -

-

- {$t('worldtravel.loading_globe_spin')} -

-
-
-
-
-
-
-
- {:else if globeSpinData} - -
-
-

- - {$t('worldtravel.destination_revealed')} - -

-

- {$t('worldtravel.your_random_adventure_awaits')} -

-
- - -
- -
-
- {globeSpinData.country.name} flag - -
-
- -
- {globeSpinData.country.country_code} -
- {#if globeSpinData.country.num_visits > 0} -
- - {$t('adventures.visited')} -
- {/if} -
- - -
-

- {globeSpinData.country.name} -

- -
-
- - {globeSpinData.country.subregion} -
- {#if globeSpinData.country.capital} -
- - {globeSpinData.country.capital} -
- {/if} -
- - -
-
- {$t('worldtravel.exploration_progress')} - - {globeSpinData.country.num_visits}/{globeSpinData.country.num_regions} - -
- -
- {Math.round( - (globeSpinData.country.num_visits / globeSpinData.country.num_regions) * - 100 - )}% explored -
-
-
-
- - - {#if globeSpinData.region || globeSpinData.city} -
-
- {$t('worldtravel.dive_deeper')} -
- -
- {#if globeSpinData.region} -
-
-

- - {$t('adventures.region')} -

-

{globeSpinData.region.name}

-

- {globeSpinData.region.num_cities} - {$t('worldtravel.cities_available')} -

-
-
- {/if} - - {#if globeSpinData.city} -
-
-

- - {$t('adventures.city')} -

-

{globeSpinData.city.name}

-

- {$t('worldtravel.in')} - {globeSpinData.city.region_name} -

-
-
- {/if} -
-
- {/if} - - -
- - - {$t('worldtravel.explore_country')} - - -
-
- {:else} - -
-
- -
-

- {$t('worldtravel.no_globe_spin_data')} -

-

- {$t('worldtravel.globe_spin_error_desc')} -

- -
- {/if} -
-
-
- {/if} -
{#if filteredCountries.length === 0} @@ -870,48 +567,3 @@
- -