Initial LinkedSign platform

This commit is contained in:
2026-04-06 20:33:59 -05:00
commit 1074c7c9d2
159 changed files with 19072 additions and 0 deletions
@@ -0,0 +1,125 @@
import { Alert, FlatList, StyleSheet, Text, TextInput, View } from 'react-native';
import { useState } from 'react';
import * as DocumentPicker from 'expo-document-picker';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { colors } from '../../theme/colors';
import { useSync } from '../../context/SyncContext';
import { uploadCertificate } from '../../api/certificates';
export default function CertificatesScreen() {
const { certificates, syncNow } = useSync();
const [alias, setAlias] = useState('');
const [password, setPassword] = useState('');
const handleSync = async () => {
try {
await syncNow();
Alert.alert('Certificados', 'Certificados sincronizados.');
} catch (error) {
Alert.alert('Certificados', error.response?.data?.error || 'No se pudo sincronizar.');
}
};
const handleUpload = async () => {
const picked = await DocumentPicker.getDocumentAsync({
type: [
'application/x-pkcs12',
'application/octet-stream',
],
copyToCacheDirectory: true,
});
if (picked.canceled) return;
const file = picked.assets[0];
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType || 'application/octet-stream',
});
formData.append('alias', alias);
formData.append('password', password);
try {
await uploadCertificate(formData);
setAlias('');
setPassword('');
await syncNow();
Alert.alert('Certificados', 'Certificado subido correctamente.');
} catch (error) {
Alert.alert('Certificados', error.response?.data?.error || 'No se pudo subir el certificado.');
}
};
return (
<ScreenContainer>
<View style={styles.card}>
<Text style={styles.title}>Subir certificado</Text>
<TextInput style={styles.input} placeholder="Alias" value={alias} onChangeText={setAlias} />
<TextInput style={styles.input} placeholder="Contraseña del P12/PFX (opcional)" secureTextEntry value={password} onChangeText={setPassword} />
<PrimaryButton title="Seleccionar y subir" onPress={handleUpload} />
</View>
<PrimaryButton title="Sincronizar certificados" onPress={handleSync} variant="secondary" />
<View style={styles.card}>
<Text style={styles.title}>Certificados sincronizados</Text>
<FlatList
data={certificates}
keyExtractor={(item) => `${item.id}`}
scrollEnabled={false}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
renderItem={({ item }) => (
<View style={styles.item}>
<Text style={styles.itemTitle}>{item.alias}</Text>
<Text style={styles.itemMeta}>{item.subject_cn || 'Sin sujeto'}</Text>
<Text style={styles.itemMeta}>Válido hasta: {item.valid_to || 'N/D'}</Text>
</View>
)}
ListEmptyComponent={<Text style={styles.empty}>No hay certificados sincronizados.</Text>}
/>
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 18,
padding: 16,
gap: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#fff',
},
item: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 14,
gap: 4,
},
itemTitle: {
fontWeight: '700',
color: colors.text,
},
itemMeta: {
color: colors.textMuted,
fontSize: 12,
},
empty: {
color: colors.textMuted,
},
});
@@ -0,0 +1,112 @@
import { Alert, StyleSheet, Text, View } from 'react-native';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { useAuth } from '../../context/AuthContext';
import { useSync } from '../../context/SyncContext';
import { colors } from '../../theme/colors';
export default function DashboardScreen() {
const { user } = useAuth();
const { documents, certificates, signedDocuments, syncNow, syncing, lastSyncAt } = useSync();
const handleSync = async () => {
try {
await syncNow();
Alert.alert('Sincronización', 'Los datos del teléfono fueron sincronizados con el backend.');
} catch (error) {
Alert.alert('Sincronización', error.response?.data?.error || 'No se pudo sincronizar.');
}
};
return (
<ScreenContainer>
<View style={styles.hero}>
<Text style={styles.greeting}>Hola, {user?.name || user?.email}</Text>
<Text style={styles.subtext}>LinkedSign móvil comparte autenticación con la app web y sincroniza datos bajo petición.</Text>
</View>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statValue}>{documents.length}</Text>
<Text style={styles.statLabel}>Documentos</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{certificates.length}</Text>
<Text style={styles.statLabel}>Certificados</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{signedDocuments.length}</Text>
<Text style={styles.statLabel}>Firmados</Text>
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>Sincronización local</Text>
<Text style={styles.cardText}>
La base local del teléfono se actualiza solo cuando tú lo pides. La sesión permanece guardada de forma segura.
</Text>
<Text style={styles.syncMeta}>
Última sincronización: {lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'}
</Text>
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={handleSync} disabled={syncing} />
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
hero: {
backgroundColor: colors.dark,
borderRadius: 20,
padding: 20,
gap: 8,
},
greeting: {
color: '#fff',
fontSize: 24,
fontWeight: '800',
},
subtext: {
color: '#cbd5e1',
lineHeight: 20,
},
statsGrid: {
flexDirection: 'row',
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: colors.surface,
borderRadius: 18,
padding: 16,
alignItems: 'center',
},
statValue: {
fontSize: 28,
fontWeight: '800',
color: colors.primary,
},
statLabel: {
color: colors.textMuted,
marginTop: 4,
},
card: {
backgroundColor: colors.surface,
borderRadius: 18,
padding: 18,
gap: 10,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
cardText: {
color: colors.textMuted,
lineHeight: 20,
},
syncMeta: {
color: colors.text,
fontSize: 13,
},
});
@@ -0,0 +1,153 @@
import { Alert, FlatList, StyleSheet, Text, View } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { colors } from '../../theme/colors';
import { useAuth } from '../../context/AuthContext';
import { useSync } from '../../context/SyncContext';
import { uploadDocument } from '../../api/documents';
import { downloadAndShareFile } from '../../utils/files';
import { API_BASE_URL } from '../../config/env';
function SectionCard({ title, rows, emptyText, actionLabel, onActionPress, onDownload, token, type }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{actionLabel ? <PrimaryButton title={actionLabel} onPress={onActionPress} /> : null}
{rows.length === 0 ? (
<Text style={styles.emptyText}>{emptyText}</Text>
) : (
<FlatList
data={rows}
keyExtractor={(item) => `${type}-${item.id}`}
scrollEnabled={false}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
renderItem={({ item }) => (
<View style={styles.itemCard}>
<Text style={styles.itemTitle}>{item.title || item.signed_filename}</Text>
<Text style={styles.itemMeta}>
{item.status || item.document_title || 'Documento'}
</Text>
<PrimaryButton
title="Descargar / Compartir"
variant="secondary"
onPress={() => onDownload(item, token)}
/>
</View>
)}
/>
)}
</View>
);
}
export default function DocumentsScreen() {
const { tokens } = useAuth();
const { documents, signedDocuments, syncNow, refreshLocalCache } = useSync();
const handleUpload = async () => {
const picked = await DocumentPicker.getDocumentAsync({
type: 'application/pdf',
copyToCacheDirectory: true,
});
if (picked.canceled) return;
const file = picked.assets[0];
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: file.name,
type: 'application/pdf',
});
try {
await uploadDocument(formData);
await syncNow();
refreshLocalCache();
Alert.alert('Documentos', 'PDF subido correctamente.');
} catch (error) {
Alert.alert('Documentos', error.response?.data?.error || 'No se pudo subir el PDF.');
}
};
const handleSync = async () => {
try {
await syncNow();
Alert.alert('Documentos', 'Documentos sincronizados.');
} catch (error) {
Alert.alert('Documentos', error.response?.data?.error || 'No se pudo sincronizar.');
}
};
const handleDownloadOriginal = async (item) => {
await downloadAndShareFile({
url: `${API_BASE_URL}/documents/${item.id}/download`,
token: tokens.accessToken,
filename: item.filename || `${item.title}.pdf`,
});
};
const handleDownloadSigned = async (item) => {
await downloadAndShareFile({
url: `${API_BASE_URL}/signatures/${item.id}/download`,
token: tokens.accessToken,
filename: item.signed_filename,
});
};
return (
<ScreenContainer>
<PrimaryButton title="Sincronizar documentos" onPress={handleSync} variant="secondary" />
<SectionCard
title="Originales"
rows={documents}
emptyText="No hay documentos sincronizados en el teléfono."
actionLabel="Subir PDF"
onActionPress={handleUpload}
onDownload={handleDownloadOriginal}
token={tokens.accessToken}
type="original"
/>
<SectionCard
title="Firmados"
rows={signedDocuments}
emptyText="No hay documentos firmados sincronizados."
onDownload={handleDownloadSigned}
token={tokens.accessToken}
type="signed"
/>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
section: {
backgroundColor: colors.surface,
borderRadius: 18,
padding: 16,
gap: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
emptyText: {
color: colors.textMuted,
},
itemCard: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 14,
gap: 8,
},
itemTitle: {
color: colors.text,
fontWeight: '700',
},
itemMeta: {
color: colors.textMuted,
fontSize: 12,
},
});
@@ -0,0 +1,104 @@
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
import { useMemo, useState } from 'react';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import PasswordRules from '../../components/PasswordRules';
import { useAuth } from '../../context/AuthContext';
import { useSync } from '../../context/SyncContext';
import { isPasswordStrong } from '../../utils/passwordRules';
import { colors } from '../../theme/colors';
export default function ProfileScreen() {
const { user, logout, requestPasswordChange } = useAuth();
const { lastSyncAt, syncNow, syncing } = useSync();
const [form, setForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [saving, setSaving] = useState(false);
const syncLabel = useMemo(
() => (lastSyncAt ? new Date(lastSyncAt).toLocaleString('es-EC') : 'Sin sincronizar'),
[lastSyncAt]
);
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handlePasswordChange = async () => {
if (!isPasswordStrong(form.newPassword)) {
Alert.alert('Seguridad', 'La nueva contraseña no cumple con los requisitos.');
return;
}
if (form.newPassword !== form.confirmPassword) {
Alert.alert('Seguridad', 'Las nuevas contraseñas no coinciden.');
return;
}
setSaving(true);
try {
await requestPasswordChange({
currentPassword: form.currentPassword,
newPassword: form.newPassword,
});
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
Alert.alert('Perfil', 'Te enviamos un correo para confirmar el cambio de contraseña.');
} catch (error) {
Alert.alert('Perfil', error.response?.data?.error || 'No se pudo solicitar el cambio de contraseña.');
} finally {
setSaving(false);
}
};
return (
<ScreenContainer>
<View style={styles.card}>
<Text style={styles.title}>Perfil</Text>
<Text style={styles.item}><Text style={styles.label}>Correo:</Text> {user?.email}</Text>
<Text style={styles.item}><Text style={styles.label}>Rol:</Text> {user?.role}</Text>
<Text style={styles.item}><Text style={styles.label}>Última sincronización:</Text> {syncLabel}</Text>
<PrimaryButton title={syncing ? 'Sincronizando...' : 'Sincronizar ahora'} onPress={syncNow} disabled={syncing} variant="secondary" />
</View>
<View style={styles.card}>
<Text style={styles.title}>Solicitar cambio de contraseña</Text>
<TextInput style={styles.input} placeholder="Contraseña actual" secureTextEntry value={form.currentPassword} onChangeText={(value) => updateField('currentPassword', value)} />
<TextInput style={styles.input} placeholder="Nueva contraseña" secureTextEntry value={form.newPassword} onChangeText={(value) => updateField('newPassword', value)} />
<PasswordRules password={form.newPassword} />
<TextInput style={styles.input} placeholder="Confirmar nueva contraseña" secureTextEntry value={form.confirmPassword} onChangeText={(value) => updateField('confirmPassword', value)} />
<PrimaryButton title={saving ? 'Enviando...' : 'Enviar solicitud'} onPress={handlePasswordChange} disabled={saving} />
</View>
<PrimaryButton title="Cerrar sesión" onPress={logout} variant="secondary" />
</ScreenContainer>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 18,
padding: 16,
gap: 10,
},
title: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
label: {
fontWeight: '700',
},
item: {
color: colors.textMuted,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#fff',
},
});
+156
View File
@@ -0,0 +1,156 @@
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
import { useEffect, useMemo, useState } from 'react';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { colors } from '../../theme/colors';
import { useAuth } from '../../context/AuthContext';
import { useSync } from '../../context/SyncContext';
import { signDocument } from '../../api/signatures';
const selectFirst = (rows) => (rows.length > 0 ? String(rows[0].id) : '');
export default function SignScreen() {
const { user } = useAuth();
const { documents, certificates, syncNow } = useSync();
const [form, setForm] = useState({
documentId: selectFirst(documents),
certificateId: selectFirst(certificates),
certPassword: '',
reason: 'Firmado digitalmente',
location: '',
});
const [loading, setLoading] = useState(false);
const activeCertificates = useMemo(
() => certificates.filter((certificate) => certificate.is_active === 1 || certificate.is_active === true),
[certificates]
);
useEffect(() => {
if (!form.documentId && documents.length > 0) {
setForm((current) => ({ ...current, documentId: String(documents[0].id) }));
}
}, [documents, form.documentId]);
useEffect(() => {
if (!form.certificateId && activeCertificates.length > 0) {
setForm((current) => ({ ...current, certificateId: String(activeCertificates[0].id) }));
}
}, [activeCertificates, form.certificateId]);
const handleSign = async () => {
if (!form.documentId || !form.certificateId) {
Alert.alert('Firmar', 'Selecciona un documento y un certificado sincronizados.');
return;
}
setLoading(true);
try {
await signDocument({
documentId: Number(form.documentId),
certificateId: Number(form.certificateId),
certPassword: form.certPassword,
reason: form.reason,
location: form.location,
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
});
await syncNow();
Alert.alert('Firmar', 'Documento firmado. Se usó la posición visible por defecto del backend.');
} catch (error) {
Alert.alert('Firmar', error.response?.data?.error || 'No se pudo firmar el documento.');
} finally {
setLoading(false);
}
};
return (
<ScreenContainer>
<View style={styles.card}>
<Text style={styles.title}>Firma básica desde Android</Text>
<Text style={styles.subtitle}>Esta versión usa la configuración visual guardada y la posición por defecto del backend.</Text>
<Text style={styles.label}>Documento</Text>
<TextInput
style={styles.input}
value={form.documentId}
onChangeText={(value) => setForm((current) => ({ ...current, documentId: value }))}
placeholder="ID de documento sincronizado"
/>
<Text style={styles.hint}>
Disponibles: {documents.map((item) => `${item.id}`).join(', ') || 'sin documentos'}
</Text>
<Text style={styles.label}>Certificado</Text>
<TextInput
style={styles.input}
value={form.certificateId}
onChangeText={(value) => setForm((current) => ({ ...current, certificateId: value }))}
placeholder="ID de certificado"
/>
<Text style={styles.hint}>
Disponibles: {activeCertificates.map((item) => `${item.id}`).join(', ') || 'sin certificados'}
</Text>
<TextInput
style={styles.input}
value={form.certPassword}
onChangeText={(value) => setForm((current) => ({ ...current, certPassword: value }))}
placeholder="Contraseña del certificado (si aplica)"
secureTextEntry
/>
<TextInput
style={styles.input}
value={form.reason}
onChangeText={(value) => setForm((current) => ({ ...current, reason: value }))}
placeholder="Razón"
/>
<TextInput
style={styles.input}
value={form.location}
onChangeText={(value) => setForm((current) => ({ ...current, location: value }))}
placeholder="Ubicación"
/>
<PrimaryButton title={loading ? 'Firmando...' : 'Firmar documento'} onPress={handleSign} disabled={loading} />
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 18,
padding: 16,
gap: 10,
},
title: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
subtitle: {
color: colors.textMuted,
lineHeight: 20,
},
label: {
fontWeight: '600',
color: colors.text,
marginTop: 4,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#fff',
},
hint: {
color: colors.textMuted,
fontSize: 12,
},
});
@@ -0,0 +1,106 @@
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { colors } from '../../theme/colors';
import { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
export default function LoginScreen({ navigation }) {
const { login } = useAuth();
const [form, setForm] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false);
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handleLogin = async () => {
setLoading(true);
try {
await login(form.email, form.password);
} catch (error) {
const code = error.response?.data?.code;
const message = error.response?.data?.error || 'No se pudo iniciar sesión';
if (code === 'EMAIL_NOT_VERIFIED') {
Alert.alert('Correo pendiente', 'Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
} else if (code === 'MAIL_SEND_FAILED') {
Alert.alert('Correo no enviado', message);
} else {
Alert.alert('Iniciar sesión', message);
}
} finally {
setLoading(false);
}
};
return (
<ScreenContainer>
<View style={styles.header}>
<Text style={styles.brand}>LinkedSign</Text>
<Text style={styles.subtitle}>Firma digital segura desde Android</Text>
</View>
<View style={styles.card}>
<Text style={styles.title}>Iniciar sesión</Text>
<TextInput
style={styles.input}
placeholder="Correo electrónico"
autoCapitalize="none"
keyboardType="email-address"
value={form.email}
onChangeText={(value) => updateField('email', value)}
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
secureTextEntry
value={form.password}
onChangeText={(value) => updateField('password', value)}
/>
<PrimaryButton title={loading ? 'Ingresando...' : 'Iniciar sesión'} onPress={handleLogin} disabled={loading} />
</View>
<Pressable onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>¿No tienes cuenta? Regístrate</Text>
</Pressable>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
header: {
paddingTop: 24,
gap: 4,
},
brand: {
fontSize: 28,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.textMuted,
fontSize: 14,
},
card: {
backgroundColor: colors.surface,
borderRadius: 20,
padding: 20,
gap: 12,
},
title: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#fff',
},
link: {
textAlign: 'center',
color: colors.primary,
fontWeight: '600',
},
});
@@ -0,0 +1,107 @@
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { useState } from 'react';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import PasswordRules from '../../components/PasswordRules';
import { isPasswordStrong } from '../../utils/passwordRules';
import { useAuth } from '../../context/AuthContext';
import { colors } from '../../theme/colors';
export default function RegisterScreen({ navigation }) {
const { register } = useAuth();
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
const [loading, setLoading] = useState(false);
const updateField = (name, value) => setForm((current) => ({ ...current, [name]: value }));
const handleRegister = async () => {
if (!isPasswordStrong(form.password)) {
Alert.alert('Contraseña insegura', 'Tu contraseña no cumple con los requisitos mínimos.');
return;
}
if (form.password !== form.confirmPassword) {
Alert.alert('Confirmación', 'Las contraseñas no coinciden.');
return;
}
setLoading(true);
try {
await register({ email: form.email, password: form.password });
navigation.replace('VerifyNotice', { email: form.email });
} catch (error) {
Alert.alert('Registro', error.response?.data?.error || 'No se pudo registrar la cuenta.');
} finally {
setLoading(false);
}
};
return (
<ScreenContainer>
<View style={styles.card}>
<Text style={styles.title}>Crear cuenta</Text>
<Text style={styles.subtitle}>Regístrate con correo y contraseña segura.</Text>
<TextInput
style={styles.input}
placeholder="Correo electrónico"
autoCapitalize="none"
keyboardType="email-address"
value={form.email}
onChangeText={(value) => updateField('email', value)}
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
secureTextEntry
value={form.password}
onChangeText={(value) => updateField('password', value)}
/>
<PasswordRules password={form.password} />
<TextInput
style={styles.input}
placeholder="Confirmar contraseña"
secureTextEntry
value={form.confirmPassword}
onChangeText={(value) => updateField('confirmPassword', value)}
/>
<PrimaryButton title={loading ? 'Creando...' : 'Crear cuenta'} onPress={handleRegister} disabled={loading} />
</View>
<Pressable onPress={() => navigation.goBack()}>
<Text style={styles.link}>Volver al inicio de sesión</Text>
</Pressable>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 20,
padding: 20,
gap: 12,
},
title: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
},
subtitle: {
fontSize: 14,
color: colors.textMuted,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: '#fff',
},
link: {
textAlign: 'center',
color: colors.primary,
fontWeight: '600',
},
});
@@ -0,0 +1,38 @@
import { StyleSheet, Text, View } from 'react-native';
import ScreenContainer from '../../components/ScreenContainer';
import PrimaryButton from '../../components/PrimaryButton';
import { colors } from '../../theme/colors';
export default function VerifyNoticeScreen({ navigation, route }) {
const email = route.params?.email || '';
return (
<ScreenContainer>
<View style={styles.card}>
<Text style={styles.title}>Confirma tu cuenta</Text>
<Text style={styles.text}>
Enviamos un enlace de validación a {email || 'tu correo'}. Debes confirmarlo antes de iniciar sesión.
</Text>
<PrimaryButton title="Volver al login" onPress={() => navigation.navigate('Login')} />
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 20,
padding: 20,
gap: 12,
},
title: {
fontSize: 22,
fontWeight: '700',
color: colors.text,
},
text: {
color: colors.textMuted,
lineHeight: 22,
},
});