diff --git a/src/apis/member/postUpdateProfile.js b/src/apis/member/postUpdateProfile.js new file mode 100644 index 0000000..0759777 --- /dev/null +++ b/src/apis/member/postUpdateProfile.js @@ -0,0 +1,10 @@ +import api from "@/apis/instance/api"; + +export const postUpdateProfile = async ({ name, location, profileColor }) => { + const response = await api.post("/member/update", { + name, + location, + profileColor, + }); + return response.data; +}; diff --git a/src/apis/member/queries.js b/src/apis/member/queries.js index d3c3f7c..2c1791d 100644 --- a/src/apis/member/queries.js +++ b/src/apis/member/queries.js @@ -1,8 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { getMyProfile } from "@/apis/member/auth"; import { getReviewCountOfMember } from "@/apis/member/getReviewCountOfMember"; import { getCheerCountOfMember } from "@/apis/member/getCheerCountOfMember"; import { getLikeCountOfMember } from "@/apis/member/getLikeCountOfMember"; +import { postUpdateProfile } from "./postUpdateProfile"; export const useMyProfile = () => { return useQuery({ @@ -34,3 +35,9 @@ export const useGetLikeCountOfMember = () => { queryFn: () => getLikeCountOfMember(), }); }; + +export const useUpdateProfile = () => { + return useMutation({ + mutationFn: postUpdateProfile, + }); +}; diff --git a/src/apis/myPage/getDetail.js b/src/apis/myPage/getDetail.js index b77835c..cd241be 100644 --- a/src/apis/myPage/getDetail.js +++ b/src/apis/myPage/getDetail.js @@ -1,5 +1,10 @@ import api from "@/apis/instance/api"; +export const getHearts = async () => { + const res = await api.get("/company/member-saves"); + return res.data; +}; + export const getReviews = async () => { const res = await api.get("/reviews/get-all-member-reviews"); return res.data; diff --git a/src/apis/myPage/queries.js b/src/apis/myPage/queries.js index 85049cd..862736f 100644 --- a/src/apis/myPage/queries.js +++ b/src/apis/myPage/queries.js @@ -1,6 +1,13 @@ import { useQuery } from "@tanstack/react-query"; -import { getReviews } from "@/apis/myPage/getDetail"; -import { getCheers } from "@/apis/myPage/getDetail"; +import { getHearts, getReviews, getCheers } from "@/apis/myPage/getDetail"; + +export const useGetHearts = ({ enabled }) => { + return useQuery({ + queryKey: ["userHearts"], + queryFn: getHearts, + enabled, + }); +}; export const useGetReviews = () => { return useQuery({ queryKey: ["userReview"], queryFn: () => getReviews() }); diff --git a/src/assets/svgs/modal/Ic_Warn.svg b/src/assets/svgs/modal/Ic_Warn.svg new file mode 100644 index 0000000..e8dfb3b --- /dev/null +++ b/src/assets/svgs/modal/Ic_Warn.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svgs/modal/index.js b/src/assets/svgs/modal/index.js new file mode 100644 index 0000000..60c3a4e --- /dev/null +++ b/src/assets/svgs/modal/index.js @@ -0,0 +1,2 @@ +export { default as IcError } from "./errorIcon.svg?react"; +export { default as IcWarnning } from "./Ic_Warn.svg?react"; diff --git a/src/pages/myPage/MyPage.jsx b/src/pages/myPage/MyPage.jsx index 9135835..d4f85aa 100644 --- a/src/pages/myPage/MyPage.jsx +++ b/src/pages/myPage/MyPage.jsx @@ -104,15 +104,15 @@ const MyPage = () => { {showLogoutModal && navigate("/")} />}
- -

{nickname}

{location}

-
-
{Object.entries(counts).map(([label, count]) => (
{ + const navigate = useNavigate(); + const { data } = useMyProfile(); + const { mutate: updateProfile } = useUpdateProfile(); + + const [name, setName] = useState(""); + const [location, setAddress] = useState(""); + const [profileColor, setProfileColor] = useState("gray"); + const [toastVisible, setToastVisible] = useState(false); + const [isNameFocused, setIsNameFocused] = useState(false); + const [isLocationFocused, setIsLocationFocused] = useState(false); + const [checked, setChecked] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + + useEffect(() => { + if (data) { + setName(data.name || ""); + setAddress(data.address || ""); + setProfileColor(data.profileColor || "gray"); + setChecked(data.address === "서울 외 지역 거주"); + } + }, [data]); + + const handleSubmit = () => { + if (!name.trim()) { + setToastVisible(true); + setToastMessage("이름을 입력해주세요."); + setTimeout(() => { + setToastVisible(false); + }, 1800); + return; + } + + const fullLocation = checked + ? "서울 외 지역 거주" + : location.trim().startsWith("서울특별시") + ? location.trim() + : `서울특별시 ${location.trim()}`; + + updateProfile( + { + name, + location: fullLocation, + profileColor, + }, + { + onSuccess: () => { + setToastVisible(true); + setToastMessage("프로필이 저장되었습니다!"); + setTimeout(() => { + setToastVisible(false); + navigate("/mypage"); + }, 1500); + }, + onError: (err) => { + console.error("프로필 업데이트 실패:", err); + alert("프로필 업데이트에 실패했습니다."); + }, + } + ); + }; + + const toggleCheck = () => { + setChecked((prev) => { + const next = !prev; + if (next) setAddress("서울 외 지역 거주"); + else setAddress(""); + return next; + }); + }; + + const trimmed = location.trim(); + const showWarning = + (!checked && !trimmed.startsWith("서울특별시")) || checked; + + const selectedIndex = profileColors.indexOf(profileColor); + const SelectedProfile = profileSvgs[selectedIndex] ?? ImgGray; + + return ( +
+
+ +

프로필 편집

+
+
+
+
+
+ +
+
+ +
+ {profileSvgs.map((SvgComponent, index) => ( +
setProfileColor(profileColors[index])} + > + + {profileColor === profileColors[index] && ( +
+ +
+ )} +
+ ))} +
+ +
+ + setIsNameFocused(true)} + onBlur={() => setIsNameFocused(false)} + onChange={(e) => setName(e.target.value)} + /> +
+ +
+ +
+ setIsLocationFocused(true)} + onBlur={() => setIsLocationFocused(false)} + onChange={(e) => setAddress(e.target.value)} + readOnly={checked} + className={`flex-1 bg-transparent outline-none b2 placeholder-gray-6 ${ + checked ? "cursor-not-allowed" : "" + }`} + /> +
+ + {showWarning && ( +

+ 현재는 서울에 한해 사회적 기업들을 소개하고 있습니다. +

+ )} + + +
+
+
+ +
+ {toastVisible && ( + setToastVisible(false)} + /> + )} +
+ ); +}; + +export default MyPageEdit; diff --git a/src/pages/myPageDetail/MyPageDetailPage.jsx b/src/pages/myPageDetail/MyPageDetailPage.jsx index b7acf29..c21a23b 100644 --- a/src/pages/myPageDetail/MyPageDetailPage.jsx +++ b/src/pages/myPageDetail/MyPageDetailPage.jsx @@ -2,24 +2,55 @@ import { useNavigate, useLocation } from "react-router-dom"; import heart from "/svgs/myPage/heart.svg"; import cheer from "/svgs/myPage/cheer.svg"; import review from "/svgs/myPage/review.svg"; -import { useGetReviews, useGetCheers } from "@/apis/myPage/queries"; +import { + useGetHearts, + useGetReviews, + useGetCheers, +} from "@/apis/myPage/queries"; import ReviewItem from "@/pages/myPageDetail/components/ReviewItem"; import StoryItem from "@/pages/myPageDetail/components/StoryItem"; import noResult from "/svgs/myPage/noResult.svg"; import Spinner from "@/components/common/Spinner"; +import HeartItem from "./components/HeartItem"; +import { useEffect, useMemo, useState } from "react"; +import { getAllCompanies } from "@/apis/company/getAllCompanies"; const MyPageDetailPage = () => { const navigate = useNavigate(); const location = useLocation(); const kind = location.state.kind; + const [allCompanies, setAllCompanies] = useState([]); + + const { data: heartsData, isLoading: isLoadingHearts } = useGetHearts({ + enabled: kind === "찜", + }); + + useEffect(() => { + if (kind === "찜") { + getAllCompanies().then(setAllCompanies); + } + }, [kind]); + + const companyMap = useMemo(() => { + return new Map(allCompanies.map((c) => [c.companyId, c])); + }, [allCompanies]); + + const enrichedHeartsData = heartsData?.map((heart) => { + const matched = companyMap.get(heart.companyId); + return { + ...heart, + latitude: matched?.latitude, + longitude: matched?.longitude, + }; + }); + const { data: reviewsData, isLoading: isLoadingReviews } = useGetReviews({ enabled: kind === "리뷰", }); const { data: cheersData, isLoading: isLoadingCheers } = useGetCheers({ enabled: kind === "응원", }); - // const { data: heartsData, isLoading: isLoadingHearts } = useGetHearts({ enabled: kind === "찜" }); if (isLoadingReviews || isLoadingCheers) { return ( @@ -47,11 +78,11 @@ const MyPageDetailPage = () => { {kind === "찜" && (
-
+

저장한 장소

-

총 0개

+

총 {heartsData?.length}개

)} @@ -65,7 +96,25 @@ const MyPageDetailPage = () => {
)} -
+
+ {kind === "찜" && ( + <> + {!isLoadingHearts && enrichedHeartsData?.length > 0 ? ( + enrichedHeartsData.map((item, idx) => ( + + )) + ) : !isLoadingHearts && enrichedHeartsData?.length === 0 ? ( +
+ +

+ 아직 저장한 장소가 +
없어요 +

+
+ ) : null} + + )} + {kind === "리뷰" && ( <> {!isLoadingReviews && reviewsData?.length > 0 ? ( diff --git a/src/pages/myPageDetail/components/HeartItem.jsx b/src/pages/myPageDetail/components/HeartItem.jsx new file mode 100644 index 0000000..d64506b --- /dev/null +++ b/src/pages/myPageDetail/components/HeartItem.jsx @@ -0,0 +1,142 @@ +import { + companyTypeNameMap, + companyTypeIconMap, + businessTypeNameMap, +} from "@constants/categoryMap"; +import HeartIcon from "/svgs/common/Ic_Heart_Fill.svg"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { unlikeCompany } from "@apis/company/getLikedCompanies"; +import ToastModal from "@components/common/ToastModal"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useUserCoords } from "@pages/search/hooks/useUserCoords"; +import { getDistanceFromLatLon } from "@pages/map/utils/getDistanceFromLatLon"; + +const HeartItem = ({ data }) => { + const { + companyId, + companyName, + companyCategory, + companyType, + companyLocation, + business, + latitude, + longitude, + } = data; + + const navigate = useNavigate(); + const userCoords = useUserCoords(); + console.log("userCoords", userCoords); + + const handleClick = () => { + navigate(`/map/${companyId}`); + }; + + const [distance, setDistance] = useState(null); + + useEffect(() => { + if (userCoords && latitude && longitude) { + const d = getDistanceFromLatLon( + userCoords.lat, + userCoords.lng, + latitude, + longitude + ); + setDistance(d); + } + }, [userCoords, latitude, longitude]); + + const formatDistance = (distanceInMeters) => { + if (distanceInMeters < 1000) { + return `${Math.round(distanceInMeters)}m`; + } + return `${(distanceInMeters / 1000).toFixed(1)}km`; + }; + + const queryClient = useQueryClient(); + const [toastVisible, setToastVisible] = useState(false); + + const { mutate: unlike, isLoading } = useMutation({ + mutationFn: () => unlikeCompany(companyId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["userHearts"] }); + setToastVisible(true); + setTimeout(() => setToastVisible(false), 2000); + }, + }); + + const handleUnlike = (e) => { + e.stopPropagation(); + if (!isLoading) unlike(); + }; + + return ( + <> +
+
+
+

+ {companyName} + + {businessTypeNameMap[companyCategory] ?? companyCategory} + +

+ + {distance !== null && ( +
+

+ {formatDistance(distance)} +

+ · +

+ {companyLocation} +

+
+ )} +
+ + +
+ + {business && ( +
+
+

{business}

+ + {companyType && ( +
+ {companyType} + + {companyTypeNameMap[companyType]} + +
+ )} +
+
+ )} +
+ + {toastVisible && ( + setToastVisible(false)} + /> + )} + + ); +}; + +export default HeartItem; diff --git a/src/pages/myPageDetail/components/MapCompanyPage.jsx b/src/pages/myPageDetail/components/MapCompanyPage.jsx new file mode 100644 index 0000000..63d2c66 --- /dev/null +++ b/src/pages/myPageDetail/components/MapCompanyPage.jsx @@ -0,0 +1,110 @@ +import { useParams, useNavigate } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { getCompanyPreview } from "@apis/company/getCompanyPreview"; +import { getAllCompanies } from "@apis/company/getAllCompanies"; +import { getLikedCompanies } from "@apis/company/getLikedCompanies"; +import useAuthStore from "@/store/authStore"; +import { useToggleLike } from "@pages/map/hooks/useToggleLike"; +import PlaceBottomSheet from "@pages/map/components/PlaceBottomSheet"; +import MapViewer from "@/pages/search/components/MapViewer"; + +const MapCompanyPage = () => { + const { companyId } = useParams(); + const navigate = useNavigate(); + + const [place, setPlace] = useState(null); + const [isBottomSheetVisible, setIsBottomSheetVisible] = useState(true); + const [isBottomSheetExpanded, setIsBottomSheetExpanded] = useState(false); + const [bottomSheetHeight, setBottomSheetHeight] = useState(220); + + useEffect(() => { + const fetchData = async () => { + try { + const [preview, allCompanies] = await Promise.all([ + getCompanyPreview(companyId), + getAllCompanies(), + ]); + + const locationData = allCompanies.find( + (c) => String(c.companyId) === String(companyId) + ); + + if (!locationData?.latitude || !locationData?.longitude) { + console.warn("기업 좌표 정보가 누락되었습니다:", locationData); + return; + } + + const isAuthenticated = await useAuthStore.getState().checkAuth(); + const likedList = isAuthenticated ? await getLikedCompanies() : []; + const liked = likedList.some((c) => c.companyId === Number(companyId)); + + const enriched = { + ...preview, + coords: { + lat: locationData.latitude, + lng: locationData.longitude, + }, + liked, + }; + + setPlace(enriched); + } catch (err) { + console.error("기업 정보 로딩 실패:", err); + } + }; + + fetchData(); + }, [companyId]); + + const { toggleLike } = useToggleLike({ + placesWithDistance: place ? [place] : [], + setPlacesWithDistance: () => {}, + setFilteredPlaces: () => {}, + selectedPlace: place, + setSelectedPlace: setPlace, + showOnlyLiked: false, + onRequireLogin: () => {}, + }); + + if (!place) return

로딩 중...

; + + return ( +
+
navigate(-1)} + > + 뒤로가기 +
+ + setIsBottomSheetVisible(true)} + moveToCurrentLocation={false} + onMoveComplete={() => {}} + userCoords={null} + disableAutoUserPan={true} + /> + {place && isBottomSheetVisible && ( + setIsBottomSheetVisible(false)} + onExpandChange={setIsBottomSheetExpanded} + onToggleLike={toggleLike} + onHeightChange={setBottomSheetHeight} + /> + )} +
+ ); +}; + +export default MapCompanyPage; diff --git a/src/pages/story/components/StoryDetail.jsx b/src/pages/story/components/StoryDetail.jsx index 6955ba1..c01610d 100644 --- a/src/pages/story/components/StoryDetail.jsx +++ b/src/pages/story/components/StoryDetail.jsx @@ -5,12 +5,14 @@ import useUIStore from "@/store/uiStore"; import { useGetBestStoryDetail } from "@/apis/story/queries"; import { usePatchStoryLike } from "@/apis/story/queries"; import HaveToLoginModal from "@components/common/HaveToLoginModal"; +import ToastModal from "@/components/common/ToastModal"; const StoryDetail = () => { const navigate = useNavigate(); const { storyId } = useParams(); const { setIsStoryDetail } = useUIStore(); const { data, isLoading } = useGetBestStoryDetail(storyId); + const [showToast, setShowToast] = useState(false); useEffect(() => { setIsStoryDetail(true); @@ -22,6 +24,10 @@ const StoryDetail = () => { const handleLike = () => { likeStory(undefined, { + onSuccess: () => { + setShowToast(true); + setTimeout(() => setShowToast(false), 3000); + }, onError: (error) => { const status = error?.response?.status; @@ -118,6 +124,13 @@ const StoryDetail = () => {
+ {showToast && ( + setShowToast(false)} + /> + )} + {errorModal.open && ( , }, + { + path: "/map/:companyId", + element: , + }, { path: "/mypage", element: , @@ -92,6 +98,10 @@ const router = createBrowserRouter([ path: "/signup", // 탭 없는 별도 페이지 element: , }, + { + path: "/mypage/edit", + element: , + }, ]); export default router;