Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions services/one-app/src/app/(site)/login/_lib/checkNickname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { z } from 'zod';
import axios from 'axios';

import { apiClient } from '@/app/api';
import { sleep } from '@/common/utils';
import { APIResponseCode, RESPONSE_MESSAGES } from '@/common/constants/api';

const CheckNicknameResponseSchema = z.object({
code: z.literal(APIResponseCode.SUCCESS),
message: z.literal(RESPONSE_MESSAGES[APIResponseCode.SUCCESS]),
result: z.object({
available: z.boolean(),
}),
});

type CheckNicknameResponse = z.infer<typeof CheckNicknameResponseSchema>;

export const checkNickname = async (nickname: string) => {
try {
const [res] = await Promise.all([
apiClient.post<CheckNicknameResponse>('/members/check-nickname', {
nickname,
}),
sleep(300),
]);

return CheckNicknameResponseSchema.parse(res.data);
} catch (error) {
if (axios.isAxiosError(error)) {
// Axios 였λ₯˜ 처리
throw new Error(
`Sign in failed: ${error.response?.data?.message || error.message}`,
);
} else if (error instanceof z.ZodError) {
// Zod 였λ₯˜ 처리
throw new Error(
`Validation failed: ${error.errors.map((e) => e.message).join(', ')}`,
);
} else {
// 기타 였λ₯˜ 처리
console.error('Unexpected error during sign in:', error);
throw new Error('An unexpected error occurred during sign in.');
}
}
};
91 changes: 91 additions & 0 deletions services/one-app/src/app/(site)/login/_lib/useCheckNickname.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';

import { useDebounce } from '@/common/hooks/useDebounce';
import { checkNickname } from '@/app/(site)/login/_lib/checkNickname';

const MAX_LENGTH = 10;
const MIN_LENGTH = 2;

enum ErrorStatus {
TOO_SHORT = `λ‹‰λ„€μž„μ€ ${MIN_LENGTH}자 이상 μž…λ ₯ν•΄μ£Όμ„Έμš”.`,
TOO_LONG = `ν•œκΈ€,영문 ${MAX_LENGTH}자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.`,
DUPLICATED_NAME = '쀑볡인 λ‹‰λ„€μž„μ΄λΌ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€.',
INVALID_FORMAT = 'μ§€μ›ν•˜μ§€ μ•ŠλŠ” ν˜•μ‹μž…λ‹ˆλ‹€.',
}

export const useCheckNickname = () => {
const { mutateAsync: nicknameChecking, status } = useMutation({
mutationFn: checkNickname,
});

const [nickname, setNickname] = useState('');
const [isTouched, setIsTouched] = useState(false);
const [errorMessage, setErrorMessage] = useState('');

const isNicknameChecking = status === 'pending';
const isValidateOk = nickname.length >= MIN_LENGTH && errorMessage === '';
const isValidateError =
isTouched && (nickname.length < MIN_LENGTH || errorMessage !== '');

const disabled =
errorMessage !== '' ||
nickname.length < MIN_LENGTH ||
nickname.length > MAX_LENGTH;

const lengthIndicator = `${nickname.length} / ${MAX_LENGTH}`;

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.slice(0, MAX_LENGTH);
setNickname(value);
if (!isTouched) setIsTouched(true);
checkNicknameValidity(value);
};

const checkNicknameValidity = useDebounce(async (value: string) => {
if (value.length < MIN_LENGTH) {
setErrorMessage(ErrorStatus.TOO_SHORT);
return;
}
if (value.length > MAX_LENGTH) {
setErrorMessage(ErrorStatus.TOO_LONG);
return;
}

try {
const res = await nicknameChecking(value);
if (!res.result.available) {
setErrorMessage(ErrorStatus.DUPLICATED_NAME);
} else {
setErrorMessage('');
}
} catch (error) {
setErrorMessage(ErrorStatus.INVALID_FORMAT);
}
}, 500);

const nickNameStatusMessage = (() => {
if (errorMessage) {
return errorMessage;
}
if (isValidateOk) {
return 'μ‚¬μš©ν•  수 μžˆλŠ” λ‹‰λ„€μž„ μž…λ‹ˆλ‹€.';
}
return `λ‹‰λ„€μž„μ€ ${MIN_LENGTH}자 이상 μž…λ ₯ν•΄μ£Όμ„Έμš”.`;
})();

return {
nickname,
disabled,
errorMessage,
lengthIndicator,
isTouched,
isValidateOk,
isValidateError,
isNicknameChecking,
nickNameStatusMessage,
handleInputChange,
};
};
15 changes: 15 additions & 0 deletions services/one-app/src/app/(site)/login/_lib/utilityFunctions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import CheckIcon from '@/common/assets/icons/check';
import AlertCircleIcon from '@/common/assets/icons/alert-circle';

export const renderIndicatorIcon = (
isValidateOk: boolean,
isValidateError: boolean,
) => {
if (isValidateOk) {
return <CheckIcon />;
}
if (isValidateError) {
return <AlertCircleIcon />;
}
return null;
};
40 changes: 26 additions & 14 deletions services/one-app/src/app/(site)/login/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
'use client';

import { useRouter } from 'next/navigation';
import { Suspense, useRef, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useShallow } from 'zustand/shallow';

import { requestLogin } from '../_lib/requestLogin';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useTemporaryAuthStore } from '@/store/auth';
import { isValidSocialSignInType } from '@/model/Auth';
import { AuthService } from '@/common/service/AuthService';
import { useRef, Suspense } from 'react';

function LoginCallback() {
const isLoadingRef = useRef(false);
const router = useRouter();
const isLoadingRef = useRef(false);
const { setTempAuth } = useTemporaryAuthStore(
useShallow((state) => ({
setTempAuth: state.setTempAuth,
})),
);

const searchParams = useSearchParams();
const providerType = searchParams.get('type');
Expand All @@ -36,14 +42,18 @@ function LoginCallback() {
providerCode,
});

const { accessToken, refreshToken } = result;
AuthService.setToken(accessToken, refreshToken);
alert('둜그인 성곡');
router.replace('/');
const { accessToken, refreshToken, isNeedAdditionalUserInfo } = result;

if (!isNeedAdditionalUserInfo) {
AuthService.setToken(accessToken, refreshToken);
router.replace('/');
} else {
setTempAuth({ accessToken, refreshToken });
router.replace('/login/nickname');
}
} catch (error) {
// console.error(error);
alert(error);
console.error(error);

router.back();
} finally {
isLoadingRef.current = false;
Expand All @@ -58,7 +68,9 @@ function LoginCallback() {
}

export default function LoginCallbackPage() {
return <Suspense fallback={<div>Loading...</div>}>
<LoginCallback />
</Suspense>;
return (
<Suspense fallback={<div>Loading...</div>}>
<LoginCallback />
</Suspense>
);
}
128 changes: 128 additions & 0 deletions services/one-app/src/app/(site)/login/nickname/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use client';

import React from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { useShallow } from 'zustand/shallow';

import { updateUser } from '../../my/_lib/updateUser';
import { useCheckNickname } from '../_lib/useCheckNickname';
import { renderIndicatorIcon } from '../_lib/utilityFunctions';
import { useTemporaryAuthStore } from '@/store/auth';
import { AuthService } from '@/common/service/AuthService';
import { cn } from '@/common/utils/cn';
import ArrowLeftIcon from '@/common/assets/icons/arrow-left';
import SpinnerIcon from '@/common/assets/icons/loading-spinner';

const NicknameSetup = () => {
const router = useRouter();
const {
nickname,
disabled,
lengthIndicator,
isTouched,
isValidateOk,
isValidateError,
isNicknameChecking,
nickNameStatusMessage,
handleInputChange,
} = useCheckNickname();

const { auth, reset: removeTemporaryAuth } = useTemporaryAuthStore(
useShallow((state) => ({
auth: state.auth,
reset: state.reset,
})),
);
const { mutate: updateUserAndTryLoginProcessDone, status } = useMutation({
mutationFn: updateUser,
onSuccess: () => {
if (!auth) return;

const { accessToken, refreshToken } = auth;
AuthService.setToken(accessToken, refreshToken);
removeTemporaryAuth();
router.replace('/');
},
});

const handleSubmit = () => {
if (disabled || !auth) return;
updateUserAndTryLoginProcessDone({ nickname, auth });
};

const isUpdating = status === 'pending';
const isProcessing = isNicknameChecking || isUpdating;

return (
<main className="relative min-h-screen bg-black pt-9 px-5">
<div className="flex items-center mb-8">
<button
onClick={() => router.back()}
className="p-2 -ml-2 text-white hover:bg-white/10 rounded-full"
>
<ArrowLeftIcon />
</button>
<h2 className="ml-2 text-white">νšŒμ›κ°€μž…</h2>
</div>

<h1 className="pb-8 text-white text-2xl font-semibold">
<strong className="text-[#2ACF6C]">λ‹‰λ„€μž„</strong>을 μ„€μ •ν•΄μ£Όμ„Έμš”
</h1>

<div className="w-full space-y-2">
<div className="relative">
<input
value={nickname}
onChange={handleInputChange}
className={cn(
'w-full h-12 bg-white/10 border-0 px-3 text-white placeholder:text-gray-500',
'focus:outline-none focus:bg-white/15',
isValidateError && 'ring-2 ring-red-500',
isValidateOk && 'ring-2 ring-[#2ACF6C]',
)}
placeholder="λ‹‰λ„€μž„μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{renderIndicatorIcon(isValidateOk, isValidateError)}
</div>
</div>

<div className="flex justify-between items-center px-1">
<span
className={cn(
'text-sm',
isValidateError && 'text-red-500',
isValidateOk && 'text-[#2ACF6C]',
!isTouched && 'text-gray-500',
)}
>
{nickNameStatusMessage}
</span>
<span className="text-sm text-gray-500">{lengthIndicator}</span>
</div>
</div>

<button
disabled={disabled}
className={cn(
'w-full h-12 mt-8 text-white',
'bg-[#2ACF6C] hover:bg-[#2ACF6C]/90',
'disabled:bg-gray-600 disabled:cursor-not-allowed',
isProcessing && 'cursor-events-none',
)}
onClick={handleSubmit}
>
{isProcessing ? (
<span className="flex items-center justify-center">
<SpinnerIcon className="animate-spin mr-2" />
</span>
) : (
'μ™„λ£Œ'
)}
</button>
</main>
);
};

export default NicknameSetup;
Loading
Loading