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
4 changes: 2 additions & 2 deletions src/apis/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const postReIssueAccessToken = async (): Promise<string> => {
};

/**
* Google OAuth2 로그인 URL로 리다이렉트
* Google OAuth2 로그인 요청 함수
* - components/Onboarding/SocialLoginButton.tsx
*/
export const redirectToGoogleLogin = () => {
Expand All @@ -36,7 +36,7 @@ export const redirectToGoogleLogin = () => {
};

/**
* Kakao OAuth2 로그인 URL로 리다이렉트
* Kakao OAuth2 로그인 요청 함수
* - components/Onboarding/SocialLoginButton.tsx
*/
export const redirectToKakaoLogin = () => {
Expand Down
17 changes: 4 additions & 13 deletions src/apis/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ import axios, { type InternalAxiosRequestConfig } from 'axios';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { LOCAL_STORAGE_KEY } from '../constants/key';

// 커스텀 인터페이스: 재시도 여부를 위한 플래그 추가
interface CustomInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}

// accessToken 재발급 요청 중복 방지를 위한 전역 변수
let tokenReissuePromise: Promise<string> | null = null;

// 기본 axios 인스턴스
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_SERVER_API_URL,
withCredentials: true,
Expand Down Expand Up @@ -45,46 +42,40 @@ axiosInstance.interceptors.response.use(
async (error) => {
const originalRequest: CustomInternalAxiosRequestConfig = error.config;

// accessToken이 만료된 경우 && 아직 재시도한 적 없는 경우
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

// 재발급 요청 자체가 실패한 경우 → 온보딩 처음 페이지로 이동
if (originalRequest.url?.includes('/api/token/reissue')) {
const { removeItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
removeItem(); // accessToken 삭제
removeItem();
window.location.href = '/onboarding';
return Promise.reject(error);
}

// 이미 진행 중인 refresh 요청이 없으면 실행
if (!tokenReissuePromise) {
tokenReissuePromise = axiosInstance
.post('/api/token/reissue', null, {
withCredentials: true, // 쿠키 포함 (refreshToken)
withCredentials: true,
})
.then((res) => {
const newAccessToken = res.data.result?.accessToken;
if (!newAccessToken) throw new Error('accessToken 발급 실패');

const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
setItem(newAccessToken); // 새로운 accessToken 저장
setItem(newAccessToken);

return newAccessToken;
})
.catch((err) => {
const { removeItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
removeItem(); // 실패 시 accessToken 삭제
removeItem();
window.location.href = '/onboarding';
return Promise.reject(err);
})
.finally(() => {
// 다음 요청에서 재시도 가능하도록 초기화
tokenReissuePromise = null;
});
}

// 재발급 성공 시 -> 기존 요청에 새 accessToken 붙여서 재전송
return tokenReissuePromise.then((newAccessToken) => {
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
Expand Down
20 changes: 7 additions & 13 deletions src/components/Onboarding/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,26 @@ import copy from '../../assets/icons/copy.svg';
import speechbubblebody from '../../assets/icons/speechbubblebody.svg';
import speechbubbletail from '../../assets/icons/speechbubbletail.svg';

// props로 전달받은 inputRef를 통해 복사 대상 엘리먼트에 접근
interface CopyToClipboardProps {
inputRef: React.RefObject<HTMLTextAreaElement | null>;
}

const CopyToClipboard = ({ inputRef }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false); // 복사 완료 여부 상태
const [copied, setCopied] = useState(false);

const handleCopy = () => {
try {
if (inputRef.current) {
const textarea = inputRef.current;

// 일부 iOS 브라우저에서 select() 동작이 안 되는 이슈를 피하기 위해 readOnly로 설정
textarea.readOnly = true;
textarea.select(); // 전체 텍스트 선택
textarea.select();

const success = document.execCommand('copy'); // 복사 시도
textarea.readOnly = false; // 다시 원래대로 복원
const success = document.execCommand('copy');
textarea.readOnly = false;

if (success) {
setCopied(true); // 복사 성공 시 상태를 true로 변경 (말풍선 띄우기 위함)
setTimeout(() => setCopied(false), 2000); // 2초 후 다시 false로 (말풍선 사라짐)
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
alert('복사에 실패했습니다. 직접 복사해주세요.');
}
Expand All @@ -39,19 +36,16 @@ const CopyToClipboard = ({ inputRef }: CopyToClipboardProps) => {
return (
<button
onClick={handleCopy}
className="flex relative w-[4rem] h-[3.6rem] rounded-[0.6rem] bg-primary-blue items-center justify-center"
className="flex relative w-[4rem] h-[3.6rem] rounded-[0.6rem] bg-primary-blue items-center justify-center cursor-pointer"
aria-label="클립보드에 복사"
>
<img src={copy} alt="Copy" className="w-[1.6rem] h-[1.6rem]" />
{copied && (
<div className="absolute top-[-6.2rem] w-[14.4rem] h-[6.5rem] flex flex-col items-center">
{/* 말풍선 바디 + 텍스트 */}
<div className="relative w-[14.4rem] h-[4.7rem] flex items-center justify-center">
<img src={speechbubblebody} alt="말풍선 바디" className="absolute w-full h-full" />
<span className="z-10 text-white font-xsmall-r">복사가 완료되었습니다!</span>
</div>

{/* 꼬리 */}
<img
src={speechbubbletail}
alt="말풍선 꼬리"
Expand Down
12 changes: 4 additions & 8 deletions src/components/Onboarding/InvitePwInput.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
interface InvitePwInputProps {
inputPw: string; // 상위 컴포넌트에서 내려준 입력값 (input의 value로 사용)
onChange: (value: string) => void; // 입력이 변경되었을 때 상위에 전달할 함수
hasError: boolean; // 에러 상태
inputPw: string;
onChange: (value: string) => void;
hasError: boolean;
}

const InvitePwInput = ({ inputPw, onChange, hasError }: InvitePwInputProps) => {
// input의 값이 바뀔 때 호출되는 함수
// 사용자가 입력한 값을 상위 컴포넌트로 전달
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value); // 상위 컴포넌트로 전달
onChange(e.target.value);
};

return (
<div className="flex flex-col gap-[1rem]">
{/* 암호 입력창 */}
<input
type="text"
value={inputPw}
Expand All @@ -24,7 +21,6 @@ const InvitePwInput = ({ inputPw, onChange, hasError }: InvitePwInputProps) => {
focus:outline-none text-gray-400 placeholder:text-gray-400
${hasError ? 'border border-error-400 bg-gray-onboard' : 'border bg-gray-200 border-transparent'}`}
/>
{/* 에러 메시지 */}
<span className={`font-xsmall-r ${hasError ? 'text-error-400' : 'text-transparent'}`}>
입력 정보를 다시 확인하세요
</span>
Expand Down
4 changes: 0 additions & 4 deletions src/components/Onboarding/OnboardingGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// src/components/guards/OnboardingGuard.tsx
import { Navigate, useLocation } from 'react-router-dom';
import onboardingSteps from '../../constants/onboardingSteps';
import { useLocalStorage } from '../../hooks/useLocalStorage';
Expand All @@ -11,18 +10,15 @@ const ONBOARDING_STATUS_KEY = 'onboarding-status';

export default function OnboardingGuard({ children }: Props) {
const location = useLocation();

const guardedPaths = onboardingSteps.slice(1); // '/onboarding' 제외
const needsCheck = guardedPaths.includes(location.pathname);

// 새로고침/직접입력 구분
const navEntry = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined;
const isReload = navEntry?.type === 'reload';
const isManualNav = location.key === 'default' && !isReload;

// 필요한 순간에만 LS 조회
const { getItem: getInviteUrl } = useLocalStorage(LOCAL_STORAGE_KEY.inviteUrl);
const { getItem: getInvitePassword } = useLocalStorage(LOCAL_STORAGE_KEY.invitePassword);
const { getItem: getIsInvite } = useLocalStorage(LOCAL_STORAGE_KEY.isInvite);
Expand Down
20 changes: 7 additions & 13 deletions src/components/Onboarding/PageIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
// src/components/Onboarding/PageIndicator.tsx

import { useNavigate } from 'react-router-dom';

interface PageIndicatorProps {
currentStep: number; // 현재 위치한 단계 (0부터 시작)
steps: string[]; // 전체 경로 배열
currentStep: number;
steps: string[];
}

const PageIndicator = ({ currentStep, steps }: PageIndicatorProps) => {
const navigate = useNavigate();

// 이전 단계 클릭 시에만 이동 허용
const handleClick = (stepIndex: number) => {
if (stepIndex < currentStep) {
navigate(steps[stepIndex]);
Expand All @@ -19,26 +16,23 @@ const PageIndicator = ({ currentStep, steps }: PageIndicatorProps) => {

return (
<div className="flex justify-center gap-[0.6rem] ">
{/* 로그인 단계(index 0)는 제외하고 index 1~3만 인디케이터로 표시 */}
{steps.slice(1).map((_, idx) => {
const stepIndex = idx + 1;

const isActive = stepIndex === currentStep; // 현재 단계 여부
const isPast = stepIndex < currentStep; // 이미 지난 단계 여부
const isActive = stepIndex === currentStep;
const isPast = stepIndex < currentStep;

// 바의 공통 스타일 (남색이면 길게, 회색이면 동그랗게)
const baseStyle = `
transition-all
${isActive ? 'w-[3.2rem] bg-primary-blue' : 'w-[0.8rem] bg-gray-300'}
h-[0.8rem] rounded-full
`;

// 마우스 커서 조건별 스타일
const cursorStyle = isPast
? 'cursor-pointer hover:opacity-80' // 이전 단계: 클릭 가능
? 'cursor-pointer hover:opacity-80'
: isActive
? 'cursor-default' // 현재 단계: 기본 커서
: 'cursor-not-allowed'; // 다음 단계: 금지 커서
? 'cursor-default'
: 'cursor-not-allowed';

return (
<button
Expand Down
38 changes: 15 additions & 23 deletions src/components/Onboarding/PrimaryButton.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,44 @@
import { useNavigate } from 'react-router-dom';

// 버튼 컴포넌트가 받을 props 타입 정의
interface PrimaryButtonProps {
text: string; // 버튼에 표시할 텍스트
to?: string; // 이동할 경로 (선택)
onClick?: () => void; // 클릭 시 실행할 사용자 정의 함수 (선택)
disabled?: boolean; // 버튼 비활성화 여부 (기본값 false)
className?: string; // 추가적인 커스텀 클래스 (선택)
text: string;
to?: string;
onClick?: () => void;
disabled?: boolean;
className?: string;
}

// PrimaryButton 컴포넌트 정의
const PrimaryButton = ({
text,
to,
onClick,
disabled = false,
className = '',
}: PrimaryButtonProps) => {
const navigate = useNavigate(); // 라우팅을 위한 navigate 함수 생성
const navigate = useNavigate();

// 버튼 클릭 시 실행될 함수
const handleClick = () => {
if (disabled) return; // 비활성화 상태면 클릭 무시
if (onClick) onClick(); // 사용자 정의 onClick 함수 실행
if (to) navigate(to); // to가 있으면 해당 경로로 이동
if (disabled) return;
if (onClick) onClick();
if (to) navigate(to);
};

// 공통으로 적용되는 버튼 스타일
const baseClass =
'w-[40rem] h-[6.2rem] font-title-sub-r text-gray-100 text-center rounded-[0.5rem]';

// 활성화 상태일 때 배경색
const enabledClass = 'bg-primary-blue cursor-pointer hover:opacity-90';

// 비활성화 상태일 때 배경색
const disabledClass = 'bg-gray-300';

return (
<button
onClick={handleClick} // 클릭 시 handleClick 실행
disabled={disabled} // HTML 비활성화 속성 반영
onClick={handleClick}
disabled={disabled}
className={`
${baseClass} // 기본 스타일
${disabled ? disabledClass : enabledClass} // 상태에 따라 배경색 변경
${className} // 외부에서 넘긴 추가 클래스 병합
${baseClass}
${disabled ? disabledClass : enabledClass}
${className}
`}
>
{text} {/* 버튼 내부에 텍스트 출력 */}
{text}
</button>
);
};
Expand Down
9 changes: 1 addition & 8 deletions src/components/Onboarding/SocialLoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import googlelogo from '../../assets/logos/googlelogo.png';
import kakaologo from '../../assets/logos/kakaologo.svg';
import { redirectToGoogleLogin, redirectToKakaoLogin } from '../../apis/auth';

// 컴포넌트에 넘길 props 정의
interface SocialLoginButtonProps {
provider: 'google' | 'kakao'; // 로그인 버튼 종류 (google 또는 kakao)
provider: 'google' | 'kakao';
}

// SocialLoginButton 컴포넌트 정의
const SocialLoginButton = ({ provider }: SocialLoginButtonProps) => {
// provider에 따라 리다이렉트
const handleClick = async () => {
try {
if (provider === 'google') {
Expand All @@ -22,14 +19,12 @@ const SocialLoginButton = ({ provider }: SocialLoginButtonProps) => {
}
};

// 구글 로그인 버튼 렌더링
if (provider === 'google') {
return (
<button
onClick={handleClick}
className="pl-[2rem] w-[40rem] h-[6.2rem] rounded-[0.5rem] bg-gray-200 cursor-pointer hover:opacity-90"
>
{/* 내부 콘텐츠 영역: 로고 + 텍스트 */}
<div className="w-[23.3rem] flex items-center justify-between">
<div className="w-[4.2rem] h-[4.2rem] bg-gray-200 flex items-center justify-center shrink-0">
<img src={googlelogo} alt="Google" className="w-[1.966rem] h-[2.0553rem] shrink-0" />
Expand All @@ -40,13 +35,11 @@ const SocialLoginButton = ({ provider }: SocialLoginButtonProps) => {
);
}

// 카카오 로그인 버튼 렌더링
return (
<button
onClick={handleClick}
className="pl-[2rem] w-[40rem] h-[6.2rem] rounded-[0.5rem] bg-kakao-yellow cursor-pointer hover:opacity-90"
>
{/* 내부 콘텐츠 영역: 로고 + 텍스트 */}
<div className="w-[25.5rem] flex items-center justify-between">
<img src={kakaologo} alt="Kakao" className="w-[4.2rem] h-[4.2rem]" />
<span className="font-body-r text-gray-600">카카오톡으로 계속하기</span>
Expand Down
Loading