diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 5328ca7..1d225bd 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -15,6 +15,7 @@ import Map from "@/components/map/Map";
import SightDetailCard from "@/components/sight/SightDetailCard";
import { useCurationDetail } from "@/hooks/sight/useCurationDetail";
+import { useSightNavigation } from "@/hooks/sight/useSightNavigation";
import { useLocation } from "@/hooks/useLocation";
import { useSightMap } from "@/hooks/useSightMap";
@@ -65,6 +66,17 @@ export default function Index() {
fetchCurations();
}, [fetchCurations]);
+ const { navigateSightId, navigateToSight } = useSightNavigation({
+ mapRef,
+ location,
+ });
+
+ useEffect(() => {
+ if (navigateSightId && location) {
+ navigateToSight(navigateSightId);
+ }
+ }, [navigateSightId, location.latitude, location.longitude, navigateToSight]);
+
const handleSearch = useCallback(async () => {
if (!searchText.trim()) return;
diff --git a/app/(tabs)/myPage/_layout.tsx b/app/(tabs)/myPage/_layout.tsx
index cb85f2e..8c0a25b 100644
--- a/app/(tabs)/myPage/_layout.tsx
+++ b/app/(tabs)/myPage/_layout.tsx
@@ -23,6 +23,8 @@ export default function MyPageLayout() {
/>
+
+
);
}
diff --git a/app/(tabs)/myPage/index.tsx b/app/(tabs)/myPage/index.tsx
index a20fc47..fbbd506 100644
--- a/app/(tabs)/myPage/index.tsx
+++ b/app/(tabs)/myPage/index.tsx
@@ -114,7 +114,7 @@ function LoggedInView({
북마크
- console.log("지난 여행")}>
+ router.push("/myPage/pastTrip" as any)}>
지난 여행
diff --git a/app/(tabs)/myPage/pastTrip.tsx b/app/(tabs)/myPage/pastTrip.tsx
new file mode 100644
index 0000000..56210ee
--- /dev/null
+++ b/app/(tabs)/myPage/pastTrip.tsx
@@ -0,0 +1,288 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { ActivityIndicator, Alert, FlatList, Modal } from "react-native";
+
+import { useRouter } from "expo-router";
+import { Pencil, Trash2 } from "lucide-react-native";
+import styled from "styled-components/native";
+
+import { useCompletedRoute } from "@/hooks/route/useCompletedRoute";
+
+import { CompletedRouteSummary } from "@/types/completedRoute";
+
+import { theme } from "@/styles/theme";
+
+export default function PastTrip() {
+ const router = useRouter();
+ const {
+ routes,
+ hasNext,
+ isLoading,
+ fetchRoutes,
+ modifyRouteName,
+ deleteRoute,
+ } = useCompletedRoute();
+
+ const [editModalVisible, setEditModalVisible] = useState(false);
+ const [editingRoute, setEditingRoute] = useState(null);
+ const [editName, setEditName] = useState("");
+
+ useEffect(() => {
+ fetchRoutes(true);
+ }, []);
+
+ const handleItemPress = (routeId: number) => {
+ router.push({
+ pathname: "/myPage/pastTripDetail",
+ params: { routeId },
+ });
+ };
+
+ const handleEditPress = (route: CompletedRouteSummary) => {
+ setEditingRoute(route);
+ setEditName(route.name);
+ setEditModalVisible(true);
+ };
+
+ const handleEditSubmit = async () => {
+ if (!editingRoute || !editName.trim()) return;
+
+ try {
+ await modifyRouteName(editingRoute.routeId, editName.trim());
+ setEditModalVisible(false);
+ setEditingRoute(null);
+ setEditName("");
+ } catch (error) {
+ Alert.alert("오류", "이름 수정에 실패했습니다.");
+ }
+ };
+
+ const handleDeletePress = (route: CompletedRouteSummary) => {
+ Alert.alert(
+ "여행 삭제",
+ `"${route.name}"기록을 정말 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.`,
+ [
+ { text: "취소", style: "cancel" },
+ {
+ text: "삭제",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await deleteRoute(route.routeId);
+ } catch (error) {
+ Alert.alert("오류", "삭제에 실패했습니다.");
+ }
+ },
+ },
+ ]
+ );
+ };
+
+ const renderItem = useCallback(
+ ({ item }: { item: CompletedRouteSummary }) => (
+ handleItemPress(item.routeId)}>
+
+ {item.name}
+ {item.completedAt}
+
+
+ handleEditPress(item)}>
+
+
+ handleDeletePress(item)}>
+
+
+
+
+ ),
+ []
+ );
+
+ return (
+
+
+
+ item.routeId.toString()}
+ renderItem={renderItem}
+ refreshing={isLoading && routes.length === 0}
+ onRefresh={() => fetchRoutes(true)}
+ onEndReached={() => {
+ if (hasNext && !isLoading) {
+ fetchRoutes(false);
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={
+ isLoading && routes.length > 0 ? (
+
+ ) : null
+ }
+ ListEmptyComponent={
+ !isLoading ? (
+
+ 지난 여행이 없습니다
+
+ ) : null
+ }
+ contentContainerStyle={{ flexGrow: 1 }}
+ />
+
+ setEditModalVisible(false)}
+ >
+
+
+ 여행 이름 수정
+
+
+ setEditModalVisible(false)}>
+ 취소
+
+
+ 확인
+
+
+
+
+
+
+ );
+}
+
+const Container = styled.View`
+ flex: 1;
+ background-color: ${theme.colors.white};
+`;
+
+const Header = styled.View`
+ height: 50px;
+ justify-content: center;
+ align-items: center;
+ border-bottom-width: 1px;
+ border-bottom-color: ${theme.colors.grey.neutral200};
+`;
+
+const HeaderTitle = styled.Text`
+ font-family: ${theme.typography.fontFamily.semiBold};
+ font-size: ${theme.typography.fontSize.lg}px;
+ color: ${theme.colors.text.textPrimary};
+`;
+
+const RouteItem = styled.TouchableOpacity`
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom-width: 1px;
+ border-bottom-color: ${theme.colors.grey.neutral200};
+`;
+
+const RouteInfo = styled.View`
+ flex: 1;
+`;
+
+const RouteName = styled.Text`
+ font-family: ${theme.typography.fontFamily.medium};
+ font-size: ${theme.typography.fontSize.md}px;
+ color: ${theme.colors.text.textPrimary};
+ margin-bottom: 4px;
+`;
+
+const RouteDate = styled.Text`
+ font-family: ${theme.typography.fontFamily.regular};
+ font-size: ${theme.typography.fontSize.xs}px;
+ color: ${theme.colors.text.textTertiary};
+`;
+
+const ButtonGroup = styled.View`
+ flex-direction: row;
+ gap: 8px;
+`;
+
+const ActionButton = styled.TouchableOpacity`
+ padding: 8px 12px;
+`;
+
+const ActionButtonText = styled.Text`
+ font-family: ${theme.typography.fontFamily.medium};
+ font-size: ${theme.typography.fontSize.xs}px;
+ color: ${theme.colors.text.textSecondary};
+`;
+
+const EmptyContainer = styled.View`
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+`;
+
+const EmptyText = styled.Text`
+ font-family: ${theme.typography.fontFamily.regular};
+ font-size: ${theme.typography.fontSize.sm}px;
+ color: ${theme.colors.text.textTertiary};
+`;
+
+const ModalOverlay = styled.View`
+ flex: 1;
+ background-color: ${theme.colors.background.modalBackground};
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+`;
+
+const ModalContent = styled.View`
+ width: 100%;
+ background-color: ${theme.colors.white};
+ border-radius: ${theme.borderRadius.md}px;
+ padding: 20px;
+`;
+
+const ModalTitle = styled.Text`
+ font-family: ${theme.typography.fontFamily.semiBold};
+ font-size: ${theme.typography.fontSize.md}px;
+ color: ${theme.colors.text.textPrimary};
+ margin-bottom: 16px;
+ text-align: center;
+`;
+
+const ModalInput = styled.TextInput`
+ background-color: ${theme.colors.background.background50};
+ border-radius: ${theme.borderRadius.md}px;
+ padding: 12px 16px;
+ font-size: ${theme.typography.fontSize.md}px;
+ color: ${theme.colors.text.textPrimary};
+ margin-bottom: 16px;
+`;
+
+const ModalButtonGroup = styled.View`
+ flex-direction: row;
+ gap: 12px;
+`;
+
+const ModalButton = styled.TouchableOpacity<{ primary?: boolean }>`
+ flex: 1;
+ padding: 12px;
+ border-radius: ${theme.borderRadius.md}px;
+ background-color: ${(props) =>
+ props.primary ? theme.colors.main.primary : theme.colors.grey.neutral200};
+ align-items: center;
+`;
+
+const ModalButtonText = styled.Text<{ primary?: boolean }>`
+ font-family: ${theme.typography.fontFamily.medium};
+ font-size: ${theme.typography.fontSize.sm}px;
+ color: ${(props) =>
+ props.primary ? theme.colors.white : theme.colors.text.textPrimary};
+`;
\ No newline at end of file
diff --git a/app/(tabs)/myPage/pastTripDetail.tsx b/app/(tabs)/myPage/pastTripDetail.tsx
new file mode 100644
index 0000000..36d5618
--- /dev/null
+++ b/app/(tabs)/myPage/pastTripDetail.tsx
@@ -0,0 +1,175 @@
+import { useEffect } from "react";
+
+import { ActivityIndicator, FlatList } from "react-native";
+
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { ChevronLeft, MessageSquareHeart } from "lucide-react-native";
+import styled from "styled-components/native";
+
+import SightCard from "@/components/common/SightCard";
+
+import { useCompletedRoute } from "@/hooks/route/useCompletedRoute";
+
+import { CompletedRouteItem } from "@/types/completedRoute";
+
+import { theme } from "@/styles/theme";
+
+import { useStoryStore } from "@/store/story/useStoryStore";
+import { useSightStore } from "@/store/useSightStore";
+
+export default function PastTripDetail() {
+ const router = useRouter();
+ const { routeId } = useLocalSearchParams<{ routeId: string }>();
+ const {
+ selectedRoute,
+ selectedRouteItems,
+ isDetailLoading,
+ fetchRouteDetail,
+ clearDetail,
+ } = useCompletedRoute();
+
+ const handleCardPress = (item: CompletedRouteItem) => {
+ if (item.itemType === "SIGHT") {
+ useSightStore.getState().setNavigateSightId(item.itemId);
+ router.push("/(tabs)");
+ } else {
+ useStoryStore.getState().setNavigateStorySpotId(Number(item.itemId));
+ router.push("/(tabs)/story");
+ }
+ };
+
+ useEffect(() => {
+ if (routeId) {
+ fetchRouteDetail(Number(routeId));
+ }
+
+ return () => {
+ clearDetail();
+ };
+ }, [routeId]);
+
+ const renderItem = ({ item }: { item: CompletedRouteItem }) => {
+ if (item.itemType === "SIGHT") {
+ return (
+
+ handleCardPress(item)}
+ />
+
+ );
+ }
+
+ return (
+ handleCardPress(item)}>
+
+ {item.itemName}
+
+ );
+ };
+
+ if (isDetailLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ router.back()}>
+
+
+ {selectedRoute?.name || "여행 상세"}
+
+
+
+ `${item.itemType}-${item.itemId}-${index}`}
+ renderItem={renderItem}
+ contentContainerStyle={{ padding: 20, gap: 12 }}
+ ListEmptyComponent={
+
+ 방문한 장소가 없습니다
+
+ }
+ />
+
+ );
+}
+
+const Container = styled.View`
+ flex: 1;
+ background-color: ${theme.colors.white};
+`;
+
+const LoadingContainer = styled.View`
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+ background-color: ${theme.colors.white};
+`;
+
+const Header = styled.View`
+ height: 50px;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding-horizontal: 15px;
+ border-bottom-width: 1px;
+ border-bottom-color: ${theme.colors.grey.neutral200};
+`;
+
+const BackButton = styled.TouchableOpacity`
+ width: 40px;
+ height: 40px;
+ justify-content: center;
+ align-items: flex-start;
+`;
+
+const HeaderTitle = styled.Text`
+ font-family: ${theme.typography.fontFamily.semiBold};
+ font-size: ${theme.typography.fontSize.lg}px;
+ color: ${theme.colors.text.textPrimary};
+ flex: 1;
+ text-align: center;
+`;
+
+const HeaderSpacer = styled.View`
+ width: 40px;
+`;
+
+const CardWrapper = styled.View`
+ align-items: center;
+`;
+
+const EmptyContainer = styled.View`
+ padding: 40px;
+ align-items: center;
+`;
+
+const EmptyText = styled.Text`
+ font-family: ${theme.typography.fontFamily.regular};
+ font-size: ${theme.typography.fontSize.sm}px;
+ color: ${theme.colors.text.textTertiary};
+`;
+
+const StorySpotItem = styled.TouchableOpacity`
+ flex-direction: row;
+ align-items: center;
+ padding: 12px 16px;
+ gap: 10px;
+`;
+
+const StorySpotText = styled.Text`
+ font-family: ${theme.typography.fontFamily.regular};
+ font-size: ${theme.typography.fontSize.sm}px;
+ color: ${theme.colors.text.textPrimary};
+`;
\ No newline at end of file
diff --git a/app/(tabs)/story/index.tsx b/app/(tabs)/story/index.tsx
index 16dd3a1..890bb79 100644
--- a/app/(tabs)/story/index.tsx
+++ b/app/(tabs)/story/index.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from "react";
+import React, { useEffect, useRef } from "react";
import { StyleSheet } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -12,7 +12,9 @@ import MainStoryHeader from "@/components/story/MainStoryHeader";
import { StoryAddButton } from "@/components/story/StoryAddButton";
import StorySpotHeader from "@/components/story/StorySpotHeader";
+import { useStoryNavigation } from "@/hooks/story/useStoryNavigation";
import { useStorySpotMap } from "@/hooks/story/useStorySpotMap";
+import { useLocation } from "@/hooks/useLocation";
import { useStoryStore } from "@/store/story/useStoryStore";
@@ -23,6 +25,7 @@ export default function Index() {
const {
mapRef,
selectedMarker,
+ setSelectedMarker,
handleMapPress,
handleRegionChange,
handleStoryMarkerPress,
@@ -30,6 +33,20 @@ export default function Index() {
const { spotLocationInMap } = useStoryStore();
+ const { location } = useLocation();
+
+ const { navigateStorySpotId, navigateToStorySpot } = useStoryNavigation({
+ mapRef,
+ location,
+ setSelectedMarker,
+ });
+
+ useEffect(() => {
+ if (navigateStorySpotId && location) {
+ navigateToStorySpot(navigateStorySpotId);
+ }
+ }, [navigateStorySpotId, location.latitude, location.longitude, navigateToStorySpot]);
+
return (
diff --git a/src/api/route/completedRouteApi.ts b/src/api/route/completedRouteApi.ts
new file mode 100644
index 0000000..af4e1ca
--- /dev/null
+++ b/src/api/route/completedRouteApi.ts
@@ -0,0 +1,55 @@
+import { BaseResponse } from "@/types/auth";
+import {
+ CompletedRouteDetailResponse,
+ CompletedRouteListRequest,
+ CompletedRouteListResponse,
+ CompletedRouteSummary,
+ ModifyCompletedRouteRequest,
+} from "@/types/completedRoute";
+
+import API_ENDPOINTS from "@/constants/endpoints";
+
+import api from "@/api/axios";
+
+// 완료된 경로 리스트 조회
+export const getCompletedRoutes = async (
+ params?: CompletedRouteListRequest
+): Promise => {
+ const response = await api.get>(
+ API_ENDPOINTS.ROUTE.COMPLETED_LIST,
+ {
+ params: {
+ page: params?.page ?? 0,
+ size: params?.size ?? 10,
+ },
+ }
+ );
+ return response.data.data;
+};
+
+// 완료된 경로 상세 조회
+export const getCompletedRouteDetail = async (
+ routeId: number
+): Promise => {
+ const response = await api.get>(
+ API_ENDPOINTS.ROUTE.COMPLETED_DETAIL(routeId)
+ );
+ return response.data.data;
+};
+
+// 완료된 경로 이름 수정
+export const modifyCompletedRouteName = async (
+ routeId: number,
+ data: ModifyCompletedRouteRequest
+): Promise => {
+ const response = await api.put>(
+ API_ENDPOINTS.ROUTE.COMPLETED_MODIFY(routeId),
+ data
+ );
+ return response.data.data;
+};
+
+// 완료된 경로 삭제
+export const deleteCompletedRoute = async (routeId: number): Promise => {
+ await api.delete(API_ENDPOINTS.ROUTE.COMPLETED_MODIFY(routeId));
+};
\ No newline at end of file
diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts
index db31bfd..dce7864 100644
--- a/src/constants/endpoints.ts
+++ b/src/constants/endpoints.ts
@@ -48,6 +48,9 @@ export const API_ENDPOINTS = {
ROUTE: {
CREATE_IN_PROGRESS_ROUTE: "/api/user/route/in-progress",
COMPLETE_ROUTE: (routeId: number) => `/api/user/route/${routeId}/complete`,
+ COMPLETED_LIST: "/api/user/route/completed",
+ COMPLETED_DETAIL: (routeId: number) => `/api/user/route/completed/detail/${routeId}`,
+ COMPLETED_MODIFY: (routeId: number) => `/api/user/route/completed/${routeId}`,
},
MEMBER: {
GET_PROFILE: "/api/user/member/profile",
diff --git a/src/hooks/route/useCompletedRoute.ts b/src/hooks/route/useCompletedRoute.ts
new file mode 100644
index 0000000..b27a557
--- /dev/null
+++ b/src/hooks/route/useCompletedRoute.ts
@@ -0,0 +1,122 @@
+import { create } from "zustand";
+
+import {
+ CompletedRouteItem,
+ CompletedRouteSummary,
+} from "@/types/completedRoute";
+
+import {
+ deleteCompletedRoute,
+ getCompletedRouteDetail,
+ getCompletedRoutes,
+ modifyCompletedRouteName,
+} from "@/api/route/completedRouteApi";
+
+type CompletedRouteState = {
+ routes: CompletedRouteSummary[];
+ hasNext: boolean;
+ currentPage: number;
+ isLoading: boolean;
+
+ selectedRoute: CompletedRouteSummary | null;
+ selectedRouteItems: CompletedRouteItem[];
+ isDetailLoading: boolean;
+
+ fetchRoutes: (isRefresh?: boolean) => Promise;
+ fetchRouteDetail: (routeId: number) => Promise;
+ modifyRouteName: (routeId: number, name: string) => Promise;
+ deleteRoute: (routeId: number) => Promise;
+ clearDetail: () => void;
+};
+
+export const useCompletedRoute = create((set, get) => ({
+ routes: [],
+ hasNext: false,
+ currentPage: 0,
+ isLoading: false,
+
+ selectedRoute: null,
+ selectedRouteItems: [],
+ isDetailLoading: false,
+
+ fetchRoutes: async (isRefresh = false) => {
+ const { isLoading, currentPage, hasNext } = get();
+
+ if (isLoading) return;
+ if (!isRefresh && !hasNext && currentPage > 0) return;
+
+ set({ isLoading: true });
+
+ try {
+ const page = isRefresh ? 0 : currentPage;
+ const response = await getCompletedRoutes({ page, size: 10 });
+
+ set((state) => ({
+ routes: isRefresh
+ ? response.routes
+ : [...state.routes, ...response.routes],
+ hasNext: response.hasNext,
+ currentPage: page + 1,
+ }));
+ } catch (error) {
+ console.error("완료된 경로 조회 실패:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ fetchRouteDetail: async (routeId: number) => {
+ set({ isDetailLoading: true });
+
+ try {
+ const response = await getCompletedRouteDetail(routeId);
+
+ set({
+ selectedRoute: response.route,
+ selectedRouteItems: response.items,
+ });
+ } catch (error) {
+ console.error("경로 상세 조회 실패:", error);
+ } finally {
+ set({ isDetailLoading: false });
+ }
+ },
+
+ modifyRouteName: async (routeId: number, name: string) => {
+ try {
+ const updated = await modifyCompletedRouteName(routeId, { name });
+
+ set((state) => ({
+ routes: state.routes.map((route) =>
+ route.routeId === routeId ? { ...route, name: updated.name } : route
+ ),
+ selectedRoute: state.selectedRoute?.routeId === routeId
+ ? { ...state.selectedRoute, name: updated.name }
+ : state.selectedRoute,
+ }));
+ } catch (error) {
+ console.error("경로 이름 수정 실패:", error);
+ throw error;
+ }
+ },
+
+ deleteRoute: async (routeId: number) => {
+ try {
+ await deleteCompletedRoute(routeId);
+
+ set((state) => ({
+ routes: state.routes.filter((route) => route.routeId !== routeId),
+ }));
+ } catch (error) {
+ console.error("경로 삭제 실패:", error);
+ throw error;
+ }
+ },
+
+ clearDetail: () => {
+ set({
+ selectedRoute: null,
+ selectedRouteItems: [],
+ });
+ },
+}));
\ No newline at end of file
diff --git a/src/hooks/sight/useSightNavigation.ts b/src/hooks/sight/useSightNavigation.ts
new file mode 100644
index 0000000..b366b1a
--- /dev/null
+++ b/src/hooks/sight/useSightNavigation.ts
@@ -0,0 +1,61 @@
+import { useCallback } from "react";
+
+import { MapRef } from "@/types/map";
+import { SightInfo } from "@/types/sight";
+
+import { getSightDetail } from "@/api/sight/getSight";
+import { useSightStore } from "@/store/useSightStore";
+
+interface UseSightNavigationParams {
+ mapRef: React.RefObject;
+ location: {
+ latitude: number;
+ longitude: number;
+ };
+}
+
+export const useSightNavigation = ({ mapRef, location }: UseSightNavigationParams) => {
+ const navigateSightId = useSightStore((state) => state.navigateSightId);
+
+ const navigateToSight = useCallback(async (sightId: string) => {
+ try {
+ useSightStore.getState().setDetailLoading(true);
+
+ const detail = await getSightDetail({
+ id: sightId,
+ longitude: location.longitude,
+ latitude: location.latitude,
+ });
+
+ const sight: SightInfo = {
+ id: sightId,
+ title: detail.title,
+ longitude: detail.longitude,
+ latitude: detail.latitude,
+ geoHash: "",
+ };
+
+ // 지도 이동
+ mapRef.current?.moveToLocation({
+ latitude: detail.latitude,
+ longitude: detail.longitude,
+ latitudeDelta: 0.01,
+ longitudeDelta: 0.01,
+ });
+
+ useSightStore.getState().selectSight(sight);
+ useSightStore.getState().setSightDetail(detail);
+ useSightStore.getState().clearNavigateSightId();
+ } catch (error) {
+ console.error("관광지 조회 실패:", error);
+ useSightStore.getState().clearNavigateSightId();
+ } finally {
+ useSightStore.getState().setDetailLoading(false);
+ }
+ }, [location.latitude, location.longitude, mapRef]);
+
+ return {
+ navigateSightId,
+ navigateToSight,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/story/useStoryNavigation.ts b/src/hooks/story/useStoryNavigation.ts
new file mode 100644
index 0000000..f25341c
--- /dev/null
+++ b/src/hooks/story/useStoryNavigation.ts
@@ -0,0 +1,71 @@
+import { useCallback } from "react";
+
+import { MapRef } from "@/types/map";
+
+import { useStoryStore } from "@/store/story/useStoryStore";
+
+interface UseStoryNavigationParams {
+ mapRef: React.RefObject;
+ location: {
+ latitude: number;
+ longitude: number;
+ };
+ setSelectedMarker: (id: number | undefined) => void;
+}
+
+export const useStoryNavigation = ({ mapRef, location, setSelectedMarker }: UseStoryNavigationParams) => {
+ const navigateStorySpotId = useStoryStore((state) => state.navigateStorySpotId);
+ const { setStoryInfo, setStorySpotBriefInfo } = useStoryStore();
+
+ const navigateToStorySpot = useCallback(async (storySpotId: number) => {
+ try {
+ const storyRequest = {
+ storySpotId,
+ query: {
+ query: {
+ longitude: location.longitude,
+ latitude: location.latitude,
+ locale: "KO" as const,
+ page: 0,
+ size: 1000,
+ sort: "createdAt,desc" as const,
+ },
+ },
+ };
+
+ // 스토어 업데이트 (바텀시트 데이터)
+ const response = await setStoryInfo(storyRequest);
+
+ const briefSpotInfo = response?.briefSpotInfo;
+
+ if (briefSpotInfo?.latitude && briefSpotInfo?.longitude) {
+ // 스토어 업데이트
+ setStorySpotBriefInfo({
+ latitude: briefSpotInfo.latitude,
+ longitude: briefSpotInfo.longitude,
+ });
+
+ // 지도 이동
+ mapRef.current?.moveToLocation({
+ latitude: briefSpotInfo.latitude,
+ longitude: briefSpotInfo.longitude,
+ latitudeDelta: 0.01,
+ longitudeDelta: 0.01,
+ });
+
+ // 바텀시트 전환
+ setSelectedMarker(storySpotId);
+ }
+
+ useStoryStore.getState().clearNavigateStorySpotId();
+ } catch (error) {
+ console.error("이야기 스팟 조회 실패:", error);
+ useStoryStore.getState().clearNavigateStorySpotId();
+ }
+ }, [location.latitude, location.longitude, mapRef, setSelectedMarker, setStoryInfo, setStorySpotBriefInfo]);
+
+ return {
+ navigateStorySpotId,
+ navigateToStorySpot,
+ };
+};
\ No newline at end of file
diff --git a/src/hooks/story/useStorySpotMap.ts b/src/hooks/story/useStorySpotMap.ts
index b97cb35..fd3d06a 100644
--- a/src/hooks/story/useStorySpotMap.ts
+++ b/src/hooks/story/useStorySpotMap.ts
@@ -133,6 +133,7 @@ export const useStorySpotMap = () => {
return {
mapRef,
selectedMarker: selectedMarkerId,
+ setSelectedMarker: setSelectedMarkerId,
handleMapPress,
handleRegionChange,
handleStoryMarkerPress,
diff --git a/src/store/story/useStoryStore.ts b/src/store/story/useStoryStore.ts
index a88105c..4172488 100644
--- a/src/store/story/useStoryStore.ts
+++ b/src/store/story/useStoryStore.ts
@@ -42,6 +42,10 @@ interface StoryStore {
isLoading: boolean;
loading: boolean;
+ navigateStorySpotId: number | null;
+ setNavigateStorySpotId: (storySpotId: number) => void;
+ clearNavigateStorySpotId: () => void;
+
setSearchStory: (
param: GetSearchTitleRequest
) => Promise;
@@ -191,6 +195,11 @@ export const useStoryStore = create((set, get) => ({
}
},
+ navigateStorySpotId: null,
+
+ setNavigateStorySpotId: (storySpotId: number) => set({ navigateStorySpotId: storySpotId }),
+
+ clearNavigateStorySpotId: () => set({ navigateStorySpotId: null }),
setStoryLoading: (loading: boolean) => {
set({ isLoading: loading });
},
diff --git a/src/store/useSightStore.ts b/src/store/useSightStore.ts
index 45ed90a..7325d16 100644
--- a/src/store/useSightStore.ts
+++ b/src/store/useSightStore.ts
@@ -10,6 +10,8 @@ interface SightState {
isDetailLoading: boolean;
error: string | null;
+ navigateSightId: string | null;
+
setSights: (sights: SightInfo[]) => void;
selectSight: (sight: SightInfo | null) => void;
setSightDetail: (detail: SightDetailInfo | null) => void;
@@ -18,6 +20,9 @@ interface SightState {
setError: (error: string | null) => void;
clearSelection: () => void;
reset: () => void;
+
+ setNavigateSightId: (sightId: string) => void;
+ clearNavigateSightId: () => void;
}
const initialState = {
@@ -27,6 +32,7 @@ const initialState = {
isLoading: false,
isDetailLoading: false,
error: null,
+ navigateSightId: null,
};
export const useSightStore = create((set) => ({
@@ -55,4 +61,8 @@ export const useSightStore = create((set) => ({
}),
reset: () => set(initialState),
+
+ setNavigateSightId: (sightId) => set({ navigateSightId: sightId }),
+
+ clearNavigateSightId: () => set({ navigateSightId: null }),
}));
diff --git a/src/types/completedRoute.ts b/src/types/completedRoute.ts
new file mode 100644
index 0000000..b14f9c8
--- /dev/null
+++ b/src/types/completedRoute.ts
@@ -0,0 +1,39 @@
+import { RouteItemType } from "./route";
+
+// 완료된 경로 요약 정보
+export interface CompletedRouteSummary {
+ routeId: number;
+ name: string;
+ completedAt: string; // yyyy.MM.dd 형식
+}
+
+// 완료된 경로 리스트 응답
+export interface CompletedRouteListResponse {
+ routes: CompletedRouteSummary[];
+ hasNext: boolean;
+}
+
+// 완료된 경로 상세 아이템
+export interface CompletedRouteItem {
+ itemType: RouteItemType;
+ itemId: string;
+ itemName: string;
+ itemImageUrl: string;
+}
+
+// 완료된 경로 상세 응답
+export interface CompletedRouteDetailResponse {
+ route: CompletedRouteSummary;
+ items: CompletedRouteItem[];
+}
+
+// 경로 이름 수정 요청
+export interface ModifyCompletedRouteRequest {
+ name: string;
+}
+
+// 페이징 요청 파라미터
+export interface CompletedRouteListRequest {
+ page?: number;
+ size?: number;
+}
\ No newline at end of file