diff --git a/public/icons/dynamic/icon-check.svg b/public/icons/dynamic/icon-check.svg new file mode 100644 index 00000000..6be94f68 --- /dev/null +++ b/public/icons/dynamic/icon-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/sprite.svg b/public/icons/sprite.svg index 99f0515d..7b72107d 100644 --- a/public/icons/sprite.svg +++ b/public/icons/sprite.svg @@ -53,6 +53,15 @@ /> + + + - + - + @@ -216,7 +225,7 @@ /> - + - + @@ -243,7 +252,7 @@ /> - + - + diff --git a/src/app/login/_temp/login-temp-actions.tsx b/src/app/login/_temp/login-temp-actions.tsx index e210e745..4805be3e 100644 --- a/src/app/login/_temp/login-temp-actions.tsx +++ b/src/app/login/_temp/login-temp-actions.tsx @@ -1,16 +1,18 @@ 'use client'; import { MyPageActionButton } from '@/components/pages/user/mypage/mypage-setting-button'; -import { useLogout, useWithdraw } from '@/hooks/use-auth'; +import { useLogout, useRefresh, useWithdraw } from '@/hooks/use-auth'; const LoginTempActions = () => { const logout = useLogout(); const withdraw = useWithdraw(); + const refresh = useRefresh(); return ( 로그아웃 회원탈퇴 + refresh ); }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2abab9fe..393549aa 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,19 +1,20 @@ import { cookies } from 'next/headers'; -// import { redirect } from 'next/navigation'; import { Icon } from '@/components/icon'; -import { LoginForm } from '@/components/pages/login'; +import { LoginForm, LoginToastEffect } from '@/components/pages/login'; import { AuthSwitch } from '@/components/shared'; import LoginTempActions from './_temp/login-temp-actions'; -const LoginPage = async () => { +type PageProps = { + searchParams: Promise>; +}; + +const LoginPage = async ({ searchParams }: PageProps) => { const cookieStore = await cookies(); const accessToken = cookieStore.get('accessToken')?.value; - // if (accessToken) { - // redirect('/'); - // } + const searchParamsData = await searchParams; return ( @@ -22,6 +23,7 @@ const LoginPage = async () => { + {/* 📜 임시, 삭제 예정 */} {accessToken && } diff --git a/src/components/icon/index.tsx b/src/components/icon/index.tsx index b7034bd3..e4228619 100644 --- a/src/components/icon/index.tsx +++ b/src/components/icon/index.tsx @@ -9,6 +9,7 @@ export type DynamicIconId = | 'bell-read' | 'calendar-1' | 'calendar-2' + | 'check' | 'chevron-left-1' | 'chevron-left-2' | 'chevron-right-1' @@ -78,6 +79,10 @@ export const iconMetadataMap: IconMetadata[] = [ id: 'calendar-2', variant: 'dynamic', }, + { + id: 'check', + variant: 'dynamic', + }, { id: 'chevron-left-1', variant: 'dynamic', diff --git a/src/components/pages/login/index.ts b/src/components/pages/login/index.ts index 61b7acac..bc0bcb1b 100644 --- a/src/components/pages/login/index.ts +++ b/src/components/pages/login/index.ts @@ -1 +1,2 @@ export { LoginForm } from './login-form'; +export { LoginToastEffect } from './login-toast-effect'; diff --git a/src/components/pages/login/login-toast-effect/index.tsx b/src/components/pages/login/login-toast-effect/index.tsx new file mode 100644 index 00000000..a3b2b7d0 --- /dev/null +++ b/src/components/pages/login/login-toast-effect/index.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import { Toast } from '@/components/ui'; +import { useToast } from '@/components/ui/toast/core'; + +type Props = { + error?: string | string[]; +}; + +export const LoginToastEffect = ({ error }: Props) => { + const { run } = useToast(); + const lastErrorRef = useRef(''); + + useEffect(() => { + if (!error) return; + + const normalized = Array.isArray(error) ? error.join(',') : error; + + if (lastErrorRef.current === normalized) return; + lastErrorRef.current = normalized; + + run(로그인이 필요한 서비스입니다.); + }, [error, run]); + + return null; +}; diff --git a/src/components/pages/signup/signup-agreement-modal/index.tsx b/src/components/pages/signup/signup-agreement-modal/index.tsx new file mode 100644 index 00000000..926cefff --- /dev/null +++ b/src/components/pages/signup/signup-agreement-modal/index.tsx @@ -0,0 +1,81 @@ +import { ReactNode } from 'react'; + +import { ModalContent, ModalTitle } from '@/components/ui'; + +const SubTitle = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +const Contents = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +export const SignupAgreementModal = () => { + return ( + + + 서비스 이용 약관 + + + + 1. 개인정보 수집 및 이용 + WeGo는 서비스 제공을 위해 최소한의 개인정보를 수집합니다. + + + + 수집하는 정보 + + + 이메일 주소 + + + + + + 이용 목적 + + + 회원 가입 및 본인 확인 + 서비스 이용에 따른 알림 발송 + 모임 관련 정보 제공 + + + + + + 개인정보 보호 + + 수집된 이메일 주소는 서비스 제공 목적 외에 절대 사용하지 않습니다. + + 제3자에게 제공하거나 판매하지 않습니다 + 마케팅 목적으로 사용하지 않습니다 + + + + + + 2. 서비스 이용 + + + 본 서비스는 모임 관리를 위한 플랫폼입니다 + 타인에게 피해를 주는 행위는 금지됩니다 + 서비스의 정상적인 운영을 방해하는 행위는 제재 대상입니다 + + + + + + 3. 회원 탈퇴 + + + 언제든지 회원 탈퇴가 가능합니다 + 탈퇴 시 개인정보는 즉시 삭제됩니다 + 관련 법령에 따라 보관이 필요한 경우에만 일정 기간 보관 후 삭제됩니다 + + + + + + + ); +}; diff --git a/src/components/pages/signup/signup-form/index.tsx b/src/components/pages/signup/signup-form/index.tsx index 82c0a14e..bd2bbdc4 100644 --- a/src/components/pages/signup/signup-form/index.tsx +++ b/src/components/pages/signup/signup-form/index.tsx @@ -2,10 +2,16 @@ import { type AnyFieldApi, useForm } from '@tanstack/react-form'; +import { API } from '@/api'; +import { Icon } from '@/components/icon'; import { FormInput } from '@/components/shared'; -import { Button } from '@/components/ui'; +import { Button, useModal } from '@/components/ui'; import { useSignup } from '@/hooks/use-auth'; +import { useAvailabilityCheck } from '@/hooks/use-auth/use-auth-availabilityCheck'; import { signupSchema } from '@/lib/schema/auth'; +import { cn } from '@/lib/utils'; + +import { SignupAgreementModal } from '../signup-agreement-modal'; const getHintMessage = (field: AnyFieldApi) => { const { @@ -21,6 +27,7 @@ const getHintMessage = (field: AnyFieldApi) => { export const SignupForm = () => { const signup = useSignup(); + const { open } = useModal(); const form = useForm({ defaultValues: { @@ -28,6 +35,7 @@ export const SignupForm = () => { nickname: '', password: '', confirmPassword: '', + termsAgreement: false, }, validators: { onChange: signupSchema, @@ -44,6 +52,30 @@ export const SignupForm = () => { }, }); + const emailCheck = useAvailabilityCheck( + API.userService.getEmailAvailability, + (email) => ({ email }), + { + checking: '확인 중...', + available: '사용 가능한 이메일입니다.', + unavailable: '이미 사용 중인 이메일입니다.', + error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + ); + + const nicknameCheck = useAvailabilityCheck( + API.userService.getNicknameAvailability, + (nickName) => ({ nickName }), + { + checking: '확인 중...', + available: '사용 가능한 닉네임입니다.', + unavailable: '이미 사용 중인 닉네임입니다.', + error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + ); + + const canSubmit = emailCheck.isAvailable && nicknameCheck.isAvailable; + return ( { {(field) => { - const hintMessage = getHintMessage(field); + const validationHint = getHintMessage(field); + + const trimmedValue = field.state.value.trim(); + const hasValidationError = field.state.meta.errors.length > 0; + + const availabilityButtonDisabled = + !trimmedValue || hasValidationError || emailCheck.isChecking; return ( field.handleChange(e.target.value), + onChange: (e) => { + field.handleChange(e.target.value); + emailCheck.reset(); + }, }} labelName='이메일' + onClick={() => { + void emailCheck.check(trimmedValue); + console.log(nicknameCheck.state); + }} /> ); }} @@ -75,18 +123,34 @@ export const SignupForm = () => { {(field) => { - const hintMessage = getHintMessage(field); + const validationHint = getHintMessage(field); + + const trimmedValue = field.state.value.trim(); + const hasValidationError = field.state.meta.errors.length > 0; + + const availabilityButtonDisabled = + !trimmedValue || hasValidationError || nicknameCheck.isChecking; return ( field.handleChange(e.target.value), + onChange: (e) => { + field.handleChange(e.target.value); + nicknameCheck.reset(); + }, }} labelName='닉네임' + onClick={() => { + nicknameCheck.check(trimmedValue); + console.log(nicknameCheck.state); + }} /> ); }} @@ -133,23 +197,56 @@ export const SignupForm = () => { - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - isPristine: state.isPristine, - })} - > - {({ canSubmit, isSubmitting, isPristine }) => { - const disabled = !canSubmit || isSubmitting || isPristine; - - return ( - - 회원가입하기 - - ); - }} - + + + {(field) => { + const checked = Boolean(field.state.value); + return ( + + + field.handleChange(e.target.checked)} + /> + + + 서비스 이용약관에 동의합니다. + + + + open()} + > + 보기 + + + ); + }} + + + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + isPristine: state.isPristine, + })} + > + {({ canSubmit: formCanSubmit, isSubmitting, isPristine }) => { + const disabled = !formCanSubmit || isSubmitting || isPristine || !canSubmit; + + return ( + + 회원가입하기 + + ); + }} + + ); }; diff --git a/src/components/shared/form-input/index.tsx b/src/components/shared/form-input/index.tsx index b50abc69..26de7bef 100644 --- a/src/components/shared/form-input/index.tsx +++ b/src/components/shared/form-input/index.tsx @@ -11,6 +11,31 @@ interface PasswordToggleButtonProps { onToggle: () => void; } +interface AvailabilityButtonProps { + isEmailField: boolean; + onClick: () => void; + disabled: boolean; +} + +interface FormInputProps { + className?: string; + labelName?: string; + hintMessage?: string; + availabilityHint?: string; + availabilityButtonDisabled?: boolean; + availabilityStatus?: AvailabilityState; + required?: boolean; + inputProps?: InputHTMLAttributes; + onClick?: () => void; +} + +type AvailabilityState = + | { status: 'idle' } + | { status: 'checking' } + | { status: 'available' } + | { status: 'unavailable' } + | { status: 'error' }; + const PasswordToggleButton = ({ isVisible, onToggle }: PasswordToggleButtonProps) => { return ( ; -} +const AvailabilityButton = ({ isEmailField, onClick, disabled }: AvailabilityButtonProps) => { + return ( + + 중복 확인 + + ); +}; export const FormInput = ({ className, labelName, hintMessage, + availabilityHint, + availabilityButtonDisabled = false, + availabilityStatus, required = true, inputProps = {}, + onClick, }: FormInputProps) => { const { type = 'text', id, required: _, ...restInputProps } = inputProps; const generatedId = useId(); const [isVisible, setIsVisible] = useState(false); + const isEmailField = type === 'email'; const isPasswordField = type === 'password'; const inputType = isPasswordField && isVisible ? 'text' : type; const inputId = id ?? generatedId; + const isAvailability = !isPasswordField && typeof onClick === 'function'; + + let tone: 'default' | 'error' | 'success' = 'default'; + + if (hintMessage || availabilityStatus?.status === 'unavailable') { + tone = 'error'; + } + + if (availabilityStatus?.status === 'available') { + tone = 'success'; + } const handleToggle = () => { setIsVisible((prev) => !prev); }; + let iconButton: React.ReactNode = null; + + if (isPasswordField) { + iconButton = ; + } + + if (!isPasswordField && isAvailability) { + iconButton = ( + + ); + } + return ( @@ -63,18 +130,25 @@ export const FormInput = ({ id={inputId} className={cn( 'bg-mono-white focus:border-mint-500 h-14 rounded-2xl border border-gray-300', - hintMessage && 'border-error-500', + tone === 'error' && 'border-error-500', + tone === 'success' && 'border-gray-300', + isAvailability && 'pr-23', )} - iconButton={ - isPasswordField ? ( - - ) : undefined - } + iconButton={iconButton} required={required} type={inputType} {...restInputProps} /> {hintMessage && } + {availabilityHint && ( + + )} ); }; diff --git a/src/components/ui/label/index.tsx b/src/components/ui/label/index.tsx index b1af8a80..008bb9f8 100644 --- a/src/components/ui/label/index.tsx +++ b/src/components/ui/label/index.tsx @@ -10,7 +10,7 @@ export const Label = ({ children, className, required, ...props }: LabelProps) = {children} {required && ( - + * )} diff --git a/src/hooks/use-auth/index.ts b/src/hooks/use-auth/index.ts index e5bd3fc3..b1ad7d7a 100644 --- a/src/hooks/use-auth/index.ts +++ b/src/hooks/use-auth/index.ts @@ -1,4 +1,5 @@ export { useLogin } from './use-auth-login'; export { useLogout } from './use-auth-logout'; +export { useRefresh } from './use-auth-refresh'; export { useSignup } from './use-auth-signup'; export { useWithdraw } from './use-auth-withdraw'; diff --git a/src/hooks/use-auth/use-auth-availabilityCheck/index.ts b/src/hooks/use-auth/use-auth-availabilityCheck/index.ts new file mode 100644 index 00000000..e419c55a --- /dev/null +++ b/src/hooks/use-auth/use-auth-availabilityCheck/index.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; + +import { Availability } from '@/types/service/user'; + +type AvailabilityState = + | { status: 'idle' } + | { status: 'checking' } + | { status: 'available'; message: string } + | { status: 'unavailable'; message: string } + | { status: 'error'; message: string }; + +type Messages = { + available: string; + unavailable: string; + error: string; + checking: string; +}; + +export const useAvailabilityCheck = ( + request: (query: TQuery) => Promise, + buildQuery: (value: string) => TQuery, + messages: Messages, +) => { + const [state, setState] = useState({ status: 'idle' }); + + const hint = useMemo(() => { + if (state.status === 'checking') return messages.checking; + if (state.status === 'available') return state.message; + if (state.status === 'unavailable') return state.message; + if (state.status === 'error') return state.message; + return undefined; + }, [messages.checking, state]); + + const isChecking = state.status === 'checking'; + const isAvailable = state.status === 'available'; + + const reset = useCallback(() => { + setState({ status: 'idle' }); + }, []); + + const check = useCallback( + async (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return; + if (isChecking) return; + + try { + setState({ status: 'checking' }); + + const data = await request(buildQuery(trimmed)); + const available = Boolean(data.available); + + if (available) { + setState({ status: 'available', message: messages.available }); + } else { + setState({ status: 'unavailable', message: messages.unavailable }); + } + } catch { + setState({ status: 'error', message: messages.error }); + } + }, + [buildQuery, isChecking, messages, request], + ); + + return { state, hint, isChecking, isAvailable, reset, check }; +}; diff --git a/src/hooks/use-auth/use-auth-login/index.ts b/src/hooks/use-auth/use-auth-login/index.ts index bc50a838..a6196737 100644 --- a/src/hooks/use-auth/use-auth-login/index.ts +++ b/src/hooks/use-auth/use-auth-login/index.ts @@ -1,6 +1,6 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { AxiosError } from 'axios'; import Cookies from 'js-cookie'; @@ -9,8 +9,19 @@ import { API } from '@/api'; import { LoginRequest } from '@/types/service/auth'; import { CommonErrorResponse } from '@/types/service/common'; +const normalizePath = (raw: string | null) => { + const value = (raw ?? '').trim(); + + if (!value) return '/'; + + if (value.startsWith('//') || value.includes('://')) return '/'; + + return value.startsWith('/') ? value : `/${value}`; +}; + export const useLogin = () => { const router = useRouter(); + const searchParams = useSearchParams(); const handleLogin = async (payload: LoginRequest, formApi: { reset: () => void }) => { try { @@ -25,7 +36,8 @@ export const useLogin = () => { }); formApi.reset(); - router.push('/'); + const nextPath = normalizePath(searchParams.get('path')); + router.replace(nextPath); } catch (error) { const axiosError = error as AxiosError; const problem = axiosError.response?.data; diff --git a/src/hooks/use-auth/use-auth-refresh/index.ts b/src/hooks/use-auth/use-auth-refresh/index.ts new file mode 100644 index 00000000..2f0944f0 --- /dev/null +++ b/src/hooks/use-auth/use-auth-refresh/index.ts @@ -0,0 +1,32 @@ +'use client'; + +import { AxiosError } from 'axios'; + +import { API } from '@/api'; +import { CommonErrorResponse } from '@/types/service/common'; + +export const useRefresh = () => { + const handleRefresh = async () => { + try { + const result = await API.authService.refresh(); + + // 📜 추후 삭제 (테스트 확인용) + console.log('refresh success:', result); + return result; + } catch (error) { + const axiosError = error as AxiosError; + const problem = axiosError.response?.data; + + // 📜 에러 UI 결정나면 변경 + if (problem) { + console.error('[REFRESH ERROR]', problem.errorCode, problem.detail); + alert(problem.detail || '토큰 갱신에 실패했습니다.'); + } else { + console.error(error); + alert('알 수 없는 오류가 발생했습니다.'); + } + } + }; + + return handleRefresh; +}; diff --git a/src/lib/schema/auth.ts b/src/lib/schema/auth.ts index fb639999..741930cf 100644 --- a/src/lib/schema/auth.ts +++ b/src/lib/schema/auth.ts @@ -16,8 +16,14 @@ export const signupSchema = loginSchema password: z .string() .min(8, '비밀번호는 8자 이상이어야 합니다.') - .regex(/[!@#$%^&*]/, '!, @, #, $, %, ^, &, * 중 1개 이상 포함해야 합니다.'), - confirmPassword: z.string(), + .regex(/[!@#$%^&*]/, '!, @, #, $, %, ^, &, * 중 1개 이상 포함해야 합니다.') + .regex(/\d/, '숫자를 1개 이상 포함해야 합니다.'), + confirmPassword: z.string().min(1, '비밀번호 확인을 입력해주세요.'), + termsAgreement: z.literal(true), + }) + .refine((data) => data.termsAgreement === true, { + path: ['termsAgreement'], + message: '서비스 이용약관에 동의해주세요.', }) .refine((data) => data.password === data.confirmPassword, { path: ['confirmPassword'],
수집된 이메일 주소는 서비스 제공 목적 외에 절대 사용하지 않습니다.