diff --git a/apps/mobile/.claude/ui-components.md b/apps/mobile/.claude/ui-components.md index 5fc06877..65e4e3e8 100644 --- a/apps/mobile/.claude/ui-components.md +++ b/apps/mobile/.claude/ui-components.md @@ -46,7 +46,9 @@ import { ScrollView, FlatList, Image } from 'react-native'; |----------|------|------| | `Text`, `H1`~`H4` | 텍스트, 헤딩 | `src/shared/ui/Text/README.md` | | `Button` | 기본 버튼 | `src/shared/ui/Button/Button.md` | +| `KeyboardAdaptiveButton` | 키보드 반응 버튼 | `src/shared/ui/Button/Button.md` | | `TextButton` | 텍스트/링크 버튼 | `src/shared/ui/TextButton/TextButton.md` | +| `Input` | 입력 필드 | `src/shared/ui/Input/Input.md` | | `Spacing` | 간격 유틸리티 | `src/shared/ui/Spacing/Spacing.md` | | `Box` | 단순 컨테이너 | `src/shared/ui/Box/README.md` | | `Flex` | Flexbox 레이아웃 | `src/shared/ui/Flex/README.md` | diff --git a/apps/mobile/app/(auth)/_layout.tsx b/apps/mobile/app/(auth)/_layout.tsx index 15241c3f..ff585ffb 100644 --- a/apps/mobile/app/(auth)/_layout.tsx +++ b/apps/mobile/app/(auth)/_layout.tsx @@ -12,6 +12,8 @@ const AuthLayout = () => { > + + ); }; diff --git a/apps/mobile/app/(auth)/email-login.tsx b/apps/mobile/app/(auth)/email-login.tsx index f24d4c0b..4a007376 100644 --- a/apps/mobile/app/(auth)/email-login.tsx +++ b/apps/mobile/app/(auth)/email-login.tsx @@ -1,4 +1,6 @@ +import { ErrorCode } from '@aido/errors'; import { emailLoginMutationOptions } from '@src/features/auth/presentations/queries/email-login-mutation-options'; +import { isApiError } from '@src/shared/errors'; import { Button } from '@src/shared/ui/Button/Button'; import { HStack } from '@src/shared/ui/HStack/HStack'; import { ArrowLeftIcon } from '@src/shared/ui/Icon'; @@ -47,6 +49,10 @@ const EmailLoginScreen = () => { { email: email.trim(), password }, { onError: (error) => { + if (isApiError(error) && error.hasCode(ErrorCode.EMAIL_0503)) { + router.push({ pathname: './verify-email', params: { email: email.trim() } }); + return; + } showError('로그인 실패', error.message || '로그인에 실패했습니다'); }, }, @@ -116,7 +122,7 @@ const EmailLoginScreen = () => { 계정이 없으신가요? - {}}> + router.push('/sign-up')}> 회원가입 diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index 9d2f6faf..aadd5436 100644 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -127,7 +127,7 @@ const LoginScreen = () => { - {}}> + router.push('/sign-up')}> 회원가입 diff --git a/apps/mobile/app/(auth)/sign-up.tsx b/apps/mobile/app/(auth)/sign-up.tsx new file mode 100644 index 00000000..77bd361f --- /dev/null +++ b/apps/mobile/app/(auth)/sign-up.tsx @@ -0,0 +1,70 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +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 { + type SignUpFormData, + signUpFormSchema, +} from '@src/features/auth/presentations/schemas/sign-up-form.schema'; +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; + +const SignUpScreen = () => { + const { step, setStep } = useStepper(SIGN_UP_STEPS); + + const form = useForm({ + resolver: zodResolver(signUpFormSchema), + defaultValues: { + email: '', + password: '', + passwordConfirm: '', + name: '', + }, + mode: 'onTouched', + }); + + return ( + + + router.back()}> + + + + {SIGN_UP_STEP_TITLES[step]} + + + + + + {match(step) + .with('정보_입력', () => ( + setStep('비밀번호_설정')} /> + )) + .with('비밀번호_설정', () => ( + setStep('이메일_인증')} /> + )) + .with('이메일_인증', () => ) + .exhaustive()} + + + ); +}; + +export default SignUpScreen; diff --git a/apps/mobile/app/(auth)/verify-email.tsx b/apps/mobile/app/(auth)/verify-email.tsx new file mode 100644 index 00000000..82cf66ce --- /dev/null +++ b/apps/mobile/app/(auth)/verify-email.tsx @@ -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(null); + const [cooldown, setCooldown] = useCooldown(0); + const [isInvalid, setIsInvalid] = useState(false); + + const { control, handleSubmit, setValue, reset } = useForm({ + 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 ( + + router.back()}> + 돌아가기 + + } + /> + + ); + } + + return ( + + {/* Header */} + + router.back()}> + + + + 이메일 인증 + + + + + + + +

+ {maskedEmail}로{'\n'}발송된 코드를 입력해주세요 +

+
+ + + ( + + + + + + + + + + + + + + )} + /> + + {verify.isPending && ( + + 인증 중... + + )} + + + + 코드를 받지 못하셨나요? + + 0 || resend.isPending} + > + {cooldown > 0 ? `${cooldown}초 후 재발송` : '인증코드 재발송'} + + + +
+
+
+ ); +}; + +export default VerifyEmailScreen; diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index cf2fcb18..9f240eb9 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -9,6 +9,7 @@ import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { useEffect } from 'react'; import { ActivityIndicator, View } from 'react-native'; +import { KeyboardProvider } from 'react-native-keyboard-controller'; import '../global.css'; SplashScreen.preventAutoHideAsync(); @@ -71,17 +72,19 @@ const AppBootstrapLayout = () => { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/apps/mobile/assets/icons/ic_checkmark.svg b/apps/mobile/assets/icons/ic_checkmark.svg new file mode 100644 index 00000000..a01e208a --- /dev/null +++ b/apps/mobile/assets/icons/ic_checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/icons/ic_eye.svg b/apps/mobile/assets/icons/ic_eye.svg new file mode 100644 index 00000000..0dc0ded5 --- /dev/null +++ b/apps/mobile/assets/icons/ic_eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/icons/ic_eye_off.svg b/apps/mobile/assets/icons/ic_eye_off.svg new file mode 100644 index 00000000..975dca1e --- /dev/null +++ b/apps/mobile/assets/icons/ic_eye_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/global.css b/apps/mobile/global.css index de7ff010..ad2893c3 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -134,3 +134,12 @@ --text-e2--line-height: 16px; --text-e2--letter-spacing: -0.3px; } + +@layer utilities { + .text-input-lg { + font-size: var(--text-b2); + } + .text-input-md { + font-size: var(--text-b3); + } +} diff --git a/apps/mobile/src/features/auth/models/auth-tokens.model.ts b/apps/mobile/src/features/auth/models/auth-tokens.model.ts new file mode 100644 index 00000000..6592579d --- /dev/null +++ b/apps/mobile/src/features/auth/models/auth-tokens.model.ts @@ -0,0 +1,7 @@ +export interface AuthTokens { + userId: string; + accessToken: string; + refreshToken: string; + userName: string | null; + userProfileImage: string | null; +} diff --git a/apps/mobile/src/features/auth/models/auth.model.ts b/apps/mobile/src/features/auth/models/auth.model.ts deleted file mode 100644 index d1150d27..00000000 --- a/apps/mobile/src/features/auth/models/auth.model.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SubscriptionStatus } from '@aido/validators'; -import { z } from 'zod'; - -export const UserSchema = z.object({ - id: z.string(), - email: z.string(), - name: z.string().nullable(), - profileImage: z.string().nullable(), - userTag: z.string(), - subscriptionStatus: z.enum(['FREE', 'ACTIVE', 'EXPIRED', 'CANCELLED']), - isSubscribed: z.boolean(), - createdAt: z.date(), -}); - -export type User = z.infer; - -export const AuthTokensSchema = z.object({ - userId: z.string(), - accessToken: z.string(), - refreshToken: z.string(), - userName: z.string().nullable(), - userProfileImage: z.string().nullable(), -}); - -export type AuthTokens = z.infer; - -/** Auth 도메인 비즈니스 규칙 */ -export const AuthPolicy = { - /** 구독 상태가 활성 상태인지 확인 */ - isSubscriptionActive: (status: SubscriptionStatus): boolean => status === 'ACTIVE', -} as const; diff --git a/apps/mobile/src/features/auth/models/auth.policy.ts b/apps/mobile/src/features/auth/models/auth.policy.ts new file mode 100644 index 00000000..05993e52 --- /dev/null +++ b/apps/mobile/src/features/auth/models/auth.policy.ts @@ -0,0 +1,7 @@ +import type { SubscriptionStatus } from './user.model'; + +/** Auth 도메인 비즈니스 규칙 */ +export const AuthPolicy = { + /** 구독 상태가 활성 상태인지 확인 */ + isSubscriptionActive: (status: SubscriptionStatus): boolean => status === 'ACTIVE', +} as const; diff --git a/apps/mobile/src/features/auth/models/user.model.ts b/apps/mobile/src/features/auth/models/user.model.ts new file mode 100644 index 00000000..9addb049 --- /dev/null +++ b/apps/mobile/src/features/auth/models/user.model.ts @@ -0,0 +1,12 @@ +export type SubscriptionStatus = 'FREE' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED'; + +export interface User { + id: string; + email: string; + name: string | null; + profileImage: string | null; + userTag: string; + subscriptionStatus: SubscriptionStatus; + isSubscribed: boolean; + createdAt: Date; +} diff --git a/apps/mobile/src/features/auth/presentations/components/SignUpPasswordForm.tsx b/apps/mobile/src/features/auth/presentations/components/SignUpPasswordForm.tsx new file mode 100644 index 00000000..931187c2 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/components/SignUpPasswordForm.tsx @@ -0,0 +1,203 @@ +import { PASSWORD_RULES, passwordSchema } from '@aido/validators'; +import type { SignUpFormData } from '@src/features/auth/presentations/schemas/sign-up-form.schema'; +import { ANIMATION } from '@src/shared/constants/animation.constants'; +import { useStepper } from '@src/shared/hooks/useStepper'; +import { KeyboardAdaptiveButton } from '@src/shared/ui/Button'; +import { HStack } from '@src/shared/ui/HStack/HStack'; +import { CheckmarkIcon, EyeIcon, EyeOffIcon } from '@src/shared/ui/Icon/icons'; +import { Input } from '@src/shared/ui/Input'; +import { Text } from '@src/shared/ui/Text/Text'; +import { H3 } from '@src/shared/ui/Text/Typography'; +import { VStack } from '@src/shared/ui/VStack/VStack'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { Keyboard, Pressable, ScrollView, type TextInput, View } from 'react-native'; +import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated'; +import { match } from 'ts-pattern'; +import { TermsBottomSheet } from './TermsBottomSheet'; + +const PASSWORD_STEPS = ['password', 'passwordConfirm'] as const; + +interface SignUpPasswordFormProps { + onNextStep: () => void; +} + +export const SignUpPasswordForm = ({ onNextStep }: SignUpPasswordFormProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + const [password, passwordConfirm] = useWatch({ control, name: ['password', 'passwordConfirm'] }); + const { step, setStep } = useStepper(PASSWORD_STEPS); + const [isTermsOpen, setIsTermsOpen] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const passwordConfirmInputRef = useRef(null); + + useEffect(() => { + if (step !== 'passwordConfirm') return; + + const passwordConfirmFocusTimeoutId = setTimeout(() => { + passwordConfirmInputRef.current?.focus(); + }, 350); + + return () => clearTimeout(passwordConfirmFocusTimeoutId); + }, [step]); + + const isPasswordValid = passwordSchema.safeParse(password).success; + const isPasswordConfirmValid = isPasswordValid && password === passwordConfirm; + + const hasMinLength = (password?.length ?? 0) >= PASSWORD_RULES.MIN_LENGTH; + const hasLetter = PASSWORD_RULES.HAS_LETTER.test(password || ''); + const hasNumber = PASSWORD_RULES.HAS_NUMBER.test(password || ''); + + const handleNext = () => { + match(step) + .with('password', () => { + setStep('passwordConfirm'); + }) + .with('passwordConfirm', () => { + Keyboard.dismiss(); + setIsTermsOpen(true); + }) + .exhaustive(); + }; + + const isNextEnabled = match(step) + .with('password', () => isPasswordValid) + .with('passwordConfirm', () => isPasswordConfirmValid) + .exhaustive(); + + return ( + <> + + + +

{`영문 숫자를 포함한\n비밀번호를 설정해주세요`}

+
+ + {step === 'passwordConfirm' && ( + + + ( + { + if (isPasswordConfirmValid) handleNext(); + }} + rightContent={ + setShowPasswordConfirm(!showPasswordConfirm)}> + {showPasswordConfirm ? ( + + ) : ( + + )} + + } + /> + )} + /> + + + )} + + + ( + + { + if (isPasswordValid) handleNext(); + }} + rightContent={ + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + } + /> + + + + + + + + )} + /> + +
+ + + 다음 + +
+ + + + ); +}; + +interface PasswordRuleItemProps { + isSatisfied: boolean; + label: string; +} + +const PasswordRuleItem = ({ isSatisfied, label }: PasswordRuleItemProps) => { + const colorClassName = isSatisfied ? 'text-success' : 'text-gray-5'; + + return ( + + + + {label} + + + ); +}; diff --git a/apps/mobile/src/features/auth/presentations/components/SignUpUserInfoForm.tsx b/apps/mobile/src/features/auth/presentations/components/SignUpUserInfoForm.tsx new file mode 100644 index 00000000..0974256a --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/components/SignUpUserInfoForm.tsx @@ -0,0 +1,211 @@ +import { emailSchema } from '@aido/validators'; +import { + type SignUpFormData, + signUpFormSchema, +} from '@src/features/auth/presentations/schemas/sign-up-form.schema'; +import { ANIMATION } from '@src/shared/constants/animation.constants'; +import { useStepper } from '@src/shared/hooks/useStepper'; +import { KeyboardAdaptiveButton } from '@src/shared/ui/Button'; +import { HStack } from '@src/shared/ui/HStack/HStack'; +import { Input } from '@src/shared/ui/Input'; +import { Spacing } from '@src/shared/ui/Spacing/Spacing'; +import { H3 } from '@src/shared/ui/Text/Typography'; +import { VStack } from '@src/shared/ui/VStack/VStack'; +import { Chip } from 'heroui-native'; +import { useEffect, useRef } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { ScrollView, type TextInput, View } from 'react-native'; +import Animated, { FadeIn, FadeInUp } from 'react-native-reanimated'; +import { match } from 'ts-pattern'; + +const USER_INFO_STEPS = ['name', 'email'] as const; + +const STEP_DESCRIPTIONS = { + name: '반가워요!\n어떤 닉네임으로 불러드릴까요?', + email: '로그인에 사용할\n이메일을 입력해주세요.', +} as const; + +interface SignUpUserInfoFormProps { + onNextStep: () => void; +} + +export const SignUpUserInfoForm = ({ onNextStep }: SignUpUserInfoFormProps) => { + const { + control, + formState: { errors }, + } = useFormContext(); + const [email, name] = useWatch({ control, name: ['email', 'name'] }); + const { step, setStep } = useStepper(USER_INFO_STEPS); + const emailInputRef = useRef(null); + + useEffect(() => { + if (step !== 'email') return; + + const emailFocusTimeoutId = setTimeout(() => { + emailInputRef.current?.focus(); + }, 300); + + return () => clearTimeout(emailFocusTimeoutId); + }, [step]); + + const handleNext = () => { + match(step) + .with('name', () => { + setStep('email'); + }) + .with('email', () => onNextStep()) + .exhaustive(); + }; + + const isNameValid = signUpFormSchema.shape.name.safeParse(name).success; + const isEmailValid = emailSchema.safeParse(email).success; + + const isNextButtonEnabled = match(step) + .with('name', () => isNameValid) + .with('email', () => isNameValid && isEmailValid) + .exhaustive(); + + return ( + + + +

{STEP_DESCRIPTIONS[step]}

+
+ + {step === 'email' && ( + + ( + + { + if (isEmailValid) handleNext(); + }} + /> + + + )} + /> + + + )} + + + ( + { + if (isNameValid) handleNext(); + }} + /> + )} + /> + +
+ + + 다음 + +
+ ); +}; + +const EMAIL_DOMAINS = [ + 'gmail.com', + 'naver.com', + 'daum.net', + 'outlook.com', + 'icloud.com', + 'kakao.com', +] as const; +const MAX_SUGGESTED_DOMAINS = 3; + +const SuggestedEmailDomainList = () => { + const { setValue, control } = useFormContext(); + const email = useWatch({ control, name: 'email' }); + + const normalizedEmail = email ?? ''; + const [localPart, domainPart] = splitEmail(normalizedEmail); + const suggestedDomains = getSuggestedDomains(normalizedEmail, domainPart); + + if (suggestedDomains.length === 0) { + return null; + } + + return ( + + + {suggestedDomains.slice(0, MAX_SUGGESTED_DOMAINS).map((domain) => ( + setValue('email', `${localPart}@${domain}`)} + > + @{domain} + + ))} + + + ); +}; + +const splitEmail = (value: string): [string, string] => { + const atIndex = value.lastIndexOf('@'); + if (atIndex === -1) { + return [value, '']; + } + return [value.substring(0, atIndex), value.substring(atIndex + 1)]; +}; + +const getSuggestedDomains = (rawEmail: string, domainPart: string) => { + if (!rawEmail.includes('@')) { + return []; + } + + if (domainPart && EMAIL_DOMAINS.includes(domainPart as (typeof EMAIL_DOMAINS)[number])) { + return []; + } + + return EMAIL_DOMAINS.filter((domain) => domain.startsWith(domainPart)); +}; diff --git a/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx b/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx new file mode 100644 index 00000000..b1b09b74 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/components/SignUpVerificationForm.tsx @@ -0,0 +1,144 @@ +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 type { SignUpFormData } from '@src/features/auth/presentations/schemas/sign-up-form.schema'; +import { ANIMATION } from '@src/shared/constants/animation.constants'; +import { useAppToast } from '@src/shared/hooks/useAppToast'; +import { HStack } from '@src/shared/ui/HStack/HStack'; +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 { InputOTP, type InputOTPRef } from 'heroui-native'; +import { useRef, useState } from 'react'; +import { Controller, useForm, useFormContext } from 'react-hook-form'; +import { ScrollView, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +export const SignUpVerificationForm = () => { + const { getValues } = useFormContext(); + const toast = useAppToast(); + const email = getValues('email'); + + const inputOTPRef = useRef(null); + const [cooldown, setCooldown] = useCooldown(0); + const [isInvalid, setIsInvalid] = useState(false); + + const { control, handleSubmit, setValue, reset } = useForm({ + resolver: zodResolver(verifyEmailSchema), + defaultValues: { 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) 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'); + + return ( + + + +

+ {maskedEmail}로{'\n'}발송된 코드를 입력해주세요 +

+
+ + + ( + + + + + + + + + + + + + + )} + /> + + {verify.isPending && ( + + 인증 중... + + )} + + + + 코드를 받지 못하셨나요? + + 0 || resend.isPending} + > + {cooldown > 0 ? `${cooldown}초 후 재발송` : '인증코드 재발송'} + + + +
+
+ ); +}; diff --git a/apps/mobile/src/features/auth/presentations/components/TermsBottomSheet.tsx b/apps/mobile/src/features/auth/presentations/components/TermsBottomSheet.tsx new file mode 100644 index 00000000..ee6fcb08 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/components/TermsBottomSheet.tsx @@ -0,0 +1,185 @@ +import { ErrorCode } from '@aido/errors'; +import type { RegisterInput } from '@aido/validators'; +import { registerMutationOptions } from '@src/features/auth/presentations/queries/register-mutation-options'; +import type { SignUpFormData } from '@src/features/auth/presentations/schemas/sign-up-form.schema'; +import { isApiError } from '@src/shared/errors'; +import { useAppToast } from '@src/shared/hooks/useAppToast'; +import { Button } from '@src/shared/ui/Button/Button'; +import { HStack } from '@src/shared/ui/HStack/HStack'; +import { ArrowRightIcon } from '@src/shared/ui/Icon'; +import { Text } from '@src/shared/ui/Text/Text'; +import { VStack } from '@src/shared/ui/VStack/VStack'; +import { useMutation } from '@tanstack/react-query'; +import { router } from 'expo-router'; +import { BottomSheet, Checkbox, Divider, FormField } from 'heroui-native'; +import { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Pressable } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface TermsBottomSheetProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onNextStep: () => void; +} + +export const TermsBottomSheet = ({ isOpen, onOpenChange, onNextStep }: TermsBottomSheetProps) => { + const { handleSubmit } = useFormContext(); + const toast = useAppToast(); + const insets = useSafeAreaInsets(); + + const [agreements, setAgreements] = useState(() => ({ + terms: false, + privacy: false, + marketing: false, + })); + + const register = useMutation(registerMutationOptions()); + + const isAllAgreed = agreements.terms && agreements.privacy && agreements.marketing; + const isRequiredAgreed = agreements.terms && agreements.privacy; + + const toggleAll = () => { + const newValue = !isAllAgreed; + setAgreements({ terms: newValue, privacy: newValue, marketing: newValue }); + }; + + const setAgreement = (key: keyof typeof agreements) => (isSelected: boolean) => { + setAgreements((prev) => ({ ...prev, [key]: isSelected })); + }; + + const onSubmit = (data: SignUpFormData) => { + const validatedData: RegisterInput = { + ...data, + termsAgreed: true, + privacyAgreed: true, + marketingAgreed: agreements.marketing, + }; + + register.mutate(validatedData, { + onSuccess: () => { + onOpenChange(false); + onNextStep(); + }, + onError: (error) => { + if (isApiError(error) && error.hasCode(ErrorCode.EMAIL_0501)) { + onOpenChange(false); + toast.toast('이미 가입된 이메일이에요', { + variant: 'accent', + action: { + label: '로그인하기', + onPress: ({ hide }) => { + hide(); + router.replace('/(auth)/email-login'); + }, + }, + }); + return; + } + toast.error(error, { fallback: '회원가입에 실패했습니다' }); + }, + }); + }; + + return ( + + + + + + + + + + + + + 약관에 모두 동의 + + + + + + + + + + + + + + + + + + + ); +}; + +interface TermsAgreementItemProps { + label: string; + isRequired?: boolean; + isSelected: boolean; + onSelectedChange: (isSelected: boolean) => void; + onPressLink?: () => void; +} + +const TermsAgreementItem = ({ + label, + isRequired = false, + isSelected, + onSelectedChange, + onPressLink, +}: TermsAgreementItemProps) => { + const requiredLabel = isRequired ? '필수' : '선택'; + + return ( + + + + + + + + {label} + + ({requiredLabel}) + + + + {onPressLink && ( + + + + )} + + + ); +}; diff --git a/apps/mobile/src/features/auth/presentations/hooks/useCooldown.ts b/apps/mobile/src/features/auth/presentations/hooks/useCooldown.ts new file mode 100644 index 00000000..55c561c1 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/hooks/useCooldown.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useCooldown = (initialValue = 0) => { + const [cooldown, setCooldown] = useState(initialValue); + + useEffect(() => { + if (cooldown <= 0) return; + + const timer = setInterval(() => { + setCooldown((prev) => Math.max(0, prev - 1)); + }, 1000); + + return () => clearInterval(timer); + }, [cooldown]); + + return [cooldown, setCooldown] as const; +}; diff --git a/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts new file mode 100644 index 00000000..71af673a --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/queries/register-mutation-options.ts @@ -0,0 +1,11 @@ +import type { RegisterInput } from '@aido/validators'; +import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { mutationOptions } from '@tanstack/react-query'; + +export const registerMutationOptions = () => { + const authService = useAuthService(); + + return mutationOptions({ + mutationFn: (input: RegisterInput) => authService.register(input), + }); +}; diff --git a/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts new file mode 100644 index 00000000..38f1d713 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/queries/resend-verification-mutation-options.ts @@ -0,0 +1,11 @@ +import type { ResendVerificationInput } from '@aido/validators'; +import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { mutationOptions } from '@tanstack/react-query'; + +export const resendVerificationMutationOptions = () => { + const authService = useAuthService(); + + return mutationOptions({ + mutationFn: (input: ResendVerificationInput) => authService.resendVerification(input), + }); +}; diff --git a/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts b/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts new file mode 100644 index 00000000..83d08e55 --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/queries/verify-email-mutation-options.ts @@ -0,0 +1,16 @@ +import type { VerifyEmailInput } from '@aido/validators'; +import { useAuth } from '@src/bootstrap/providers/auth-provider'; +import { useAuthService } from '@src/bootstrap/providers/di-provider'; +import { mutationOptions } from '@tanstack/react-query'; + +export const verifyEmailMutationOptions = () => { + const authService = useAuthService(); + const { setStatus } = useAuth(); + + return mutationOptions({ + mutationFn: (input: VerifyEmailInput) => authService.verifyEmail(input), + onSuccess: () => { + setStatus('authenticated'); + }, + }); +}; diff --git a/apps/mobile/src/features/auth/presentations/schemas/sign-up-form.schema.ts b/apps/mobile/src/features/auth/presentations/schemas/sign-up-form.schema.ts new file mode 100644 index 00000000..b7ed1c9e --- /dev/null +++ b/apps/mobile/src/features/auth/presentations/schemas/sign-up-form.schema.ts @@ -0,0 +1,20 @@ +import { emailSchema, passwordSchema } from '@aido/validators'; +import { z } from 'zod'; + +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; diff --git a/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts b/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts index cd5f5cf6..d25bc7dd 100644 --- a/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts +++ b/apps/mobile/src/features/auth/repositories/auth.repository.impl.ts @@ -7,10 +7,17 @@ import { currentUserSchema, type ExchangeCodeInput, preferenceResponseSchema, + type RegisterInput, + type RegisterResponse, + type ResendVerificationInput, + type ResendVerificationResponse, + registerResponseSchema, + resendVerificationResponseSchema, type UpdateMarketingConsentInput, type UpdatePreferenceInput, updateMarketingConsentResponseSchema, updatePreferenceResponseSchema, + type VerifyEmailInput, } from '@aido/validators'; import type { HttpClient } from '@src/core/ports/http'; import type { Storage } from '@src/core/ports/storage'; @@ -153,4 +160,46 @@ export class AuthRepositoryImpl implements AuthRepository { return result.data; } + + async register(input: RegisterInput): Promise { + const { data } = await this.#publicHttpClient.post('v1/auth/register', input); + + const result = registerResponseSchema.safeParse(data); + if (!result.success) { + throw new AuthValidationError(result.error, 'v1/auth/register'); + } + + return result.data; + } + + async verifyEmail(input: VerifyEmailInput): Promise { + const { data } = await this.#publicHttpClient.post('v1/auth/verify-email', input); + + const result = authTokensSchema.safeParse(data); + if (!result.success) { + throw new AuthValidationError(result.error, 'v1/auth/verify-email'); + } + + // Store tokens on successful verification + await Promise.all([ + this.#storage.set('accessToken', result.data.accessToken), + this.#storage.set('refreshToken', result.data.refreshToken), + ]); + + return result.data; + } + + async resendVerification(input: ResendVerificationInput): Promise { + const { data } = await this.#publicHttpClient.post( + 'v1/auth/resend-verification', + input, + ); + + const result = resendVerificationResponseSchema.safeParse(data); + if (!result.success) { + throw new AuthValidationError(result.error, 'v1/auth/resend-verification'); + } + + return result.data; + } } diff --git a/apps/mobile/src/features/auth/repositories/auth.repository.ts b/apps/mobile/src/features/auth/repositories/auth.repository.ts index 6fabe0f1..e611882b 100644 --- a/apps/mobile/src/features/auth/repositories/auth.repository.ts +++ b/apps/mobile/src/features/auth/repositories/auth.repository.ts @@ -5,10 +5,15 @@ import type { CurrentUser, ExchangeCodeInput, PreferenceResponse, + RegisterInput, + RegisterResponse, + ResendVerificationInput, + ResendVerificationResponse, UpdateMarketingConsentInput, UpdateMarketingConsentResponse, UpdatePreferenceInput, UpdatePreferenceResponse, + VerifyEmailInput, } from '@aido/validators'; export interface AuthRepository { @@ -37,4 +42,10 @@ export interface AuthRepository { ): Promise; appleLogin(input: AppleMobileCallbackInput): Promise; + + register(input: RegisterInput): Promise; + + verifyEmail(input: VerifyEmailInput): Promise; + + resendVerification(input: ResendVerificationInput): Promise; } diff --git a/apps/mobile/src/features/auth/services/auth.mapper.ts b/apps/mobile/src/features/auth/services/auth.mapper.ts index 79339ba5..c458db20 100644 --- a/apps/mobile/src/features/auth/services/auth.mapper.ts +++ b/apps/mobile/src/features/auth/services/auth.mapper.ts @@ -1,5 +1,7 @@ import type { AuthTokens as AuthTokensDTO, CurrentUser } from '@aido/validators'; -import { AuthPolicy, type AuthTokens, type User } from '../models/auth.model'; +import { AuthPolicy } from '../models/auth.policy'; +import type { AuthTokens } from '../models/auth-tokens.model'; +import type { User } from '../models/user.model'; export const toUser = (dto: CurrentUser): User => ({ id: dto.userId, diff --git a/apps/mobile/src/features/auth/services/auth.service.ts b/apps/mobile/src/features/auth/services/auth.service.ts index c69e7e3d..2a6ddc69 100644 --- a/apps/mobile/src/features/auth/services/auth.service.ts +++ b/apps/mobile/src/features/auth/services/auth.service.ts @@ -3,10 +3,15 @@ import type { ConsentResponse, ExchangeCodeInput, PreferenceResponse, + RegisterInput, + RegisterResponse, + ResendVerificationInput, + ResendVerificationResponse, UpdateMarketingConsentInput, UpdateMarketingConsentResponse, UpdatePreferenceInput, UpdatePreferenceResponse, + VerifyEmailInput, } from '@aido/validators'; import { ENV } from '@src/shared/config/env'; import * as AppleAuthentication from 'expo-apple-authentication'; @@ -22,7 +27,8 @@ import { isAuthError, isExpoCodedError, } from '../models/auth.error'; -import type { AuthTokens, User } from '../models/auth.model'; +import type { AuthTokens } from '../models/auth-tokens.model'; +import type { User } from '../models/user.model'; import type { AuthRepository } from '../repositories/auth.repository'; import { toAuthTokens, toUser } from './auth.mapper'; @@ -182,4 +188,19 @@ export class AuthService { ): Promise => { return this.#authRepository.updateMarketingConsent(input); }; + + register = async (input: RegisterInput): Promise => { + return this.#authRepository.register(input); + }; + + verifyEmail = async (input: VerifyEmailInput): Promise => { + const dto = await this.#authRepository.verifyEmail(input); + return toAuthTokens(dto); + }; + + resendVerification = async ( + input: ResendVerificationInput, + ): Promise => { + return this.#authRepository.resendVerification(input); + }; } diff --git a/apps/mobile/app/(app)/notifications/notification-list-item.tsx b/apps/mobile/src/features/notification/presentations/components/NotificationListItem.tsx similarity index 100% rename from apps/mobile/app/(app)/notifications/notification-list-item.tsx rename to apps/mobile/src/features/notification/presentations/components/NotificationListItem.tsx diff --git a/apps/mobile/src/shared/constants/animation.constants.ts b/apps/mobile/src/shared/constants/animation.constants.ts new file mode 100644 index 00000000..bd035c65 --- /dev/null +++ b/apps/mobile/src/shared/constants/animation.constants.ts @@ -0,0 +1,16 @@ +/** + * 애니메이션 상수 + * React Native Reanimated에서 사용하는 duration, delay 값 + */ +export const ANIMATION = { + duration: { + fast: 150, + normal: 200, + slow: 300, + }, + delay: { + short: 50, + medium: 100, + long: 200, + }, +} as const; diff --git a/apps/mobile/src/shared/hooks/useStepper.ts b/apps/mobile/src/shared/hooks/useStepper.ts new file mode 100644 index 00000000..955898fc --- /dev/null +++ b/apps/mobile/src/shared/hooks/useStepper.ts @@ -0,0 +1,10 @@ +import { type Dispatch, type SetStateAction, useState } from 'react'; + +export const useStepper = (steps: T) => { + const [step, setStep] = useState(steps[0] as T[number]); + + return { step, setStep } as { + step: T[number]; + setStep: Dispatch>; + }; +}; diff --git a/apps/mobile/src/shared/infra/http/error-handler.ts b/apps/mobile/src/shared/infra/http/error-handler.ts index 883da07b..f605319a 100644 --- a/apps/mobile/src/shared/infra/http/error-handler.ts +++ b/apps/mobile/src/shared/infra/http/error-handler.ts @@ -175,8 +175,8 @@ interface ServerErrorResponse { error: { code: string; message: string }; } +/** auth-client용: 401은 refresh 로직에서 처리하므로 건너뜀 */ export const handleApiErrors: AfterResponseHook = async (_request, _options, response) => { - // 401은 refresh 로직에서 처리하므로 건너뜀 if (!response.ok && response.status !== 401) { const { error } = (await response.json()) as ServerErrorResponse; const userMessage = @@ -186,3 +186,15 @@ export const handleApiErrors: AfterResponseHook = async (_request, _options, res throw new ApiError(error.code, userMessage, response.status); } }; + +/** public-client용: 401 포함 모든 에러 처리 */ +export const handlePublicApiErrors: AfterResponseHook = async (_request, _options, response) => { + if (!response.ok) { + const { error } = (await response.json()) as ServerErrorResponse; + const userMessage = + MOBILE_ERROR_MESSAGES[error.code as ErrorCodeType] || + error.message || + '알 수 없는 오류가 발생했어요'; + throw new ApiError(error.code, userMessage, response.status); + } +}; diff --git a/apps/mobile/src/shared/infra/http/public-client.ts b/apps/mobile/src/shared/infra/http/public-client.ts index 45b382b1..9af3f0c7 100644 --- a/apps/mobile/src/shared/infra/http/public-client.ts +++ b/apps/mobile/src/shared/infra/http/public-client.ts @@ -1,6 +1,6 @@ import { ENV } from '@src/shared/config/env'; import ky, { type KyInstance } from 'ky'; -import { handleApiErrors } from './error-handler'; +import { handlePublicApiErrors } from './error-handler'; /** * 토큰 없이 호출하는 공개 API용 HTTP 클라이언트 @@ -14,7 +14,7 @@ export const createPublicClient = (): KyInstance => { 'Content-Type': 'application/json', }, hooks: { - afterResponse: [handleApiErrors], + afterResponse: [handlePublicApiErrors], }, }); }; diff --git a/apps/mobile/src/shared/ui/Button/Button.md b/apps/mobile/src/shared/ui/Button/Button.md index 3086d2b3..453d3703 100644 --- a/apps/mobile/src/shared/ui/Button/Button.md +++ b/apps/mobile/src/shared/ui/Button/Button.md @@ -22,6 +22,25 @@ import { Button } from '@src/shared/ui/Button/Button'; ``` +## KeyboardAdaptiveButton + +키보드 상태에 따라 하단 버튼의 여백과 모서리를 자동으로 조정하는 컴포넌트입니다. + +### 사용법 + +```tsx +import { KeyboardAdaptiveButton } from '@src/shared/ui/Button/KeyboardAdaptiveButton'; + + + 회원가입 + +``` + +### 동작 방식 + +- 키보드 닫힘: 좌우 패딩과 하단 안전영역 여백 적용, 둥근 모서리 +- 키보드 열림: 패딩 제거, 하단에 꽉 차는 직사각형 버튼 + ## Props | Prop | 타입 | 기본값 | 설명 | diff --git a/apps/mobile/src/shared/ui/Button/KeyboardAdaptiveButton.tsx b/apps/mobile/src/shared/ui/Button/KeyboardAdaptiveButton.tsx new file mode 100644 index 00000000..0b343ef0 --- /dev/null +++ b/apps/mobile/src/shared/ui/Button/KeyboardAdaptiveButton.tsx @@ -0,0 +1,61 @@ +import { KeyboardStickyView, useKeyboardHandler } from 'react-native-keyboard-controller'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button } from './Button'; +import type { ButtonProps } from './Button.types'; + +const ANIMATION_DURATION_MS = 200; +const CONTAINER_PADDING_X = 16; +const BUTTON_RADIUS = 12; +const DEFAULT_BOTTOM_PADDING = 16; +const STICKY_OFFSET = 0; + +interface KeyboardAdaptiveButtonProps extends ButtonProps { + enabled?: boolean; +} + +/** + * 키보드 상태에 따라 스타일이 변하는 버튼 컴포넌트 + */ +export const KeyboardAdaptiveButton = ({ + children, + enabled = true, + ...buttonProps +}: KeyboardAdaptiveButtonProps) => { + const insets = useSafeAreaInsets(); + const isKeyboardOpen = useSharedValue(false); + + useKeyboardHandler({ + onStart: (e) => { + 'worklet'; + isKeyboardOpen.value = e.height > 0; + }, + }); + + const containerStyle = useAnimatedStyle(() => ({ + paddingHorizontal: withTiming(isKeyboardOpen.value ? 0 : CONTAINER_PADDING_X, { + duration: ANIMATION_DURATION_MS, + }), + paddingBottom: withTiming(isKeyboardOpen.value ? 0 : insets.bottom || DEFAULT_BOTTOM_PADDING, { + duration: ANIMATION_DURATION_MS, + }), + })); + + const buttonStyle = useAnimatedStyle(() => ({ + borderRadius: withTiming(isKeyboardOpen.value ? 0 : BUTTON_RADIUS, { + duration: ANIMATION_DURATION_MS, + }), + })); + + return ( + + + + + + + + ); +}; diff --git a/apps/mobile/src/shared/ui/Button/index.ts b/apps/mobile/src/shared/ui/Button/index.ts new file mode 100644 index 00000000..253195df --- /dev/null +++ b/apps/mobile/src/shared/ui/Button/index.ts @@ -0,0 +1,10 @@ +export { Button } from './Button'; +export type { + ButtonColor, + ButtonDisplay, + ButtonProps, + ButtonRadius, + ButtonSize, + ButtonVariant, +} from './Button.types'; +export { KeyboardAdaptiveButton } from './KeyboardAdaptiveButton'; diff --git a/apps/mobile/src/shared/ui/Icon/icons.ts b/apps/mobile/src/shared/ui/Icon/icons.ts index d58a5fb9..999a2288 100644 --- a/apps/mobile/src/shared/ui/Icon/icons.ts +++ b/apps/mobile/src/shared/ui/Icon/icons.ts @@ -3,7 +3,10 @@ import ArrowLeftIconSvg from '@assets/icons/ic_arrow_left.svg'; import ArrowRightIconSvg from '@assets/icons/ic_arrow_right.svg'; import BellIconSvg from '@assets/icons/ic_bell.svg'; import CheckIconSvg from '@assets/icons/ic_check.svg'; +import CheckmarkIconSvg from '@assets/icons/ic_checkmark.svg'; import DocsIconSvg from '@assets/icons/ic_docs.svg'; +import EyeIconSvg from '@assets/icons/ic_eye.svg'; +import EyeOffIconSvg from '@assets/icons/ic_eye_off.svg'; import GoogleIconSvg from '@assets/icons/ic_google.svg'; import KakaoIconSvg from '@assets/icons/ic_kakao.svg'; import LockIconSvg from '@assets/icons/ic_lock.svg'; @@ -23,7 +26,10 @@ export const ArrowLeftIcon = createStyledIcon(ArrowLeftIconSvg); export const ArrowRightIcon = createStyledIcon(ArrowRightIconSvg); export const BellIcon = createStyledIcon(BellIconSvg); export const CheckIcon = createStyledIcon(CheckIconSvg); +export const CheckmarkIcon = createStyledIcon(CheckmarkIconSvg); export const DocsIcon = createStyledIcon(DocsIconSvg); +export const EyeIcon = createStyledIcon(EyeIconSvg); +export const EyeOffIcon = createStyledIcon(EyeOffIconSvg); export const GoogleIcon = createStyledIcon(GoogleIconSvg); export const KakaoIcon = createStyledIcon(KakaoIconSvg); export const LockIcon = createStyledIcon(LockIconSvg); diff --git a/apps/mobile/src/shared/ui/Input/Input.md b/apps/mobile/src/shared/ui/Input/Input.md new file mode 100644 index 00000000..f0c8c415 --- /dev/null +++ b/apps/mobile/src/shared/ui/Input/Input.md @@ -0,0 +1,89 @@ +# Input + +사용자 입력을 받는 기본 Input 컴포넌트입니다. + +## 사용법 + +```tsx +import { Input } from '@src/shared/ui/Input/Input'; + +// 기본 + + +// 라벨 + 에러 + + +// 좌우 콘텐츠 +} + rightContent={} +/> +``` + +## Props + +| Prop | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `variant` | `'filled' \| 'line'` | `'filled'` | 입력 필드 스타일 | +| `size` | `'medium' \| 'large'` | `'large'` | 입력 필드 크기 | +| `label` | `string` | - | 라벨 텍스트 | +| `isDisabled` | `boolean` | `false` | 비활성화 상태 | +| `isInvalid` | `boolean` | `false` | 에러 상태 | +| `errorMessage` | `string` | - | 에러 메시지 | +| `leftContent` | `ReactNode` | - | 왼쪽 아이콘/컴포넌트 | +| `rightContent` | `ReactNode` | - | 오른쪽 아이콘/컴포넌트 | +| `className` | `string` | - | 컨테이너 추가 스타일 | +| `...TextInputProps` | - | - | `TextInput` 기본 props | + +## 스타일 (variant) + +### filled + +배경이 채워진 기본 스타일입니다. + +```tsx + +``` + +### line + +하단 라인만 있는 스타일입니다. + +```tsx + +``` + +## 크기 (size) + +| Size | 높이 | 패딩 | +|------|------|------| +| `medium` | 48px (h-12) | px-4 | +| `large` | 56px (h-14) | px-4 | + +```tsx + + +``` + +## 상태 + +### 비활성화 +`isDisabled`가 `true`면 입력이 불가능하고 투명도가 적용됩니다. + +```tsx + +``` + +### 에러 +`isInvalid`와 `errorMessage`로 에러 상태를 표시합니다. + +```tsx + +``` diff --git a/apps/mobile/src/shared/ui/Input/Input.tsx b/apps/mobile/src/shared/ui/Input/Input.tsx new file mode 100644 index 00000000..3b598c7f --- /dev/null +++ b/apps/mobile/src/shared/ui/Input/Input.tsx @@ -0,0 +1,70 @@ +import { clsx } from 'clsx'; +import { forwardRef, useState } from 'react'; +import { TextInput, View } from 'react-native'; +import { Text } from '../Text/Text'; +import type { InputProps } from './Input.types'; +import { inputContainerVariants, inputLabelVariants, inputTextVariants } from './Input.variants'; + +export const Input = forwardRef( + ( + { + variant = 'filled', + size = 'large', + label, + isDisabled = false, + isInvalid = false, + errorMessage, + leftContent, + rightContent, + placeholder, + className, + onFocus, + onBlur, + ...props + }, + ref, + ) => { + const [isFocused, setIsFocused] = useState(false); + + return ( + + {label && ( + + {label} + + )} + + {leftContent && {leftContent}} + { + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} + {...props} + /> + {rightContent && {rightContent}} + + {errorMessage && ( + + {errorMessage} + + )} + + ); + }, +); + +Input.displayName = 'Input'; diff --git a/apps/mobile/src/shared/ui/Input/Input.types.ts b/apps/mobile/src/shared/ui/Input/Input.types.ts new file mode 100644 index 00000000..b768032f --- /dev/null +++ b/apps/mobile/src/shared/ui/Input/Input.types.ts @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; +import type { TextInputProps } from 'react-native'; + +export type InputVariant = 'filled' | 'line'; +export type InputSize = 'medium' | 'large'; + +export interface InputProps extends Omit { + variant?: InputVariant; + size?: InputSize; + label?: string; + isDisabled?: boolean; + isInvalid?: boolean; + errorMessage?: string; + leftContent?: ReactNode; + rightContent?: ReactNode; + className?: string; +} diff --git a/apps/mobile/src/shared/ui/Input/Input.variants.ts b/apps/mobile/src/shared/ui/Input/Input.variants.ts new file mode 100644 index 00000000..5670e975 --- /dev/null +++ b/apps/mobile/src/shared/ui/Input/Input.variants.ts @@ -0,0 +1,63 @@ +import { tv } from 'tailwind-variants'; + +export const inputLabelVariants = tv({ + base: 'text-gray-6', + variants: { + isFocused: { + true: 'text-main', + }, + isInvalid: { + true: 'text-error', + }, + }, +}); + +export const inputContainerVariants = tv({ + base: 'flex-row items-center', + variants: { + variant: { + filled: 'bg-gray-1 rounded-xl border border-transparent', + line: 'bg-transparent border-b border-gray-3 rounded-none', + }, + size: { + medium: 'h-12 px-4', + large: 'h-14 px-4', + }, + isFocused: { + true: '', + }, + isDisabled: { + true: 'opacity-40', + }, + isInvalid: { + true: '', + }, + }, + compoundVariants: [ + { variant: 'filled', isFocused: true, className: 'border-main' }, + { variant: 'line', isFocused: true, className: 'border-main' }, + { variant: 'filled', isInvalid: true, className: 'bg-error/10' }, + { variant: 'line', isInvalid: true, className: 'border-error' }, + ], + defaultVariants: { + variant: 'filled', + size: 'large', + }, +}); + +export const inputTextVariants = tv({ + base: 'flex-1 text-gray-8 placeholder:text-gray-5', + variants: { + size: { + medium: 'text-input-md', + large: 'text-input-lg', + }, + hasLeftContent: { + false: 'pl-0', + }, + }, + defaultVariants: { + size: 'large', + hasLeftContent: false, + }, +}); diff --git a/apps/mobile/src/shared/ui/Input/index.ts b/apps/mobile/src/shared/ui/Input/index.ts new file mode 100644 index 00000000..9242c242 --- /dev/null +++ b/apps/mobile/src/shared/ui/Input/index.ts @@ -0,0 +1,2 @@ +export { Input } from './Input'; +export type { InputProps, InputSize, InputVariant } from './Input.types'; diff --git a/packages/validators/src/domains/auth/auth.constants.ts b/packages/validators/src/domains/auth/auth.constants.ts index 472f2b7f..ca3f70bf 100644 --- a/packages/validators/src/domains/auth/auth.constants.ts +++ b/packages/validators/src/domains/auth/auth.constants.ts @@ -1,6 +1,10 @@ export const PASSWORD_RULES = { MIN_LENGTH: 8, MAX_LENGTH: 72, + /** 영문 포함 여부 검사 */ + HAS_LETTER: /[A-Za-z]/, + /** 숫자 포함 여부 검사 */ + HAS_NUMBER: /\d/, /** 8자 이상, 영문과 숫자 필수 조합 */ PATTERN: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,}$/, ERROR_MESSAGE: '비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다',