diff --git a/src/app/components/loading-spinner/SmallLoadingSpinner.tsx b/src/app/components/loading-spinner/SmallLoadingSpinner.tsx
new file mode 100644
index 00000000..b604941e
--- /dev/null
+++ b/src/app/components/loading-spinner/SmallLoadingSpinner.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Player } from "@lottiefiles/react-lottie-player";
+import React from "react";
+
+export default function SamllLoadingSpinner() {
+ return (
+
+
+
+
+ 페이지 이동 중 입니다
+
+
+ 잠시만 기다려주세요...
+
+
+
+ );
+}
diff --git a/src/app/components/loading-spinner/Spinner.css b/src/app/components/loading-spinner/Spinner.css
new file mode 100644
index 00000000..0c5179bc
--- /dev/null
+++ b/src/app/components/loading-spinner/Spinner.css
@@ -0,0 +1,41 @@
+.spinner {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.icon {
+ position: absolute;
+ color: #ababab;
+ opacity: 0;
+ animation: fade 4s infinite;
+}
+
+.icon:nth-child(1) {
+ animation-delay: 0s;
+}
+.icon:nth-child(2) {
+ animation-delay: 1s;
+}
+.icon:nth-child(3) {
+ animation-delay: 2s;
+}
+.icon:nth-child(4) {
+ animation-delay: 3s;
+}
+
+@keyframes fade {
+ 0% {
+ opacity: 0;
+ }
+ 25% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/src/app/components/modal/modals/apply/MyApplicationModal.tsx b/src/app/components/modal/modals/apply/MyApplicationModal.tsx
index b7e41091..68ac1b89 100644
--- a/src/app/components/modal/modals/apply/MyApplicationModal.tsx
+++ b/src/app/components/modal/modals/apply/MyApplicationModal.tsx
@@ -13,27 +13,21 @@ import { FiDownload } from "react-icons/fi";
import Image from "next/image";
import { useUser } from "@/hooks/queries/user/me/useUser";
import { useGuestApplication } from "@/hooks/queries/user/me/useGuestApplication";
+import Chip from "@/app/components/chip/Chip";
const ModalOverlay = ({ onClick }: { onClick: (e: React.MouseEvent) => void }) => (
-
+
+
+
);
-
const InfoRow = ({ label, value, isIntroduction }: InfoRowProps) => {
if (isIntroduction) {
return (
);
}
@@ -90,6 +84,7 @@ const ApplicationContent = ({
}: ApplicationResponse) => {
return (
+
@@ -112,9 +107,8 @@ export default function MyApplicationModal({
formId,
className,
verifyData,
+ initialData,
}: MyApplicationModalProps) {
- console.log("MyApplicationModal 열림");
-
const { user } = useUser();
// 회원/비회원에 따라 다른 훅 사용
@@ -125,8 +119,9 @@ export default function MyApplicationModal({
!user ? verifyData : undefined // user가 없을 때만 실행
);
- const myApplicationData = user ? memberApplicationData : guestApplicationData;
- const isLoading = user ? isMemberLoading : isGuestLoading;
+ // initialData가 있으면 API 호출 없이 바로 사용
+ const myApplicationData = initialData || (user ? memberApplicationData : guestApplicationData);
+ const isLoading = !initialData && (user ? isMemberLoading : isGuestLoading);
if (!isOpen) return null;
diff --git a/src/app/components/modal/modals/apply/VerifyApplicationModal.tsx b/src/app/components/modal/modals/apply/VerifyApplicationModal.tsx
index 13fe9049..2e5c620e 100644
--- a/src/app/components/modal/modals/apply/VerifyApplicationModal.tsx
+++ b/src/app/components/modal/modals/apply/VerifyApplicationModal.tsx
@@ -8,6 +8,7 @@ import DotLoadingSpinner from "@/app/components/loading-spinner/DotLoadingSpinne
import Button from "@/app/components/button/default/Button";
import { VerifyApplicationModalProps } from "@/types/modal";
import { useState } from "react";
+import toast from "react-hot-toast";
const verifyApplicationSchema = z.object({
name: z.string().min(1, "이름을 입력해주세요"),
@@ -73,6 +74,7 @@ const VerifyApplicationModal = ({ isOpen, onClose, onVerify, className }: Verify
reset();
onClose?.(); // optional chaining 사용
} catch (error) {
+ toast.error("지원내역 조회에 실패했습니다.");
console.error(error);
} finally {
setIsSubmitting(false);
diff --git a/src/app/components/modal/modals/confirm/SelectProgressModal.tsx b/src/app/components/modal/modals/confirm/SelectProgressModal.tsx
index 6916828d..84f5c5a2 100644
--- a/src/app/components/modal/modals/confirm/SelectProgressModal.tsx
+++ b/src/app/components/modal/modals/confirm/SelectProgressModal.tsx
@@ -8,19 +8,19 @@ import axios from "axios";
import toast from "react-hot-toast";
import type { ConfirmFormModalProps } from "@/types/modal";
import DotLoadingSpinner from "@/app/components/loading-spinner/DotLoadingSpinner";
-import { applicationStatus, ApplicationStatusType } from "@/types/applicationStatus";
+import { APPLICATION_STATUS, ApplicationStatusType } from "@/types/applicationStatus";
const SelectProgressModal = ({ id, isOpen, onClose, className }: ConfirmFormModalProps) => {
const [isSubmitting, setIsSubmitting] = useState(false);
- const [selectedValue, setSelectedValue] = useState
(applicationStatus.INTERVIEW_PENDING);
+ const [selectedValue, setSelectedValue] = useState(APPLICATION_STATUS.INTERVIEW_PENDING);
if (!isOpen) return null;
const radioOptions = [
- { id: "rejected", value: applicationStatus.REJECTED, label: "거절" },
- { id: "interviewPending", value: applicationStatus.INTERVIEW_PENDING, label: "면접대기" },
- { id: "interviewCompleted", value: applicationStatus.INTERVIEW_COMPLETED, label: "면접 완료" },
- { id: "hired", value: applicationStatus.HIRED, label: "채용 완료" },
+ { id: "rejected", value: APPLICATION_STATUS.REJECTED, label: "거절" },
+ { id: "interviewPending", value: APPLICATION_STATUS.INTERVIEW_PENDING, label: "면접대기" },
+ { id: "interviewCompleted", value: APPLICATION_STATUS.INTERVIEW_COMPLETED, label: "면접 완료" },
+ { id: "hired", value: APPLICATION_STATUS.HIRED, label: "채용 완료" },
] as const;
const handleValueChange = (value: ApplicationStatusType) => {
diff --git a/src/app/components/mouseTrail/CustomCursor.tsx b/src/app/components/mouseTrail/CustomCursor.tsx
new file mode 100644
index 00000000..cd7a7e54
--- /dev/null
+++ b/src/app/components/mouseTrail/CustomCursor.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+
+interface CursorPosition {
+ x: number;
+ y: number;
+}
+
+export default function CustomCursor() {
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+
+ useEffect(() => {
+ const updatePosition = (e: MouseEvent) => {
+ setPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ window.addEventListener("mousemove", updatePosition);
+
+ return () => {
+ window.removeEventListener("mousemove", updatePosition);
+ };
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+function TreeSVG() {
+ return (
+
+ );
+}
diff --git a/src/app/components/mouseTrail/MouseTrail.tsx b/src/app/components/mouseTrail/MouseTrail.tsx
new file mode 100644
index 00000000..e0749e0b
--- /dev/null
+++ b/src/app/components/mouseTrail/MouseTrail.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import React, { useState, useEffect, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+
+interface TrailPosition {
+ x: number;
+ y: number;
+ id: string;
+}
+
+export default function MouseTrail() {
+ const [trail, setTrail] = useState([]);
+ const [lastPosition, setLastPosition] = useState(null);
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ const uniqueId = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ const newPosition = { x: e.clientX, y: e.clientY, id: uniqueId };
+
+ if (lastPosition) {
+ const distance = Math.sqrt(
+ Math.pow(newPosition.x - lastPosition.x, 2) + Math.pow(newPosition.y - lastPosition.y, 2)
+ );
+
+ if (distance > 50) {
+ setTrail((prevTrail) => [newPosition, ...prevTrail.slice(0, 9)]);
+ setLastPosition(newPosition);
+ }
+ } else {
+ setTrail([newPosition]);
+ setLastPosition(newPosition);
+ }
+ },
+ [lastPosition]
+ );
+
+ useEffect(() => {
+ const options = { passive: true };
+ window.addEventListener("mousemove", handleMouseMove, options.passive);
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove, options.passive);
+ };
+ }, [handleMouseMove]);
+
+ return (
+
+
+ {trail.map((position, index) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function TrailDot({ opacity = 1 }) {
+ return (
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index a63f446a..c8f0e39b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -292,6 +292,14 @@ input[type="checkbox"]:disabled {
@apply bg-grayscale-50 !important;
}
+.react-datepicker__day--disabled {
+ @apply bg-grayscale-200 bg-opacity-50;
+}
+
+.react-datepicker__day--disabled {
+ @apply rounded-sm bg-grayscale-100 bg-opacity-50;
+}
+
/* --------------------- date picker커스텀 끝--------------------- */
.bg-black50 {
@@ -303,3 +311,22 @@ input[type="checkbox"]:disabled {
/* 또는 */
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap");
+
+/* 마우스 커스텀 */
+* {
+ cursor: none !important;
+}
+
+html,
+body {
+ cursor: none;
+}
+
+a,
+button,
+[role="button"],
+input,
+select,
+textarea {
+ cursor: none;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 43f84214..73319066 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,6 +5,8 @@ import "./globals.css";
import { metadata } from "./metadata";
import { viewport } from "./viewport";
import { hakgyoFont, nexonFont } from "./fonts";
+import MouseTrail from "./components/mouseTrail/MouseTrail";
+import CustomCursor from "./components/mouseTrail/CustomCursor";
export { metadata, viewport };
@@ -16,6 +18,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+
+
{children}
diff --git a/src/app/metadata.ts b/src/app/metadata.ts
index 7c44a440..a56c36e4 100644
--- a/src/app/metadata.ts
+++ b/src/app/metadata.ts
@@ -3,10 +3,10 @@ import { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_DOMAIN_URL || "http://localhost:3000"),
title: {
- default: "워크루트 | 알바 구인구직 서비스",
+ default: "워크루트 | 구인구직 플랫폼",
template: "%s | 워크루트",
},
- description: "간편한 알바 구인구직 서비스, 워크루트에서 시작하세요.",
+ description: "간편한 알바 구인구직 플랫폼, 워크루트에서 시작하세요.",
keywords: ["알바", "구인", "구직", "아르바이트", "워크루트", "일자리", "채용"],
authors: [{ name: "워크루트" }],
@@ -20,8 +20,8 @@ export const metadata: Metadata = {
openGraph: {
type: "website",
siteName: "워크루트",
- title: "워크루트 | 알바 구인구직 서비스",
- description: "간편한 알바 구인구직 서비스, 워크루트에서 시작하세요.",
+ title: "워크루트 | 구인구직 플랫폼",
+ description: "간편한 알바 구인구직 플랫폼, 워크루트에서 시작하세요.",
url: process.env.NEXT_PUBLIC_DOMAIN_URL,
images: [
{
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
new file mode 100644
index 00000000..6863ab0a
--- /dev/null
+++ b/src/app/privacy/page.tsx
@@ -0,0 +1,207 @@
+export default function PrivacyPolicyPage() {
+ return (
+
+
+ 개인정보처리방침
+
+
+ WorkRoot는 사용자의 개인정보를 중요하게 생각하며, 「개인정보 보호법」 및 관련 법령을 준수하고
+ 있습니다. 본 개인정보처리방침은 WorkRoot가 제공하는 서비스와 관련하여 사용자의 개인정보를
+ 어떻게 수집, 이용, 보관 및 보호하는지에 대한 내용을 담고 있습니다.
+
+
+
+
+
+ 1. 개인정보의 수집 항목 및 수집 방법
+
+ WorkRoot는 서비스 이용을 위해 필요한 최소한의 개인정보만을 수집합니다.
+
+
+ 1.1 수집 항목
+
+ -
+ 회원가입 시
+
+ - 필수: 이름, 이메일 주소, 비밀번호
+ - 선택: 전화번호, 프로필 사진
+
+
+ -
+ 소셜 로그인 이용 시
+
+ - OAuth 제공자로부터 제공되는 이름, 이메일, 프로필 사진 등
+
+
+ -
+ 서비스 이용 시
+
+ - 서비스 이용 기록, 접속 로그, IP 주소, 쿠키 정보, 기기 정보
+
+
+
+
+ 1.2 수집 방법
+
+ - 회원가입 및 서비스 이용 과정에서 사용자가 직접 입력
+ - 소셜 로그인 연동 시 외부 플랫폼으로부터 제공
+ - 서비스 이용 과정에서 자동으로 수집
+
+
+
+
+
+
+ 2. 개인정보의 이용 목적
+
+ WorkRoot는 수집된 개인정보를 다음의 목적으로 사용합니다:
+
+
+ -
+ 서비스 제공 및 회원 관리
+
+ - 회원가입, 본인 인증, 계정 관리
+ - 서비스 이용 기록 관리 및 맞춤형 서비스 제공
+
+
+ -
+ 고객 지원
+
+
+ -
+ 마케팅 및 광고
+
+ - 사용자 동의하에 맞춤형 광고 제공 및 이벤트 정보 전달
+
+
+ -
+ 법적 의무 준수
+
+
+
+
+
+
+
+
+ 3. 개인정보의 보관 및 파기
+ 3.1 보관 기간
+
+ -
+ 회원 탈퇴 시: 즉시 삭제, 단 법령에서 정한 경우 일정 기간 보관
+
+ - 전자상거래법에 따른 계약 또는 청약 철회 기록: 5년
+ - 소비자 불만 또는 분쟁 처리 기록: 3년
+ - 로그인 기록: 1년
+
+
+
+
+ 3.2 파기 절차 및 방법
+
+ -
+ 파기 절차: 보유 기간이 만료되거나 처리 목적이 달성된 개인정보는 즉시 파기
+
+ -
+ 파기 방법:
+
+ - 전자적 파일 형태: 복구 불가능한 방식으로 영구 삭제
+ - 문서 형태: 분쇄 또는 소각
+
+
+
+
+
+
+
+
+ 4. 개인정보의 제3자 제공
+
+ WorkRoot는 사용자의 동의 없이는 개인정보를 제3자에게 제공하지 않습니다. 단, 법령에서
+ 요구하거나 사용자가 동의한 경우에 한하여 제공합니다.
+
+
+
+
+
+
+ 5. 개인정보의 처리 위탁
+
+ WorkRoot는 서비스 제공을 위해 필요한 경우 개인정보 처리를 외부 기관에 위탁할 수 있습니다.
+ 위탁 계약 시 개인정보 보호 관련 법규를 준수하며, 위탁받은 기관에 대한 관리를 철저히 합니다.
+
+
+
+
+
+
+ 6. 개인정보 보호를 위한 기술적·관리적 대책
+
+ -
+ 기술적 대책:
+
+ - 데이터 암호화
+ - 방화벽 및 보안 솔루션 운영
+
+
+ -
+ 관리적 대책:
+
+ - 개인정보 접근 권한 최소화
+ - 정기적인 보안 점검 및 교육 실시
+
+
+
+
+
+
+
+
+ 7. 사용자 권리 및 행사 방법
+ 사용자는 언제든지 자신의 개인정보에 대해 다음의 권리를 행사할 수 있습니다:
+
+ - 개인정보 열람, 수정, 삭제 요청
+ - 처리 정지 요청
+ - 동의 철회 요청
+
+ 요청은 서비스 내 설정 페이지나 고객센터를 통해 가능합니다.
+
+
+
+
+
+ 8. 개인정보 보호책임자
+
+ WorkRoot는 개인정보 처리와 관련된 문의, 불만 처리 및 피해 구제를 위해 아래와 같은 개인정보
+ 보호책임자를 지정하고 있습니다:
+
+
+ -
+ 책임자: 김원
+
+ -
+ 연락처: cccwon2@kakao.com
+
+
+
+
+
+
+
+ 9. 정책 변경
+
+ WorkRoot는 본 개인정보처리방침을 변경할 수 있으며, 변경 시 서비스 공지사항 또는 이메일을
+ 통해 사전에 고지합니다.
+
+
+
+
+
+ );
+}
diff --git a/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx b/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx
index 2b2d1b39..c75d49ac 100644
--- a/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx
+++ b/src/app/stories/design-system/components/card/cardList/MyApplicationListItem.stories.tsx
@@ -1,5 +1,5 @@
import MyApplicationListItem from "@/app/components/card/cardList/apply/MyApplicationListItem";
-import { applicationStatus } from "@/types/applicationStatus";
+import { APPLICATION_STATUS } from "@/types/applicationStatus";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta = {
@@ -17,7 +17,7 @@ const mockProps = {
id: 1,
createdAt: new Date("2024-03-21T14:30:00"),
updatedAt: new Date("2024-03-21T14:30:00"),
- status: applicationStatus.INTERVIEW_PENDING,
+ status: APPLICATION_STATUS.INTERVIEW_PENDING,
resumeName: "이력서.pdf",
resumeId: 123,
form: {
@@ -46,7 +46,7 @@ export const Default: Story = {
export const InterviewPending: Story = {
args: {
...mockProps,
- status: applicationStatus.INTERVIEW_PENDING,
+ status: APPLICATION_STATUS.INTERVIEW_PENDING,
},
};
@@ -54,7 +54,7 @@ export const InterviewPending: Story = {
export const InterviewCompleted: Story = {
args: {
...mockProps,
- status: applicationStatus.INTERVIEW_COMPLETED,
+ status: APPLICATION_STATUS.INTERVIEW_COMPLETED,
},
};
@@ -62,7 +62,7 @@ export const InterviewCompleted: Story = {
export const Hired: Story = {
args: {
...mockProps,
- status: applicationStatus.HIRED,
+ status: APPLICATION_STATUS.HIRED,
},
};
@@ -70,7 +70,7 @@ export const Hired: Story = {
export const Rejected: Story = {
args: {
...mockProps,
- status: applicationStatus.REJECTED,
+ status: APPLICATION_STATUS.REJECTED,
},
};
diff --git a/src/app/stories/design-system/components/input/image/ImageInput.stories.tsx b/src/app/stories/design-system/components/input/image/ImageInput.stories.tsx
index d7d3bf7b..039c6b42 100644
--- a/src/app/stories/design-system/components/input/image/ImageInput.stories.tsx
+++ b/src/app/stories/design-system/components/input/image/ImageInput.stories.tsx
@@ -1,20 +1,29 @@
-import ImageInput from "@/app/components/input/file/ImageInput/ImageInput";
+import * as React from "react";
+import ImageInputPlaceHolder from "@/app/components/input/file/ImageInput/ImageInputPlaceHolder";
import { Meta, StoryObj } from "@storybook/react";
const meta = {
title: "Design System/Components/FileInput",
- component: ImageInput,
+ component: ImageInputPlaceHolder,
parameters: {
layout: "centered",
},
-} satisfies Meta;
+} satisfies Meta;
export default meta;
-type Story = StoryObj;
+type Story = StoryObj;
export const Upload_Image: Story = {
args: {
- name: "uploadImage",
+ size: "large",
+ initialImages: [],
+ onImageUpload: async (file: File) => {
+ // 스토리북용 mock 함수
+ return URL.createObjectURL(file);
+ },
+ onImagesChange: (images) => {
+ console.log("Images changed:", images);
+ },
},
};
diff --git a/src/app/stories/design-system/components/input/picker/DatePicker.stories.tsx b/src/app/stories/design-system/components/input/picker/DatePicker.stories.tsx
index 196df6b0..ef2072f4 100644
--- a/src/app/stories/design-system/components/input/picker/DatePicker.stories.tsx
+++ b/src/app/stories/design-system/components/input/picker/DatePicker.stories.tsx
@@ -1,7 +1,7 @@
-import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput";
import "react-datepicker/dist/react-datepicker.css";
import { Meta, StoryObj } from "@storybook/react";
import { FormProvider, useForm } from "react-hook-form";
+import DatePickerInput from "@/app/components/input/dateTimeDaypicker/DatePickerInput";
const meta = {
title: "Design System/Components/Date-Time-Day Picker/DatePicker",
@@ -28,8 +28,8 @@ type Story = StoryObj;
export const DatePicker: Story = {
render: () => (
{}}
/>
diff --git a/src/app/stories/design-system/pages/albaList/page.tsx b/src/app/stories/design-system/pages/albaList/page.tsx
index cd189051..a599348b 100644
--- a/src/app/stories/design-system/pages/albaList/page.tsx
+++ b/src/app/stories/design-system/pages/albaList/page.tsx
@@ -113,7 +113,7 @@ const AlbaList: React.FC = () => {
{items.length === 0 ? (
-
+
) : (
diff --git a/src/constants/oauthProviders.ts b/src/constants/oauthProviders.ts
index 3d9cc8dc..05efe1f7 100644
--- a/src/constants/oauthProviders.ts
+++ b/src/constants/oauthProviders.ts
@@ -2,3 +2,5 @@ export const oauthProviders = {
GOOGLE: "google",
KAKAO: "kakao",
} as const;
+
+export type OAuthProvider = (typeof oauthProviders)[keyof typeof oauthProviders];
diff --git a/src/hooks/queries/auth/useOAuth.ts b/src/hooks/queries/auth/useOAuth.ts
new file mode 100644
index 00000000..8cf5cb9c
--- /dev/null
+++ b/src/hooks/queries/auth/useOAuth.ts
@@ -0,0 +1,108 @@
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import axios from "axios";
+import { toast } from "react-hot-toast";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { AuthResponse } from "@/types/response/auth";
+
+interface JwtPayload {
+ sub: string;
+ user_metadata: {
+ email: string;
+ name: string;
+ provider_id: string;
+ };
+}
+
+interface OAuthData {
+ email: string;
+ password: string;
+ isNewUser: boolean;
+ name?: string;
+ nickname?: string;
+ phoneNumber?: string;
+ role?: string;
+ storeName?: string;
+ storePhoneNumber?: string;
+ location?: string;
+}
+
+export const useOAuth = () => {
+ const router = useRouter();
+ const [error, setError] = useState
(null);
+ const queryClient = useQueryClient();
+
+ const oauthMutation = useMutation({
+ mutationFn: async (data: OAuthData) => {
+ const response = await axios.post("/api/auth/oauthlogin", data);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ if (data?.user) {
+ queryClient.setQueryData(["user"], { user: data.user });
+ toast.success(data.user.id ? "로그인되었습니다!" : "회원가입이 완료되었습니다!");
+ router.push("/");
+ router.refresh();
+ }
+ },
+ onError: (error) => {
+ console.error("OAuth auth error:", error);
+ if (axios.isAxiosError(error)) {
+ setError(error.response?.data?.message || "인증에 실패했습니다.");
+ } else {
+ setError("인증 처리 중 문제가 발생했습니다.");
+ }
+ },
+ });
+
+ const handleOAuthCallback = async (token: string) => {
+ try {
+ // JWT 디코딩 (한글 처리)
+ const [, payloadBase64] = token.split(".");
+ const decodedPayload = Buffer.from(payloadBase64, "base64").toString("utf-8");
+ const payload: JwtPayload = JSON.parse(decodedPayload);
+ const oauthPassword = payload.sub.replace(/-/g, "");
+
+ const userData = {
+ email: payload.user_metadata.email,
+ password: oauthPassword,
+ name: payload.user_metadata.name,
+ nickname: payload.user_metadata.name,
+ phoneNumber: "",
+ role: "APPLICANT",
+ storeName: "",
+ storePhoneNumber: "",
+ location: "",
+ };
+
+ try {
+ // 먼저 로그인 시도
+ await oauthMutation.mutateAsync({
+ email: userData.email,
+ password: oauthPassword,
+ isNewUser: false,
+ });
+ } catch (error) {
+ if (axios.isAxiosError(error) && error.response?.status === 404) {
+ // 로그인 실패 시 회원가입 시도
+ await oauthMutation.mutateAsync({
+ ...userData,
+ isNewUser: true,
+ });
+ } else {
+ throw error;
+ }
+ }
+ } catch (error) {
+ console.error("OAuth callback error:", error);
+ setError("인증 처리 중 문제가 발생했습니다. 다시 시도해주세요.");
+ }
+ };
+
+ return {
+ handleOAuthCallback,
+ error,
+ setError,
+ isPending: oauthMutation.isPending,
+ };
+};
diff --git a/src/hooks/queries/form/detail/useApplyStatus.ts b/src/hooks/queries/form/detail/useApplicationStatus.ts
similarity index 71%
rename from src/hooks/queries/form/detail/useApplyStatus.ts
rename to src/hooks/queries/form/detail/useApplicationStatus.ts
index 3d522b62..e6253c18 100644
--- a/src/hooks/queries/form/detail/useApplyStatus.ts
+++ b/src/hooks/queries/form/detail/useApplicationStatus.ts
@@ -4,7 +4,7 @@ import { ApplicationListResponse } from "@/types/response/application";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-interface UseApplyStatusProps {
+interface UseApplicationStatusProps {
formId: number;
limit: number;
cursor?: number;
@@ -12,7 +12,7 @@ interface UseApplyStatusProps {
orderByStatus?: string;
}
-export const useApplyStatus = (props: UseApplyStatusProps) => {
+export const useApplicationStatus = (props: UseApplicationStatusProps) => {
const query = useQuery({
queryKey: ["applyStatus", props.formId, props.limit, props.cursor, props.orderByExperience, props.orderByStatus],
queryFn: async () => {
@@ -24,6 +24,15 @@ export const useApplyStatus = (props: UseApplyStatusProps) => {
orderByStatus: props.orderByStatus,
},
});
+
+ // 응답 데이터의 각 항목에 고유한 키 추가
+ if (response.data.data) {
+ response.data.data = response.data.data.map((item: { id: string }, index: string) => ({
+ ...item,
+ uniqueKey: `${item.id}_${index}`,
+ }));
+ }
+
return response.data;
},
enabled: !!props.formId,
diff --git a/src/hooks/queries/form/detail/useUpdateApplicationStatus.ts b/src/hooks/queries/form/detail/useUpdateApplicationStatus.ts
new file mode 100644
index 00000000..97e55750
--- /dev/null
+++ b/src/hooks/queries/form/detail/useUpdateApplicationStatus.ts
@@ -0,0 +1,27 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+import axios from "axios";
+
+interface UpdateApplicationStatusParams {
+ applicationId: string;
+ status: string;
+}
+
+export const useUpdateApplicationStatus = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ applicationId, status }: UpdateApplicationStatusParams) => {
+ const response = await axios.patch(`/api/applications/${applicationId}`, {
+ status,
+ });
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["applyStatus"] });
+ },
+ onError: (error: Error | AxiosError) => {
+ console.error("지원서 상태 업데이트 실패:", error);
+ },
+ });
+};
diff --git a/src/hooks/queries/oauth/useOAuthApps.ts b/src/hooks/queries/oauth/useOAuthApps.ts
new file mode 100644
index 00000000..5796150b
--- /dev/null
+++ b/src/hooks/queries/oauth/useOAuthApps.ts
@@ -0,0 +1,34 @@
+import { OAuthAppSchema } from "@/schemas/oauthSchema";
+import { OauthAppResponse } from "@/types/oauth/oauth";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { toast } from "react-hot-toast";
+
+export const useOAuthApps = () => {
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: async (data: OAuthAppSchema) => {
+ const response = await axios.post("/api/oauth/apps", data);
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["oauthApps"] });
+ },
+ onError: (error) => {
+ if (axios.isAxiosError(error)) {
+ const errorMessage = error.response?.data?.message || "OAuth 앱 등록에 실패했습니다.";
+ toast.error(errorMessage);
+ } else {
+ toast.error("OAuth 앱 등록 중 오류가 발생했습니다.");
+ }
+ console.error("OAuth app registration failed:", error);
+ },
+ });
+
+ return {
+ registerOAuthApp: mutation.mutate,
+ isLoading: mutation.isPending,
+ error: mutation.error,
+ };
+};
diff --git a/src/hooks/queries/oauth/useOAuthLogin.ts b/src/hooks/queries/oauth/useOAuthLogin.ts
new file mode 100644
index 00000000..1ad26876
--- /dev/null
+++ b/src/hooks/queries/oauth/useOAuthLogin.ts
@@ -0,0 +1,42 @@
+import { OAuthLoginSchema } from "@/schemas/oauthSchema";
+import { AuthResponse } from "@/types/response/auth";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { useRouter } from "next/navigation";
+import { toast } from "react-hot-toast";
+import { OAuthProvider } from "@/constants/oauthProviders";
+
+export const useOAuthLogin = (provider: OAuthProvider) => {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: async (data: OAuthLoginSchema) => {
+ const response = await axios.post(`/api/oauth/login/${provider}`, data);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ if (data?.user) {
+ queryClient.setQueryData(["user"], { user: data.user });
+ toast.success("로그인되었습니다!");
+ router.push("/");
+ router.refresh();
+ }
+ },
+ onError: (error) => {
+ if (axios.isAxiosError(error)) {
+ const errorMessage = error.response?.data?.message || "로그인에 실패했습니다.";
+ toast.error(errorMessage);
+ } else {
+ toast.error("로그인 중 오류가 발생했습니다.");
+ }
+ console.error("OAuth login failed:", error);
+ },
+ });
+
+ return {
+ oauthLogin: mutation.mutate,
+ isLoading: mutation.isPending,
+ error: mutation.error,
+ };
+};
diff --git a/src/hooks/queries/oauth/useOAuthSignup.ts b/src/hooks/queries/oauth/useOAuthSignup.ts
new file mode 100644
index 00000000..06920f90
--- /dev/null
+++ b/src/hooks/queries/oauth/useOAuthSignup.ts
@@ -0,0 +1,42 @@
+import { OAuthSignupSchema } from "@/schemas/oauthSchema";
+import { AuthResponse } from "@/types/response/auth";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import axios from "axios";
+import { useRouter } from "next/navigation";
+import { toast } from "react-hot-toast";
+import { OAuthProvider } from "@/constants/oauthProviders";
+
+export const useOAuthSignup = (provider: OAuthProvider) => {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const mutation = useMutation({
+ mutationFn: async (data: OAuthSignupSchema) => {
+ const response = await axios.post(`/api/oauth/signup/${provider}`, data);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ if (data?.user) {
+ queryClient.setQueryData(["user"], { user: data.user });
+ toast.success("회원가입이 완료되었습니다!");
+ router.push("/");
+ router.refresh();
+ }
+ },
+ onError: (error) => {
+ if (axios.isAxiosError(error)) {
+ const errorMessage = error.response?.data?.message || "회원가입에 실패했습니다.";
+ toast.error(errorMessage);
+ } else {
+ toast.error("회원가입 중 오류가 발생했습니다.");
+ }
+ console.error("OAuth signup failed:", error);
+ },
+ });
+
+ return {
+ oauthSignup: mutation.mutate,
+ isLoading: mutation.isPending,
+ error: mutation.error,
+ };
+};
diff --git a/src/hooks/queries/user/me/useGuestApplication.ts b/src/hooks/queries/user/me/useGuestApplication.ts
index 8bc7bc14..68c83936 100644
--- a/src/hooks/queries/user/me/useGuestApplication.ts
+++ b/src/hooks/queries/user/me/useGuestApplication.ts
@@ -1,7 +1,6 @@
import { ApplicationResponse } from "@/types/response/application";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
-import toast from "react-hot-toast";
interface GuestVerifyData {
name: string;
@@ -18,10 +17,8 @@ export const useGuestApplication = (formId: string | number, verifyData?: GuestV
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
- const errorMessage = error.response?.data?.message || "지원내역을 불러오는데 실패했습니다.";
- toast.error(errorMessage);
+ throw error;
}
- throw error;
}
},
enabled: !!formId && !!verifyData, // verifyData가 있을 때만 실행
diff --git a/src/hooks/useApplyCard.ts b/src/hooks/useApplyCard.ts
index 6185a9aa..2b20bca2 100644
--- a/src/hooks/useApplyCard.ts
+++ b/src/hooks/useApplyCard.ts
@@ -1,11 +1,11 @@
import { useState } from "react";
-import { useApplyStatus } from "@/hooks/queries/form/detail/useApplyStatus";
+import { useApplicationStatus } from "./queries/form/detail/useApplicationStatus";
export const useApplyStatusCard = (formId: number) => {
const [experienceSort, setExperienceSort] = useState<"asc" | "desc">("asc");
const [statusSort, setStatusSort] = useState<"asc" | "desc">("desc");
- const { applyStatusData, isLoading } = useApplyStatus({
+ const { applyStatusData, isLoading } = useApplicationStatus({
formId,
limit: 30,
cursor: 0,
diff --git a/src/lib/supabaseClient.ts b/src/lib/supabaseClient.ts
new file mode 100644
index 00000000..8a43de9a
--- /dev/null
+++ b/src/lib/supabaseClient.ts
@@ -0,0 +1,12 @@
+import { createClient } from "@supabase/supabase-js";
+
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
+const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
+
+if (!supabaseUrl || !supabaseAnonKey) {
+ if (typeof window === "undefined") {
+ throw new Error("Supabase 환경 변수가 없습니다.");
+ }
+}
+
+export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey);
diff --git a/src/lib/supabaseUtils.ts b/src/lib/supabaseUtils.ts
new file mode 100644
index 00000000..cc844c64
--- /dev/null
+++ b/src/lib/supabaseUtils.ts
@@ -0,0 +1,37 @@
+import { supabaseClient } from "@/lib/supabaseClient";
+import { toast } from "react-hot-toast";
+import { OAuthProvider } from "@/constants/oauthProviders";
+
+// 소셜 로그인
+export const signInWithProvider = async (provider: OAuthProvider) => {
+ try {
+ const { data, error } = await supabaseClient.auth.signInWithOAuth({
+ provider,
+ options: {
+ redirectTo: `${process.env.NEXT_PUBLIC_DOMAIN_URL}/auth/callback`,
+ },
+ });
+
+ if (error) {
+ console.error("Social login failed:", error.message);
+ return null;
+ }
+
+ return data;
+ } catch (error) {
+ console.error("Social auth error:", error);
+ toast.error("소셜 인증 중 오류가 발생했습니다.");
+ return null;
+ }
+};
+
+// 소셜 유저 정보 가져오기
+export const getSocialUser = async () => {
+ const { data, error } = await supabaseClient.auth.getUser();
+ if (error) {
+ console.error("Error fetching social user:", error.message);
+ return null;
+ }
+
+ return data?.user;
+};
diff --git a/src/schemas/oauthSchema.ts b/src/schemas/oauthSchema.ts
index a873bd3c..5c3840ec 100644
--- a/src/schemas/oauthSchema.ts
+++ b/src/schemas/oauthSchema.ts
@@ -1,10 +1,33 @@
import { z } from "zod";
-import { providerSchema } from "./commonSchema";
+import { providerSchema, roleSchema } from "./commonSchema";
-// 소셜 로그인
-export const oauthSchema = z.object({
+// 소셜 로그인 App 등록/수정
+export const oauthAppSchema = z.object({
appKey: z.string(),
provider: providerSchema,
});
-export type OauthSchema = z.infer;
+export type OAuthAppSchema = z.infer;
+
+// 소셜 회원가입
+export const oauthSignupSchema = z.object({
+ location: z.string(),
+ phoneNumber: z.string(),
+ storePhoneNumber: z.string(),
+ storeName: z.string(),
+ role: roleSchema,
+ nickname: z.string(),
+ name: z.string(),
+ redirectUri: z.string(),
+ token: z.string(),
+});
+
+export type OAuthSignupSchema = z.infer;
+
+// 소셜 로그인
+export const oauthLoginSchema = z.object({
+ redirectUri: z.string(),
+ token: z.string(),
+});
+
+export type OAuthLoginSchema = z.infer;
diff --git a/src/types/applicationStatus.ts b/src/types/applicationStatus.ts
index 3ef96d66..4e77deea 100644
--- a/src/types/applicationStatus.ts
+++ b/src/types/applicationStatus.ts
@@ -1,10 +1,15 @@
-// 지원 상태
-export const applicationStatus = {
- ALL: "",
+export const APPLICATION_STATUS = {
+ HIRED: "HIRED",
REJECTED: "REJECTED",
INTERVIEW_PENDING: "INTERVIEW_PENDING",
INTERVIEW_COMPLETED: "INTERVIEW_COMPLETED",
- HIRED: "HIRED",
} as const;
-export type ApplicationStatusType = (typeof applicationStatus)[keyof typeof applicationStatus];
+export const APPLICATION_STATUS_MAP = {
+ [APPLICATION_STATUS.HIRED]: "채용 완료",
+ [APPLICATION_STATUS.REJECTED]: "거절",
+ [APPLICATION_STATUS.INTERVIEW_PENDING]: "면접 대기",
+ [APPLICATION_STATUS.INTERVIEW_COMPLETED]: "면접 완료",
+} as const;
+
+export type ApplicationStatusType = keyof typeof APPLICATION_STATUS;
diff --git a/src/types/modal.d.ts b/src/types/modal.d.ts
index a51b5020..49b2d4dd 100644
--- a/src/types/modal.d.ts
+++ b/src/types/modal.d.ts
@@ -65,11 +65,13 @@ type MyApplicationModalProps = BaseModalProps & {
formId: number | string;
className?: string;
verifyData?: {
- // 추가: 비회원 인증 데이터
+ // 비회원 인증 데이터
name: string;
phoneNumber: string;
password: string;
};
+ // 지원자 상세 데이터
+ initialData?: ApplicationResponse;
};
// 지원내역 조회 확인 모달
diff --git a/src/types/oauth/oauth.d.ts b/src/types/oauth/oauth.d.ts
index ca4ca5a8..d488e430 100644
--- a/src/types/oauth/oauth.d.ts
+++ b/src/types/oauth/oauth.d.ts
@@ -16,7 +16,16 @@ export interface OauthLoginUser {
}
export interface OauthResponse {
- use: KakaoSignupUser;
+ user: OauthSignupUser;
refreshToken: string;
accessToken: string;
}
+
+export interface OauthAppResponse {
+ createdAt: string;
+ updatedAt: string;
+ appKey: string;
+ provider: string;
+ teamId: string;
+ id: string;
+}
diff --git a/src/utils/translateStatus.ts b/src/utils/translateStatus.ts
index 739eb83e..7058f28d 100644
--- a/src/utils/translateStatus.ts
+++ b/src/utils/translateStatus.ts
@@ -1,11 +1,11 @@
-import { applicationStatus, ApplicationStatusType } from "@/types/applicationStatus";
+import { APPLICATION_STATUS, ApplicationStatusType } from "@/types/applicationStatus";
// 지원 상태에 따른 Chip 컴포넌트의 variant를 반환하는 함수
export const getStatusVariant = (status: ApplicationStatusType) => {
switch (status) {
- case applicationStatus.HIRED:
+ case APPLICATION_STATUS.HIRED:
return "positive";
- case applicationStatus.REJECTED:
+ case APPLICATION_STATUS.REJECTED:
return "negative";
default:
return "positive";
@@ -14,7 +14,6 @@ export const getStatusVariant = (status: ApplicationStatusType) => {
// 상태를 한글로 변환하는 함수
const statusMap: { [key: string]: string } = {
- ALL: "전체",
HIRED: "채용 완료",
REJECTED: "거절",
INTERVIEW_PENDING: "면접 대기",