Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
72baed7
refactor(mobile): NotificationListItem 컴포넌트 위치 이동
hijjoy Feb 3, 2026
8273d84
fix(mobile): public-client에서 401 에러도 ApiError로 변환
hijjoy Feb 3, 2026
b0761d4
feat(mobile): KeyboardAdaptiveButton 추가
hijjoy Feb 3, 2026
ab3f071
feat(mobile): Input 컴포넌트 추가
hijjoy Feb 3, 2026
75ee7d0
feat(mobile): 아이콘 추가
hijjoy Feb 3, 2026
668d470
feat(validators): 비밀번호 개별 규칙 검사 상수 추가
hijjoy Feb 3, 2026
15893be
feat(mobile): KeyboardProvider 추가 및 KeyboardAdaptiveButton 개선
hijjoy Feb 3, 2026
1c00f3e
feat(mobile): useStepper 훅 및 애니메이션 상수 추가
hijjoy Feb 3, 2026
0d6cf5f
feat(mobile): 회원가입 관련 Auth 도메인 확장
hijjoy Feb 3, 2026
5cb471f
feat(mobile): 회원가입 mutation options 및 useCooldown 훅 추가
hijjoy Feb 3, 2026
bbda04f
feat(mobile): 회원가입 폼 컴포넌트 구현
hijjoy Feb 3, 2026
30aefe3
feat(mobile): 회원가입 화면 및 라우팅 연결
hijjoy Feb 3, 2026
f85282f
refactor(mobile): 회원가입 폼 코드 개선 및 에러 처리 강화
hijjoy Feb 4, 2026
8bb237d
feat(mobile): 이메일 로그인 화면에서 회원가입 링크 연결
hijjoy Feb 4, 2026
a020e2c
refactor(mobile): 회원가입 스텝 제목 매핑에 타입 안전성 추가
hijjoy Feb 4, 2026
004287b
refactor(mobile): Input 컴포넌트 인라인 스타일을 Tailwind variants로 전환
hijjoy Feb 4, 2026
b4afd82
fix(mobile): 이메일 파싱 로직 개선
hijjoy Feb 4, 2026
8a29985
refactor(mobile): TermsBottomSheet detached 스타일 적용
hijjoy Feb 4, 2026
15d3224
refactor(mobile): Input 텍스트 크기 커스텀 유틸리티 클래스 적용
hijjoy Feb 4, 2026
7dae70e
fix(mobile): 회원가입 라우트 경로 절대 경로로 통일
hijjoy Feb 4, 2026
148fd14
refactor(mobile): input focus 로직을 useEffect로 분리
hijjoy Feb 4, 2026
89b550e
refactor(mobile): auth 모델 파일을 역할별로 분리
hijjoy Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/.claude/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/app/(auth)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const AuthLayout = () => {
>
<Stack.Screen name="login" />
<Stack.Screen name="email-login" />
<Stack.Screen name="sign-up" />
<Stack.Screen name="verify-email" />
</Stack>
);
};
Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/app/(auth)/email-login.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 || '로그인에 실패했습니다');
},
},
Expand Down Expand Up @@ -116,7 +122,7 @@ const EmailLoginScreen = () => {
계정이 없으신가요?
</Text>
<Divider orientation="vertical" className="mx-2 h-3 bg-gray-4" />
<PressableFeedback onPress={() => {}}>
<PressableFeedback onPress={() => router.push('/(auth)/sign-up')}>
<Text size="e1" shade={9} weight="semibold">
회원가입
</Text>
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/app/(auth)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const LoginScreen = () => {
<Spacing size={32} />

<HStack justify="center" align="center" gap={8} pb={40}>
<TextButton size="medium" onPress={() => {}}>
<TextButton size="medium" onPress={() => router.push('./sign-up')}>
회원가입
</TextButton>

Expand Down
67 changes: 67 additions & 0 deletions apps/mobile/app/(auth)/sign-up.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { type SignUpFormData, signUpFormSchema } from '@src/features/auth/models/auth.model';
import { SignUpPasswordForm } from '@src/features/auth/presentations/components/SignUpPasswordForm';
import { SignUpUserInfoForm } from '@src/features/auth/presentations/components/SignUpUserInfoForm';
import { SignUpVerificationForm } from '@src/features/auth/presentations/components/SignUpVerificationForm';
import { useStepper } from '@src/shared/hooks/useStepper';
import { HStack } from '@src/shared/ui/HStack/HStack';
import { ArrowLeftIcon } from '@src/shared/ui/Icon';
import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView';
import { Text } from '@src/shared/ui/Text/Text';
import { router } from 'expo-router';
import { PressableFeedback } from 'heroui-native';
import { FormProvider, useForm } from 'react-hook-form';
import { View } from 'react-native';
import { match } from 'ts-pattern';

const SIGN_UP_STEPS = ['정보_입력', '비밀번호_설정', '이메일_인증'] as const;
type SignUpStep = (typeof SIGN_UP_STEPS)[number];

const SIGN_UP_STEP_TITLES = {
정보_입력: '회원가입',
비밀번호_설정: '비밀번호 설정',
이메일_인증: '이메일 인증',
} as const satisfies Record<SignUpStep, string>;

const SignUpScreen = () => {
const { step, setStep } = useStepper<typeof SIGN_UP_STEPS>(SIGN_UP_STEPS);

const form = useForm<SignUpFormData>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
email: '',
password: '',
passwordConfirm: '',
name: '',
},
mode: 'onTouched',
});

return (
<StyledSafeAreaView className="flex-1 bg-white" edges={['top']}>
<HStack align="center" px={16} py={12}>
<PressableFeedback onPress={() => router.back()}>
<ArrowLeftIcon width={24} height={24} colorClassName="text-gray-8" />
</PressableFeedback>
<Text size="b2" weight="semibold" align="center" className="flex-1">
{SIGN_UP_STEP_TITLES[step]}
</Text>
<View className="w-6" />
</HStack>

<FormProvider {...form}>
{match(step)
.with('정보_입력', () => (
<SignUpUserInfoForm onNextStep={() => setStep('비밀번호_설정')} />
))
.with('비밀번호_설정', () => (
<SignUpPasswordForm onNextStep={() => setStep('이메일_인증')} />
))
.with('이메일_인증', () => <SignUpVerificationForm />)
.exhaustive()}
</FormProvider>
</StyledSafeAreaView>
);
};

export default SignUpScreen;
180 changes: 180 additions & 0 deletions apps/mobile/app/(auth)/verify-email.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { VERIFICATION_CODE, type VerifyEmailInput, verifyEmailSchema } from '@aido/validators';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCooldown } from '@src/features/auth/presentations/hooks/useCooldown';
import { resendVerificationMutationOptions } from '@src/features/auth/presentations/queries/resend-verification-mutation-options';
import { verifyEmailMutationOptions } from '@src/features/auth/presentations/queries/verify-email-mutation-options';
import { ANIMATION } from '@src/shared/constants/animation.constants';
import { useAppToast } from '@src/shared/hooks/useAppToast';
import { HStack } from '@src/shared/ui/HStack/HStack';
import { ArrowLeftIcon } from '@src/shared/ui/Icon';
import { Result } from '@src/shared/ui/Result/Result';
import { StyledSafeAreaView } from '@src/shared/ui/SafeAreaView/SafeAreaView';
import { Text } from '@src/shared/ui/Text/Text';
import { H3 } from '@src/shared/ui/Text/Typography';
import { TextButton } from '@src/shared/ui/TextButton/TextButton';
import { VStack } from '@src/shared/ui/VStack/VStack';
import { useMutation } from '@tanstack/react-query';
import { router, useLocalSearchParams } from 'expo-router';
import { InputOTP, type InputOTPRef, PressableFeedback } from 'heroui-native';
import { useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { ScrollView, View } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';

/**
* 독립적인 이메일 인증 화면
* - 로그인 시 미인증 에러(EMAIL_0503) 발생 시 이동
*/
const VerifyEmailScreen = () => {
const { email } = useLocalSearchParams<{ email: string }>();
const toast = useAppToast();

const inputOTPRef = useRef<InputOTPRef>(null);
const [cooldown, setCooldown] = useCooldown(0);
const [isInvalid, setIsInvalid] = useState(false);

const { control, handleSubmit, setValue, reset } = useForm<VerifyEmailInput>({
resolver: zodResolver(verifyEmailSchema),
defaultValues: { email: email ?? '', code: '' },
});

const verify = useMutation(verifyEmailMutationOptions());
const resend = useMutation(resendVerificationMutationOptions());

const onSubmit = (data: VerifyEmailInput) => {
setIsInvalid(false);
verify.mutate(data, {
onError: (error) => {
setIsInvalid(true);
setValue('code', '');
inputOTPRef.current?.clear();
toast.error(error, { fallback: '인증 코드가 올바르지 않습니다' });
},
});
};

const handleComplete = (code: string) => {
setValue('code', code);
handleSubmit(onSubmit)();
};

const handleResend = () => {
if (cooldown > 0 || !email) return;

resend.mutate(
{ email },
{
onSuccess: (response) => {
setCooldown(response.retryAfterSeconds ?? VERIFICATION_CODE.RESEND_COOLDOWN_SECONDS);
reset({ email, code: '' });
inputOTPRef.current?.clear();
setIsInvalid(false);
toast.success('인증 코드가 재발송되었습니다');
},
onError: (error) => {
toast.error(error, { fallback: '인증 코드 재발송에 실패했습니다' });
},
},
);
};

const maskedEmail = email?.replace(/(.{2})(.*)(@.*)/, '$1****$3') ?? '';

if (!email) {
return (
<StyledSafeAreaView className="flex-1 bg-white items-center justify-center">
<Result
title="이메일 정보가 없습니다"
button={
<Result.Button color="dark" onPress={() => router.back()}>
돌아가기
</Result.Button>
}
/>
</StyledSafeAreaView>
);
}

return (
<StyledSafeAreaView className="flex-1 bg-white" edges={['top']}>
{/* Header */}
<HStack align="center" px={16} py={12}>
<PressableFeedback onPress={() => router.back()}>
<ArrowLeftIcon width={24} height={24} colorClassName="text-gray-8" />
</PressableFeedback>
<Text size="b2" weight="semibold" align="center" className="flex-1">
이메일 인증
</Text>
<View className="w-6" />
</HStack>

<View className="flex-1">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 24, paddingBottom: 100 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Animated.View
entering={FadeIn.duration(ANIMATION.duration.slow)}
style={{ marginBottom: 24 }}
>
<H3>
{maskedEmail}로{'\n'}발송된 코드를 입력해주세요
</H3>
</Animated.View>

<VStack gap={32} align="center">
<Controller
control={control}
name="code"
render={({ field: { onChange, value } }) => (
<InputOTP
ref={inputOTPRef}
maxLength={6}
value={value}
onChange={onChange}
onComplete={handleComplete}
isInvalid={isInvalid}
>
<InputOTP.Group>
<InputOTP.Slot index={0} />
<InputOTP.Slot index={1} />
<InputOTP.Slot index={2} />
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
<InputOTP.Slot index={3} />
<InputOTP.Slot index={4} />
<InputOTP.Slot index={5} />
</InputOTP.Group>
</InputOTP>
)}
/>

{verify.isPending && (
<Text size="b4" className="text-main">
인증 중...
</Text>
)}

<HStack gap={8} justify="center">
<Text size="b4" shade={7}>
코드를 받지 못하셨나요?
</Text>
<TextButton
size="medium"
onPress={handleResend}
disabled={cooldown > 0 || resend.isPending}
>
{cooldown > 0 ? `${cooldown}초 후 재발송` : '인증코드 재발송'}
</TextButton>
</HStack>
</VStack>
</ScrollView>
</View>
</StyledSafeAreaView>
);
};

export default VerifyEmailScreen;
25 changes: 14 additions & 11 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -71,17 +72,19 @@ const AppBootstrapLayout = () => {

return (
<GestureHandlerProvider>
<HeroUIProvider>
<QueryProvider>
<DIProvider>
<AuthProvider>
<NotificationProvider>
<AuthGateLayout />
</NotificationProvider>
</AuthProvider>
</DIProvider>
</QueryProvider>
</HeroUIProvider>
<KeyboardProvider>
<HeroUIProvider>
<QueryProvider>
<DIProvider>
<AuthProvider>
<NotificationProvider>
<AuthGateLayout />
</NotificationProvider>
</AuthProvider>
</DIProvider>
</QueryProvider>
</HeroUIProvider>
</KeyboardProvider>
</GestureHandlerProvider>
);
};
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/assets/icons/ic_checkmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/mobile/assets/icons/ic_eye.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/mobile/assets/icons/ic_eye_off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions apps/mobile/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
20 changes: 19 additions & 1 deletion apps/mobile/src/features/auth/models/auth.model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SubscriptionStatus } from '@aido/validators';
import { emailSchema, passwordSchema, type SubscriptionStatus } from '@aido/validators';
import { z } from 'zod';

export const UserSchema = z.object({
Expand All @@ -24,6 +24,24 @@ export const AuthTokensSchema = z.object({

export type AuthTokens = z.infer<typeof AuthTokensSchema>;

export const signUpFormSchema = z
.object({
email: emailSchema,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프론트엔드의 도메인 모델링을 하기 위해서 *.model.ts파일을 만들어 뒀는데 서버 계약(@aido/validators)은 repository에서 요청과 응답에 대한 검증으로만 끝내야 할 것 같습니다!

  흐름 요약                                                                                   
                                                                                              
  Repository          Service             Model                                               
      │                   │                  │                                                
      │  DTO (서버 계약)   │                  │                                               
      ├──────────────────→│                  │                                                
      │                   │  Mapper          │                                                
      │                   ├─────────────────→│                                                
      │                   │                  │ Domain (순수)                                  
                                                                                              
  @aido/validators → Repository에서만 사용                                                    
  Domain Model → 서버 의존 없음           

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

폼 검증이 서버 규칙과 동일하게 관리되면 좋겠다고 생각해서 해당 부분을 계속 이렇게 짰던 것 같습니다 .. 😢

  1. 프론트 앱은 빌드된 코드라서, 서버가 규칙을 바꿔도 앱이 업데이트되지 않으면 그 규칙은 바뀌지 않음
  2. 어짜피 UI에 “8자리”가 하드코딩된 부분은 모두 수동으로 변경해야 함

제가 위 2가지를 생각하지 못했던 것 같습니다. 2번이 해결될 것 이라 생각했는데 그러려면
차라리 프론트에서 MIN_PASSWORD_LENGTH 같은 값을 한 곳에서 공유하고 스키마도 이것을 참조해서 프론트에서 만드는 것이 한번에 수정할 수 있는 방향인 것 같아요
수정해보겠습니다 ! 🧹🫧

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 오버 엔지니어링 같긴한데.. 사실 요런 폼 검증은 진짜 같이 쓸때 더 좋다고 생각해서..! 이부분은 한번 더 고민해보는걸로 해서 결정하면 될 것 같습니다!!

Copy link
Member Author

@hijjoy hijjoy Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 아래 제안해주신 model 파일에서 스키마는 분리하였습니다! (폼 검증은 presentation레이어이기 때문)
@aido/validators를 presentation 폼 검증에서 사용하는 것을 조금 더 고민해보고있는데,.....
공유해도/안해도 둘다 뭔가 서버에서 변경될 경우 문제는 동일하게 발생하지만 문제가 발생했을때 문제의 원인을 찾고 해결하는 방식이 다를 것 같기도해서
이 부분은 조금 더 고민해보고싶어서 일단은 남겨두겠습니다!

password: passwordSchema,
passwordConfirm: z.string(),
name: z
.string()
.min(1, '닉네임을 입력해주세요')
.max(100, '이름은 100자 이내여야 합니다')
.trim(),
})
.refine((data) => data.password === data.passwordConfirm, {
message: '비밀번호가 일치하지 않습니다',
path: ['passwordConfirm'],
});

export type SignUpFormData = z.infer<typeof signUpFormSchema>;

/** Auth 도메인 비즈니스 규칙 */
export const AuthPolicy = {
/** 구독 상태가 활성 상태인지 확인 */
Expand Down
Loading