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 ( +
+ + + +
+ ); + }} +
+ + ({ + 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 ( + ); +}; 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 (
); }; 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) =