diff --git a/package-lock.json b/package-lock.json
index c72f63a..c52c7de 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,13 +20,14 @@
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@tanstack/react-query": "^5.83.0",
- "@tanstack/react-query": "^5.83.0",
+ "@use-funnel/react-router-dom": "^0.0.14",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"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"
},
@@ -1131,6 +1132,29 @@
}
}
},
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
+ "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-slider": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz",
@@ -1961,6 +1985,28 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@use-funnel/core": {
+ "version": "0.0.12",
+ "resolved": "https://registry.npmjs.org/@use-funnel/core/-/core-0.0.12.tgz",
+ "integrity": "sha512-IikmJCqiQxbqxMAKdIEvX4MiuaM4nu0mfuAdnbI4CKf1FFLy/c4GHGKHW2+zm4aQAdEtxCuwou/wOwDhrTSWbg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@use-funnel/react-router-dom": {
+ "version": "0.0.14",
+ "resolved": "https://registry.npmjs.org/@use-funnel/react-router-dom/-/react-router-dom-0.0.14.tgz",
+ "integrity": "sha512-bQtSvV8lqwDdytabVEkaL5H/LyckQwvnCyUQdY4lSeN1RXdXtQEyhUV0Z2bnigMkt0Qd6iOxIVn1zwoYMK6oEw==",
+ "license": "MIT",
+ "dependencies": {
+ "@use-funnel/core": "^0.0.12"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-router-dom": ">=6"
+ }
+ },
"node_modules/@vitejs/plugin-react-swc": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz",
@@ -2286,7 +2332,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2762,6 +2807,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/goober": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
+ "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "csstype": "^3.0.10"
+ }
+ },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -3439,6 +3493,23 @@
"react": "^19.1.0"
}
},
+ "node_modules/react-hot-toast": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
+ "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.1.3",
+ "goober": "^2.1.16"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">=16",
+ "react-dom": ">=16"
+ }
+ },
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
diff --git a/package.json b/package.json
index a3ae7d6..8f8e98b 100644
--- a/package.json
+++ b/package.json
@@ -22,12 +22,14 @@
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0",
"@tanstack/react-query": "^5.83.0",
+ "@use-funnel/react-router-dom": "^0.0.14",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"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"
},
diff --git a/src/App.tsx b/src/App.tsx
index b9cbc2a..965b01a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,16 +1,11 @@
import { Outlet } from "react-router-dom";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import GlobalLayout from "./components/layout/GlobalLayout";
-const queryClient = new QueryClient();
-
function App() {
return (
-
-
-
-
-
+
+
+
);
}
diff --git a/src/components/common/.gitkeep b/src/components/common/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/components/common/LocationSelector.tsx b/src/components/common/LocationSelector.tsx
new file mode 100644
index 0000000..a1dbfe5
--- /dev/null
+++ b/src/components/common/LocationSelector.tsx
@@ -0,0 +1,126 @@
+import { useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { MapPin, Loader2 } from "lucide-react";
+import { useLocationQuery } from "@/hooks/queries/useLocationQuery";
+
+interface LocationSelectorProps {
+ onLocationChange: (location: { lng: number; lat: number }) => void;
+ initialLocation?: { lng: number; lat: number } | null;
+}
+
+export const LocationSelector = ({
+ onLocationChange,
+ initialLocation,
+}: LocationSelectorProps) => {
+ const { location, isLoading, isError, refetchLocation } = useLocationQuery();
+
+ useEffect(() => {
+ if (location && !initialLocation) {
+ onLocationChange(location);
+ }
+ }, [location, onLocationChange, initialLocation]);
+
+ const handleGetLocation = async () => {
+ try {
+ await refetchLocation();
+ } catch (error) {
+ console.error("위치 가져오기 실패:", error);
+ }
+ };
+
+ const getStatus = () => {
+ if (isLoading) return "loading";
+ if (isError) return "error";
+ if (location) return "success";
+ return "initial";
+ };
+
+ const status = getStatus();
+
+ return (
+
+ {status === "initial" && (
+
+
+
+ 위치 정보 수집
+
+
+ 주변 개발자와의 연결을 위해 위치 정보가 필요합니다. 브라우저에서
+ 위치 정보 접근을 허용해주세요.
+
+
+
+ )}
+
+ {status === "loading" && (
+
+
+
+ 위치 정보 수집 중...
+
+
+ GPS 신호를 받고 있습니다. 잠시만 기다려주세요.
+
+
+ )}
+
+ {status === "success" && location && (
+
+
+
+
+ 위치 정보 수집 완료
+
+
+ {location.address ? (
+
+
+ 현재 위치: {location.address}
+
+
+ * 약간의 오차가 있을 수 있습니다.
+
+
+ ) : (
+
+ 위도: {location.lat.toFixed(6)}, 경도: {location.lng.toFixed(6)}
+
+ )}
+
+
+ )}
+
+ {status === "error" && (
+
+
+
+
+ 위치 정보 오류
+
+
+
+ 위치 정보를 가져오는 중 오류가 발생했습니다.
+
+
+
+ )}
+
+ );
+};
diff --git a/src/components/common/PositionSelector.tsx b/src/components/common/PositionSelector.tsx
index 262e754..8e04ad8 100644
--- a/src/components/common/PositionSelector.tsx
+++ b/src/components/common/PositionSelector.tsx
@@ -1,4 +1,3 @@
-import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Accordion,
@@ -8,8 +7,8 @@ import {
} from "@/components/ui/accordion";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
-import { fetchPositions } from "@/lib/api";
-import type { PositionCategory } from "@/types/filter";
+import { usePositionsInterests } from "@/hooks/queries/useTags";
+import type { Position } from "@/types/tags.types";
interface PositionSelectorProps {
selectedPosition: number | null;
@@ -22,28 +21,27 @@ export const PositionSelector = ({
onPositionChange,
label = "포지션",
}: PositionSelectorProps) => {
- const [positions, setPositions] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
-
- useEffect(() => {
- const loadPositions = async () => {
- try {
- const fetchedPositions = await fetchPositions();
- setPositions(fetchedPositions);
- } catch (error) {
- console.error("포지션 로딩 실패:", error);
- } finally {
- setIsLoading(false);
- }
- };
-
- loadPositions();
- }, []);
+ const { data: positionsInterestsData, isLoading } = usePositionsInterests();
if (isLoading) {
return 로딩 중...
;
}
+ if (!positionsInterestsData?.positions) {
+ return 포지션을 불러올 수 없습니다.
;
+ }
+
+ const positions = positionsInterestsData.positions;
+
+ // positions를 field별로 그룹화
+ const groupedPositions = positions.reduce((acc, position) => {
+ if (!acc[position.field]) {
+ acc[position.field] = [];
+ }
+ acc[position.field].push(position);
+ return acc;
+ }, {} as Record);
+
return (
@@ -51,31 +49,28 @@ export const PositionSelector = ({
type="single"
collapsible
className="w-full border rounded-md border-brand-surface">
- {positions.map((cat) => (
+ {Object.entries(groupedPositions).map(([field, fieldPositions]) => (
- {cat.category}
+ {field}
- {cat.positions.map((pos) => (
+ {fieldPositions.map((position) => (
))}
diff --git a/src/components/common/StackSelector.tsx b/src/components/common/StackSelector.tsx
index 56382ba..655384c 100644
--- a/src/components/common/StackSelector.tsx
+++ b/src/components/common/StackSelector.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Check, ChevronsUpDown, X } from "lucide-react";
@@ -16,8 +16,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
-import { fetchStacks } from "@/lib/api";
-import type { Stack } from "@/types/filter";
+import { useStacks } from "@/hooks/queries/useTags";
interface StackSelectorProps {
selectedStackIds: number[];
@@ -32,24 +31,8 @@ export const StackSelector = ({
label = "기술 스택 / 분야",
maxSelection,
}: StackSelectorProps) => {
- const [stacks, setStacks] = useState
([]);
const [open, setOpen] = useState(false);
- const [isLoading, setIsLoading] = useState(true);
-
- useEffect(() => {
- const loadStacks = async () => {
- try {
- const fetchedStacks = await fetchStacks();
- setStacks(fetchedStacks);
- } catch (error) {
- console.error("스택 로딩 실패:", error);
- } finally {
- setIsLoading(false);
- }
- };
-
- loadStacks();
- }, []);
+ const { data: stacks, isLoading } = useStacks();
const handleSelectStack = (stackId: number) => {
const newStackIds = selectedStackIds.includes(stackId)
@@ -71,6 +54,10 @@ export const StackSelector = ({
return 로딩 중...
;
}
+ if (!stacks) {
+ return 기술 스택을 불러올 수 없습니다.
;
+ }
+
return (
@@ -102,7 +89,7 @@ export const StackSelector = ({
{stacks.map((stack) => (
handleSelectStack(stack.id)}
className="aria-selected:bg-brand-primary text-white">
- {stack.label}
+ {stack.value}
))}
@@ -129,7 +116,7 @@ export const StackSelector = ({
key={stackId}
variant="secondary"
className="flex items-center gap-x-1 bg-brand-primary text-black">
-
{stack?.label}
+
{stack?.value}
);
diff --git a/src/pages/home/_components/modal/PostItem.tsx b/src/pages/home/_components/modal/PostItem.tsx
index 554737e..fdd3128 100644
--- a/src/pages/home/_components/modal/PostItem.tsx
+++ b/src/pages/home/_components/modal/PostItem.tsx
@@ -1,19 +1,27 @@
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
-import { useAddPostModal } from "../../../../hooks/useAddPostModal";
-export const PostItem = () => {
- const { title, content, setTitle, setContent } = useAddPostModal();
+interface PostItemProps {
+ title: string;
+ content: string;
+ onTitleChange: (title: string) => void;
+ onContentChange: (content: string) => void;
+}
+export const PostItem = ({
+ title,
+ content,
+ onTitleChange,
+ onContentChange,
+}: PostItemProps) => {
return (
-
- {/* 내용 입력 */}
-
- {/* 모집 인원 */}
-
- {/* 포지션 선택 */}
-
- {/* 스택 선택 */}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
index b571f96..ec2e623 100644
--- a/src/pages/home/index.tsx
+++ b/src/pages/home/index.tsx
@@ -1,50 +1,41 @@
-import { useState, useEffect } from "react";
+import { useState } from "react";
import { Bell, Plus, Search } from "lucide-react";
import { useParams, useNavigate } from "react-router-dom";
-import type { Post } from "../../types/board";
-import { fetchPosts } from "../../lib/api";
-import { AppTabs } from "../../components/common/AppTabs";
+import { usePosts, useCreatePost, useBoards } from "@/hooks/queries/usePosts";
+import { AppTabs } from "@/components/common/AppTabs";
import { AddPostModal } from "./_components/modal/AddPostModal";
import { ListItem } from "./_components/ListItem";
-import { BOARD_CATEGORIES, BOARD_CATEGORY_VALUES } from "../../constants/board";
-import type { UICategory } from "../../constants/board";
+import type { UICategory } from "@/constants/board";
import { Button } from "@/components/ui/button";
import Header from "@/components/layout/Header";
export const BoardPage = () => {
const { category = "all" } = useParams<{ category: string }>();
const navigate = useNavigate();
- const [posts, setPosts] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- useEffect(() => {
- const isValidCategory = (cat: string): cat is UICategory => {
- return (BOARD_CATEGORY_VALUES as readonly string[]).includes(cat);
- };
+ const { data: boards = [], isLoading: boardsLoading } = useBoards();
- const loadPosts = async () => {
- setIsLoading(true);
- setError(null);
- if (isValidCategory(category)) {
- try {
- const fetchedPosts = await fetchPosts(category);
- setPosts(fetchedPosts);
- } catch (err) {
- setError(err as Error);
- } finally {
- setIsLoading(false);
- }
- } else {
- setError(new Error("잘못된 카테고리입니다."));
- setIsLoading(false);
- }
- };
+ const validCategories = [
+ "all",
+ ...boards.map((board) => board.id.toString()),
+ ];
+ const currentCategory = validCategories.includes(category) ? category : "all";
- loadPosts();
- }, [category]);
+ const {
+ data: posts = [],
+ isLoading: postsLoading,
+ error,
+ } = usePosts(currentCategory as UICategory);
+ const createPostMutation = useCreatePost();
+
+ const boardTabs = [
+ { value: "all", label: "전체" },
+ ...boards.map((board) => ({
+ value: board.id.toString(),
+ label: board.value,
+ })),
+ ];
const handleTabChange = (value: string) => {
navigate(`/board/${value}`);
@@ -68,23 +59,16 @@ export const BoardPage = () => {
content: string;
boardId: number;
}) => {
- setIsSubmitting(true);
try {
- // TODO: API 연동
- console.log("게시글 작성:", data);
+ await createPostMutation.mutateAsync(data);
setIsModalOpen(false);
- // 게시글 작성 후 목록 새로고침
- const fetchedPosts = await fetchPosts(category as UICategory);
- setPosts(fetchedPosts);
} catch (err) {
console.error("게시글 작성 실패:", err);
alert("게시글 작성에 실패했습니다.");
- } finally {
- setIsSubmitting(false);
}
};
- if (isLoading)
+ if (boardsLoading || postsLoading)
return 로딩 중...
;
if (error)
return (
@@ -103,13 +87,15 @@ export const BoardPage = () => {
onRightClick={handleSearchClick}
/>
-
+
{posts.map((post) => (
@@ -127,7 +113,7 @@ export const BoardPage = () => {
setIsModalOpen(false)}
onSubmit={handlePostSubmit}
- isSubmitting={isSubmitting}
+ isSubmitting={createPostMutation.isPending}
/>
)}
diff --git a/src/pages/onboarding/OnboardingPage.tsx b/src/pages/onboarding/OnboardingPage.tsx
deleted file mode 100644
index 1165f9c..0000000
--- a/src/pages/onboarding/OnboardingPage.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useState } from "react";
-import { OnboardingStep1 } from "@/pages/onboarding/__components/OnboardingStep1";
-import { OnboardingStep2 } from "@/pages/onboarding/__components/OnboardingStep2";
-import { OnboardingStep3 } from "@/pages/onboarding/__components/OnboardingStep3";
-import { OnboardingStep4 } from "@/pages/onboarding/__components/OnboardingStep4";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import Header from "@/components/layout/Header";
-import { ArrowLeft } from "lucide-react";
-
-export interface OnboardingData {
- nickname?: string;
- age?: number;
- experience?: number;
- bio?: string;
- position?: number;
- techStack?: number[];
- interests?: number[];
- linkedin?: string;
- email?: string;
- instagram?: string;
- blog?: string;
-}
-
-export function OnboardingPage() {
- const [step, setStep] = useState(1);
- const [onboardingData, setOnboardingData] = useState
>(
- {}
- );
- const TOTAL_STEPS = 4;
-
- const handleNext = (currentStepData: Partial) => {
- const newData = { ...onboardingData, ...currentStepData };
- setOnboardingData(newData);
-
- if (step < TOTAL_STEPS) {
- setStep((prev) => prev + 1);
- } else {
- console.log("최종 데이터:", newData);
- }
- };
-
- const progress = (step / TOTAL_STEPS) * 100;
-
- const getStepTitle = () => {
- switch (step) {
- case 1:
- return "프로필 설정";
- case 2:
- return "기술 / 관심 분야";
- case 3:
- return "추가 정보";
- case 4:
- return "";
- default:
- return "프로필 설정";
- }
- };
-
- return (
-
-
-
- {step < TOTAL_STEPS && (
-
- )}
-
- {step < TOTAL_STEPS && (
-
-
- {getStepTitle()}
-
-
- )}
-
- {step === 1 && (
-
- )}
- {step === 2 && (
-
- )}
- {step === 3 && (
-
- )}
- {step === 4 && }
-
-
-
-
- );
-}
diff --git a/src/pages/onboarding/__components/OnboardingStep4.tsx b/src/pages/onboarding/__components/OnboardingStep4.tsx
deleted file mode 100644
index 6c5f4cd..0000000
--- a/src/pages/onboarding/__components/OnboardingStep4.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useState, useEffect } from "react";
-import { useNavigate } from "react-router-dom";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-
-export function OnboardingStep4() {
- const navigate = useNavigate();
- const [showText1, setShowText1] = useState(false);
- const [showText2, setShowText2] = useState(false);
- const [showButton, setShowButton] = useState(false);
-
- useEffect(() => {
- const timer1 = setTimeout(() => {
- setShowText1(true);
- }, 500);
-
- const timer2 = setTimeout(() => {
- setShowText2(true);
- }, 1300);
-
- const timer3 = setTimeout(() => {
- setShowButton(true);
- }, 2100);
-
- return () => {
- clearTimeout(timer1);
- clearTimeout(timer2);
- clearTimeout(timer3);
- };
- }, []);
-
- const handleSubmit = () => {
- navigate("/board");
- };
-
- return (
-
-
- 환영합니다!
-
-
-
- CO-MEET과 함께 즐거운 개발 생활 되세요!
-
-
-
-
- 홈으로
-
-
-
- );
-}
diff --git a/src/pages/onboarding/__components/OnboardingStep1.tsx b/src/pages/onboarding/_components/OnboardingStep1.tsx
similarity index 67%
rename from src/pages/onboarding/__components/OnboardingStep1.tsx
rename to src/pages/onboarding/_components/OnboardingStep1.tsx
index 28ffec4..13e45b9 100644
--- a/src/pages/onboarding/__components/OnboardingStep1.tsx
+++ b/src/pages/onboarding/_components/OnboardingStep1.tsx
@@ -1,5 +1,5 @@
-import { useState } from "react";
-import type { OnboardingData } from "@/pages/onboarding/OnboardingPage";
+import { useState, useEffect } from "react";
+import type { OnboardingData } from "@/pages/onboarding/index.tsx";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -12,6 +12,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
+import { LocationSelector } from "@/components/common/LocationSelector";
const MAX_AGE_LIMIT = 120;
@@ -27,6 +28,23 @@ export function OnboardingStep1({ onNext, data }: StepProps) {
data.experience ? `${data.experience}년차` : ""
);
const [bio, setBio] = useState(data.bio || "");
+ const [location, setLocation] = useState<{ lng: number; lat: number } | null>(
+ data.location || null
+ );
+ const [isValid, setIsValid] = useState(false);
+
+ useEffect(() => {
+ const isValidForm =
+ nickname.trim() !== "" &&
+ age !== "" &&
+ Number(age) > 0 &&
+ Number(age) < MAX_AGE_LIMIT &&
+ experience !== "" &&
+ bio.trim() !== "" &&
+ location !== null;
+
+ setIsValid(isValidForm);
+ }, [nickname, age, experience, bio, location]);
const handleAgeChange = (e: React.ChangeEvent) => {
const value = e.target.value;
@@ -38,31 +56,28 @@ export function OnboardingStep1({ onNext, data }: StepProps) {
}
};
+ const handleLocationChange = (newLocation: { lng: number; lat: number }) => {
+ setLocation(newLocation);
+ };
+
const handleSubmit = () => {
- if (age === "") {
- alert("나이를 입력해주세요.");
- return;
- }
- if (Number(age) >= MAX_AGE_LIMIT) {
- alert(`나이는 ${MAX_AGE_LIMIT} 미만으로 입력해주세요.`);
- return;
- }
- if (age < 1) {
- alert("나이를 올바르게 입력해주세요.");
- return;
- }
- if (!nickname || !age || !experience || !bio) {
- alert("모든 항목을 입력해주세요.");
+ if (!isValid) {
+ alert("모든 항목을 올바르게 입력해주세요.");
return;
}
const experienceValue = parseInt(experience, 10);
-
- onNext({ nickname, age: Number(age), experience: experienceValue, bio });
+ onNext({
+ nickname: nickname.trim(),
+ age: Number(age),
+ experience: experienceValue,
+ bio: bio.trim(),
+ location: location!,
+ });
};
return (
-
+
@@ -89,6 +104,11 @@ export function OnboardingStep1({ onNext, data }: StepProps) {
"border-red-500 focus:border-red-500 text-red-500"
)}
/>
+ {Number(age) >= MAX_AGE_LIMIT && (
+
+ 나이는 {MAX_AGE_LIMIT} 미만으로 입력해주세요.
+
+ )}
@@ -118,11 +138,25 @@ export function OnboardingStep1({ onNext, data }: StepProps) {
className="bg-brand-surface border-transparent focus:!ring-0 focus:!border-brand-primary h-[120px] resize-none"
/>
+
+
+
+
+
+ disabled={!isValid}
+ className={cn(
+ "w-full font-bold text-lg py-6",
+ isValid
+ ? "bg-brand-primary hover:bg-brand-primary/90 text-black"
+ : "bg-gray-400 text-gray-600 cursor-not-allowed"
+ )}>
다음
diff --git a/src/pages/onboarding/__components/OnboardingStep2.tsx b/src/pages/onboarding/_components/OnboardingStep2.tsx
similarity index 55%
rename from src/pages/onboarding/__components/OnboardingStep2.tsx
rename to src/pages/onboarding/_components/OnboardingStep2.tsx
index 88a9780..94d259a 100644
--- a/src/pages/onboarding/__components/OnboardingStep2.tsx
+++ b/src/pages/onboarding/_components/OnboardingStep2.tsx
@@ -1,20 +1,11 @@
-import { useState } from "react";
-import type { OnboardingData } from "@/pages/onboarding/OnboardingPage";
+import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { PositionSelector } from "@/components/common/PositionSelector";
import { StackSelector } from "@/components/common/StackSelector";
-const INTEREST_TOPICS = [
- { id: 1, name: "업계 동향" },
- { id: 2, name: "직무 정보" },
- { id: 3, name: "커피챗" },
- { id: 4, name: "정보 공유" },
- { id: 5, name: "취업 준비" },
- { id: 6, name: "자기계발" },
- { id: 7, name: "이벤트" },
- { id: 8, name: "기타" },
-];
+import { usePositionsInterests } from "@/hooks/queries/useTags";
+import { cn } from "@/lib/utils";
+import type { OnboardingData } from "../index";
interface StepProps {
onNext: (data: Partial
) => void;
@@ -22,13 +13,28 @@ interface StepProps {
}
export function OnboardingStep2({ onNext, data }: StepProps) {
- const [position, setPosition] = useState(data.position);
+ const [position, setPosition] = useState(
+ data.position || null
+ );
const [selectedStackIds, setSelectedStackIds] = useState(
data.techStack || []
);
const [selectedInterestIds, setSelectedInterestIds] = useState(
data.interests || []
);
+ const [isValid, setIsValid] = useState(false);
+
+ const { data: positionsInterestsData, isLoading } = usePositionsInterests();
+
+ // 유효성 검사
+ useEffect(() => {
+ const isValidForm =
+ position !== null &&
+ selectedStackIds.length > 0 &&
+ selectedInterestIds.length > 0;
+
+ setIsValid(isValidForm);
+ }, [position, selectedStackIds, selectedInterestIds]);
const handleInterestToggle = (topicId: number) => {
const newInterestIds = selectedInterestIds.includes(topicId)
@@ -40,28 +46,33 @@ export function OnboardingStep2({ onNext, data }: StepProps) {
};
const handleSubmit = () => {
- if (!position) {
- alert("포지션을 선택해주세요.");
- return;
- }
- if (selectedInterestIds.length === 0) {
- alert("관심 분야를 1개 이상 선택해주세요.");
+ if (!isValid) {
+ alert("포지션, 기술 스택, 관심 분야를 모두 선택해주세요.");
return;
}
+
onNext({
- position: position,
+ position: position!,
techStack: selectedStackIds,
interests: selectedInterestIds,
});
};
+ if (isLoading) {
+ return 로딩 중...
;
+ }
+
+ if (!positionsInterestsData?.interests) {
+ return 관심사를 불러올 수 없습니다.
;
+ }
+
return (
-
+
{/* 포지션 */}
setPosition(positionId)}
+ selectedPosition={position}
+ onPositionChange={setPosition}
/>
{/* 기술 스택 */}
@@ -73,17 +84,17 @@ export function OnboardingStep2({ onNext, data }: StepProps) {
- {INTEREST_TOPICS.map((topic) => (
+ {positionsInterestsData.interests.map((interest) => (
handleInterestToggle(topic.id)}
+ onClick={() => handleInterestToggle(interest.id)}
className={cn(
"rounded-md border-brand-surface bg-transparent hover:bg-brand-primary hover:text-white",
- selectedInterestIds.includes(topic.id) &&
+ selectedInterestIds.includes(interest.id) &&
"border-brand-primary text-brand-primary border-1"
)}>
- {topic.name}
+ {interest.value}
))}
@@ -93,7 +104,13 @@ export function OnboardingStep2({ onNext, data }: StepProps) {
+ disabled={!isValid}
+ className={cn(
+ "w-full font-bold text-lg py-6",
+ isValid
+ ? "bg-brand-primary hover:bg-brand-primary/80 text-black"
+ : "bg-gray-400 text-gray-600 cursor-not-allowed"
+ )}>
다음
diff --git a/src/pages/onboarding/__components/OnboardingStep3.tsx b/src/pages/onboarding/_components/OnboardingStep3.tsx
similarity index 93%
rename from src/pages/onboarding/__components/OnboardingStep3.tsx
rename to src/pages/onboarding/_components/OnboardingStep3.tsx
index e0ba646..72e0407 100644
--- a/src/pages/onboarding/__components/OnboardingStep3.tsx
+++ b/src/pages/onboarding/_components/OnboardingStep3.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import type { OnboardingData } from "@/pages/onboarding/OnboardingPage";
+import type { OnboardingData } from "@/pages/onboarding/index.tsx";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -17,7 +17,12 @@ export function OnboardingStep3({ onNext, data }: StepProps) {
const [blog, setBlog] = useState(data.blog || "");
const handleSubmit = () => {
- onNext({ linkedin, email, instagram, blog });
+ onNext({
+ linkedin: linkedin.trim(),
+ email: email.trim(),
+ instagram: instagram.trim(),
+ blog: blog.trim(),
+ });
};
return (
@@ -87,7 +92,7 @@ export function OnboardingStep3({ onNext, data }: StepProps) {
- 다음
+ 완료
diff --git a/src/pages/onboarding/_components/OnboardingStep4.tsx b/src/pages/onboarding/_components/OnboardingStep4.tsx
new file mode 100644
index 0000000..cc0805d
--- /dev/null
+++ b/src/pages/onboarding/_components/OnboardingStep4.tsx
@@ -0,0 +1,133 @@
+import { useState, useEffect } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { useSignUp } from "@/hooks/queries/useAuth";
+import type { OnboardingData } from "../index";
+
+interface StepProps {
+ data: OnboardingData;
+}
+
+export function OnboardingStep4({ data }: StepProps) {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const [showText1, setShowText1] = useState(false);
+ const [showText2, setShowText2] = useState(false);
+ const [showButton, setShowButton] = useState(false);
+
+ const signUpMutation = useSignUp();
+
+ useEffect(() => {
+ const timer1 = setTimeout(() => {
+ setShowText1(true);
+ }, 500);
+
+ const timer2 = setTimeout(() => {
+ setShowText2(true);
+ }, 1300);
+
+ const timer3 = setTimeout(() => {
+ setShowButton(true);
+ }, 2100);
+
+ return () => {
+ clearTimeout(timer1);
+ clearTimeout(timer2);
+ clearTimeout(timer3);
+ };
+ }, []);
+
+ const handleSubmit = async () => {
+ try {
+ const sessionId = searchParams.get("sessionId");
+
+ if (!sessionId) {
+ navigate("/login");
+ return;
+ }
+
+ if (
+ !data.nickname ||
+ !data.age ||
+ !data.experience ||
+ !data.bio ||
+ !data.location ||
+ !data.position ||
+ !data.techStack ||
+ !data.interests
+ ) {
+ console.error("온보딩 데이터가 불완전합니다:", {
+ nickname: data.nickname,
+ age: data.age,
+ experience: data.experience,
+ bio: data.bio,
+ location: data.location,
+ position: data.position,
+ techStack: data.techStack,
+ interests: data.interests,
+ });
+ return;
+ }
+
+ const requestData = {
+ nickname: data.nickname,
+ age: data.age,
+ experience: data.experience,
+ bio: data.bio,
+ location: {
+ lng: data.location.lng,
+ lat: data.location.lat,
+ },
+ positionId: data.position,
+ techIds: data.techStack,
+ interestIds: data.interests,
+ ...(data.email && { email: data.email }),
+ ...(data.instagram && { instagram: data.instagram }),
+ ...(data.linkedin && { linkedIn: data.linkedin }),
+ ...(data.blog && { blog: data.blog }),
+ };
+
+ await signUpMutation.mutateAsync({
+ sessionId,
+ data: requestData,
+ });
+ navigate("/board");
+ } catch (error) {
+ alert("회원가입에 실패했습니다. 다시 시도해주세요.");
+ }
+ };
+
+ return (
+
+
+ 환영합니다!
+
+
+
+ CO-MEET과 함께 즐거운 개발 생활 되세요!
+
+
+
+
+ {signUpMutation.isPending ? "처리 중..." : "홈으로"}
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/index.tsx b/src/pages/onboarding/index.tsx
new file mode 100644
index 0000000..6f21586
--- /dev/null
+++ b/src/pages/onboarding/index.tsx
@@ -0,0 +1,219 @@
+import { useFunnel } from "@use-funnel/react-router-dom";
+import { OnboardingStep1 } from "@/pages/onboarding/_components/OnboardingStep1";
+import { OnboardingStep2 } from "@/pages/onboarding/_components/OnboardingStep2";
+import { OnboardingStep3 } from "@/pages/onboarding/_components/OnboardingStep3";
+import { OnboardingStep4 } from "@/pages/onboarding/_components/OnboardingStep4";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import Header from "@/components/layout/Header";
+import { ArrowLeft } from "lucide-react";
+
+type ProfileSetup = {
+ nickname?: string;
+ age?: number;
+ experience?: number;
+ bio?: string;
+ location?: {
+ lng: number;
+ lat: number;
+ };
+};
+
+type TechInterests = {
+ nickname: string;
+ age: number;
+ experience: number;
+ bio: string;
+ location: {
+ lng: number;
+ lat: number;
+ };
+ position?: number;
+ techStack?: number[];
+ interests?: number[];
+};
+
+type AdditionalInfo = {
+ nickname: string;
+ age: number;
+ experience: number;
+ bio: string;
+ location: {
+ lng: number;
+ lat: number;
+ };
+ position: number;
+ techStack: number[];
+ interests: number[];
+ linkedin?: string;
+ email?: string;
+ instagram?: string;
+ blog?: string;
+};
+
+type Complete = {
+ nickname: string;
+ age: number;
+ experience: number;
+ bio: string;
+ location: {
+ lng: number;
+ lat: number;
+ };
+ position: number;
+ techStack: number[];
+ interests: number[];
+ linkedin?: string;
+ email?: string;
+ instagram?: string;
+ blog?: string;
+};
+
+export interface OnboardingData {
+ nickname?: string;
+ age?: number;
+ experience?: number;
+ bio?: string;
+ location?: {
+ lng: number;
+ lat: number;
+ };
+ position?: number;
+ techStack?: number[];
+ interests?: number[];
+ linkedin?: string;
+ email?: string;
+ instagram?: string;
+ blog?: string;
+}
+
+export function OnboardingPage() {
+ const funnel = useFunnel<{
+ ProfileSetup: ProfileSetup;
+ TechInterests: TechInterests;
+ AdditionalInfo: AdditionalInfo;
+ Complete: Complete;
+ }>({
+ id: "onboarding",
+ initial: {
+ step: "ProfileSetup",
+ context: {},
+ },
+ });
+
+ const getStepTitle = () => {
+ switch (funnel.step) {
+ case "ProfileSetup":
+ return "프로필 설정";
+ case "TechInterests":
+ return "기술 / 관심 분야";
+ case "AdditionalInfo":
+ return "추가 정보";
+ case "Complete":
+ default:
+ return "";
+ }
+ };
+
+ const getProgress = () => {
+ const stepOrder = [
+ "ProfileSetup",
+ "TechInterests",
+ "AdditionalInfo",
+ "Complete",
+ ];
+ const currentIndex = stepOrder.indexOf(funnel.step);
+ return ((currentIndex + 1) / stepOrder.length) * 100;
+ };
+
+ const canGoBack =
+ funnel.step !== "ProfileSetup" && funnel.step !== "Complete";
+
+ const handleBack = () => {
+ if (canGoBack) {
+ funnel.history.back();
+ }
+ };
+
+ return (
+
+
: undefined}
+ onLeftClick={canGoBack ? handleBack : undefined}
+ />
+
+ {funnel.step !== "Complete" && (
+
+ )}
+
+ {funnel.step !== "Complete" && (
+
+
+ {getStepTitle()}
+
+
+ )}
+
+ (
+ {
+ if (
+ data.nickname &&
+ data.age &&
+ data.experience &&
+ data.bio &&
+ data.location
+ ) {
+ history.push("TechInterests", (prev) => ({
+ ...prev,
+ nickname: data.nickname!,
+ age: data.age!,
+ experience: data.experience!,
+ bio: data.bio!,
+ location: data.location!,
+ }));
+ }
+ }}
+ data={funnel.context}
+ />
+ )}
+ TechInterests={({ context, history }) => (
+ {
+ if (data.position && data.techStack && data.interests) {
+ history.push("AdditionalInfo", (prev) => ({
+ ...prev,
+ position: data.position!,
+ techStack: data.techStack!,
+ interests: data.interests!,
+ }));
+ }
+ }}
+ data={context}
+ />
+ )}
+ AdditionalInfo={({ context, history }) => (
+ {
+ history.push("Complete", (prev) => ({
+ ...prev,
+ ...data,
+ }));
+ }}
+ data={context}
+ />
+ )}
+ Complete={({ context }) => }
+ />
+
+
+
+
+ );
+}
diff --git a/src/types/.gitkeep b/src/types/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/types/auth.ts b/src/types/auth.ts
deleted file mode 100644
index 4e6d959..0000000
--- a/src/types/auth.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export interface User {
- nickname: string;
- age: number;
- experience: number;
- bio: string;
- position: number;
- techStack: number[];
- interests: number[];
- location: [number, number];
- email?: string;
- linkedin?: string;
- instagram?: string;
- blog?: string;
-}
-
-export interface ExistingUserResponse {
- status: 200;
- accessToken: string;
- sessionId: string;
- user: User;
-}
-
-export interface NewUserResponse {
- status: 210;
- githubId: string;
- user: User;
-}
-
-export type FetchLoginResponse = ExistingUserResponse | NewUserResponse;
\ No newline at end of file
diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts
new file mode 100644
index 0000000..5b783d8
--- /dev/null
+++ b/src/types/auth.types.ts
@@ -0,0 +1,29 @@
+export interface LoginResponse {
+ accessToken?: string;
+ sessionId?: string;
+ result?: {
+ nickname: string;
+ avatar: string;
+ accessToken: string;
+ };
+}
+
+export type FetchLoginResponse = LoginResponse;
+
+export interface SignUpRequest {
+ nickname: string;
+ age: number;
+ experience: number;
+ bio: string;
+ location: {
+ lng: number;
+ lat: number;
+ };
+ positionId: number;
+ techIds: number[];
+ interestIds: number[];
+ email?: string;
+ instagram?: string;
+ linkedIn?: string;
+ blog?: string;
+}
diff --git a/src/types/board.ts b/src/types/board.ts
deleted file mode 100644
index 308db15..0000000
--- a/src/types/board.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export interface Post {
- id: number;
- categoryId: number;
- title: string;
- author: string;
- date: string;
- likes: number;
- comments: number;
-}
diff --git a/src/types/community.ts b/src/types/community.types.ts
similarity index 100%
rename from src/types/community.ts
rename to src/types/community.types.ts
diff --git a/src/types/developer.ts b/src/types/developer.types.ts
similarity index 100%
rename from src/types/developer.ts
rename to src/types/developer.types.ts
diff --git a/src/types/filter.ts b/src/types/filter.ts
deleted file mode 100644
index aba34ce..0000000
--- a/src/types/filter.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-export interface Position {
- id: number;
- name: string;
- description: string;
-}
-
-export interface PositionCategory {
- category: string;
- positions: readonly Position[];
-}
-
-export interface Stack {
- id: number;
- label: string;
-}
diff --git a/src/types/post.types.ts b/src/types/post.types.ts
new file mode 100644
index 0000000..efad29d
--- /dev/null
+++ b/src/types/post.types.ts
@@ -0,0 +1,16 @@
+export interface Post {
+ id: number;
+ board: {
+ id: number;
+ value: string;
+ isRecruit: boolean;
+ };
+ author: {
+ avatar: string;
+ nickname: string;
+ };
+ title: string;
+ nComments: number;
+ nLikes: number;
+ createdAt: string;
+}
diff --git a/src/types/tags.types.ts b/src/types/tags.types.ts
new file mode 100644
index 0000000..126d35c
--- /dev/null
+++ b/src/types/tags.types.ts
@@ -0,0 +1,20 @@
+export interface Position {
+ id: number;
+ field: string;
+ role: string;
+}
+
+export interface Interest {
+ id: number;
+ value: string;
+}
+
+export interface Stack {
+ id: number;
+ value: string;
+}
+
+export interface PositionsInterestsResponse {
+ positions: Position[];
+ interests: Interest[];
+}