95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Pressable, StyleSheet, Text } from 'react-native';
|
|
import { useLocalSearchParams } from 'expo-router';
|
|
import { api } from '@/api';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { useT } from '@/i18n';
|
|
import { AuthShell } from '@/components/AuthShell';
|
|
import { Button, Field } from '@/components/ui';
|
|
import { CODE_LENGTH, CodeField } from '@/components/CodeField';
|
|
import { colors, fontSize, spacing } from '@/theme';
|
|
|
|
/** Matches the backend's resend throttle. */
|
|
const RESEND_COOLDOWN = 60;
|
|
|
|
export default function VerifyEmailScreen() {
|
|
const { email } = useLocalSearchParams<{ email: string }>();
|
|
const { signInWithVerification } = useAuth();
|
|
const t = useT();
|
|
|
|
const [code, setCode] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [cooldown, setCooldown] = useState(RESEND_COOLDOWN);
|
|
const submitted = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (cooldown <= 0) return;
|
|
const id = setTimeout(() => setCooldown((s) => s - 1), 1000);
|
|
return () => clearTimeout(id);
|
|
}, [cooldown]);
|
|
|
|
async function onVerify(value = code) {
|
|
if (value.length < CODE_LENGTH || submitted.current) return;
|
|
submitted.current = true;
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
// The session starts here; the root layout moves us into the app.
|
|
await signInWithVerification(String(email), value);
|
|
} catch (e: any) {
|
|
// Wrong and expired are distinct, so the message can say which.
|
|
setError(e?.code === 'code_expired' ? t('verify.expired') : t('verify.invalid'));
|
|
setCode('');
|
|
} finally {
|
|
submitted.current = false;
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function onResend() {
|
|
if (cooldown > 0) return;
|
|
setError(null);
|
|
setCooldown(RESEND_COOLDOWN);
|
|
try {
|
|
await api.auth.resendVerification(String(email));
|
|
} catch {
|
|
// The endpoint always succeeds by design.
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AuthShell
|
|
title={t('verify.title')}
|
|
subtitle={t('verify.subtitle', { email: String(email ?? '') })}
|
|
back
|
|
error={error}
|
|
>
|
|
<Field label={t('verify.code')}>
|
|
<CodeField value={code} onChange={setCode} onComplete={onVerify} autoFocus />
|
|
</Field>
|
|
|
|
<Button
|
|
title={t('verify.submit')}
|
|
size="lg"
|
|
block
|
|
loading={loading}
|
|
disabled={code.length < CODE_LENGTH}
|
|
onPress={() => onVerify()}
|
|
style={{ marginTop: spacing[2] }}
|
|
/>
|
|
|
|
<Pressable onPress={onResend} disabled={cooldown > 0} style={styles.resend}>
|
|
<Text style={[styles.resendText, cooldown > 0 && { color: colors.textMuted2 }]}>
|
|
{cooldown > 0 ? t('verify.resendIn', { count: cooldown }) : t('verify.resend')}
|
|
</Text>
|
|
</Pressable>
|
|
</AuthShell>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
resend: { marginTop: spacing[5], alignItems: 'center' },
|
|
resendText: { fontSize: fontSize.sm, fontWeight: '600', color: colors.teal700 },
|
|
});
|