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자 이상, 영문과 숫자를 포함해야 합니다',