First commit of the new app
This commit is contained in:
337
app/(tabs)/contactos/index.tsx
Normal file
337
app/(tabs)/contactos/index.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { API_ENDPOINTS, buildApiUrl } from '@/assets/config/api';
|
||||
import { useAuth } from '@/assets/contexts/useAuth';
|
||||
import { cacheContacts, getCachedContacts } from '@/assets/services/offlineStorage';
|
||||
import { colors } from '@/assets/styles/colors';
|
||||
import { ContactData, ContactResponse, SocialMedia } from '@/assets/types';
|
||||
import styles from '@/styles/screens/tabs/contactos.styles';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingSpinner } from '@/assets/components/LoadingSpinner';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const openPhone = (phone: string) => {
|
||||
const clean = phone.replace(/[^\d+]/g, '');
|
||||
Linking.openURL(`tel:${clean}`).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir a aplicação de telefone.')
|
||||
);
|
||||
};
|
||||
|
||||
const openEmail = (email: string) => {
|
||||
Linking.openURL(`mailto:${email}`).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir a aplicação de e-mail.')
|
||||
);
|
||||
};
|
||||
|
||||
const openWhatsApp = (value: string) => {
|
||||
const clean = value.replace(/[^\d+]/g, '');
|
||||
const url = `https://wa.me/${clean}`;
|
||||
Linking.openURL(url).catch(() =>
|
||||
Alert.alert('Erro', 'Não foi possível abrir o WhatsApp.')
|
||||
);
|
||||
};
|
||||
|
||||
const openMaps = (address: string, lat?: number, lng?: number) => {
|
||||
const url =
|
||||
lat != null && lng != null
|
||||
? `maps://?ll=${lat},${lng}&q=${encodeURIComponent(address)}`
|
||||
: `maps:?q=${encodeURIComponent(address)}`;
|
||||
|
||||
Linking.openURL(url).catch(() =>
|
||||
Linking.openURL(
|
||||
lat != null && lng != null
|
||||
? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
|
||||
: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`
|
||||
).catch(() => Alert.alert('Erro', 'Não foi possível abrir a aplicação de mapas.'))
|
||||
);
|
||||
};
|
||||
|
||||
const copyToClipboard = async (value: string, label: string) => {
|
||||
await Clipboard.setStringAsync(value);
|
||||
Alert.alert('Copiado', `${label} copiado para a área de transferência.`);
|
||||
};
|
||||
|
||||
const extractPhone = (url: string): string => {
|
||||
const match = url.match(/(?:phone=|wa\.me\/)([\d+]+)/);
|
||||
if (match) return `+${match[1].replace(/^\+/, '')}`;
|
||||
const clean = url.replace(/[^\d+]/g, '');
|
||||
return clean.startsWith('+') ? clean : `+${clean}`;
|
||||
};
|
||||
|
||||
const parseCoords = (raw?: string | null): { lat: number; lng: number } | null => {
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(',').map((s) => parseFloat(s.trim()));
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
return { lat: parts[0], lng: parts[1] };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const parseHorarios = (raw: string): { dia: string; horas: string }[] => {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const sep = line.lastIndexOf(':');
|
||||
if (sep > 0) {
|
||||
return { dia: line.slice(0, sep).trim(), horas: line.slice(sep + 1).trim() };
|
||||
}
|
||||
const parts = line.split(/\s{2,}|\t/);
|
||||
if (parts.length >= 2) {
|
||||
return { dia: parts[0].trim(), horas: parts.slice(1).join(' ').trim() };
|
||||
}
|
||||
return { dia: line, horas: '' };
|
||||
});
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, string> = {
|
||||
facebook: 'facebook',
|
||||
instagram: 'instagram',
|
||||
tiktok: 'music', // FontAwesome doesn't have tiktok
|
||||
twitter: 'twitter',
|
||||
linkedin: 'linkedin',
|
||||
youtube: 'youtube-play',
|
||||
};
|
||||
|
||||
// ─── screen ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Contactos() {
|
||||
const { token } = useAuth();
|
||||
const [contactData, setContactData] = useState<ContactData | null>(null);
|
||||
const [socials, setSocials] = useState<SocialMedia[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mapLoading, setMapLoading] = useState(true);
|
||||
const [mapError, setMapError] = useState(false);
|
||||
|
||||
const fetchContacts = async () => {
|
||||
// 1. Mostrar cache imediatamente enquanto atualiza em fundo
|
||||
const cached = await getCachedContacts();
|
||||
if (cached.contactData) {
|
||||
setContactData(cached.contactData);
|
||||
setSocials(cached.socials);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (token) formData.append('token', token);
|
||||
|
||||
const response = await fetch(buildApiUrl(API_ENDPOINTS.CONTACTS), {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data: ContactResponse = await response.json();
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
if (data.status === 200 && data.contact) {
|
||||
const validSocials = (data.socials || []).filter((s) => s.value?.trim());
|
||||
setContactData(data.contact);
|
||||
setSocials(validSocials);
|
||||
setError(null);
|
||||
await cacheContacts(data.contact, validSocials);
|
||||
} else if (!cached.contactData) {
|
||||
setError(data.message || 'Não foi possível carregar os contactos.');
|
||||
}
|
||||
} catch {
|
||||
if (!cached.contactData) {
|
||||
setError('Sem ligação à internet e sem dados guardados.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [token]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchContacts();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !contactData) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const horarios = contactData?.horarios ? parseHorarios(contactData.horarios) : [];
|
||||
const coords = parseCoords(contactData?.coordenadas);
|
||||
const mapUrl = coords
|
||||
? `https://staticmap.openstreetmap.de/staticmap.php?center=${coords.lat},${coords.lng}&zoom=15&size=600x300&markers=${coords.lat},${coords.lng},red-pushpin`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
|
||||
|
||||
<Text style={styles.pageTitle}>Contactos Agência</Text>
|
||||
|
||||
{/* ── Ações rápidas ── */}
|
||||
<View style={styles.actionsRow}>
|
||||
{!!contactData?.mobilePhone && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openPhone(contactData.telephone)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/telefone.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Ligar Agora</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{contactData.telephone}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{!!contactData?.email && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openEmail(contactData.email)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/email.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Enviar Email</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{contactData.email}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{!!contactData?.mobilePhone && (
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => openWhatsApp(contactData.mobilePhone!)}>
|
||||
<View style={styles.actionIconWrap}>
|
||||
<Image source={require('@/assets/icons/whatsapp.png')} style={styles.actionIcon} />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>Falar no Chat</Text>
|
||||
<Text style={styles.actionSub} numberOfLines={1}>
|
||||
{extractPhone(contactData.mobilePhone)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Localização ── */}
|
||||
{!!contactData?.address && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Localização</Text>
|
||||
|
||||
{!!mapUrl && (
|
||||
<Pressable
|
||||
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}
|
||||
style={styles.mapImageWrap}>
|
||||
<Image
|
||||
source={{ uri: mapUrl }}
|
||||
style={styles.mapImage}
|
||||
resizeMode="cover"
|
||||
onLoadStart={() => { setMapLoading(true); setMapError(false); }}
|
||||
onLoad={() => setMapLoading(false)}
|
||||
onError={() => { setMapLoading(false); setMapError(true); }}
|
||||
/>
|
||||
{mapLoading && !mapError && (
|
||||
<View style={styles.mapOverlay}>
|
||||
<LoadingSpinner size="small" />
|
||||
</View>
|
||||
)}
|
||||
{mapError && (
|
||||
<View style={styles.mapOverlay}>
|
||||
<FontAwesome name="map-marker" size={28} color={colors.vermelho} />
|
||||
<Text style={styles.mapErrorText}>Mapa indisponível</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={styles.addressRow}>
|
||||
<FontAwesome name="map-marker" size={16} color={colors.vermelho} />
|
||||
<Text style={styles.addressText}>{contactData.address}</Text>
|
||||
<Pressable onPress={() => copyToClipboard(contactData.address, 'Morada')}>
|
||||
<FontAwesome name="copy" size={14} color={colors.azul} style={styles.copyIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.direcoeBtn}
|
||||
onPress={() => openMaps(contactData.address, coords?.lat, coords?.lng)}>
|
||||
<Text style={styles.direcoeBtnText}>Obter Direções</Text>
|
||||
<Image source={require('@/assets/icons/seta-up.png')} style={styles.saveButtonIcon} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Horário de Funcionamento ── */}
|
||||
{horarios.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Horário de Funcionamento</Text>
|
||||
{horarios.map((item, idx) => (
|
||||
<View
|
||||
key={idx}
|
||||
style={[styles.horarioRow, idx < horarios.length - 1 && styles.horarioRowBorder]}>
|
||||
<Text style={styles.horarioDia}>{item.dia}</Text>
|
||||
<Text style={[
|
||||
styles.horarioHoras,
|
||||
item.horas.toLowerCase() === 'fechado' && styles.horarioFechado,
|
||||
]}>
|
||||
{item.horas || '—'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── Redes Sociais ── */}
|
||||
{socials.length > 0 && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Redes Sociais</Text>
|
||||
<View style={styles.socialsRow}>
|
||||
{socials.map((social, idx) => (
|
||||
<Pressable
|
||||
key={idx}
|
||||
style={styles.socialBtn}
|
||||
onPress={() => Linking.openURL(social.value).catch(() => null)}>
|
||||
<FontAwesome
|
||||
name={(SOCIAL_ICONS[social.key.toLowerCase()] ?? 'globe') as any}
|
||||
size={20}
|
||||
color={colors.azul}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user