diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 8e1c417..ce569f5 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -4,7 +4,7 @@ import { Car, Music } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { supabase } from '../../services/supabase'; import * as WebBrowser from 'expo-web-browser'; -import { makeRedirectUri, useAuthRequest, exchangeCodeAsync, DiscoveryDocument } from 'expo-auth-session'; +import { makeRedirectUri, useAuthRequest, DiscoveryDocument } from 'expo-auth-session'; import { clearSpotifyTokens, setSpotifyToken, setSpotifyRefreshToken } from '../../auth/spotifyToken'; import { useAuth } from '../../contexts/AuthContext'; @@ -42,60 +42,137 @@ export default function LoginScreen({ navigation }) { discovery ); + const showSpotifyLoginFailure = () => { + Alert.alert('Erro de Autenticação', 'Não foi possível iniciar sessão com Spotify. Tenta novamente ou usa login normal.'); + }; + // Handle Spotify OAuth Response useEffect(() => { if (response) { console.log("SPOTIFY_AUTH_RESULT:", response); if (response.type === 'success') { const { code } = response.params; - console.log("SPOTIFY_CODE_EXISTS:", !!code); + console.log("[LoginScreen] Spotify auth success:", { + redirectUri, + clientIdExists: Boolean(SPOTIFY_CLIENT_ID), + authorizationCodeExists: Boolean(code), + codeVerifierExists: Boolean(request?.codeVerifier), + scopes: ['user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'], + flow: 'authorization_code_pkce', + }); exchangeCodeForTokens(code); } else if (response.type === 'error') { + console.warn('[LoginScreen] Spotify OAuth response error:', response.error?.message || response.error); Alert.alert('Erro de Autenticação', response.error?.message || 'Falha ao logar com o Spotify'); } } }, [response]); const exchangeCodeForTokens = async (code: string) => { - if (!request?.codeVerifier) return; + if (!request?.codeVerifier) { + console.warn('[LoginScreen] Missing Spotify PKCE code verifier:', { + redirectUri, + clientIdExists: Boolean(SPOTIFY_CLIENT_ID), + authorizationCodeExists: Boolean(code), + codeVerifierExists: false, + }); + showSpotifyLoginFailure(); + return; + } try { setLoading(true); - const tokenResult = await exchangeCodeAsync( - { - clientId: SPOTIFY_CLIENT_ID, - code, - redirectUri, - extraParams: { - code_verifier: request.codeVerifier, - }, - }, - discovery - ); + console.log('[LoginScreen] Spotify token exchange start:', { + redirectUri, + clientIdExists: Boolean(SPOTIFY_CLIENT_ID), + authorizationCodeExists: Boolean(code), + codeVerifierExists: Boolean(request.codeVerifier), + flow: 'authorization_code_pkce', + }); - console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.accessToken); + const tokenBody = new URLSearchParams({ + client_id: SPOTIFY_CLIENT_ID, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + code_verifier: request.codeVerifier, + }).toString(); + + const tokenResponse = await fetch(discovery.tokenEndpoint as string, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: tokenBody, + }); + const tokenResponseText = await tokenResponse.text(); + + console.log('[LoginScreen] Spotify token exchange response:', { + httpStatus: tokenResponse.status, + ok: tokenResponse.ok, + responseBodyIfFailed: tokenResponse.ok ? null : tokenResponseText, + }); + + if (!tokenResponse.ok) { + console.warn('[LoginScreen] Spotify token exchange failed:', { + httpStatus: tokenResponse.status, + responseBody: tokenResponseText, + }); + showSpotifyLoginFailure(); + return; + } + + let tokenResult: any; + try { + tokenResult = JSON.parse(tokenResponseText); + } catch (parseError) { + console.warn('[LoginScreen] Spotify token response JSON parse failed:', parseError); + showSpotifyLoginFailure(); + return; + } + + console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.access_token); - if (tokenResult.accessToken) { - await setSpotifyToken(tokenResult.accessToken); - if (tokenResult.refreshToken) { - await setSpotifyRefreshToken(tokenResult.refreshToken); + if (tokenResult.access_token) { + await setSpotifyToken(tokenResult.access_token); + if (tokenResult.refresh_token) { + await setSpotifyRefreshToken(tokenResult.refresh_token); } - // Fetch real Spotify Profile console.log("SPOTIFY_PROFILE_FETCH_START"); const profileRes = await fetch('https://api.spotify.com/v1/me', { - headers: { Authorization: `Bearer ${tokenResult.accessToken}` } + headers: { Authorization: `Bearer ${tokenResult.access_token}` } + }); + const profileText = await profileRes.text(); + + console.log('[LoginScreen] Spotify profile fetch response:', { + httpStatus: profileRes.status, + ok: profileRes.ok, + responseBodyIfFailed: profileRes.ok ? null : profileText, }); if (!profileRes.ok) { - throw new Error(`Failed to fetch Spotify profile: ${profileRes.statusText}`); + console.warn('[LoginScreen] Spotify profile fetch failed:', { + httpStatus: profileRes.status, + responseBody: profileText, + }); + showSpotifyLoginFailure(); + return; } - const profile = await profileRes.json(); + let profile: any; + try { + profile = JSON.parse(profileText); + } catch (parseError) { + console.warn('[LoginScreen] Spotify profile JSON parse failed:', parseError); + showSpotifyLoginFailure(); + return; + } + console.log("SPOTIFY_PROFILE_NAME:", profile.display_name); console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email); console.log("SPOTIFY_USER_ID:", profile.id); - console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!tokenResult.accessToken); + console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!tokenResult.access_token); const email = profile.email || `spotify_${profile.id}@roadtripdj.local`; const password = `SpotifySecure_${profile.id}`; @@ -125,7 +202,7 @@ export default function LoginScreen({ navigation }) { }); if (signUpError) { - console.error("SignUp error:", signUpError); + console.warn("SignUp error:", signUpError); throw signUpError; } @@ -134,7 +211,10 @@ export default function LoginScreen({ navigation }) { email, password, }); - if (newSignInError) throw newSignInError; + if (newSignInError) { + console.warn("Spotify user sign-in after sign-up failed:", newSignInError.message); + throw newSignInError; + } sessionData = newSession; } else { // Update metadata with latest Spotify data @@ -158,11 +238,12 @@ export default function LoginScreen({ navigation }) { // Trigger app main flow enableSpotifyMode(); } else { + console.warn('[LoginScreen] Spotify token response missing access token'); Alert.alert('Erro de Autenticação', 'Não foi possível obter o token de acesso do Spotify.'); } } catch (e: any) { - console.error('🚀 [LoginScreen] Token Exchange Error:', e); - Alert.alert('Erro de Autenticação', e.message || 'Não foi possível trocar o código pelo token.'); + console.warn('🚀 [LoginScreen] Token Exchange Error:', e?.message || e); + showSpotifyLoginFailure(); } finally { setLoading(false); } @@ -189,17 +270,24 @@ export default function LoginScreen({ navigation }) { const handleResetAuth = async () => { await supabase.auth.signOut(); await clearSpotifyTokens(); + setEmail(''); + setPassword(''); Alert.alert('Reset', 'Auth state cleared.'); }; const handleSpotifyLogin = async () => { try { - console.log("SPOTIFY_CLIENT_ID_EXISTS:", !!SPOTIFY_CLIENT_ID); - console.log("SPOTIFY_REDIRECT_URI:", redirectUri); + console.log("[LoginScreen] Spotify login start:", { + redirectUri, + clientIdExists: Boolean(SPOTIFY_CLIENT_ID), + codeVerifierExists: Boolean(request?.codeVerifier), + scopes: ['user-read-email', 'user-read-private', 'playlist-modify-public', 'playlist-modify-private'], + flow: 'authorization_code_pkce', + }); await promptAsync(); } catch (e: any) { - console.error('🚀 [LoginScreen] OAuth Error:', e); - Alert.alert('Erro de Autenticação', e.message); + console.warn('🚀 [LoginScreen] OAuth Error:', e?.message || e); + showSpotifyLoginFailure(); } }; diff --git a/src/screens/main/HomeScreen.tsx b/src/screens/main/HomeScreen.tsx index 328cf09..caff438 100644 --- a/src/screens/main/HomeScreen.tsx +++ b/src/screens/main/HomeScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, Alert, ActivityIndicator } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { NavigationProp, useFocusEffect } from '@react-navigation/native'; @@ -6,11 +6,119 @@ import { Navigation, Clock, MapPin } from 'lucide-react-native'; import { colors } from '../../utils/colors'; import { useAuth } from '../../contexts/AuthContext'; import { supabase } from '../../services/supabase'; +import { getDestinationLandmarkImage } from '../../services/destinationImage'; interface Props { navigation: NavigationProp; } +type TripCardProps = { + trip: any; +}; + +const maskGoogleKeyInImageUrl = (url: string | null) => url?.replace(/([?&]key=)[^&]+/, '$1[REDACTED]') || null; + +function TripCard({ trip }: TripCardProps) { + const [imageUrl, setImageUrl] = useState(trip.destination_image_url || null); + const [landmarkName, setLandmarkName] = useState(trip.destination_landmark_name || null); + const [placeId, setPlaceId] = useState(trip.destination_place_id || trip.place_id || null); + const tripTitle = trip.title || trip.destination || 'Viagem'; + + useEffect(() => { + let isMounted = true; + + console.log('[TripCardImageDebug] initial', { + tripName: tripTitle, + destination: trip.destination, + placeId: trip.destination_place_id || trip.place_id || null, + imageUrl: trip.destination_image_url || null, + }); + + if (trip.destination_image_url || !trip.destination) return; + + getDestinationLandmarkImage(trip.destination, { tripTitle }).then(result => { + if (!isMounted) return; + setImageUrl(result.imageUrl); + setLandmarkName(result.landmarkName); + setPlaceId(result.placeId); + console.log('[TripCardImageDebug] fetched', { + tripName: tripTitle, + destination: trip.destination, + placeId: result.placeId, + imageUrl: maskGoogleKeyInImageUrl(result.imageUrl), + source: result.source, + fallbackReason: result.fallbackReason, + }); + }); + + return () => { + isMounted = false; + }; + }, [trip.destination, trip.destination_image_url, trip.destination_place_id, trip.place_id, tripTitle]); + + useEffect(() => { + console.log('[TripCardImageDebug] render_image_uri', { + tripName: tripTitle, + destination: trip.destination, + placeId, + imageUrl: maskGoogleKeyInImageUrl(imageUrl), + hasValidRemoteUri: Boolean(imageUrl?.startsWith('https://')), + fallbackReason: imageUrl ? null : 'missing_image_url', + }); + }, [trip.destination, tripTitle, placeId, imageUrl]); + + const imageContent = ( + + {tripTitle} + + {landmarkName || trip.destination} + + + ); + + return ( + + {imageUrl ? ( + + {imageContent} + + ) : ( + + + + {imageContent} + + )} + + + + + + {trip.distance} + + + + + {trip.duration} + + + + + + + + + + + {trip.origin} + {trip.destination} + + + + + ); +} + export default function HomeScreen({ navigation }: Props) { const { user } = useAuth(); const userName = user?.user_metadata?.display_name || user?.user_metadata?.name || user?.email || user?.user_metadata?.email || 'Viajante'; @@ -68,38 +176,7 @@ export default function HomeScreen({ navigation }: Props) { {loading ? ( ) : trips.length > 0 ? ( - trips.map(trip => ( - - - - {trip.title} - - - - - {trip.distance} - - - - - {trip.duration} - - - - - - - - - - - {trip.origin} - {trip.destination} - - - - - )) + trips.map(trip => ) ) : ( Pronto para a próxima? @@ -167,23 +244,54 @@ const styles = StyleSheet.create({ tripImage: { height: 180, justifyContent: 'flex-end', - }, - imageOverlay: { - padding: 20, - backgroundColor: 'rgba(0,0,0,0.3)', + overflow: 'hidden', borderTopLeftRadius: 24, borderTopRightRadius: 24, }, - tripTitle: { - color: colors.white, - fontSize: 22, - fontWeight: 'bold', - marginBottom: 4, + tripImageStyle: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, }, - tripDate: { + tripImageFallback: { + backgroundColor: '#2B2018', + position: 'relative', + }, + tripImageFallbackGlow: { + position: 'absolute', + top: -50, + right: -40, + width: 180, + height: 180, + borderRadius: 90, + backgroundColor: 'rgba(255, 122, 0, 0.35)', + }, + tripImageFallbackAccent: { + position: 'absolute', + left: -30, + bottom: -60, + width: 190, + height: 190, + borderRadius: 95, + backgroundColor: 'rgba(255, 214, 180, 0.16)', + }, + imageOverlay: { + padding: 20, + paddingTop: 80, + backgroundColor: 'rgba(0,0,0,0.42)', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + }, + tripImageTitle: { color: colors.white, - fontSize: 14, + fontSize: 24, + fontWeight: '800', + marginBottom: 6, + }, + tripImageSubtitle: { + color: colors.white, + fontSize: 15, fontWeight: '600', + opacity: 0.92, }, tripContent: { padding: 20, diff --git a/src/screens/trip/NewTripScreen.tsx b/src/screens/trip/NewTripScreen.tsx index a0f1fb1..63f508d 100644 --- a/src/screens/trip/NewTripScreen.tsx +++ b/src/screens/trip/NewTripScreen.tsx @@ -45,6 +45,7 @@ export default function NewTripScreen({ navigation }) { setDuration(finalDuration); let generatedPlaylistUrl = null; + let spotifyPremiumRequired = false; try { console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName); @@ -95,14 +96,21 @@ export default function NewTripScreen({ navigation }) { } } else if (!testRes.ok) { const testErr = await testRes.text(); - console.error("Spotify validation request failed:", testRes.status, testErr); + if (testRes.status === 403 || testErr.toLowerCase().includes('active premium subscription required')) { + spotifyPremiumRequired = true; + console.warn("Spotify validation skipped due to expected Premium limitation:", testRes.status, testErr); + } else { + console.warn("Spotify validation request failed:", testRes.status, testErr); + } providerToken = null; } } if (!providerToken) { console.log("Spotify token missing or expired, skipping playlist generation."); - Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.'); + if (!spotifyPremiumRequired) { + Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.'); + } } else { // B. Fetch Spotify User ID const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', { @@ -182,8 +190,13 @@ export default function NewTripScreen({ navigation }) { if (!searchRes.ok) { const errText = await searchRes.text(); - console.error("Search failed:", errText); - Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`); + if (searchRes.status === 403 || errText.toLowerCase().includes('active premium subscription required')) { + spotifyPremiumRequired = true; + console.warn("Spotify search skipped due to expected Premium limitation:", searchRes.status, errText); + } else { + console.warn("Search failed:", errText); + Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`); + } break; } @@ -212,7 +225,7 @@ export default function NewTripScreen({ navigation }) { const chunkSize = 100; for (let i = 0; i < trackUris.length; i += chunkSize) { const chunk = trackUris.slice(i, i + chunkSize); - await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { + const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks`, { method: 'POST', headers: { 'Authorization': `Bearer ${providerToken}`, @@ -220,15 +233,30 @@ export default function NewTripScreen({ navigation }) { }, body: JSON.stringify({ uris: chunk }) }); + if (!addTracksRes.ok) { + const addTracksErr = await addTracksRes.text(); + if (addTracksRes.status === 403 || addTracksErr.toLowerCase().includes('active premium subscription required')) { + spotifyPremiumRequired = true; + console.warn("Spotify add tracks skipped due to expected Premium limitation:", addTracksRes.status, addTracksErr); + break; + } + throw new Error(`Spotify API returned status ${addTracksRes.status} while adding tracks: ${addTracksErr.substring(0, 150)}`); + } } console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl); } else { - console.error("No tracks found for genres:", seed_genres); + console.warn("No tracks found for genres:", seed_genres); } } } catch (playlistError: any) { - console.warn("Expected failure generating playlist:", playlistError.message || playlistError); - Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistError.message?.substring(0, 50) || 'Erro Desconhecido'}`); + const playlistErrorMessage = String(playlistError?.message || playlistError || ''); + if (playlistErrorMessage.includes('403') || playlistErrorMessage.toLowerCase().includes('active premium subscription required')) { + spotifyPremiumRequired = true; + console.warn("Spotify playlist skipped due to expected Premium limitation:", playlistErrorMessage); + } else { + console.warn("Expected failure generating playlist:", playlistErrorMessage); + Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistErrorMessage.substring(0, 50) || 'Erro Desconhecido'}`); + } } // G. Save to Supabase unconditionally if route is valid @@ -250,7 +278,13 @@ export default function NewTripScreen({ navigation }) { console.error("DB Insert error:", dbError); Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message); } else { - Alert.alert('Sucesso!', 'Viagem calculada e guardada!'); + Alert.alert( + 'Sucesso!', + spotifyPremiumRequired + ? 'Viagem criada, mas a playlist do Spotify não pôde ser criada porque a app Spotify precisa de Premium.' + : 'Viagem calculada e guardada!' + ); + navigation.goBack(); } } catch (dbEx) { console.error("Exception during DB save:", dbEx); diff --git a/src/services/destinationImage.ts b/src/services/destinationImage.ts new file mode 100644 index 0000000..139b760 --- /dev/null +++ b/src/services/destinationImage.ts @@ -0,0 +1,339 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +type DestinationImageResult = { + imageUrl: string | null; + landmarkName: string | null; + placeId: string | null; + source: 'google_places' | 'wikimedia' | 'cache' | 'fallback'; + fallbackReason: string | null; +}; + +type PlacesSearchResponse = { + status?: string; + error_message?: string; + candidates?: Array<{ + name?: string; + place_id?: string; + photos?: Array<{ + photo_reference?: string; + }>; + }>; +}; + +const imageCache = new Map>(); +const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY; +const DESTINATION_IMAGE_CACHE_PREFIX = 'destination_image_cache:'; + +const maskGoogleKeyInUrl = (url: string) => url.replace(/([?&]key=)[^&]+/, '$1[REDACTED]'); + +const buildPhotoUrl = (photoReference: string) => { + if (!GOOGLE_MAPS_API_KEY) return null; + + return `https://maps.googleapis.com/maps/api/place/photo?maxwidth=900&photo_reference=${photoReference}&key=${encodeURIComponent(GOOGLE_MAPS_API_KEY)}`; +}; + +type WikimediaSearchResponse = { + query?: { + search?: Array<{ + title?: string; + }>; + }; +}; + +type DestinationImageDebugContext = { + tripTitle?: string; +}; + +type WikimediaPageSummary = { + title?: string; + originalimage?: { + source?: string; + }; + thumbnail?: { + source?: string; + }; +}; + +const fallbackResult = (fallbackReason: string): DestinationImageResult => ({ + imageUrl: null, + landmarkName: null, + placeId: null, + source: 'fallback', + fallbackReason, +}); + +const readCachedImage = async (cacheKey: string): Promise => { + try { + const cached = await AsyncStorage.getItem(`${DESTINATION_IMAGE_CACHE_PREFIX}${cacheKey}`); + if (!cached) return null; + + const parsed = JSON.parse(cached) as DestinationImageResult; + if (!parsed.imageUrl) return null; + + return { + ...parsed, + source: 'cache', + fallbackReason: null, + }; + } catch (error) { + console.warn('[DestinationImage] Failed to read cached image:', error); + return null; + } +}; + +const writeCachedImage = async (cacheKey: string, result: DestinationImageResult) => { + if (!result.imageUrl) return; + + try { + await AsyncStorage.setItem(`${DESTINATION_IMAGE_CACHE_PREFIX}${cacheKey}`, JSON.stringify(result)); + } catch (error) { + console.warn('[DestinationImage] Failed to write cached image:', error); + } +}; + +const getGooglePlacesImage = async (normalizedDestination: string, debugContext?: DestinationImageDebugContext): Promise => { + console.log('[DestinationImage] Google API key status:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + apiKeyExists: Boolean(GOOGLE_MAPS_API_KEY), + }); + + if (!GOOGLE_MAPS_API_KEY) { + console.warn('[DestinationImage] Google Places skipped: missing API key'); + return null; + } + + const inputs = [ + normalizedDestination, + `famous landmark or monument in ${normalizedDestination}`, + ]; + + for (const input of inputs) { + const url = `https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=${encodeURIComponent(input)}&inputtype=textquery&fields=name,place_id,photos&key=${encodeURIComponent(GOOGLE_MAPS_API_KEY)}`; + console.log('[DestinationImage] Google Places search:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + input, + endpointType: 'legacy_places_find_place', + url: maskGoogleKeyInUrl(url).replace('[REDACTED]', '***'), + }); + + const response = await fetch(url); + const data = (await response.json()) as PlacesSearchResponse; + + console.log('[DestinationImage] Google Places result:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + input, + httpStatus: response.status, + placesStatus: data.status, + candidateCount: data.candidates?.length || 0, + firstPlaceId: data.candidates?.[0]?.place_id || null, + firstName: data.candidates?.[0]?.name || null, + firstHasPhoto: Boolean(data.candidates?.[0]?.photos?.[0]?.photo_reference), + photoReferenceFound: data.candidates?.[0]?.photos?.[0]?.photo_reference || null, + errorMessage: data.error_message || null, + fullBody: data, + }); + + if (!response.ok || data.status === 'REQUEST_DENIED') { + console.warn('[DestinationImage] Google Places request failed:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + input, + httpStatus: response.status, + placesStatus: data.status, + errorMessage: data.error_message || null, + fullBody: data, + }); + continue; + } + + const candidate = data.candidates?.find(item => item.photos?.[0]?.photo_reference); + const photoReference = candidate?.photos?.[0]?.photo_reference; + const imageUrl = photoReference ? buildPhotoUrl(photoReference) : null; + + if (!imageUrl) { + console.log('[DestinationImage] Google Places no usable photo for query:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + input, + fallbackReason: 'google_place_found_without_photo', + }); + } + + if (imageUrl) { + console.log('[DestinationImage] Google Places photo URL generated:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + endpointType: 'legacy_places_photo', + placeId: candidate?.place_id || null, + landmarkName: candidate?.name || null, + photoReferenceFound: photoReference, + imageUrl: maskGoogleKeyInUrl(imageUrl).replace('[REDACTED]', '***'), + }); + + return { + imageUrl, + landmarkName: candidate?.name || null, + placeId: candidate?.place_id || null, + source: 'google_places', + fallbackReason: null, + }; + } + } + + return null; +}; + +const getWikimediaImage = async (normalizedDestination: string, debugContext?: DestinationImageDebugContext): Promise => { + const languageHosts = ['pt.wikipedia.org', 'en.wikipedia.org']; + + for (const host of languageHosts) { + const searchUrl = `https://${host}/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(normalizedDestination)}&format=json&origin=*&srlimit=1`; + console.log('[DestinationImage] Wikimedia search:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + host, + url: searchUrl, + }); + + const searchResponse = await fetch(searchUrl); + const searchData = (await searchResponse.json()) as WikimediaSearchResponse; + const title = searchData.query?.search?.[0]?.title; + + console.log('[DestinationImage] Wikimedia search result:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + host, + httpStatus: searchResponse.status, + title: title || null, + body: searchData, + }); + + if (!searchResponse.ok || !title) { + continue; + } + + const summaryUrl = `https://${host}/api/rest_v1/page/summary/${encodeURIComponent(title)}`; + const summaryResponse = await fetch(summaryUrl); + const summaryData = (await summaryResponse.json()) as WikimediaPageSummary; + const imageUrl = summaryData.originalimage?.source || summaryData.thumbnail?.source || null; + + console.log('[DestinationImage] Wikimedia image result:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + host, + title: summaryData.title || title, + httpStatus: summaryResponse.status, + imageUrl, + fallbackReason: imageUrl ? null : 'wikimedia_page_without_image', + }); + + if (!summaryResponse.ok || !imageUrl?.startsWith('https://')) { + continue; + } + + return { + imageUrl, + landmarkName: summaryData.title || title, + placeId: null, + source: 'wikimedia', + fallbackReason: null, + }; + } + + return null; +}; + +const fetchDestinationLandmarkImage = async (destination: string, cacheKey: string, debugContext?: DestinationImageDebugContext): Promise => { + const normalizedDestination = destination.trim(); + + if (!normalizedDestination) { + console.log('[DestinationImage] Fallback reason:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + reason: 'missing_destination', + }); + return fallbackResult('missing_destination'); + } + + try { + const cached = await readCachedImage(cacheKey); + if (cached) { + console.log('[DestinationImage] Final image URL from cache:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + imageUrl: cached.imageUrl, + fallbackReason: null, + }); + return cached; + } + + const googleResult = await getGooglePlacesImage(normalizedDestination, debugContext); + if (googleResult?.imageUrl) { + console.log('[DestinationImage] Final image URL:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + source: googleResult.source, + placeId: googleResult.placeId, + landmarkName: googleResult.landmarkName, + imageUrl: googleResult.imageUrl ? maskGoogleKeyInUrl(googleResult.imageUrl).replace('[REDACTED]', '***') : null, + fallbackReason: null, + }); + await writeCachedImage(cacheKey, googleResult); + return googleResult; + } + + console.log('[DestinationImage] Google fallback reason:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + reason: 'google_places_no_image_or_failed', + }); + + const wikimediaResult = await getWikimediaImage(normalizedDestination, debugContext); + if (wikimediaResult?.imageUrl) { + console.log('[DestinationImage] Final image URL:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + source: wikimediaResult.source, + landmarkName: wikimediaResult.landmarkName, + imageUrl: wikimediaResult.imageUrl, + fallbackReason: null, + }); + await writeCachedImage(cacheKey, wikimediaResult); + return wikimediaResult; + } + + console.log('[DestinationImage] Fallback reason:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + reason: 'no_google_or_wikimedia_image', + }); + return fallbackResult('no_google_or_wikimedia_image'); + } catch (error) { + console.warn('[DestinationImage] Failed to fetch destination image:', { + tripTitle: debugContext?.tripTitle || null, + destination: normalizedDestination, + error, + fallbackReason: 'fetch_error', + }); + return fallbackResult('fetch_error'); + } +}; + +export const getDestinationLandmarkImage = (destination: string, debugContext?: DestinationImageDebugContext): Promise => { + const cacheKey = destination.trim().toLowerCase(); + + if (!cacheKey) { + return Promise.resolve(fallbackResult('missing_destination')); + } + + const cached = imageCache.get(cacheKey); + if (cached) return cached; + + const request = fetchDestinationLandmarkImage(destination, cacheKey, debugContext); + imageCache.set(cacheKey, request); + + return request; +};