422 lines
12 KiB
TypeScript
422 lines
12 KiB
TypeScript
|
|
import { ComponentType, FC, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
|
|
// eslint-disable-next-line no-restricted-imports
|
||
|
|
import { Image, ImageStyle, TextInput, TextStyle, View, ViewStyle } from "react-native"
|
||
|
|
import { useFocusEffect } from "@react-navigation/native"
|
||
|
|
|
||
|
|
import { Button } from "@/components/Button"
|
||
|
|
import { Icon, PressableIcon } from "@/components/Icon"
|
||
|
|
import { Screen } from "@/components/Screen"
|
||
|
|
import { Text } from "@/components/Text"
|
||
|
|
import { TextField, type TextFieldAccessoryProps } from "@/components/TextField"
|
||
|
|
import { useAuth } from "@/context/AuthContext"
|
||
|
|
import type { AppStackScreenProps } from "@/navigators/navigationTypes"
|
||
|
|
import { useAppTheme } from "@/theme/context"
|
||
|
|
import type { ThemedStyle } from "@/theme/types"
|
||
|
|
import { s } from "@/utils/responsive"
|
||
|
|
|
||
|
|
interface RegisterScreenProps extends AppStackScreenProps<"Register"> {}
|
||
|
|
|
||
|
|
export const RegisterScreen: FC<RegisterScreenProps> = ({ navigation }) => {
|
||
|
|
const usernameInput = useRef<TextInput>(null)
|
||
|
|
const emailInput = useRef<TextInput>(null)
|
||
|
|
const passwordInput = useRef<TextInput>(null)
|
||
|
|
const verificationCodeInput = useRef<TextInput>(null)
|
||
|
|
|
||
|
|
const [username, setUsername] = useState("")
|
||
|
|
const [email, setEmail] = useState("")
|
||
|
|
const [password, setPassword] = useState("")
|
||
|
|
const [verificationCode, setVerificationCode] = useState("")
|
||
|
|
const [isPasswordHidden, setIsPasswordHidden] = useState(true)
|
||
|
|
const [resendCountdown, setResendCountdown] = useState(0)
|
||
|
|
|
||
|
|
const {
|
||
|
|
registerStep,
|
||
|
|
pendingEmail,
|
||
|
|
isLoading,
|
||
|
|
error,
|
||
|
|
preRegister,
|
||
|
|
resendRegisterCode,
|
||
|
|
verifyRegister,
|
||
|
|
resetRegisterStep,
|
||
|
|
googleLogin,
|
||
|
|
clearError,
|
||
|
|
} = useAuth()
|
||
|
|
|
||
|
|
const {
|
||
|
|
themed,
|
||
|
|
theme: { colors },
|
||
|
|
} = useAppTheme()
|
||
|
|
|
||
|
|
// Validation
|
||
|
|
const usernameError = useMemo(() => {
|
||
|
|
if (!username || username.length === 0) return ""
|
||
|
|
if (username.length < 3) return "Username must be at least 3 characters"
|
||
|
|
if (username.length > 30) return "Username must be less than 30 characters"
|
||
|
|
return ""
|
||
|
|
}, [username])
|
||
|
|
|
||
|
|
const emailError = useMemo(() => {
|
||
|
|
if (!email || email.length === 0) return ""
|
||
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Please enter a valid email"
|
||
|
|
return ""
|
||
|
|
}, [email])
|
||
|
|
|
||
|
|
// Password validation - requires uppercase, lowercase, number, and special character
|
||
|
|
const passwordError = useMemo(() => {
|
||
|
|
if (!password || password.length === 0) return ""
|
||
|
|
if (password.length < 8) return "Password must be at least 8 characters"
|
||
|
|
if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"
|
||
|
|
if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"
|
||
|
|
if (!/[0-9]/.test(password)) return "Password must contain a number"
|
||
|
|
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password))
|
||
|
|
return "Password must contain a special character"
|
||
|
|
return ""
|
||
|
|
}, [password])
|
||
|
|
|
||
|
|
// Countdown timer for resend button
|
||
|
|
useEffect(() => {
|
||
|
|
if (resendCountdown > 0) {
|
||
|
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000)
|
||
|
|
return () => clearTimeout(timer)
|
||
|
|
}
|
||
|
|
return undefined
|
||
|
|
}, [resendCountdown])
|
||
|
|
|
||
|
|
// Clear error when screen comes into focus (e.g., navigating from login screen)
|
||
|
|
useFocusEffect(
|
||
|
|
useCallback(() => {
|
||
|
|
clearError()
|
||
|
|
}, [clearError]),
|
||
|
|
)
|
||
|
|
|
||
|
|
// Clear error when inputs change (not when error changes)
|
||
|
|
const prevInputsRef = useRef({ username, email, password, verificationCode })
|
||
|
|
useEffect(() => {
|
||
|
|
const prev = prevInputsRef.current
|
||
|
|
// Only clear error if one of the input fields actually changed
|
||
|
|
if (
|
||
|
|
prev.username !== username ||
|
||
|
|
prev.email !== email ||
|
||
|
|
prev.password !== password ||
|
||
|
|
prev.verificationCode !== verificationCode
|
||
|
|
) {
|
||
|
|
if (error) clearError()
|
||
|
|
prevInputsRef.current = { username, email, password, verificationCode }
|
||
|
|
}
|
||
|
|
}, [username, email, password, verificationCode, error, clearError])
|
||
|
|
|
||
|
|
// Handle pre-register (step 1)
|
||
|
|
const handlePreRegister = useCallback(async () => {
|
||
|
|
if (!username || usernameError || !email || emailError || !password || passwordError) return
|
||
|
|
|
||
|
|
const success = await preRegister(username, password, email)
|
||
|
|
if (success) {
|
||
|
|
setResendCountdown(60)
|
||
|
|
}
|
||
|
|
}, [username, usernameError, email, emailError, password, passwordError, preRegister])
|
||
|
|
|
||
|
|
// Handle verify register (step 2)
|
||
|
|
const handleVerifyRegister = useCallback(async () => {
|
||
|
|
if (!verificationCode || verificationCode.length !== 6) return
|
||
|
|
|
||
|
|
await verifyRegister(verificationCode)
|
||
|
|
}, [verificationCode, verifyRegister])
|
||
|
|
|
||
|
|
// Handle resend code
|
||
|
|
const handleResendCode = useCallback(async () => {
|
||
|
|
if (resendCountdown > 0) return
|
||
|
|
|
||
|
|
const success = await resendRegisterCode()
|
||
|
|
if (success) {
|
||
|
|
setResendCountdown(60)
|
||
|
|
}
|
||
|
|
}, [resendCountdown, resendRegisterCode])
|
||
|
|
|
||
|
|
// Handle back to credentials step
|
||
|
|
const handleBackToCredentials = useCallback(() => {
|
||
|
|
resetRegisterStep()
|
||
|
|
setVerificationCode("")
|
||
|
|
}, [resetRegisterStep])
|
||
|
|
|
||
|
|
// Navigate to login
|
||
|
|
const handleGoToLogin = useCallback(() => {
|
||
|
|
navigation.navigate("Login")
|
||
|
|
}, [navigation])
|
||
|
|
|
||
|
|
// Handle Google login
|
||
|
|
const handleGoogleLogin = useCallback(async () => {
|
||
|
|
await googleLogin()
|
||
|
|
}, [googleLogin])
|
||
|
|
|
||
|
|
// Password visibility toggle
|
||
|
|
const PasswordRightAccessory: ComponentType<TextFieldAccessoryProps> = useMemo(
|
||
|
|
() =>
|
||
|
|
function PasswordRightAccessory(props: TextFieldAccessoryProps) {
|
||
|
|
return (
|
||
|
|
<PressableIcon
|
||
|
|
icon={isPasswordHidden ? "view" : "hidden"}
|
||
|
|
color={colors.palette.neutral800}
|
||
|
|
containerStyle={props.style}
|
||
|
|
size={s(20)}
|
||
|
|
onPress={() => setIsPasswordHidden(!isPasswordHidden)}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
},
|
||
|
|
[isPasswordHidden, colors.palette.neutral800],
|
||
|
|
)
|
||
|
|
|
||
|
|
// Render credentials step (step 1)
|
||
|
|
const renderCredentialsStep = () => (
|
||
|
|
<>
|
||
|
|
<Text
|
||
|
|
testID="register-heading"
|
||
|
|
text="Create Account"
|
||
|
|
preset="heading"
|
||
|
|
style={themed($title)}
|
||
|
|
/>
|
||
|
|
<Text
|
||
|
|
text="Fill in your details to get started"
|
||
|
|
preset="subheading"
|
||
|
|
style={themed($subtitle)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
ref={usernameInput}
|
||
|
|
value={username}
|
||
|
|
onChangeText={setUsername}
|
||
|
|
containerStyle={themed($textField)}
|
||
|
|
autoCapitalize="none"
|
||
|
|
autoComplete="username"
|
||
|
|
autoCorrect={false}
|
||
|
|
label="Username"
|
||
|
|
placeholder="Enter your username"
|
||
|
|
helper={usernameError}
|
||
|
|
status={usernameError ? "error" : undefined}
|
||
|
|
onSubmitEditing={() => emailInput.current?.focus()}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
ref={emailInput}
|
||
|
|
value={email}
|
||
|
|
onChangeText={setEmail}
|
||
|
|
containerStyle={themed($textField)}
|
||
|
|
autoCapitalize="none"
|
||
|
|
autoComplete="email"
|
||
|
|
autoCorrect={false}
|
||
|
|
keyboardType="email-address"
|
||
|
|
label="Email"
|
||
|
|
placeholder="Enter your email"
|
||
|
|
helper={emailError}
|
||
|
|
status={emailError ? "error" : undefined}
|
||
|
|
onSubmitEditing={() => passwordInput.current?.focus()}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
ref={passwordInput}
|
||
|
|
value={password}
|
||
|
|
onChangeText={setPassword}
|
||
|
|
containerStyle={themed($textField)}
|
||
|
|
autoCapitalize="none"
|
||
|
|
autoComplete="password-new"
|
||
|
|
autoCorrect={false}
|
||
|
|
secureTextEntry={isPasswordHidden}
|
||
|
|
label="Password"
|
||
|
|
placeholder="Enter your password"
|
||
|
|
helper={passwordError}
|
||
|
|
status={passwordError ? "error" : undefined}
|
||
|
|
onSubmitEditing={handlePreRegister}
|
||
|
|
RightAccessory={PasswordRightAccessory}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
testID="register-button"
|
||
|
|
text="Create Account"
|
||
|
|
style={themed($button)}
|
||
|
|
preset="reversed"
|
||
|
|
onPress={handlePreRegister}
|
||
|
|
disabled={
|
||
|
|
isLoading ||
|
||
|
|
!username ||
|
||
|
|
!!usernameError ||
|
||
|
|
!email ||
|
||
|
|
!!emailError ||
|
||
|
|
!password ||
|
||
|
|
!!passwordError
|
||
|
|
}
|
||
|
|
loading={isLoading}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<View style={themed($dividerContainer)}>
|
||
|
|
<View style={themed($dividerLine)} />
|
||
|
|
<Text text="OR" size="xs" style={themed($dividerText)} />
|
||
|
|
<View style={themed($dividerLine)} />
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
testID="google-login-button"
|
||
|
|
text="Continue with Google"
|
||
|
|
style={themed($googleButton)}
|
||
|
|
preset="default"
|
||
|
|
onPress={handleGoogleLogin}
|
||
|
|
disabled={isLoading}
|
||
|
|
LeftAccessory={() => (
|
||
|
|
<Image source={require("@assets/images/google-logo.png")} style={themed($googleLogo)} />
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<View style={themed($loginLinkContainer)}>
|
||
|
|
<Text text="Already have an account? " size="sm" />
|
||
|
|
<Button testID="go-to-login-button" text="Log In" preset="link" onPress={handleGoToLogin} />
|
||
|
|
</View>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
|
||
|
|
// Render verification step (step 2)
|
||
|
|
const renderVerificationStep = () => (
|
||
|
|
<>
|
||
|
|
<Text
|
||
|
|
testID="verification-heading"
|
||
|
|
text="Verify Your Email"
|
||
|
|
preset="heading"
|
||
|
|
style={themed($title)}
|
||
|
|
/>
|
||
|
|
<Text
|
||
|
|
text={`We've sent a verification code to ${pendingEmail}`}
|
||
|
|
preset="subheading"
|
||
|
|
style={themed($subtitle)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{error ? <Text text={error} size="sm" style={themed($errorText)} /> : null}
|
||
|
|
|
||
|
|
<TextField
|
||
|
|
ref={verificationCodeInput}
|
||
|
|
value={verificationCode}
|
||
|
|
onChangeText={(text) => setVerificationCode(text.replace(/[^0-9]/g, "").slice(0, 6))}
|
||
|
|
containerStyle={themed($textField)}
|
||
|
|
autoCapitalize="none"
|
||
|
|
autoComplete="one-time-code"
|
||
|
|
autoCorrect={false}
|
||
|
|
keyboardType="number-pad"
|
||
|
|
label="Verification Code"
|
||
|
|
placeholder="Enter 6-digit code"
|
||
|
|
maxLength={6}
|
||
|
|
onSubmitEditing={handleVerifyRegister}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
testID="verify-button"
|
||
|
|
text="Verify"
|
||
|
|
style={themed($button)}
|
||
|
|
preset="reversed"
|
||
|
|
onPress={handleVerifyRegister}
|
||
|
|
disabled={isLoading || verificationCode.length !== 6}
|
||
|
|
loading={isLoading}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<View style={themed($resendContainer)}>
|
||
|
|
<Text text="Didn't receive the code? " size="sm" />
|
||
|
|
<Button
|
||
|
|
testID="resend-button"
|
||
|
|
text={resendCountdown > 0 ? `Resend in ${resendCountdown}s` : "Resend Code"}
|
||
|
|
preset="link"
|
||
|
|
onPress={handleResendCode}
|
||
|
|
disabled={isLoading || resendCountdown > 0}
|
||
|
|
/>
|
||
|
|
</View>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
testID="back-button"
|
||
|
|
text="Back"
|
||
|
|
preset="default"
|
||
|
|
style={themed($backButton)}
|
||
|
|
onPress={handleBackToCredentials}
|
||
|
|
disabled={isLoading}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Screen
|
||
|
|
preset="auto"
|
||
|
|
contentContainerStyle={themed($screenContentContainer)}
|
||
|
|
safeAreaEdges={["top", "bottom"]}
|
||
|
|
>
|
||
|
|
{registerStep === "credentials" ? renderCredentialsStep() : renderVerificationStep()}
|
||
|
|
</Screen>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const $screenContentContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
paddingVertical: spacing.xxl,
|
||
|
|
paddingHorizontal: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||
|
|
marginBottom: spacing.sm,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $subtitle: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||
|
|
marginBottom: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $errorText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
|
|
color: colors.error,
|
||
|
|
marginBottom: spacing.md,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $textField: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
marginBottom: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $button: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
marginTop: spacing.xs,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $loginLinkContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
marginTop: spacing.xl,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $resendContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
justifyContent: "center",
|
||
|
|
marginTop: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $backButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
marginTop: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $dividerContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||
|
|
flexDirection: "row",
|
||
|
|
alignItems: "center",
|
||
|
|
marginTop: spacing.xl,
|
||
|
|
marginBottom: spacing.lg,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $dividerLine: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||
|
|
flex: 1,
|
||
|
|
height: 1,
|
||
|
|
backgroundColor: colors.border,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $dividerText: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||
|
|
color: colors.textDim,
|
||
|
|
marginHorizontal: spacing.md,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $googleButton: ThemedStyle<ViewStyle> = () => ({
|
||
|
|
borderWidth: 1,
|
||
|
|
})
|
||
|
|
|
||
|
|
const $googleLogo: ThemedStyle<ImageStyle> = ({ spacing }) => ({
|
||
|
|
width: s(20),
|
||
|
|
height: s(20),
|
||
|
|
marginRight: spacing.sm,
|
||
|
|
})
|