Files
cruiseLovers/app/(tabs)/home/index.tsx

426 lines
15 KiB
TypeScript

import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
import { useAuth } from '@/assets/contexts/useAuth';
import ConfirmadasIcon from '@/assets/icons/icon-reservas-confirmadas.svg';
import MalaIcon from '@/assets/icons/icon-mala.svg';
import TotalReservasIcon from '@/assets/icons/icon-total-reservas.svg';
import WifiIcon from '@/assets/icons/wifi-solid.svg';
import { downloadDocumentos, getDocumentosChecksums, verificarDocumentosParaDownload } from '@/assets/services/documentSync';
import { cacheReservas, getCachedReservas } from '@/assets/services/offlineStorage';
import { colors } from '@/assets/styles/colors';
import { Reserva, ReservasResponse } from '@/assets/types';
import styles from '@/styles/screens/tabs/home.styles';
import { FontAwesome } from '@expo/vector-icons';
import { Href, router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Image,
Pressable,
RefreshControl,
ScrollView,
Text,
View,
} from 'react-native';
const getStatusPriority = (statusCode: string): number => {
switch (statusCode) {
case '10':
return 1; // Em viagem
case '1':
case '5':
return 2; // Confirmada
case '-1':
return 3; // Em pagamento
case '-2':
case '-3':
return 4; // Pendente
case '99':
return 5; // Viagem realizada
case '-5':
return 7; // Cancelada — sempre no fim
default:
return 6;
}
};
type HomeFilter = 'proxima' | 'total' | 'confirmadas';
const sortReservas = (items: Reserva[]) =>
[...items].sort((a, b) => {
const priorityDiff = getStatusPriority(a.statusCode) - getStatusPriority(b.statusCode);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.startDate).getTime() - new Date(b.startDate).getTime();
});
export default function Home() {
const { token } = useAuth();
const [reservas, setReservas] = useState<Reserva[]>([]);
const [activeFilter, setActiveFilter] = useState<HomeFilter>('total');
const [isLoading, setIsLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFromCache, setIsFromCache] = useState(false);
const syncDocumentos = async () => {
if (!token) return;
try {
const checksumsData = await getDocumentosChecksums(token);
if (!checksumsData || checksumsData.status !== 200 || !checksumsData.documentos?.length) return;
const documentosParaDownload = await verificarDocumentosParaDownload(checksumsData.documentos);
if (!documentosParaDownload.length) return;
Alert.alert(
'Documentos disponíveis',
`Existem ${documentosParaDownload.length} ficheiro${documentosParaDownload.length === 1 ? '' : 's'} novo${documentosParaDownload.length === 1 ? '' : 's'} para descarregar/atualizar. Pretende descarregá-los agora?`,
[
{ text: 'Não', style: 'cancel' },
{
text: 'Sim',
onPress: () => downloadDocumentos(documentosParaDownload),
},
],
);
} catch {
// sync de documentos é silencioso — não bloqueia o utilizador
}
};
const fetchReservas = async () => {
try {
setError(null);
if (!token) {
setReservas([]);
setIsFromCache(false);
return;
}
const url = buildApiUrl(API_ENDPOINTS.USER_RESERVAS);
const formData = new FormData();
formData.append('token', token);
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
},
body: formData,
});
const data: ReservasResponse = await response.json();
if (!response.ok || (data.status !== 200 && data.status !== '200')) {
throw new Error(data.message || 'Erro ao carregar reservas');
}
const orderedReservas = sortReservas(data.reservas || []);
setReservas(orderedReservas);
setIsFromCache(false);
await cacheReservas(orderedReservas);
syncDocumentos();
} catch {
const cached = await getCachedReservas();
if (cached && cached.length > 0) {
setReservas(cached);
setIsFromCache(true);
setError(null);
} else {
setError('Sem ligação à internet e sem dados guardados.');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setReservas([]);
setIsFromCache(false);
setIsLoading(true);
fetchReservas();
}, [token]);
const onRefresh = async () => {
setRefreshing(true);
await fetchReservas();
setRefreshing(false);
};
const stats = useMemo(() => {
const now = new Date();
const proximas = reservas.filter((reserva) => new Date(reserva.startDate) >= now);
const total = reservas.length;
const confirmadas = reservas.filter(
(reserva) => reserva.statusCode === '1' || reserva.statusCode === '5'
).length;
const proxima = sortReservas(proximas)[0];
let dias = '--';
if (proxima) {
const diff = new Date(proxima.startDate).getTime() - now.getTime();
dias = `${Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))}`;
}
const reservaEmViagem = reservas.find((reserva) => reserva.statusCode === '10');
return {
dias,
total,
confirmadas,
emViagem: Boolean(reservaEmViagem),
destinoEmViagem: reservaEmViagem?.destino?.trim() || '—',
reservaEmViagem,
proximaReserva: proxima ?? null,
};
}, [reservas]);
const filteredReservas = useMemo(() => {
const now = new Date();
if (activeFilter === 'total') {
return sortReservas(reservas);
}
if (activeFilter === 'confirmadas') {
return sortReservas(
reservas.filter(
(reserva) => reserva.statusCode === '1' || reserva.statusCode === '5'
)
);
}
if (stats.reservaEmViagem) {
return [stats.reservaEmViagem];
}
const proxima = reservas
.filter((reserva) => new Date(reserva.startDate) >= now)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())[0];
return proxima ? [proxima] : [];
}, [activeFilter, reservas, stats.reservaEmViagem]);
const sectionTitle = useMemo(() => {
if (activeFilter === 'confirmadas') return 'Reservas Confirmadas';
if (activeFilter === 'proxima') {
return stats.emViagem ? 'Em Viagem' : 'Próxima Viagem';
}
return 'As minhas Reservas';
}, [activeFilter, stats.emViagem]);
const emptyMessage = useMemo(() => {
if (activeFilter === 'confirmadas') return 'Sem reservas confirmadas.';
if (activeFilter === 'proxima') {
return stats.emViagem
? 'Sem viagem em curso.'
: 'Sem viagens futuras para apresentar.';
}
return 'Sem reservas para apresentar.';
}, [activeFilter, stats.emViagem]);
const getStatusType = (statusCode: string) => {
if (statusCode === '1' || statusCode === '5') return 'verde';
if (statusCode === '-5') return 'vermelho';
if (statusCode === '10' || statusCode === '99') return 'roxo';
if (statusCode === '-2' || statusCode === '-1' || statusCode === '-3') return 'amarelo';
return 'neutral';
};
const getStatusColor = (statusType: string) => {
switch (statusType) {
case 'verde':
return '#13AE45';
case 'vermelho':
return '#E6463B';
case 'roxo':
return '#B138E6';
case 'amarelo':
return '#C99700';
default:
return '#4A6592';
}
};
const getStatusConfig = (statusCode: string) => {
switch (statusCode) {
case '1':
return { label: 'Confirmada', icon: 'check-circle' as const };
case '5':
return { label: 'Confirmada*', icon: 'check-circle' as const };
case '-5':
return { label: 'Cancelada', icon: 'times-circle' as const };
case '10':
return { label: 'Em Viagem', icon: 'ship' as const };
case '99':
return { label: 'Viagem Realizada', icon: 'ship' as const };
case '-2':
case '-3':
return { label: 'Pendente', icon: 'clock-o' as const };
case '-1':
return { label: 'Em pagamento', icon: 'clock-o' as const };
default:
return { label: 'Não definido', icon: 'question-circle' as const };
}
};
if (isLoading) {
return (
<View style={styles.centered}>
<LoadingSpinner size="large" />
</View>
);
}
const isProximaActive = activeFilter === 'proxima';
const isTotalActive = activeFilter === 'total';
const isConfirmadasActive = activeFilter === 'confirmadas';
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.statsRow}>
<Pressable
style={[styles.statCard, isProximaActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('proxima')}>
{stats.emViagem ? (
<View style={styles.statIconEmViagemWrap}>
<FontAwesome name="ship" size={20} color={colors.azul} />
</View>
) : isProximaActive ? (
<Image source={require('@/assets/icons/mala.png')} style={styles.statIcon} />
) : (
<MalaIcon width={38} height={38} style={styles.statIconSvg} />
)}
<Text style={isProximaActive ? styles.statLabelPrimary : styles.statLabel}>
{stats.emViagem ? 'Em viagem' : 'Proxima Viagem'}
</Text>
<Text
style={isProximaActive ? styles.statValuePrimary : styles.statValue}
numberOfLines={stats.emViagem ? 2 : 1}>
{stats.emViagem ? stats.destinoEmViagem : `${stats.dias} dias`}
</Text>
</Pressable>
<Pressable
style={[styles.statCard, isTotalActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('total')}>
{isTotalActive ? (
<TotalReservasIcon width={38} height={38} style={styles.statIconSvg} />
) : (
<Image source={require('@/assets/icons/calendario.png')} style={styles.statIcon} />
)}
<Text style={isTotalActive ? styles.statLabelPrimary : styles.statLabel}>
Total Reservas
</Text>
<Text style={isTotalActive ? styles.statValuePrimary : styles.statValue}>
{stats.total}
</Text>
</Pressable>
<Pressable
style={[styles.statCard, isConfirmadasActive && styles.statCardPrimary]}
onPress={() => setActiveFilter('confirmadas')}>
{isConfirmadasActive ? (
<ConfirmadasIcon width={38} height={38} style={styles.statIconSvg} />
) : (
<Image source={require('@/assets/icons/confirmadas.png')} style={styles.statIcon} />
)}
<Text style={isConfirmadasActive ? styles.statLabelPrimary : styles.statLabel}>
Confirmadas
</Text>
<Text style={isConfirmadasActive ? styles.statValuePrimary : styles.statValue}>
{stats.confirmadas}
</Text>
</Pressable>
</View>
{isFromCache && (
<View style={styles.offlineBanner}>
<WifiIcon width={13} height={13} fill={colors.branco} />
<Text style={styles.offlineBannerText}>Sem ligação a mostrar dados guardados</Text>
</View>
)}
<Text style={styles.sectionTitle}>{sectionTitle}</Text>
{!!error && <Text style={styles.errorText}>{error}</Text>}
{!error && filteredReservas.length === 0 && (
<Text style={styles.emptyText}>{emptyMessage}</Text>
)}
{!error &&
filteredReservas.map((reserva, index) => {
const statusType = getStatusType(reserva.statusCode);
const statusConfig = getStatusConfig(reserva.statusCode);
const statusColor = getStatusColor(statusType);
return (
<Pressable
key={`${reserva.referenciaViagem}-${index}`}
style={styles.card}
onPress={() =>
router.push(`/reserva/${encodeURIComponent(reserva.referenciaViagem || '')}` as Href)
}>
<Image
source={
reserva.imagemCidade
? { uri: reserva.imagemCidade }
: require('@/assets/icons/logo.png')
}
style={styles.cardImage}
/>
<View style={styles.cardContent}>
<View style={styles.cardTopRow}>
<Text style={styles.refText}>{reserva.referenciaViagem || '---'}</Text>
<View
style={[
styles.statusBadge,
statusType === 'verde' && styles.statusSuccess,
statusType === 'vermelho' && styles.statusDanger,
statusType === 'roxo' && styles.statusInfo,
statusType === 'amarelo' && styles.statusPendente,
]}>
<View style={styles.statusBadgeContent}>
<FontAwesome name={statusConfig.icon} size={12} color={statusColor} />
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={[
styles.statusText,
statusType === 'verde' && styles.statusTextSuccess,
statusType === 'vermelho' && styles.statusTextDanger,
statusType === 'roxo' && styles.statusTextInfo,
statusType === 'amarelo' && styles.statusTextPendente,
]}>
{statusConfig.label}
</Text>
</View>
</View>
</View>
<Text style={styles.destination} numberOfLines={1} ellipsizeMode="tail">
{reserva.destino}
</Text>
<Text style={styles.meta}><FontAwesome name="map" size={12} color="#4A6592" /> {reserva.pais}</Text>
<View style={styles.dateRow}>
<FontAwesome name="calendar" size={12} color="#4A6592" />
<Text style={styles.meta}>{reserva.startDate}</Text>
</View>
</View>
<View style={styles.arrowWrap}>
<FontAwesome name="angle-right" size={20} color="#EB2415" />
</View>
</Pressable>
);
})}
</ScrollView>
</View>
);
}