eletrotupi / tcc/ commit / a812da8

frontend/auth: enter the activation code in six boxes

The activation screen took the code in a single field, which hid how
many digits were entered and could not carry a leading zero. The code is
now entered in six single-digit boxes that advance as it is typed, fill
from a paste or the phone's one-time-code autofill, and submit on the
sixth digit, while a rejected code clears the boxes and returns focus to
the first

Resending now waits behind a visible sixty-second countdown, started on
mount and after each attempt, and the subtitle states the code is six
digits
Pedro Lucas Porcellis porcellis@eletrotupi.com · 24 days ago a812da89fb85271a569adfcc0b45998ddd699b40 Browse files
Parents 1c52a0b
Changed files
3
Additions
+242
Deletions
-45
frontend/app/auth/activate.tsx
1 -
import React, { useState } from 'react';
1 +
import React, { useEffect, useRef, useState } from 'react';
2 2
import {
3 3
  View,
4 4
  StyleSheet,
5 5
  ScrollView,
6 6
  KeyboardAvoidingView,
7 7
} from 'react-native';
8 8
import { Text } from '@/components/ui/Text';
9 -
import { Link, useLocalSearchParams, router } from 'expo-router';
9 +
import { router } from 'expo-router';
10 10
import { SafeAreaView } from 'react-native-safe-area-context';
11 11
import { useThemeColor } from '@/hooks/use-theme-color';
12 -
import { Button, Input } from '@/components/ui';
13 -
import { Colors } from '@/constants/theme'
12 +
import { Button, CodeInput } from '@/components/ui';
13 +
import type { CodeInputHandle } from '@/components/ui/CodeInput';
14 +
import { Colors } from '@/constants/theme';
14 15
import { useAuth } from '@/context/AuthContext';
15 16
import { useToast } from '@/context/ToastContext';
16 17
import { translateError } from '@/lib/errors/translations';
18 +

19 +
const CODE_LENGTH = 6;
20 +
const RESEND_COOLDOWN_SECONDS = 60;
17 21

18 22
export default function ActivateScreen() {
19 -
  const { userId } = useLocalSearchParams();
20 23
  const [code, setCode] = useState('');
21 -
  const [errors, setErrors] = useState<{
22 -
    code?: string;
23 -
  }>({});
24 +
  const [codeError, setCodeError] = useState<string>();
25 +
  const [loading, setLoading] = useState(false);
26 +
  const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
27 +
  const codeInputRef = useRef<CodeInputHandle>(null);
24 28

25 29
  const { activate, requestActivateCode } = useAuth();
26 30
  const { showToast } = useToast();
27 -
  const [loading, setLoading] = useState(false);
28 31
  const textColor = useThemeColor({}, 'text');
29 32
  const backgroundColor = useThemeColor({}, 'background');
30 -
  const tintColor = useThemeColor({}, 'tint');
31 33

32 -
  const validateForm = () => {
33 -
    const newErrors: typeof errors = {};
34 +
  useEffect(() => {
35 +
    if (secondsLeft <= 0) return;
34 36

35 -
    if (!code) {
36 -
      newErrors.code = 'O código é obrigatório';
37 -
    } else if (code.length < 6) {
38 -
      newErrors.code = 'O código deve conter pelo menos 6 caracteres';
39 -
    }
40 -

41 -
    setErrors(newErrors);
37 +
    const timer = setTimeout(() => setSecondsLeft((value) => value - 1), 1000);
38 +
    return () => clearTimeout(timer);
39 +
  }, [secondsLeft]);
42 40

43 -
    return Object.keys(newErrors).length === 0;
44 -
  };
41 +
  const submitCode = async (value: string) => {
42 +
    if (loading) return;
45 43

46 -
  const handleActivate = async () => {
47 -
    if (!validateForm()) return;
44 +
    if (value.length !== CODE_LENGTH) {
45 +
      setCodeError(`O código deve ter ${CODE_LENGTH} dígitos`);
46 +
      return;
47 +
    }
48 48

49 +
    setCodeError(undefined);
49 50
    setLoading(true);
50 51
    try {
51 -
      await activate(code);
52 +
      await activate(value);
52 53
      showToast('Conta ativada com sucesso!', 'success');
53 54
      router.replace('/');
54 55
    } catch (error: any) {
55 -
      showToast(translateError(error.message) || 'Falha em ativar a conta', 'error');
56 +
      setCode('');
57 +
      setCodeError(
58 +
        translateError(error.message) ||
59 +
          'Código incorreto. Verifique o código e tente novamente.',
60 +
      );
61 +
      codeInputRef.current?.focus();
56 62
    } finally {
57 63
      setLoading(false);
58 64
    }
59 65
  };
60 66

67 +
  const handleCodeChange = (value: string) => {
68 +
    setCode(value);
69 +
    setCodeError(undefined);
70 +
  };
71 +

61 72
  const resendCode = async () => {
73 +
    if (secondsLeft > 0) return;
74 +

75 +
    setSecondsLeft(RESEND_COOLDOWN_SECONDS);
62 76
    try {
63 77
      await requestActivateCode();
64 78
      showToast('Novo código enviado. Verifique sua caixa de entrada.', 'success');
65 79
    } catch {
80 +
      setSecondsLeft(0);
66 81
      showToast('Falha ao reenviar o código. Tente novamente.', 'error');
67 82
    }
68 -
  }
83 +
  };
69 84

70 85
  return (
71 86
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
72 -
      <KeyboardAvoidingView
73 -
        behavior='height'
74 -
        style={styles.keyboardView}
75 -
      >
87 +
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
76 88
        <ScrollView
77 89
          contentContainerStyle={styles.scrollContainer}
78 90
          showsVerticalScrollIndicator={false}
79 91
        >
80 92
          <View style={styles.header}>
81 93
            <Text style={[styles.title, { color: textColor }]}>Ativar a conta</Text>
82 94
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
83 -
              Digite o código de ativação enviado para seu email
95 +
              Digite o código de 6 dígitos enviado para seu email
84 96
            </Text>
85 97
          </View>
86 98

87 99
          <View style={styles.form}>
88 -
            <Input
89 -
              label="Código"
90 -
              type="text"
100 +
            <CodeInput
101 +
              ref={codeInputRef}
91 102
              value={code}
92 -
              onChangeText={setCode}
93 -
              keyboardType="number-pad"
94 -
              placeholder="Digite o código enviado para sua conta"
95 -
              error={errors.code}
103 +
              onChange={handleCodeChange}
104 +
              onComplete={submitCode}
105 +
              length={CODE_LENGTH}
106 +
              error={codeError}
107 +
              editable={!loading}
108 +
              accessibilityLabel="Código de ativação de 6 dígitos"
109 +
              testIdPrefix="activate-code"
96 110
            />
97 111

98 112
            <Button
99 113
              title="Ativar conta"
100 -
              onPress={handleActivate}
114 +
              onPress={() => submitCode(code)}
101 115
              loading={loading}
102 -
              style={styles.resetButton}
116 +
              style={styles.activateButton}
103 117
            />
104 118
          </View>
105 119

106 120
          <View style={styles.footer}>
107 121
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
108 122
              Não recebeu?{' '}
109 123
            </Text>
110 124

111 -
            <Button variant="outline" title="Reenviar um novo código" onPress={resendCode} />
125 +
            <Button
126 +
              variant="outline"
127 +
              title={
128 +
                secondsLeft > 0
129 +
                  ? `Reenviar em ${secondsLeft}s`
130 +
                  : 'Reenviar um novo código'
131 +
              }
132 +
              onPress={resendCode}
133 +
              disabled={secondsLeft > 0}
134 +
            />
112 135
          </View>
113 136
        </ScrollView>
114 137
      </KeyboardAvoidingView>
115 138
    </SafeAreaView>
116 139
  );
143 166
    textAlign: 'center',
144 167
  },
145 168
  form: {
146 169
    marginBottom: 32,
147 170
  },
148 -
  resetButton: {
171 +
  activateButton: {
149 172
    marginTop: 8,
150 173
  },
151 174
  footer: {
152 175
    marginTop: 20,
153 176
    borderTopColor: Colors.light.accentBlue,
154 177
    borderTopWidth: 1,
155 -
    paddingTop: 20
178 +
    paddingTop: 20,
156 179
  },
157 180
  footerText: {
158 181
    textAlign: 'center',
159 -
    marginBottom: 10
160 -
  }
182 +
    marginBottom: 10,
183 +
  },
161 184
});
/dev/null → frontend/components/ui/CodeInput.tsx
1 +
import React, {
2 +
  forwardRef,
3 +
  useImperativeHandle,
4 +
  useRef,
5 +
  useState,
6 +
} from 'react';
7 +
import { StyleSheet, TextInput, View } from 'react-native';
8 +
import { Text } from '@/components/ui/Text';
9 +
import { useThemeColor } from '@/hooks/use-theme-color';
10 +
import { BorderRadius, Spacing } from '@/constants/theme';
11 +

12 +
export interface CodeInputHandle {
13 +
  focus: () => void;
14 +
}
15 +

16 +
interface CodeInputProps {
17 +
  value: string;
18 +
  onChange: (value: string) => void;
19 +
  length?: number;
20 +
  error?: string;
21 +
  editable?: boolean;
22 +
  testIdPrefix?: string;
23 +
  accessibilityLabel?: string;
24 +
  onComplete?: (value: string) => void;
25 +
}
26 +

27 +
// A single hidden TextInput backs the six boxes: it keeps paste and the
28 +
// platform one-time-code autofill working, and exposes the code as one
29 +
// accessible control instead of six unrelated fields.
30 +
export const CodeInput = forwardRef<CodeInputHandle, CodeInputProps>(
31 +
  (
32 +
    {
33 +
      value,
34 +
      onChange,
35 +
      length = 6,
36 +
      error,
37 +
      editable = true,
38 +
      testIdPrefix = 'code-input',
39 +
      accessibilityLabel,
40 +
      onComplete,
41 +
    },
42 +
    ref,
43 +
  ) => {
44 +
    const inputRef = useRef<TextInput>(null);
45 +
    const [focused, setFocused] = useState(false);
46 +
    const [caret, setCaret] = useState(0);
47 +

48 +
    const textColor = useThemeColor({}, 'text');
49 +
    const backgroundColor = useThemeColor({}, 'inputBackgroundColor');
50 +
    const borderColor = useThemeColor({}, 'inputBorderColor');
51 +
    const focusBorderColor = useThemeColor({}, 'inputFocusBorderColor');
52 +
    const errorColor = useThemeColor({}, 'danger');
53 +

54 +
    useImperativeHandle(ref, () => ({
55 +
      focus: () => inputRef.current?.focus(),
56 +
    }));
57 +

58 +
    const digits = Array.from({ length }, (_, index) => value[index] ?? '');
59 +
    const cursor = Math.min(caret, value.length);
60 +
    const activeIndex = focused && cursor < length ? cursor : -1;
61 +

62 +
    const handleChangeText = (text: string) => {
63 +
      const sanitized = text.replace(/\D/g, '').slice(0, length);
64 +

65 +
      if (sanitized === value) return;
66 +

67 +
      onChange(sanitized);
68 +
      setCaret(sanitized.length);
69 +

70 +
      if (sanitized.length === length) {
71 +
        onComplete?.(sanitized);
72 +
      }
73 +
    };
74 +

75 +
    return (
76 +
      <View style={styles.container}>
77 +
        <View style={styles.boxes}>
78 +
          {digits.map((digit, index) => {
79 +
            const isActive = index === activeIndex;
80 +
            const boxBorderColor = error
81 +
              ? errorColor
82 +
              : isActive
83 +
                ? focusBorderColor
84 +
                : borderColor;
85 +

86 +
            return (
87 +
              <View
88 +
                key={index}
89 +
                accessible={false}
90 +
                testID={`${testIdPrefix}-box-${index}`}
91 +
                style={[
92 +
                  styles.box,
93 +
                  {
94 +
                    backgroundColor,
95 +
                    borderColor: boxBorderColor,
96 +
                    borderWidth: isActive || error ? 2 : 1,
97 +
                  },
98 +
                ]}
99 +
              >
100 +
                <Text style={[styles.digit, { color: textColor }]}>{digit}</Text>
101 +
              </View>
102 +
            );
103 +
          })}
104 +

105 +
          <TextInput
106 +
            ref={inputRef}
107 +
            testID={`${testIdPrefix}-input`}
108 +
            value={value}
109 +
            onChangeText={handleChangeText}
110 +
            onFocus={() => setFocused(true)}
111 +
            onBlur={() => setFocused(false)}
112 +
            onSelectionChange={(event) =>
113 +
              setCaret(event.nativeEvent.selection.end)
114 +
            }
115 +
            keyboardType="number-pad"
116 +
            inputMode="numeric"
117 +
            textContentType="oneTimeCode"
118 +
            autoComplete="one-time-code"
119 +
            editable={editable}
120 +
            caretHidden
121 +
            accessibilityLabel={accessibilityLabel}
122 +
            style={styles.hiddenInput}
123 +
          />
124 +
        </View>
125 +

126 +
        {error ? (
127 +
          <Text
128 +
            accessibilityLiveRegion="polite"
129 +
            style={[styles.errorText, { color: errorColor }]}
130 +
          >
131 +
            {error}
132 +
          </Text>
133 +
        ) : null}
134 +
      </View>
135 +
    );
136 +
  },
137 +
);
138 +

139 +
CodeInput.displayName = 'CodeInput';
140 +

141 +
const styles = StyleSheet.create({
142 +
  container: {
143 +
    marginBottom: 16,
144 +
  },
145 +
  boxes: {
146 +
    flexDirection: 'row',
147 +
    gap: Spacing.inlineGapSm,
148 +
    height: 48,
149 +
  },
150 +
  box: {
151 +
    flex: 1,
152 +
    alignItems: 'center',
153 +
    justifyContent: 'center',
154 +
    borderRadius: BorderRadius.md,
155 +
  },
156 +
  digit: {
157 +
    fontSize: 24,
158 +
    fontWeight: '600',
159 +
  },
160 +
  hiddenInput: {
161 +
    position: 'absolute',
162 +
    top: 0,
163 +
    left: 0,
164 +
    right: 0,
165 +
    bottom: 0,
166 +
    opacity: 0.01,
167 +
    color: 'transparent',
168 +
  },
169 +
  errorText: {
170 +
    fontSize: 12,
171 +
    marginTop: 6,
172 +
  },
173 +
});
frontend/components/ui/index.ts
1 1
export { Button } from './Button';
2 +
export { CodeInput } from './CodeInput';
2 3
export { Input } from './Input';
3 4
export { Text } from './Text';