WIP: update trip cards and Spotify handling
This commit is contained in:
@@ -4,7 +4,7 @@ import { Car, Music } from 'lucide-react-native';
|
|||||||
import { colors } from '../../utils/colors';
|
import { colors } from '../../utils/colors';
|
||||||
import { supabase } from '../../services/supabase';
|
import { supabase } from '../../services/supabase';
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
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 { clearSpotifyTokens, setSpotifyToken, setSpotifyRefreshToken } from '../../auth/spotifyToken';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
@@ -42,60 +42,137 @@ export default function LoginScreen({ navigation }) {
|
|||||||
discovery
|
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
|
// Handle Spotify OAuth Response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (response) {
|
if (response) {
|
||||||
console.log("SPOTIFY_AUTH_RESULT:", response);
|
console.log("SPOTIFY_AUTH_RESULT:", response);
|
||||||
if (response.type === 'success') {
|
if (response.type === 'success') {
|
||||||
const { code } = response.params;
|
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);
|
exchangeCodeForTokens(code);
|
||||||
} else if (response.type === 'error') {
|
} 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');
|
Alert.alert('Erro de Autenticação', response.error?.message || 'Falha ao logar com o Spotify');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [response]);
|
}, [response]);
|
||||||
|
|
||||||
const exchangeCodeForTokens = async (code: string) => {
|
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const tokenResult = await exchangeCodeAsync(
|
console.log('[LoginScreen] Spotify token exchange start:', {
|
||||||
{
|
|
||||||
clientId: SPOTIFY_CLIENT_ID,
|
|
||||||
code,
|
|
||||||
redirectUri,
|
redirectUri,
|
||||||
extraParams: {
|
clientIdExists: Boolean(SPOTIFY_CLIENT_ID),
|
||||||
|
authorizationCodeExists: Boolean(code),
|
||||||
|
codeVerifierExists: Boolean(request.codeVerifier),
|
||||||
|
flow: 'authorization_code_pkce',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenBody = new URLSearchParams({
|
||||||
|
client_id: SPOTIFY_CLIENT_ID,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
code_verifier: request.codeVerifier,
|
code_verifier: request.codeVerifier,
|
||||||
},
|
}).toString();
|
||||||
},
|
|
||||||
discovery
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("SPOTIFY_TOKEN_RECEIVED:", !!tokenResult.accessToken);
|
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();
|
||||||
|
|
||||||
if (tokenResult.accessToken) {
|
console.log('[LoginScreen] Spotify token exchange response:', {
|
||||||
await setSpotifyToken(tokenResult.accessToken);
|
httpStatus: tokenResponse.status,
|
||||||
if (tokenResult.refreshToken) {
|
ok: tokenResponse.ok,
|
||||||
await setSpotifyRefreshToken(tokenResult.refreshToken);
|
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.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");
|
console.log("SPOTIFY_PROFILE_FETCH_START");
|
||||||
const profileRes = await fetch('https://api.spotify.com/v1/me', {
|
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) {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: any;
|
||||||
|
try {
|
||||||
|
profile = JSON.parse(profileText);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('[LoginScreen] Spotify profile JSON parse failed:', parseError);
|
||||||
|
showSpotifyLoginFailure();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await profileRes.json();
|
|
||||||
console.log("SPOTIFY_PROFILE_NAME:", profile.display_name);
|
console.log("SPOTIFY_PROFILE_NAME:", profile.display_name);
|
||||||
console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email);
|
console.log("SPOTIFY_PROFILE_EMAIL_EXISTS:", !!profile.email);
|
||||||
console.log("SPOTIFY_USER_ID:", profile.id);
|
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 email = profile.email || `spotify_${profile.id}@roadtripdj.local`;
|
||||||
const password = `SpotifySecure_${profile.id}`;
|
const password = `SpotifySecure_${profile.id}`;
|
||||||
@@ -125,7 +202,7 @@ export default function LoginScreen({ navigation }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (signUpError) {
|
if (signUpError) {
|
||||||
console.error("SignUp error:", signUpError);
|
console.warn("SignUp error:", signUpError);
|
||||||
throw signUpError;
|
throw signUpError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +211,10 @@ export default function LoginScreen({ navigation }) {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (newSignInError) throw newSignInError;
|
if (newSignInError) {
|
||||||
|
console.warn("Spotify user sign-in after sign-up failed:", newSignInError.message);
|
||||||
|
throw newSignInError;
|
||||||
|
}
|
||||||
sessionData = newSession;
|
sessionData = newSession;
|
||||||
} else {
|
} else {
|
||||||
// Update metadata with latest Spotify data
|
// Update metadata with latest Spotify data
|
||||||
@@ -158,11 +238,12 @@ export default function LoginScreen({ navigation }) {
|
|||||||
// Trigger app main flow
|
// Trigger app main flow
|
||||||
enableSpotifyMode();
|
enableSpotifyMode();
|
||||||
} else {
|
} 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.');
|
Alert.alert('Erro de Autenticação', 'Não foi possível obter o token de acesso do Spotify.');
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('🚀 [LoginScreen] Token Exchange Error:', e);
|
console.warn('🚀 [LoginScreen] Token Exchange Error:', e?.message || e);
|
||||||
Alert.alert('Erro de Autenticação', e.message || 'Não foi possível trocar o código pelo token.');
|
showSpotifyLoginFailure();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -189,17 +270,24 @@ export default function LoginScreen({ navigation }) {
|
|||||||
const handleResetAuth = async () => {
|
const handleResetAuth = async () => {
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
await clearSpotifyTokens();
|
await clearSpotifyTokens();
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
Alert.alert('Reset', 'Auth state cleared.');
|
Alert.alert('Reset', 'Auth state cleared.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSpotifyLogin = async () => {
|
const handleSpotifyLogin = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("SPOTIFY_CLIENT_ID_EXISTS:", !!SPOTIFY_CLIENT_ID);
|
console.log("[LoginScreen] Spotify login start:", {
|
||||||
console.log("SPOTIFY_REDIRECT_URI:", redirectUri);
|
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();
|
await promptAsync();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('🚀 [LoginScreen] OAuth Error:', e);
|
console.warn('🚀 [LoginScreen] OAuth Error:', e?.message || e);
|
||||||
Alert.alert('Erro de Autenticação', e.message);
|
showSpotifyLoginFailure();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { View, Text, StyleSheet, ScrollView, TouchableOpacity, ImageBackground, Alert, ActivityIndicator } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { NavigationProp, useFocusEffect } from '@react-navigation/native';
|
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 { colors } from '../../utils/colors';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { supabase } from '../../services/supabase';
|
import { supabase } from '../../services/supabase';
|
||||||
|
import { getDestinationLandmarkImage } from '../../services/destinationImage';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
navigation: NavigationProp<any>;
|
navigation: NavigationProp<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TripCardProps = {
|
||||||
|
trip: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskGoogleKeyInImageUrl = (url: string | null) => url?.replace(/([?&]key=)[^&]+/, '$1[REDACTED]') || null;
|
||||||
|
|
||||||
|
function TripCard({ trip }: TripCardProps) {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(trip.destination_image_url || null);
|
||||||
|
const [landmarkName, setLandmarkName] = useState<string | null>(trip.destination_landmark_name || null);
|
||||||
|
const [placeId, setPlaceId] = useState<string | null>(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 = (
|
||||||
|
<View style={styles.imageOverlay}>
|
||||||
|
<Text style={styles.tripImageTitle} numberOfLines={2}>{tripTitle}</Text>
|
||||||
|
<Text style={styles.tripImageSubtitle} numberOfLines={1}>
|
||||||
|
{landmarkName || trip.destination}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.mainTripCard}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<ImageBackground source={{ uri: imageUrl }} style={styles.tripImage} imageStyle={styles.tripImageStyle}>
|
||||||
|
{imageContent}
|
||||||
|
</ImageBackground>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.tripImage, styles.tripImageFallback]}>
|
||||||
|
<View style={styles.tripImageFallbackGlow} />
|
||||||
|
<View style={styles.tripImageFallbackAccent} />
|
||||||
|
{imageContent}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.tripContent}>
|
||||||
|
<View style={styles.statsRow}>
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Navigation color={colors.primary} size={16} />
|
||||||
|
<Text style={styles.statText}>{trip.distance}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statDot} />
|
||||||
|
<View style={styles.statItem}>
|
||||||
|
<Clock color={colors.primary} size={16} />
|
||||||
|
<Text style={styles.statText}>{trip.duration}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.itinerarySnippet}>
|
||||||
|
<View style={styles.timeline}>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<View style={styles.line} />
|
||||||
|
<View style={[styles.dot, styles.dotEmpty]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.locations}>
|
||||||
|
<Text style={styles.locationText}>{trip.origin}</Text>
|
||||||
|
<Text style={styles.locationText}>{trip.destination}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomeScreen({ navigation }: Props) {
|
export default function HomeScreen({ navigation }: Props) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const userName = user?.user_metadata?.display_name || user?.user_metadata?.name || user?.email || user?.user_metadata?.email || 'Viajante';
|
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 ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 40 }} />
|
||||||
) : trips.length > 0 ? (
|
) : trips.length > 0 ? (
|
||||||
trips.map(trip => (
|
trips.map(trip => <TripCard key={trip.id} trip={trip} />)
|
||||||
<View key={trip.id} style={styles.mainTripCard}>
|
|
||||||
<View style={[styles.tripImage, { backgroundColor: colors.inputBackground, borderTopLeftRadius: 24, borderTopRightRadius: 24 }]} />
|
|
||||||
<View style={styles.tripContent}>
|
|
||||||
<Text style={styles.tripTitle}>{trip.title}</Text>
|
|
||||||
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Navigation color={colors.primary} size={16} />
|
|
||||||
<Text style={styles.statText}>{trip.distance}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statDot} />
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Clock color={colors.primary} size={16} />
|
|
||||||
<Text style={styles.statText}>{trip.duration}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.itinerarySnippet}>
|
|
||||||
<View style={styles.timeline}>
|
|
||||||
<View style={styles.dot} />
|
|
||||||
<View style={styles.line} />
|
|
||||||
<View style={[styles.dot, styles.dotEmpty]} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.locations}>
|
|
||||||
<Text style={styles.locationText}>{trip.origin}</Text>
|
|
||||||
<Text style={styles.locationText}>{trip.destination}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.promptCard}>
|
<View style={styles.promptCard}>
|
||||||
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
|
<Text style={styles.promptTitle}>Pronto para a próxima?</Text>
|
||||||
@@ -167,23 +244,54 @@ const styles = StyleSheet.create({
|
|||||||
tripImage: {
|
tripImage: {
|
||||||
height: 180,
|
height: 180,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
overflow: 'hidden',
|
||||||
imageOverlay: {
|
|
||||||
padding: 20,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
},
|
},
|
||||||
tripTitle: {
|
tripImageStyle: {
|
||||||
color: colors.white,
|
borderTopLeftRadius: 24,
|
||||||
fontSize: 22,
|
borderTopRightRadius: 24,
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
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,
|
color: colors.white,
|
||||||
fontSize: 14,
|
fontSize: 24,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
tripImageSubtitle: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
opacity: 0.92,
|
||||||
},
|
},
|
||||||
tripContent: {
|
tripContent: {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
setDuration(finalDuration);
|
setDuration(finalDuration);
|
||||||
|
|
||||||
let generatedPlaylistUrl = null;
|
let generatedPlaylistUrl = null;
|
||||||
|
let spotifyPremiumRequired = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
|
console.log("GENERATING_PLAYLIST_FOR_TRIP:", tripName);
|
||||||
@@ -95,14 +96,21 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
}
|
}
|
||||||
} else if (!testRes.ok) {
|
} else if (!testRes.ok) {
|
||||||
const testErr = await testRes.text();
|
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;
|
providerToken = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerToken) {
|
if (!providerToken) {
|
||||||
console.log("Spotify token missing or expired, skipping playlist generation.");
|
console.log("Spotify token missing or expired, skipping playlist generation.");
|
||||||
|
if (!spotifyPremiumRequired) {
|
||||||
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// B. Fetch Spotify User ID
|
// B. Fetch Spotify User ID
|
||||||
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
|
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
|
||||||
@@ -182,8 +190,13 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
|
|
||||||
if (!searchRes.ok) {
|
if (!searchRes.ok) {
|
||||||
const errText = await searchRes.text();
|
const errText = await searchRes.text();
|
||||||
console.error("Search failed:", errText);
|
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)}`);
|
Alert.alert('Erro Spotify', `Aviso ao adicionar músicas: ${errText.substring(0, 100)}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +225,7 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
const chunkSize = 100;
|
const chunkSize = 100;
|
||||||
for (let i = 0; i < trackUris.length; i += chunkSize) {
|
for (let i = 0; i < trackUris.length; i += chunkSize) {
|
||||||
const chunk = trackUris.slice(i, 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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${providerToken}`,
|
'Authorization': `Bearer ${providerToken}`,
|
||||||
@@ -220,15 +233,30 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ uris: chunk })
|
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);
|
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
|
||||||
} else {
|
} else {
|
||||||
console.error("No tracks found for genres:", seed_genres);
|
console.warn("No tracks found for genres:", seed_genres);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (playlistError: any) {
|
} catch (playlistError: any) {
|
||||||
console.warn("Expected failure generating playlist:", playlistError.message || playlistError);
|
const playlistErrorMessage = String(playlistError?.message || playlistError || '');
|
||||||
Alert.alert('Erro Playlist', `A viagem foi calculada, mas a playlist falhou: ${playlistError.message?.substring(0, 50) || 'Erro Desconhecido'}`);
|
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
|
// G. Save to Supabase unconditionally if route is valid
|
||||||
@@ -250,7 +278,13 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
console.error("DB Insert error:", dbError);
|
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);
|
Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message);
|
||||||
} else {
|
} 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) {
|
} catch (dbEx) {
|
||||||
console.error("Exception during DB save:", dbEx);
|
console.error("Exception during DB save:", dbEx);
|
||||||
|
|||||||
339
src/services/destinationImage.ts
Normal file
339
src/services/destinationImage.ts
Normal file
@@ -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<string, Promise<DestinationImageResult>>();
|
||||||
|
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<DestinationImageResult | null> => {
|
||||||
|
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<DestinationImageResult | null> => {
|
||||||
|
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<DestinationImageResult | null> => {
|
||||||
|
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<DestinationImageResult> => {
|
||||||
|
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<DestinationImageResult> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user