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} + {isError && ( +
+

로그인 실패

+
+

+ {error?.message || "로그인 처리 중 오류가 발생했습니다"} +

- )} -
- + +
+ )} +
); -} \ No newline at end of file +} diff --git a/src/pages/community/__components/CommentItem.tsx b/src/pages/community/__components/CommentItem.tsx index 9d67f5f..238a431 100644 --- a/src/pages/community/__components/CommentItem.tsx +++ b/src/pages/community/__components/CommentItem.tsx @@ -1,4 +1,4 @@ -import type { Comment } from "@/types/community"; +import type { Comment } from "@/types/community.types"; import { Button } from "@/components/ui/button"; import { Heart, MessageCircle } from "lucide-react"; import { ReplyItem } from "./ReplyItem"; diff --git a/src/pages/community/__components/CommentSection.tsx b/src/pages/community/__components/CommentSection.tsx index b5ec7e0..d6a64be 100644 --- a/src/pages/community/__components/CommentSection.tsx +++ b/src/pages/community/__components/CommentSection.tsx @@ -1,4 +1,4 @@ -import type { Comment } from "@/types/community"; +import type { Comment } from "@/types/community.types"; import { CommentItem } from "./CommentItem"; interface CommentSectionProps { diff --git a/src/pages/community/__components/PostContent.tsx b/src/pages/community/__components/PostContent.tsx index ed42d25..c261db6 100644 --- a/src/pages/community/__components/PostContent.tsx +++ b/src/pages/community/__components/PostContent.tsx @@ -1,4 +1,4 @@ -import type { Post } from "@/types/community"; +import type { Post } from "@/types/community.types"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; diff --git a/src/pages/community/__components/ReplyItem.tsx b/src/pages/community/__components/ReplyItem.tsx index e500086..7167482 100644 --- a/src/pages/community/__components/ReplyItem.tsx +++ b/src/pages/community/__components/ReplyItem.tsx @@ -1,4 +1,4 @@ -import type { Reply } from "@/types/community"; +import type { Reply } from "@/types/community.types"; import { Button } from "@/components/ui/button"; import { Heart } from "lucide-react"; diff --git a/src/pages/explore/_components/DeveloperProfileCard.tsx b/src/pages/explore/_components/DeveloperProfileCard.tsx index dc729ae..f1c8edd 100644 --- a/src/pages/explore/_components/DeveloperProfileCard.tsx +++ b/src/pages/explore/_components/DeveloperProfileCard.tsx @@ -1,4 +1,4 @@ -import type { Developer } from "@/types/developer"; +import type { Developer } from "@/types/developer.types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; diff --git a/src/pages/explore/_components/layout/GridView.tsx b/src/pages/explore/_components/layout/GridView.tsx index 16e007f..b26bfd8 100644 --- a/src/pages/explore/_components/layout/GridView.tsx +++ b/src/pages/explore/_components/layout/GridView.tsx @@ -1,4 +1,4 @@ -import type { Developer } from "@/types/developer"; +import type { Developer } from "@/types/developer.types"; import { DeveloperProfileCard } from "../DeveloperProfileCard"; interface GridViewProps { diff --git a/src/pages/explore/_components/layout/ListView.tsx b/src/pages/explore/_components/layout/ListView.tsx index ce44e51..813f4d8 100644 --- a/src/pages/explore/_components/layout/ListView.tsx +++ b/src/pages/explore/_components/layout/ListView.tsx @@ -1,4 +1,4 @@ -import type { Developer } from "@/types/developer"; +import type { Developer } from "@/types/developer.types"; import { DeveloperProfileCard } from "../DeveloperProfileCard"; interface ListViewProps { diff --git a/src/pages/explore/index.tsx b/src/pages/explore/index.tsx index 04cdb7b..5093dc3 100644 --- a/src/pages/explore/index.tsx +++ b/src/pages/explore/index.tsx @@ -3,12 +3,12 @@ import { Settings2, LayoutGrid, List } from "lucide-react"; import { Button } from "../../components/ui/button"; import { FilteringModal } from "./_components/FilteringModal"; import { EXPLORE_TABS, type ExploreTabValue } from "../../constants/explore"; -import type { Developer } from "../../types/developer"; +import type { Developer } from "../../types/developer.types"; import { ListView } from "./_components/layout/ListView"; import { GridView } from "./_components/layout/GridView"; import { AppTabs } from "../../components/common/AppTabs"; import { cn } from "../../lib/utils"; -import { fetchDevelopers } from "../../lib/api"; +import { fetchDevelopers } from "../../lib/api/developers"; export const ExplorePage = () => { const [activeTab, setActiveTab] = useState("nearby"); diff --git a/src/pages/home/_components/ListItem.tsx b/src/pages/home/_components/ListItem.tsx index 6e88562..14924e2 100644 --- a/src/pages/home/_components/ListItem.tsx +++ b/src/pages/home/_components/ListItem.tsx @@ -2,8 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Heart, MessageCircle } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import type { Post } from "@/types/board"; -import { BOARD_CATEGORIES } from "@/constants/board"; +import type { Post } from "@/types/post.types"; interface ListItemProps { post: Post; @@ -11,36 +10,41 @@ interface ListItemProps { export const ListItem = ({ post }: ListItemProps) => { const navigate = useNavigate(); - const categoryLabel = - BOARD_CATEGORIES.find((c) => c.value === post.categoryId.toString()) - ?.label || ""; const handleClick = () => { navigate(`/community/${post.id}`); }; + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + month: "short", + day: "numeric", + }); + }; + return ( - {categoryLabel} + {post.board.value} {post.title} - {post.author} · {post.date} + {post.author.nickname} · {formatDate(post.createdAt)}
- {post.likes} + {post.nLikes}
- {post.comments} + {post.nComments}
diff --git a/src/pages/home/_components/modal/AddPostModal.tsx b/src/pages/home/_components/modal/AddPostModal.tsx index 5d6909b..4e9f6b0 100644 --- a/src/pages/home/_components/modal/AddPostModal.tsx +++ b/src/pages/home/_components/modal/AddPostModal.tsx @@ -8,10 +8,10 @@ import { } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { ModalWrapper } from "@/components/common/ModalWrapper"; -import { BOARD_CATEGORIES } from "@/constants/board"; import { PostItem } from "./PostItem"; import { RecruitItem } from "./RecruitItem"; import { useAddPostModal } from "../../../../hooks/useAddPostModal"; +import { useBoards } from "../../../../hooks/queries/usePosts"; interface AddPostModalProps { onClose: () => void; @@ -33,11 +33,46 @@ export const AddPostModal = ({ position, selectedStackIds, isRecruitBoard, + setTitle, + setContent, setSelectedBoard, + setDescription, + setRecruitCount, + setPosition, + setSelectedStackIds, reset, } = useAddPostModal(); - const handleSubmit = () => {}; + const { data: boards = [] } = useBoards(); + + const handleSubmit = () => { + if (!title.trim() || !selectedBoard) { + return; + } + + if (isRecruitBoard) { + if (!description.trim() || !position || selectedStackIds.length === 0) { + return; + } + } else { + if (!content.trim()) { + return; + } + } + + const selectedBoardData = boards.find( + (board) => board.value === selectedBoard + ); + if (!selectedBoardData) return; + + const postData = { + title: title.trim(), + content: isRecruitBoard ? description.trim() : content.trim(), + boardId: selectedBoardData.id, + }; + + onSubmit(postData); + }; const handleClose = () => { reset(); @@ -62,7 +97,7 @@ export const AddPostModal = ({ ? !description.trim() || !position || selectedStackIds.length === 0 : !content.trim()) } - className="flex-1 bg-brand-primary hover:bg-brand-primary/90 font-bold text-base py-6"> + className="flex-1 bg-brand-primary hover:bg-brand-primary/90 font-bold text-base py-6"> {isSubmitting ? "작성 중..." : "작성하기"}
@@ -79,22 +114,40 @@ export const AddPostModal = ({ - {BOARD_CATEGORIES.filter((cat) => cat.value !== "all").map( - (category) => ( - - {category.label} - - ) - )} + {boards.map((board) => ( + + {board.value} + + ))} {/*포스트, 리크루트 조건부 렌더링*/} - {isRecruitBoard ? : } + {isRecruitBoard ? ( + + ) : ( + + )} ); 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 (
- {/* 제목 입력 */}
setTitle(e.target.value)} + onChange={(e) => onTitleChange(e.target.value)} placeholder="제목을 입력하세요" className="bg-brand-surface border-transparent focus:!ring-0 focus:!border-brand-primary" maxLength={100} @@ -22,13 +30,11 @@ export const PostItem = () => { {title.length}/100
- - {/* 내용 입력 */}