Skip to content
Closed
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
2,156 changes: 1,759 additions & 397 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"react-router-dom": "^7.7.0",
"tailwind-merge": "^3.3.1"
},
Expand Down
13 changes: 13 additions & 0 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}

export { Skeleton }
21 changes: 21 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState, useEffect } from "react";

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
if (delay <= 0) {
setDebouncedValue(value);
return;
}
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);

return debouncedValue;
}
83 changes: 34 additions & 49 deletions src/hooks/useGitHubCallback.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,56 @@
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { fetchLogin } from '@/lib/api';
import { useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { fetchLogin } from "@/lib/api";
import type { FetchLoginResponse } from "@/types/auth";

export const useGitHubCallback = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const hasProcessed = useRef(false);
const hasFetched = useRef(false);

const signInMutation = useMutation({
const signInMutation = useMutation<FetchLoginResponse, Error, string>({
mutationFn: fetchLogin,
retry: false,
onSuccess: (result) => {
sessionStorage.removeItem('github_oauth_state');

if (result.status === 200) {
localStorage.setItem('access_token', result.accessToken);
localStorage.setItem('session_id', result.sessionId);
navigate('/', { replace: true });
} else if (result.status === 210) {
sessionStorage.setItem('github_id', result.githubId);
navigate('/onboarding', { replace: true });
sessionStorage.removeItem("github_oauth_state");

if (result.status === 200 && result.accessToken) {
sessionStorage.setItem("sessionId", result.accessToken);
navigate("/", { replace: true });
} else if (result.status === 210 && result.sessionId) {
sessionStorage.setItem("signUpSessionId", result.sessionId);
navigate("/onboarding", { replace: true });
} else {
navigate("/login", { replace: true });
}
},
onError: (error) => {
console.error('OAuth callback error:', error);
}
onError: () => {
navigate("/login", { replace: true });
},
});

useEffect(() => {
if (hasProcessed.current) return;

const handleCallback = () => {
hasProcessed.current = true;

const code = searchParams.get('code');
const error = searchParams.get('error');
const state = searchParams.get('state');

if (error) {
navigate('/login', { replace: true });
console.error('GitHub OAuth 에러:', error);
return;
}

if (!code) {
navigate('/login', { replace: true });
console.log('GitHub에서 인증 코드를 받지 못했습니다');
return;
}
const code = searchParams.get("code");
const state = searchParams.get("state");

const storedState = sessionStorage.getItem('github_oauth_state');
if (state !== storedState) {
navigate('/login', { replace: true });
console.log('보안 검증 실패');
return;
}
if (!code || !state || hasFetched.current) {
return;
}

signInMutation.mutate(code);
};
const storedState = sessionStorage.getItem("github_oauth_state");
if (state !== storedState) {
navigate("/login", { replace: true });
return;
}

handleCallback();
}, [searchParams, navigate]);
hasFetched.current = true;
signInMutation.mutate(code);
}, [searchParams, navigate, signInMutation.mutate]);

return {
isPending: signInMutation.isPending,
isError: signInMutation.isError,
error: signInMutation.error,
};
};
};
90 changes: 90 additions & 0 deletions src/hooks/useOnboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useMutation } from "@tanstack/react-query";
import type {
OnboardingData,
Coordinates,
} from "@/pages/onboarding/OnboardingPage";

interface SignUpPayload {
nickname?: string;
age?: number;
experience?: number;
bio?: string;
location?: Coordinates;
positionId?: number;
techIds?: number[];
interestIds?: number[];
linkedIn?: string;
email?: string;
instagram?: string;
blog?: string;
}

const signUpUserProfile = async (data: OnboardingData) => {
const sessionId = sessionStorage.getItem("signUpSessionId");
if (!sessionId) {
throw new Error("세션이 만료되었습니다. 다시 로그인해주세요.");
}

const payload: SignUpPayload = {
nickname: data.nickname,
age: data.age,
experience: data.experience,
bio: data.bio,
location: data.location,
positionId: data.position,
techIds: data.techStack,
interestIds: data.interests,
linkedIn: data.linkedIn,
email: data.email,
instagram: data.instagram,
blog: data.blog,
};

Object.keys(payload).forEach((key) => {
const value = payload[key as keyof SignUpPayload];
if (
value === undefined ||
value === null ||
(typeof value === "string" && value === "")
) {
delete payload[key as keyof SignUpPayload];
}
});

const res = await fetch(`/api/auth/sign-up?sessionId=${sessionId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});

if (!res.ok) {
let errorMsg = "프로필 등록에 실패했습니다.";
try {
const errorData = await res.json();
errorMsg = Array.isArray(errorData.message)
? errorData.message[0]
: errorData.message;
} catch {}
throw new Error(errorMsg);
}

if (res.status === 204 || res.status === 205) {
return;
}

return res.json();
};

export function useUpdateUserProfile() {
return useMutation<any, Error, OnboardingData>({
mutationFn: signUpUserProfile,
onSuccess: () => {
sessionStorage.removeItem("signUpSessionId");
},
onError: (error) => {
throw error;
},
});
}
57 changes: 57 additions & 0 deletions src/hooks/useTags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useQuery } from "@tanstack/react-query";

export interface TypeDTO {
id: number;
value: string;
}

export interface PositionDTO {
id: number;
field: string;
role: string;
}

export interface PositionsAndInterestsResponse {
positions: PositionDTO[];
interests: TypeDTO[];
}

const fetchPositionsAndInterests =
async (): Promise<PositionsAndInterestsResponse> => {
const res = await fetch("/api/tags/positions-interests");
if (!res.ok) {
throw new Error("목록을 불러오는데 실패했습니다.");
}
return res.json();
};

const searchTechs = async (keyword: string): Promise<TypeDTO[]> => {
if (!keyword) return [];
const res = await fetch(
`/api/tags/tech-stack?keyword=${encodeURIComponent(keyword)}`
);
if (!res.ok) {
throw new Error("검색에 실패했습니다.");
}
const data = await res.json();
return data.results;
};

export function usePositionsAndInterests() {
return useQuery<PositionsAndInterestsResponse, Error>({
queryKey: ["tags"],
queryFn: fetchPositionsAndInterests,
staleTime: 1000 * 60 * 60,
gcTime: 1000 * 60 * 60,
});
}

export function useTechSearch(keyword: string) {
return useQuery<TypeDTO[], Error>({
queryKey: ["techs", keyword],
queryFn: () => searchTechs(keyword),
enabled: !!keyword,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
});
}
40 changes: 25 additions & 15 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,43 @@ export const fetchStacks = async (): Promise<Stack[]> => {
};

export const fetchLogin = async (code: string): Promise<FetchLoginResponse> => {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/sign-in?code=${encodeURIComponent(code)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
});
const response = await fetch(
`/api/auth/sign-in?code=${encodeURIComponent(code)}`,
{
method: "GET",
headers: {
Accept: "application/json",
},
}
);

if (!response.ok) {
throw new Error(`GitHub 로그인 실패: ${response.status}`);
}

const data = await response.json();

if (response.status === 200) {
// [수정] 백엔드의 새로운 응답 형식에 맞춰 데이터를 해석합니다.
// 경우 1: 기존 유저 (응답에 'result' 객체와 그 안의 'accessToken'이 있음)
if (data.result && data.result.accessToken) {
return {
status: 200,
accessToken: data.accessToken,
sessionId: data.sessionId,
user: data.user
accessToken: data.result.accessToken,
sessionId: data.result.sessionId,
user: data.result.user,
};
} else if (response.status === 210) {
}
// 경우 2: 신규 유저 (응답 최상위에 'sessionId'가 있음)
else if (data.sessionId) {
return {
status: 210,
githubId: data.githubId,
user: data.user
sessionId: data.sessionId,
user: data.user,
};
} else {
throw new Error(`예상하지 못한 응답 상태: ${response.status}`);
}
};
// 경우 3: 예상치 못한 응답
else {
throw new Error(`예상하지 못한 응답 데이터: ${JSON.stringify(data)}`);
}
};
Loading