Skip to content
4 changes: 1 addition & 3 deletions src/app/login/_temp/login-temp-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
'use client';

import { MyPageActionButton } from '@/components/pages/user/mypage/mypage-setting-button';
import { useLogout, useRefresh, useWithdraw } from '@/hooks/use-auth';
import { useLogout, useWithdraw } from '@/hooks/use-auth';

const LoginTempActions = () => {
const logout = useLogout();
const withdraw = useWithdraw();
const refresh = useRefresh();

return (
<div className='flex-center'>
<MyPageActionButton onClick={logout}>로그아웃</MyPageActionButton>
<MyPageActionButton onClick={withdraw}>회원탈퇴</MyPageActionButton>
<MyPageActionButton onClick={refresh}>refresh</MyPageActionButton>
</div>
);
};
Expand Down
2 changes: 1 addition & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';

import { Icon } from '@/components/icon';
import { LoginForm, LoginToastEffect } from '@/components/pages/login';
import { LoginForm, LoginToastEffect } from '@/components/pages/auth';
import { AuthSwitch } from '@/components/shared';

import LoginTempActions from './_temp/login-temp-actions';
Expand Down
2 changes: 1 addition & 1 deletion src/app/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Icon } from '@/components/icon';
import { SignupForm } from '@/components/pages/signup';
import { SignupForm } from '@/components/pages/auth';
import { AuthSwitch } from '@/components/shared';

const SignupPage = () => {
Expand Down
23 changes: 23 additions & 0 deletions src/components/pages/auth/auth-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type AnyFormState } from '@tanstack/react-form';

import { Button } from '@/components/ui';

interface Props {
state: AnyFormState;
type: 'login' | 'signup';
canSubmitAll?: boolean;
}

export const AuthSubmitButton = ({ state, type, canSubmitAll = true }: Props) => {
const { canSubmit, isSubmitting, isPristine } = state;

const disabled = !canSubmit || isSubmitting || isPristine || !canSubmitAll;

const buttonName = type === 'login' ? '로그인하기' : '회원가입하기';

return (
<Button disabled={disabled} size='md' type='submit' variant='primary'>
{buttonName}
</Button>
);
};
98 changes: 98 additions & 0 deletions src/components/pages/auth/fields/email-field/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client';

import { type AnyFieldApi } from '@tanstack/react-form';

import { FormInput } from '@/components/shared';
import { getHintMessage } from '@/lib/auth/utils';
import { cn } from '@/lib/utils';

interface AvailabilityButtonProps {
onClick: () => void;
disabled: boolean;
}

type EmailCheck = {
hint?: string;
state: { status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error' };
isChecking: boolean;
check: (value: string) => void | Promise<void>;
reset: () => void;
};

interface Props {
field: AnyFieldApi;
withAvailabilityCheck?: boolean;
emailCheck?: EmailCheck;
}

const AvailabilityButton = ({ onClick, disabled }: AvailabilityButtonProps) => {
return (
<button
className={cn(
'text-text-xs-semibold absolute top-4 right-5 rounded-lg bg-gray-100 px-3 py-1 text-gray-800',
disabled && '!cursor-not-allowed',
)}
aria-disabled={disabled}
aria-label='이메일 중복 확인'
disabled={disabled}
type='button'
onClick={onClick}
>
중복 확인
</button>
);
};

export const EmailField = ({ field, withAvailabilityCheck = false, emailCheck }: Props) => {
const hintMessage = getHintMessage(field);

if (!withAvailabilityCheck || !emailCheck) {
return (
<FormInput
hintMessage={hintMessage}
inputProps={{
type: 'email',
autoComplete: 'email',
placeholder: '이메일을 입력해주세요',
value: field.state.value,
onChange: (e) => field.handleChange(e.target.value),
}}
labelName='이메일'
/>
);
}

const trimmedValue = field.state.value.trim();
const hasValidationError = field.state.meta.errors.length > 0;

const availabilityButtonDisabled = !trimmedValue || hasValidationError || emailCheck.isChecking;

const iconButton = (
<AvailabilityButton
disabled={availabilityButtonDisabled}
onClick={() => {
void emailCheck.check(trimmedValue);
}}
/>
);

return (
<FormInput
availabilityHint={emailCheck.hint}
availabilityStatus={emailCheck.state}
hintMessage={hintMessage}
inputProps={{
type: 'email',
autoComplete: 'email',
placeholder: '이메일을 입력해주세요',
value: field.state.value,
iconButton: iconButton,
onChange: (e) => {
field.handleChange(e.target.value);
emailCheck.reset();
},
}}
labelName='이메일'
/>
);
};
4 changes: 4 additions & 0 deletions src/components/pages/auth/fields/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { EmailField } from './email-field';
export { NicknameField } from './nickname-field';
export { PasswordField } from './password-field';
export { TermsAgreementField } from './termsAgreement-field';
82 changes: 82 additions & 0 deletions src/components/pages/auth/fields/nickname-field/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

import { type AnyFieldApi } from '@tanstack/react-form';

import { FormInput } from '@/components/shared';
import { getHintMessage } from '@/lib/auth/utils';
import { cn } from '@/lib/utils';

interface AvailabilityButtonProps {
onClick: () => void;
disabled: boolean;
}

type NicknameCheck = {
hint?: string;
state: { status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error' };
isChecking: boolean;
check: (value: string) => void | Promise<void>;
reset: () => void;
};

interface Props {
field: AnyFieldApi;
nicknameCheck: NicknameCheck;
}

const AvailabilityButton = ({ onClick, disabled }: AvailabilityButtonProps) => {
return (
<button
className={cn(
'text-text-xs-semibold absolute top-4 right-5 rounded-lg bg-gray-100 px-3 py-1 text-gray-800',
disabled && '!cursor-not-allowed',
)}
aria-disabled={disabled}
aria-label='닉네임 중복 확인'
disabled={disabled}
type='button'
onClick={onClick}
>
중복 확인
</button>
);
};

export const NicknameField = ({ field, nicknameCheck }: Props) => {
const hintMessage = getHintMessage(field);

const trimmedValue = field.state.value.trim();
const hasValidationError = field.state.meta.errors.length > 0;

const availabilityButtonDisabled =
!trimmedValue || hasValidationError || nicknameCheck.isChecking;

const iconButton = (
<AvailabilityButton
disabled={availabilityButtonDisabled}
onClick={() => {
void nicknameCheck.check(trimmedValue);
}}
/>
);

return (
<FormInput
availabilityHint={nicknameCheck.hint}
availabilityStatus={nicknameCheck.state}
hintMessage={hintMessage}
inputProps={{
type: 'text',
autoComplete: 'nickname',
placeholder: '닉네임을 입력해주세요',
value: field.state.value,
iconButton: iconButton,
onChange: (e) => {
field.handleChange(e.target.value);
nicknameCheck.reset();
},
}}
labelName='닉네임'
/>
);
};
66 changes: 66 additions & 0 deletions src/components/pages/auth/fields/password-field/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use client';

import { useState } from 'react';

import { type AnyFieldApi } from '@tanstack/react-form';

import { Icon } from '@/components/icon';
import { FormInput } from '@/components/shared';
import { getHintMessage } from '@/lib/auth/utils';

interface PasswordToggleButtonProps {
isVisible: boolean;
onToggle: () => void;
}

interface Props {
field: AnyFieldApi;
passwordType: 'loginPassword' | 'signupPassword' | 'confirmPassword';
}

const PasswordToggleButton = ({ isVisible, onToggle }: PasswordToggleButtonProps) => {
return (
<button
className='absolute top-4 right-5 h-6 w-6'
aria-label={isVisible ? '비밀번호 숨기기' : '비밀번호 보기'}
tabIndex={-1}
type='button'
onClick={onToggle}
>
<Icon id={isVisible ? 'visibility-true' : 'visibility-false'} className='text-gray-600' />
</button>
);
};

export const PasswordField = ({ field, passwordType }: Props) => {
const [isVisible, setIsVisible] = useState(false);

const hintMessage = getHintMessage(field);

const isConfirmPassword = passwordType === 'confirmPassword';
const isLogin = passwordType === 'loginPassword';

const handleToggle = () => {
setIsVisible((prev) => !prev);
};

const iconButton = <PasswordToggleButton isVisible={isVisible} onToggle={handleToggle} />;

return (
<FormInput
hintMessage={hintMessage}
inputProps={{
type: isVisible ? 'text' : 'password',
autoComplete: isLogin ? 'current-password' : 'new-password',
placeholder: !isConfirmPassword
? '비밀번호를 입력해주세요'
: '비밀번호를 한 번 더 입력해주세요',
value: field.state.value,
iconButton: iconButton,
onChange: (e) => field.handleChange(e.target.value),
onBlur: () => field.handleBlur(),
}}
labelName={!isConfirmPassword ? '비밀번호' : '비밀번호 확인'}
/>
);
};
42 changes: 42 additions & 0 deletions src/components/pages/auth/fields/termsAgreement-field/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { type AnyFieldApi } from '@tanstack/react-form';

import { Icon } from '@/components/icon';
import { useModal } from '@/components/ui';
import { cn } from '@/lib/utils';

import { SignupAgreementModal } from '../../signup/signup-agreement-modal';

interface Props {
field: AnyFieldApi;
}

export const TermsAgreementField = ({ field }: Props) => {
const { open } = useModal();
const checked = Boolean(field.state.value);

return (
<div className='flex w-full items-center justify-between'>
<label className='flex-center cursor-pointer'>
<input
className='peer sr-only'
checked={checked}
name={field.name}
type='checkbox'
onChange={(e) => field.handleChange(e.target.checked)}
/>
<Icon id='check' className={cn(checked ? 'text-mint-500' : 'text-gray-500')} />
<span className='text-text-sm-medium text-gray-700'>서비스 이용약관에 동의합니다.</span>
</label>

<button
className='text-text-sm-medium text-gray-500 underline'
type='button'
onClick={() => open(<SignupAgreementModal />)}
>
보기
</button>
</div>
);
};
3 changes: 3 additions & 0 deletions src/components/pages/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { LoginForm } from './login/login-form';
export { LoginToastEffect } from './login/login-toast-effect';
export { SignupForm } from './signup/signup-form';
Loading