From cbb4c7d0c4d6a9ade2ff7c19441fbbc73771f334 Mon Sep 17 00:00:00 2001 From: manbron236 Date: Wed, 4 Feb 2026 17:51:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#51]=20feat=20:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EA=B2=BD=EB=A1=9C=20API=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/completedRoute.ts | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/types/completedRoute.ts 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 From f51da31584af80d94aa2d57fe6f82eab7f5ce3fd Mon Sep 17 00:00:00 2001 From: manbron236 Date: Wed, 4 Feb 2026 17:51:45 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#51]=20feat=20:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EA=B2=BD=EB=A1=9C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/endpoints.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts index 9ec142c..3bde90e 100644 --- a/src/constants/endpoints.ts +++ b/src/constants/endpoints.ts @@ -43,6 +43,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", From 853135f5d86f7784ea0ba893196f3d8da98bc992 Mon Sep 17 00:00:00 2001 From: manbron236 Date: Wed, 4 Feb 2026 17:52:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[#51]=20feat=20:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EA=B2=BD=EB=A1=9C=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/route/completedRouteApi.ts | 55 ++++++++++++ src/hooks/route/useCompletedRoute.ts | 122 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/api/route/completedRouteApi.ts create mode 100644 src/hooks/route/useCompletedRoute.ts 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/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 From 31a1645f48068b1163bd19b489433230ba23ca97 Mon Sep 17 00:00:00 2001 From: manbron236 Date: Wed, 4 Feb 2026 17:54:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[#51]=20feat=20:=20=EC=A7=80=EB=82=9C=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EB=B0=8F=20=EC=97=B0=EA=B2=B0=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 42 ++++ app/(tabs)/myPage/_layout.tsx | 2 + app/(tabs)/myPage/index.tsx | 2 +- app/(tabs)/myPage/pastTrip.tsx | 288 +++++++++++++++++++++++++++ app/(tabs)/myPage/pastTripDetail.tsx | 152 ++++++++++++++ 5 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 app/(tabs)/myPage/pastTrip.tsx create mode 100644 app/(tabs)/myPage/pastTripDetail.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 8c289ec..c04d324 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -5,6 +5,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { useSharedValue } from "react-native-reanimated"; import { Ionicons } from "@expo/vector-icons"; +import { useLocalSearchParams } from "expo-router"; import styled from "styled-components/native"; import CustomBottomSheet from "@/components/bottomSheet/CustomBottomSheet"; @@ -24,8 +25,10 @@ import { SightInfo } from "@/types/sight"; import { theme } from "@/styles/theme"; +import { getSightDetail } from "@/api/sight/getSight"; import { useHeaderButtonStore } from "@/store/useHeaderButtonStore"; import { RouteCartItem, useRouteCartStore } from "@/store/useRouteCartStore"; +import { useSightStore } from "@/store/useSightStore"; export default function Index() { const bottomSheetRef = useRef(null); @@ -38,6 +41,7 @@ export default function Index() { const [searchText, setSearchText] = useState(""); const [showResults, setShowResults] = useState(false); + const { sightId } = useLocalSearchParams<{ sightId?: string }>(); const { sights, @@ -67,6 +71,44 @@ export default function Index() { fetchCurations(); }, [fetchCurations]); + useEffect(() => { + if (sightId && location) { + const fetchAndMove = async () => { + try { + const detail = await getSightDetail({ + id: sightId, + longitude: location.longitude, + latitude: location.latitude, + }); + + // 지도 이동 + mapRef.current?.moveToLocation({ + latitude: detail.latitude, + longitude: detail.longitude, + latitudeDelta: 0.01, + longitudeDelta: 0.01, + }); + + // 선택된 관광지 설정 + const sight: SightInfo = { + id: sightId, + title: detail.title, + longitude: detail.longitude, + latitude: detail.latitude, + geoHash: "", + }; + + useSightStore.getState().selectSight(sight); + useSightStore.getState().setSightDetail(detail); + } catch (error) { + console.error("관광지 조회 실패:", error); + } + }; + + fetchAndMove(); + } + }, [sightId, location.latitude, location.longitude]); + 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 ea81d5b..65cd4c9 100644 --- a/app/(tabs)/myPage/index.tsx +++ b/app/(tabs)/myPage/index.tsx @@ -111,7 +111,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..455a985 --- /dev/null +++ b/app/(tabs)/myPage/pastTripDetail.tsx @@ -0,0 +1,152 @@ +import { useEffect } from "react"; + +import { ActivityIndicator, FlatList } from "react-native"; + +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ChevronLeft } 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"; + +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") { + router.push({ + pathname: "/(tabs)", + params: { sightId: item.itemId }, + } as any); + } else { + router.push({ + pathname: "/(tabs)/story", + params: { storySpotId: item.itemId }, + } as any); + } + }; + + useEffect(() => { + if (routeId) { + fetchRouteDetail(Number(routeId)); + } + + return () => { + clearDetail(); + }; + }, [routeId]); + + const renderItem = ({ item }: { item: CompletedRouteItem }) => ( + + handleCardPress(item)} + /> + + ); + + 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}; +`; \ No newline at end of file