-
Notifications
You must be signed in to change notification settings - Fork 0
feat(mobile): 이메일 회원가입 화면 구현 #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
72baed7
8273d84
b0761d4
ab3f071
75ee7d0
668d470
15893be
1c00f3e
0d6cf5f
5cb471f
bbda04f
30aefe3
f85282f
8bb237d
a020e2c
004287b
b4afd82
8a29985
15d3224
7dae70e
148fd14
89b550e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||
| import { type SignUpFormData, signUpFormSchema } from '@src/features/auth/models/auth.model'; | ||
| import { SignUpPasswordForm } from '@src/features/auth/presentations/components/SignUpPasswordForm'; | ||
| import { SignUpUserInfoForm } from '@src/features/auth/presentations/components/SignUpUserInfoForm'; | ||
| import { SignUpVerificationForm } from '@src/features/auth/presentations/components/SignUpVerificationForm'; | ||
| import { useStepper } from '@src/shared/hooks/useStepper'; | ||
| import { HStack } from '@src/shared/ui/HStack/HStack'; | ||
| import { ArrowLeftIcon } from '@src/shared/ui/Icon'; | ||
| import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView'; | ||
| import { Text } from '@src/shared/ui/Text/Text'; | ||
| import { router } from 'expo-router'; | ||
| import { PressableFeedback } from 'heroui-native'; | ||
| import { FormProvider, useForm } from 'react-hook-form'; | ||
| import { View } from 'react-native'; | ||
| import { match } from 'ts-pattern'; | ||
|
|
||
| const SIGN_UP_STEPS = ['정보_입력', '비밀번호_설정', '이메일_인증'] as const; | ||
| type SignUpStep = (typeof SIGN_UP_STEPS)[number]; | ||
|
|
||
| const SIGN_UP_STEP_TITLES = { | ||
| 정보_입력: '회원가입', | ||
| 비밀번호_설정: '비밀번호 설정', | ||
| 이메일_인증: '이메일 인증', | ||
| } as const satisfies Record<SignUpStep, string>; | ||
|
|
||
| const SignUpScreen = () => { | ||
| const { step, setStep } = useStepper<typeof SIGN_UP_STEPS>(SIGN_UP_STEPS); | ||
|
|
||
| const form = useForm<SignUpFormData>({ | ||
| resolver: zodResolver(signUpFormSchema), | ||
| defaultValues: { | ||
| email: '', | ||
| password: '', | ||
| passwordConfirm: '', | ||
| name: '', | ||
| }, | ||
| mode: 'onTouched', | ||
| }); | ||
|
|
||
| return ( | ||
| <StyledSafeAreaView className="flex-1 bg-white" edges={['top']}> | ||
| <HStack align="center" px={16} py={12}> | ||
| <PressableFeedback onPress={() => router.back()}> | ||
| <ArrowLeftIcon width={24} height={24} colorClassName="text-gray-8" /> | ||
| </PressableFeedback> | ||
| <Text size="b2" weight="semibold" align="center" className="flex-1"> | ||
| {SIGN_UP_STEP_TITLES[step]} | ||
| </Text> | ||
| <View className="w-6" /> | ||
| </HStack> | ||
|
|
||
| <FormProvider {...form}> | ||
| {match(step) | ||
| .with('정보_입력', () => ( | ||
| <SignUpUserInfoForm onNextStep={() => setStep('비밀번호_설정')} /> | ||
| )) | ||
| .with('비밀번호_설정', () => ( | ||
| <SignUpPasswordForm onNextStep={() => setStep('이메일_인증')} /> | ||
| )) | ||
| .with('이메일_인증', () => <SignUpVerificationForm />) | ||
| .exhaustive()} | ||
| </FormProvider> | ||
| </StyledSafeAreaView> | ||
| ); | ||
| }; | ||
|
|
||
| export default SignUpScreen; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| import { VERIFICATION_CODE, type VerifyEmailInput, verifyEmailSchema } from '@aido/validators'; | ||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||
| import { useCooldown } from '@src/features/auth/presentations/hooks/useCooldown'; | ||
| import { resendVerificationMutationOptions } from '@src/features/auth/presentations/queries/resend-verification-mutation-options'; | ||
| import { verifyEmailMutationOptions } from '@src/features/auth/presentations/queries/verify-email-mutation-options'; | ||
| import { ANIMATION } from '@src/shared/constants/animation.constants'; | ||
| import { useAppToast } from '@src/shared/hooks/useAppToast'; | ||
| import { HStack } from '@src/shared/ui/HStack/HStack'; | ||
| import { ArrowLeftIcon } from '@src/shared/ui/Icon'; | ||
| import { Result } from '@src/shared/ui/Result/Result'; | ||
| import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView'; | ||
| import { Text } from '@src/shared/ui/Text/Text'; | ||
| import { H3 } from '@src/shared/ui/Text/Typography'; | ||
| import { TextButton } from '@src/shared/ui/TextButton/TextButton'; | ||
| import { VStack } from '@src/shared/ui/VStack/VStack'; | ||
| import { useMutation } from '@tanstack/react-query'; | ||
| import { router, useLocalSearchParams } from 'expo-router'; | ||
| import { InputOTP, type InputOTPRef, PressableFeedback } from 'heroui-native'; | ||
| import { useRef, useState } from 'react'; | ||
| import { Controller, useForm } from 'react-hook-form'; | ||
| import { ScrollView, View } from 'react-native'; | ||
| import Animated, { FadeIn } from 'react-native-reanimated'; | ||
|
|
||
| /** | ||
| * 독립적인 이메일 인증 화면 | ||
| * - 로그인 시 미인증 에러(EMAIL_0503) 발생 시 이동 | ||
| */ | ||
| const VerifyEmailScreen = () => { | ||
| const { email } = useLocalSearchParams<{ email: string }>(); | ||
| const toast = useAppToast(); | ||
|
|
||
| const inputOTPRef = useRef<InputOTPRef>(null); | ||
| const [cooldown, setCooldown] = useCooldown(0); | ||
| const [isInvalid, setIsInvalid] = useState(false); | ||
|
|
||
| const { control, handleSubmit, setValue, reset } = useForm<VerifyEmailInput>({ | ||
| resolver: zodResolver(verifyEmailSchema), | ||
| defaultValues: { email: email ?? '', code: '' }, | ||
| }); | ||
|
|
||
| const verify = useMutation(verifyEmailMutationOptions()); | ||
| const resend = useMutation(resendVerificationMutationOptions()); | ||
|
|
||
| const onSubmit = (data: VerifyEmailInput) => { | ||
| setIsInvalid(false); | ||
| verify.mutate(data, { | ||
| onError: (error) => { | ||
| setIsInvalid(true); | ||
| setValue('code', ''); | ||
| inputOTPRef.current?.clear(); | ||
| toast.error(error, { fallback: '인증 코드가 올바르지 않습니다' }); | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| const handleComplete = (code: string) => { | ||
| setValue('code', code); | ||
| handleSubmit(onSubmit)(); | ||
| }; | ||
|
|
||
| const handleResend = () => { | ||
| if (cooldown > 0 || !email) return; | ||
|
|
||
| resend.mutate( | ||
| { email }, | ||
| { | ||
| onSuccess: (response) => { | ||
| setCooldown(response.retryAfterSeconds ?? VERIFICATION_CODE.RESEND_COOLDOWN_SECONDS); | ||
| reset({ email, code: '' }); | ||
| inputOTPRef.current?.clear(); | ||
| setIsInvalid(false); | ||
| toast.success('인증 코드가 재발송되었습니다'); | ||
| }, | ||
| onError: (error) => { | ||
| toast.error(error, { fallback: '인증 코드 재발송에 실패했습니다' }); | ||
| }, | ||
| }, | ||
| ); | ||
| }; | ||
|
|
||
| const maskedEmail = email?.replace(/(.{2})(.*)(@.*)/, '$1****$3') ?? ''; | ||
|
|
||
| if (!email) { | ||
| return ( | ||
| <StyledSafeAreaView className="flex-1 bg-white items-center justify-center"> | ||
| <Result | ||
| title="이메일 정보가 없습니다" | ||
| button={ | ||
| <Result.Button color="dark" onPress={() => router.back()}> | ||
| 돌아가기 | ||
| </Result.Button> | ||
| } | ||
| /> | ||
| </StyledSafeAreaView> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <StyledSafeAreaView className="flex-1 bg-white" edges={['top']}> | ||
| {/* Header */} | ||
| <HStack align="center" px={16} py={12}> | ||
| <PressableFeedback onPress={() => router.back()}> | ||
| <ArrowLeftIcon width={24} height={24} colorClassName="text-gray-8" /> | ||
| </PressableFeedback> | ||
| <Text size="b2" weight="semibold" align="center" className="flex-1"> | ||
| 이메일 인증 | ||
| </Text> | ||
| <View className="w-6" /> | ||
| </HStack> | ||
|
|
||
| <View className="flex-1"> | ||
| <ScrollView | ||
| className="flex-1" | ||
| contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 24, paddingBottom: 100 }} | ||
| keyboardShouldPersistTaps="handled" | ||
| showsVerticalScrollIndicator={false} | ||
| > | ||
| <Animated.View | ||
| entering={FadeIn.duration(ANIMATION.duration.slow)} | ||
| style={{ marginBottom: 24 }} | ||
| > | ||
| <H3> | ||
| {maskedEmail}로{'\n'}발송된 코드를 입력해주세요 | ||
| </H3> | ||
| </Animated.View> | ||
|
|
||
| <VStack gap={32} align="center"> | ||
| <Controller | ||
| control={control} | ||
| name="code" | ||
| render={({ field: { onChange, value } }) => ( | ||
| <InputOTP | ||
| ref={inputOTPRef} | ||
| maxLength={6} | ||
| value={value} | ||
| onChange={onChange} | ||
| onComplete={handleComplete} | ||
| isInvalid={isInvalid} | ||
| > | ||
| <InputOTP.Group> | ||
| <InputOTP.Slot index={0} /> | ||
| <InputOTP.Slot index={1} /> | ||
| <InputOTP.Slot index={2} /> | ||
| </InputOTP.Group> | ||
| <InputOTP.Separator /> | ||
| <InputOTP.Group> | ||
| <InputOTP.Slot index={3} /> | ||
| <InputOTP.Slot index={4} /> | ||
| <InputOTP.Slot index={5} /> | ||
| </InputOTP.Group> | ||
| </InputOTP> | ||
| )} | ||
| /> | ||
|
|
||
| {verify.isPending && ( | ||
| <Text size="b4" className="text-main"> | ||
| 인증 중... | ||
| </Text> | ||
| )} | ||
|
|
||
| <HStack gap={8} justify="center"> | ||
| <Text size="b4" shade={7}> | ||
| 코드를 받지 못하셨나요? | ||
| </Text> | ||
| <TextButton | ||
| size="medium" | ||
| onPress={handleResend} | ||
| disabled={cooldown > 0 || resend.isPending} | ||
| > | ||
| {cooldown > 0 ? `${cooldown}초 후 재발송` : '인증코드 재발송'} | ||
| </TextButton> | ||
| </HStack> | ||
| </VStack> | ||
| </ScrollView> | ||
| </View> | ||
| </StyledSafeAreaView> | ||
| ); | ||
| }; | ||
|
|
||
| export default VerifyEmailScreen; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import type { SubscriptionStatus } from '@aido/validators'; | ||
| import { emailSchema, passwordSchema, type SubscriptionStatus } from '@aido/validators'; | ||
hijjoy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| import { z } from 'zod'; | ||
|
|
||
| export const UserSchema = z.object({ | ||
|
|
@@ -24,6 +24,24 @@ export const AuthTokensSchema = z.object({ | |
|
|
||
| export type AuthTokens = z.infer<typeof AuthTokensSchema>; | ||
|
|
||
| export const signUpFormSchema = z | ||
| .object({ | ||
| email: emailSchema, | ||
|
||
| password: passwordSchema, | ||
| passwordConfirm: z.string(), | ||
| name: z | ||
| .string() | ||
| .min(1, '닉네임을 입력해주세요') | ||
| .max(100, '이름은 100자 이내여야 합니다') | ||
| .trim(), | ||
| }) | ||
| .refine((data) => data.password === data.passwordConfirm, { | ||
| message: '비밀번호가 일치하지 않습니다', | ||
| path: ['passwordConfirm'], | ||
| }); | ||
|
|
||
| export type SignUpFormData = z.infer<typeof signUpFormSchema>; | ||
|
|
||
| /** Auth 도메인 비즈니스 규칙 */ | ||
| export const AuthPolicy = { | ||
| /** 구독 상태가 활성 상태인지 확인 */ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.