First commit of the new app
This commit is contained in:
21
assets/components/reserva/DashedDivider.tsx
Normal file
21
assets/components/reserva/DashedDivider.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
color?: string;
|
||||
width?: number;
|
||||
borderStyle?: 'dashed' | 'dotted' | 'solid';
|
||||
};
|
||||
|
||||
export function DashedDivider({ color = '#a9bcd9', width = 1, borderStyle = 'dashed' }: Props) {
|
||||
return (
|
||||
<View style={{ height: 1, overflow: 'hidden' }}>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: width,
|
||||
borderColor: color,
|
||||
borderStyle: borderStyle,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
98
assets/components/reserva/DocumentoRow.tsx
Normal file
98
assets/components/reserva/DocumentoRow.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { downloadDocumento, getLocalDocumentUri } from '@/assets/services/documentSync';
|
||||
import { Documento } from '@/assets/types';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Pressable, Text, View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
documento: Documento;
|
||||
isLast: boolean;
|
||||
};
|
||||
|
||||
const getDocumentExt = (path: string) => {
|
||||
const ext = path.split('.').pop()?.toUpperCase() ?? 'DOC';
|
||||
return ext.length > 4 ? 'DOC' : ext;
|
||||
};
|
||||
|
||||
export function DocumentoRow({ documento, isLast }: Props) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documento.idDocumento || !documento.caminhoFicheiro) return;
|
||||
getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro).then((uri) => {
|
||||
setIsDownloaded(!!uri);
|
||||
});
|
||||
}, [documento.idDocumento, documento.caminhoFicheiro]);
|
||||
|
||||
const fileName =
|
||||
documento.nome || documento.caminhoFicheiro.split('/').pop() || '---';
|
||||
|
||||
const openLocalFile = async (uri: string) => {
|
||||
const canShare = await Sharing.isAvailableAsync();
|
||||
if (canShare) {
|
||||
await Sharing.shareAsync(uri, { UTI: '.pdf', mimeType: 'application/pdf' });
|
||||
} else {
|
||||
Alert.alert('Erro', 'Não é possível abrir este ficheiro neste dispositivo.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePress = async () => {
|
||||
if (!documento.idDocumento || !documento.caminhoFicheiro) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const localUri = await getLocalDocumentUri(documento.idDocumento, documento.caminhoFicheiro);
|
||||
|
||||
if (localUri) {
|
||||
await openLocalFile(localUri);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ficheiro não está local — fazer download agora
|
||||
const uri = await downloadDocumento({
|
||||
idDocumento: documento.idDocumento,
|
||||
caminhoFicheiro: documento.caminhoFicheiro,
|
||||
checksum: documento.checksum ?? '',
|
||||
nome: documento.nome,
|
||||
});
|
||||
|
||||
if (uri) {
|
||||
setIsDownloaded(true);
|
||||
await openLocalFile(uri);
|
||||
} else {
|
||||
Alert.alert('Erro', 'Não foi possível descarregar o documento. Verifica a tua ligação à internet.');
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={isDownloading}
|
||||
style={[styles.documentoRow, isLast && styles.documentoRowLast]}>
|
||||
<View style={styles.documentoBadge}>
|
||||
<Text style={styles.documentoBadgeText}>
|
||||
{getDocumentExt(documento.caminhoFicheiro)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.documentoName} numberOfLines={1}>
|
||||
{fileName}
|
||||
</Text>
|
||||
{isDownloading ? (
|
||||
<LoadingSpinner size="small" />
|
||||
) : isDownloaded ? (
|
||||
<CircleCheckIcon width={16} height={16} fill="#13AE45" />
|
||||
) : (
|
||||
<FontAwesome name="download" size={16} color={colors.vermelho} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
18
assets/components/reserva/FieldBox.tsx
Normal file
18
assets/components/reserva/FieldBox.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
export function FieldBox({ label, value }: Props) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<View style={styles.fieldBox}>
|
||||
<Text style={styles.fieldBoxText}>{value || '---'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
47
assets/components/reserva/HotelCard.tsx
Normal file
47
assets/components/reserva/HotelCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { HotelInfo } from '@/assets/types';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import { formatDateLong } from './formatters';
|
||||
import { Stars } from './Stars';
|
||||
|
||||
type Props = {
|
||||
hotel: HotelInfo;
|
||||
isFirst: boolean;
|
||||
};
|
||||
|
||||
export function HotelCard({ hotel, isFirst }: Props) {
|
||||
const img = hotel.imagemexterna || hotel.imageminterna;
|
||||
|
||||
return (
|
||||
<View style={[styles.hotelRow, !isFirst && styles.blockBorderTop]}>
|
||||
<Image
|
||||
source={img ? { uri: img } : require('@/assets/icons/logo.png')}
|
||||
style={styles.hotelImage}
|
||||
/>
|
||||
<View style={styles.hotelInfo}>
|
||||
<Stars stars={hotel.stars} />
|
||||
<Text style={styles.hotelName} numberOfLines={2}>
|
||||
{hotel.name}
|
||||
</Text>
|
||||
{!!hotel.regime && (
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Regime: </Text>
|
||||
<Text style={styles.hotelMetaValue}>{hotel.regime}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{!!hotel.data && (
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Check-In: </Text>
|
||||
<Text style={styles.hotelMetaValue}>{formatDateLong(hotel.data)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.hotelMetaLine}>
|
||||
<Text style={styles.hotelMetaLabel}>Nr. de Noites: </Text>
|
||||
<Text style={styles.hotelMetaValue}>
|
||||
{hotel.noites} {hotel.noites === 1 ? 'noite' : 'noites'}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
126
assets/components/reserva/PassageiroCard.tsx
Normal file
126
assets/components/reserva/PassageiroCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { Passageiro } from '@/assets/types';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { DashedDivider } from './DashedDivider';
|
||||
import { FieldBox } from './FieldBox';
|
||||
|
||||
type Props = {
|
||||
passageiro: Passageiro;
|
||||
index: number;
|
||||
isOpen: boolean;
|
||||
isFirst: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const ANIM_DURATION = 280;
|
||||
|
||||
export function PassageiroCard({ passageiro, index, isOpen, onToggle }: Props) {
|
||||
const progress = useSharedValue(isOpen ? 1 : 0);
|
||||
const contentHeight = useSharedValue(0);
|
||||
const [measuredHeight, setMeasuredHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (measuredHeight > 0) {
|
||||
contentHeight.value = measuredHeight;
|
||||
}
|
||||
}, [measuredHeight, contentHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(isOpen ? 1 : 0, {
|
||||
duration: ANIM_DURATION,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}, [isOpen, progress]);
|
||||
|
||||
const bodyAnimatedStyle = useAnimatedStyle(() => ({
|
||||
height: contentHeight.value * progress.value,
|
||||
opacity: progress.value,
|
||||
}));
|
||||
|
||||
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${progress.value * 180}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.passageiroContainer}>
|
||||
<Pressable onPress={onToggle} style={styles.passageiroHeaderRow}>
|
||||
<View>
|
||||
<Text style={styles.passageiroSubtitle}>Passageiro {index + 1}</Text>
|
||||
<Text style={styles.passageiroName}>
|
||||
{passageiro.nome} {passageiro.sobrenome}
|
||||
</Text>
|
||||
</View>
|
||||
<Animated.View style={chevronAnimatedStyle}>
|
||||
<FontAwesome name="chevron-down" size={14} color={colors.vermelho} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.passageiroBodyWrap, bodyAnimatedStyle]}
|
||||
pointerEvents={isOpen ? 'auto' : 'none'}>
|
||||
<View
|
||||
style={styles.passageiroBodyMeasure}
|
||||
onLayout={(e) => {
|
||||
const h = Math.ceil(e.nativeEvent.layout.height);
|
||||
if (h > 0) setMeasuredHeight(h);
|
||||
}}>
|
||||
<View style={styles.passageiroBody}>
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Nome" value={passageiro.nome} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Apelido" value={passageiro.sobrenome} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data de Nasc." value={passageiro.dataNascimento} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Género" value={passageiro.genero} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Nacionalidade" value={passageiro.nacionalidade} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Telemóvel" value={passageiro.telemovel} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FieldBox label="Morada" value={passageiro.morada} />
|
||||
<FieldBox label="Nacionalidade Doc." value={passageiro.paisEmissao} />
|
||||
<FieldBox label="Nr Doc Identificação" value={passageiro.numeroDocumento} />
|
||||
|
||||
<View style={styles.twoCol}>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data Emissão" value={passageiro.dataDeEmissao} />
|
||||
</View>
|
||||
<View style={styles.twoColItem}>
|
||||
<FieldBox label="Data Val./Exp." value={passageiro.dataDeValidade} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FieldBox label="Email" value={passageiro.email} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
<DashedDivider width={1} borderStyle="dashed" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
22
assets/components/reserva/Section.tsx
Normal file
22
assets/components/reserva/Section.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { ReactNode } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { DashedDivider } from './DashedDivider';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Section({ title, children }: Props) {
|
||||
return (
|
||||
<View style={styles.summaryCard}>
|
||||
{!!title && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
20
assets/components/reserva/Stars.tsx
Normal file
20
assets/components/reserva/Stars.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
stars: string | number;
|
||||
};
|
||||
|
||||
export function Stars({ stars }: Props) {
|
||||
const count = Math.max(0, Math.min(5, parseInt(String(stars || '0'), 10)));
|
||||
if (!count) return null;
|
||||
return (
|
||||
<View style={styles.hotelStarsRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<FontAwesome key={`star-${i}`} name="star" size={16} color={colors.stars} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
88
assets/components/reserva/StatusBadge.tsx
Normal file
88
assets/components/reserva/StatusBadge.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { ComponentProps } from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
type StatusType = 'verde' | 'vermelho' | 'roxo' | 'amarelo' | 'neutral';
|
||||
type IconName = ComponentProps<typeof FontAwesome>['name'];
|
||||
|
||||
type Props = {
|
||||
statusCode: string;
|
||||
};
|
||||
|
||||
const getStatusType = (statusCode: string): StatusType => {
|
||||
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 = (type: StatusType) => {
|
||||
switch (type) {
|
||||
case 'verde':
|
||||
return '#13AE45';
|
||||
case 'vermelho':
|
||||
return '#E6463B';
|
||||
case 'roxo':
|
||||
return '#B138E6';
|
||||
case 'amarelo':
|
||||
return '#C99700';
|
||||
default:
|
||||
return '#4A6592';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (statusCode: string): { label: string; icon: IconName } => {
|
||||
switch (statusCode) {
|
||||
case '1':
|
||||
return { label: 'Confirmada', icon: 'check-circle' };
|
||||
case '5':
|
||||
return { label: 'Confirmada - Aguarda pagamento total', icon: 'check-circle' };
|
||||
case '-5':
|
||||
return { label: 'Cancelada', icon: 'times-circle' };
|
||||
case '10':
|
||||
return { label: 'Em Viagem', icon: 'ship' };
|
||||
case '99':
|
||||
return { label: 'Viagem Realizada', icon: 'ship' };
|
||||
case '-2':
|
||||
return { label: 'Pendente de confirmação com Agente', icon: 'clock-o' };
|
||||
case '-3':
|
||||
return { label: 'Pendente de Confirmação', icon: 'clock-o' };
|
||||
case '-1':
|
||||
return { label: 'Aguarda pagamento inicial', icon: 'clock-o' };
|
||||
default:
|
||||
return { label: 'Não definido', icon: 'question-circle' };
|
||||
}
|
||||
};
|
||||
|
||||
export function StatusBadge({ statusCode }: Props) {
|
||||
const type = getStatusType(statusCode);
|
||||
const config = getStatusConfig(statusCode);
|
||||
const color = getStatusColor(type);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
type === 'verde' && styles.statusSuccess,
|
||||
type === 'vermelho' && styles.statusDanger,
|
||||
type === 'roxo' && styles.statusInfo,
|
||||
type === 'amarelo' && styles.statusPendente,
|
||||
]}>
|
||||
<View style={styles.statusBadgeContent}>
|
||||
<FontAwesome name={config.icon} size={12} color={color} />
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
type === 'verde' && styles.statusTextSuccess,
|
||||
type === 'vermelho' && styles.statusTextDanger,
|
||||
type === 'roxo' && styles.statusTextInfo,
|
||||
type === 'amarelo' && styles.statusTextPendente,
|
||||
]}>
|
||||
{config.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
72
assets/components/reserva/ValorReservaCard.tsx
Normal file
72
assets/components/reserva/ValorReservaCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import CircleCheckIcon from '@/assets/icons/circle-check-solid.svg';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { ReservaData } from '@/assets/types';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
reserva: ReservaData;
|
||||
};
|
||||
|
||||
const formatEuro = (value: string) => {
|
||||
const n = parseFloat(value || '0');
|
||||
return `€${n.toLocaleString('pt-PT', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
export function ValorReservaCard({ reserva }: Props) {
|
||||
const total = parseFloat(reserva.precoTotalFinal || '0');
|
||||
const pago = parseFloat(reserva.valorPago || '0');
|
||||
const emFalta = parseFloat(reserva.valorAPagar || '0');
|
||||
const isFullyPaid = emFalta <= 0;
|
||||
const percent = isFullyPaid
|
||||
? 100
|
||||
: total > 0
|
||||
? Math.min(100, Math.max(0, (pago / total) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<View style={styles.valorSection}>
|
||||
<Text style={styles.sectionTitle}>Valor da Reserva</Text>
|
||||
|
||||
<View style={styles.valorInnerCard}>
|
||||
<Text style={styles.valorAmount}>{formatEuro(reserva.precoTotalFinal)}</Text>
|
||||
<Text style={styles.valorAmountLabel}>Valor Total</Text>
|
||||
|
||||
<Text style={styles.valorPagoLine}>Pago {formatEuro(reserva.valorPago)}</Text>
|
||||
|
||||
<View style={styles.valorProgressTrack}>
|
||||
{percent > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.valorProgressFill,
|
||||
{ width: `${percent}%`, minWidth: 14 },
|
||||
]}>
|
||||
<View style={styles.valorProgressDot} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.valorFaltaRow, isFullyPaid && styles.valorFaltaRowPago]}>
|
||||
{isFullyPaid ? (
|
||||
<View style={styles.valorPagoStatus}>
|
||||
<CircleCheckIcon width={18} height={18} fill={colors.success} />
|
||||
<Text style={styles.valorFaltaLabel}>Pago</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.valorFaltaLabel}>Em Falta</Text>
|
||||
<View style={styles.valorFaltaBadge}>
|
||||
<Text style={styles.valorFaltaBadgeText}>
|
||||
{formatEuro(reserva.valorAPagar)}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
158
assets/components/reserva/VooCard.tsx
Normal file
158
assets/components/reserva/VooCard.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import PlaneArrivalIcon from '@/assets/icons/plane-arrival-solid-full.svg';
|
||||
import PlaneDepartureIcon from '@/assets/icons/plane-departure-solid-full.svg';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { Escala, VooSegment } from '@/assets/types';
|
||||
import styles from '@/styles/screens/reserva/detail.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Alert, Image, Linking, Platform, Pressable, Text, View } from 'react-native';
|
||||
import { formatDateLong } from './formatters';
|
||||
|
||||
type SegmentProps = {
|
||||
voo: Escala | VooSegment;
|
||||
tipo: 'ida' | 'volta';
|
||||
isLast: boolean;
|
||||
onMapaPress?: () => void;
|
||||
};
|
||||
|
||||
const planeIconProps = { width: 16, height: 16, fill: colors.vermelho } as const;
|
||||
|
||||
function abrirMapaAeroporto(lat: number, lng: number, nome: string) {
|
||||
const url = Platform.OS === 'ios'
|
||||
? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(nome)}`
|
||||
: `geo:${lat},${lng}?q=${encodeURIComponent(nome)}`;
|
||||
|
||||
Linking.openURL(url).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'),
|
||||
);
|
||||
}
|
||||
|
||||
function VooSegmentCard({ voo, tipo }: Omit<SegmentProps, 'onMapaPress' | 'isLast'>) {
|
||||
const legLabel = tipo === 'ida' ? 'DATA DE IDA' : 'DATA DE REGRESSO';
|
||||
const LegPlaneIcon = tipo === 'ida' ? PlaneDepartureIcon : PlaneArrivalIcon;
|
||||
|
||||
const escala = voo as Escala;
|
||||
const gps = escala.infoAeroportoDeparture?.gps;
|
||||
const nomeAeroporto = escala.infoAeroportoDeparture?.name ?? voo.departureAirport ?? voo.departureAirportCode;
|
||||
|
||||
const handleMapaPress = () => {
|
||||
if (gps?.lat != null && gps?.lng != null) {
|
||||
abrirMapaAeroporto(Number(gps.lat), Number(gps.lng), nomeAeroporto);
|
||||
} else {
|
||||
Alert.alert('Sem coordenadas', 'Não há informação de localização para este aeroporto.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.vooRouteRow}>
|
||||
<View style={styles.vooSideCol}>
|
||||
<Text style={styles.vooCode}>{voo.departureAirportCode}</Text>
|
||||
<Text style={styles.vooTime}>{voo.departureTime}</Text>
|
||||
<Text style={styles.vooAirport}>
|
||||
{voo.departureAirportCode}-{voo.departureAirport}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooMidCol}>
|
||||
<Text style={styles.vooDuration}>{voo.flightTime}</Text>
|
||||
<View style={styles.vooPathWrap}>
|
||||
<View style={styles.vooPathLine} />
|
||||
<View style={styles.vooPathIcon}>
|
||||
<FontAwesome
|
||||
name="plane"
|
||||
size={16}
|
||||
color={colors.cinza}
|
||||
style={{ transform: [{ rotate: '45deg' }] }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.vooSideCol, styles.vooSideColRight]}>
|
||||
<Text style={[styles.vooCode, styles.vooCodeRight]}>{voo.arrivalAirportCode}</Text>
|
||||
<Text style={[styles.vooTime, styles.vooTimeRight]}>{voo.arrivalTime}</Text>
|
||||
<Text style={[styles.vooAirport, styles.vooAirportRight]}>
|
||||
{voo.arrivalAirportCode}-{voo.arrivalAirport}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooInfoBox}>
|
||||
<View style={styles.vooInfoColLeft}>
|
||||
<Text style={styles.vooInfoDate}>{formatDateLong(voo.departureDate)}</Text>
|
||||
<View style={styles.vooLegRow}>
|
||||
<LegPlaneIcon {...planeIconProps} />
|
||||
<Text style={styles.vooLegLabel}>{legLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.vooInfoColRight}>
|
||||
{!!voo.malaLabel && (
|
||||
<Text style={styles.vooMetaLine}>
|
||||
<Text style={styles.vooMetaLabel}>Bagagem: </Text>
|
||||
<Text style={styles.vooMetaValue}>{voo.malaLabel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
{!!voo.class && (
|
||||
<Text style={styles.vooMetaLine}>
|
||||
<Text style={styles.vooMetaLabel}>Classe: </Text>
|
||||
<Text style={styles.vooMetaValue}>{voo.class}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable style={styles.mapaBtn} onPress={handleMapaPress}>
|
||||
<Text style={styles.mapaBtnText}>
|
||||
Mapa {voo.departureAirportCode}
|
||||
</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.mapaBtnIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type GroupProps = {
|
||||
segmentos: (Escala | VooSegment)[];
|
||||
tipo: 'ida' | 'volta';
|
||||
};
|
||||
|
||||
export function VooCard({ segmentos, tipo }: GroupProps) {
|
||||
if (!segmentos.length) return null;
|
||||
const temEscalas = segmentos.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.vooCard}>
|
||||
{temEscalas && (
|
||||
<View style={styles.vooEscalasBadge}>
|
||||
<FontAwesome name="random" size={14} color={colors.azul} />
|
||||
<Text style={styles.vooEscalasBadgeText}>
|
||||
{segmentos.length - 1} {segmentos.length - 1 === 1 ? 'escala' : 'escalas'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{segmentos.map((voo, idx) => (
|
||||
<View key={`seg-${idx}`}>
|
||||
<VooSegmentCard
|
||||
voo={voo}
|
||||
tipo={tipo}
|
||||
/>
|
||||
|
||||
{idx < segmentos.length - 1 && (
|
||||
<View style={styles.vooEscalaDivider}>
|
||||
<View style={styles.vooEscalaLine} />
|
||||
<View style={styles.vooEscalaChip}>
|
||||
<FontAwesome name="clock-o" size={10} color="#4A6592" />
|
||||
<Text style={styles.vooEscalaChipText}>
|
||||
Escala em {voo.arrivalAirportCode}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.vooEscalaLine} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
30
assets/components/reserva/formatters.ts
Normal file
30
assets/components/reserva/formatters.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API_BASE_URL } from '@/assets/config/api';
|
||||
|
||||
export const formatDateShort = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
const d = new Date(dateString);
|
||||
if (Number.isNaN(d.getTime())) return dateString;
|
||||
return d.toLocaleDateString('pt-PT');
|
||||
};
|
||||
|
||||
export const formatDateLong = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
const d = new Date(dateString);
|
||||
if (Number.isNaN(d.getTime())) return dateString;
|
||||
return d.toLocaleDateString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
export const formatCurrency = (value: string) =>
|
||||
`${parseFloat(value || '0').toFixed(0)} EUR`;
|
||||
|
||||
export const getImageUrl = (path?: string) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
const baseUrl = API_BASE_URL.replace(/\/pt\/app$/, '');
|
||||
const imagePath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${baseUrl}${imagePath}`;
|
||||
};
|
||||
Reference in New Issue
Block a user