diff --git a/app/(tabs)/myPage/index.tsx b/app/(tabs)/myPage/index.tsx index ea81d5b..a20fc47 100644 --- a/app/(tabs)/myPage/index.tsx +++ b/app/(tabs)/myPage/index.tsx @@ -10,10 +10,12 @@ import LastTripIcon from "@/assets/icons/last-trip-icon.svg"; import MyStoryIcon from "@/assets/icons/mystory-icon.svg"; import { useAuthStore } from "@/store/useAuthStore"; +import { useBookmarkStore } from "@/store/useBookmarkStore"; export default function MyPageIndex() { const router = useRouter(); const { isLogined, user, logout } = useAuthStore(); + const { resetUserBookmarkList } = useBookmarkStore(); const handleLogout = () => { Alert.alert("로그아웃", "정말 로그아웃 하시겠습니까?", [ @@ -28,6 +30,7 @@ export default function MyPageIndex() { try { await logout(); Alert.alert("알림", "로그아웃 되었습니다."); + resetUserBookmarkList(); } catch (error) { console.error(error); Alert.alert("오류", "로그아웃 처리 중 문제가 발생했습니다."); diff --git a/app/(tabs)/myPage/login.tsx b/app/(tabs)/myPage/login.tsx index 482409c..4ca3864 100644 --- a/app/(tabs)/myPage/login.tsx +++ b/app/(tabs)/myPage/login.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Alert, Platform,TouchableOpacity } from "react-native"; +import { Alert, Platform, TouchableOpacity } from "react-native"; import { useRouter } from "expo-router"; import styled from "styled-components/native"; @@ -8,6 +8,7 @@ import styled from "styled-components/native"; import Button from "@/components/common/Button"; import Input from "@/components/common/Input"; +import { useBookmark } from "@/hooks/sight/useBookmark"; import { useAppleLogin } from "@/hooks/useAppleLogin"; import { theme } from "@/styles/theme"; @@ -22,7 +23,12 @@ export default function Login() { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); const { login } = useAuthStore(); - const { handleAppleLogin, isLoading: isAppleLoading, error: appleError } = useAppleLogin(); + const { fetchBookmark } = useBookmark(); + const { + handleAppleLogin, + isLoading: isAppleLoading, + error: appleError, + } = useAppleLogin(); const isIOS = Platform.OS === "ios"; const handleLogin = async () => { @@ -34,6 +40,7 @@ export default function Login() { try { setIsLoading(true); await login({ email, password }); + fetchBookmark(); router.back(); } catch (error: any) { Alert.alert("로그인 실패", "이메일 또는 비밀번호를 확인해주세요."); diff --git a/src/api/sight/getSightBookMark.ts b/src/api/sight/getSightBookMark.ts new file mode 100644 index 0000000..ad05397 --- /dev/null +++ b/src/api/sight/getSightBookMark.ts @@ -0,0 +1,66 @@ +import { BaseResponse, User } from "@/types/auth"; +import { + BookmarkEditResponse, + BookmarkInfoList, + BookmarkInfoRequest, +} from "@/types/bookmark"; + +import API_ENDPOINTS from "@/constants/endpoints"; + +import api from "../axios"; + +//북마크 추가 +export const getAddBookmark = async ( + sightId: string, + userId: number +): Promise => { + try { + const response = await api.post>( + API_ENDPOINTS.SIGHT.BOOKMARK.ADD(sightId), + {}, + { + headers: { + "X-USER-ID": userId, + }, + } + ); + return response.data.data; + } catch (error) { + throw error; + } +}; + +//북마크 삭제 +export const getDeleteBookmark = async ( + sightId: string, + userId: number +): Promise => { + try { + const response = await api.delete>( + API_ENDPOINTS.SIGHT.BOOKMARK.DELETE(sightId), + { + headers: { + "X-USER_ID": userId, + }, + } + ); + return response.data.data; + } catch (error) { + throw error; + } +}; + +//북마크 리스트 보기 +export const getBookmarkInfo = async ( + param: BookmarkInfoRequest | undefined +) => { + try { + const response = await api.get>( + `${API_ENDPOINTS.SIGHT.BOOKMARK.GET_BOOKMARK}`, + { params: param } + ); + return response.data.data; + } catch (error) { + throw error; + } +}; diff --git a/src/components/common/AddressLabel.tsx b/src/components/common/AddressLabel.tsx index 6e6ed8d..8119a22 100644 --- a/src/components/common/AddressLabel.tsx +++ b/src/components/common/AddressLabel.tsx @@ -10,6 +10,10 @@ interface AddressLabelProps { fontSize?: number; } +interface TextFontSizeProps { + fontSize?: number; +} + const AddressLabel: React.FC = ({ address, distance, @@ -26,14 +30,10 @@ const AddressLabel: React.FC = ({ return ( - + - {formatDistance()} -
{address}
+ {formatDistance()} +
{address}
); @@ -51,13 +51,15 @@ const AddressSection = styled.View` gap: 2px; `; -const Distance = styled.Text` - font-size: ${theme.typography.fontSize.xs}; +const Distance = styled.Text` + font-size: ${({ fontSize }) => + fontSize ? `${fontSize}px` : theme.typography.fontSize.xs}; color: ${theme.colors.text.textSecondary}; `; -const Address = styled.Text` - font-size: ${theme.typography.fontSize.xs}; +const Address = styled.Text` + font-size: ${({ fontSize }) => + fontSize ? `${fontSize}px` : theme.typography.fontSize.xs}; color: ${theme.colors.text.textSecondary}; `; diff --git a/src/components/curation/CurationDetail.tsx b/src/components/curation/CurationDetail.tsx index e14b518..4ed074a 100644 --- a/src/components/curation/CurationDetail.tsx +++ b/src/components/curation/CurationDetail.tsx @@ -34,14 +34,12 @@ const CurationDetail: React.FC = ({ selectedCurationDescription, handleCardPress, handleHeaderBackPress, - handleHeaderClosePress, }) => { const { setButtonStyle, setShowBackButton, setShowCloseButton, setOnBackPress, - setOnClosePress, } = useHeaderButtonStore(); const { isInCart } = useCurationDetail(); const { insertRouteCartItem, removeRouteCartItem } = useRouteCartStore(); @@ -51,7 +49,7 @@ const CurationDetail: React.FC = ({ useEffect(() => { setButtonStyle("NONE"); setShowBackButton(true); - setShowCloseButton(true); + setShowCloseButton(false); setOnBackPress(() => { handleHeaderBackPress(); }); diff --git a/src/components/sight/SightDetailCard.tsx b/src/components/sight/SightDetailCard.tsx index e1376ab..a41c60c 100644 --- a/src/components/sight/SightDetailCard.tsx +++ b/src/components/sight/SightDetailCard.tsx @@ -1,65 +1,89 @@ -import React from "react"; - -import { GestureResponderEvent } from "react-native"; - -import { - Banknote, - Clock, - Headphones, - Map, - ParkingCircle, - Phone, -} from "lucide-react-native"; +import React, { useEffect, useRef, useState } from "react"; + +import PagerView from "react-native-pager-view"; + import styled from "styled-components/native"; +import { useBookmark } from "@/hooks/sight/useBookmark"; + import { SightDetailCardProps } from "@/types/sight"; +import { theme } from "@/styles/theme"; +import AfterAddBookmark from "@/assets/icons/afterAddBookmark.svg"; import AfterAddRoute from "@/assets/icons/afterAddRoute.svg"; +import BeforeAddBookmark from "@/assets/icons/beforeAddBookmark.svg"; import BeforeAddRoute from "@/assets/icons/beforeAddRoute.svg"; -import { - sightToCustomAudioMetadata, - useAudioPlayerStore, -} from "@/store/useAudioPlayerStore"; +import { useStoryStore } from "@/store/story/useStoryStore"; +import { useAuthStore } from "@/store/useAuthStore"; +import { useBookmarkStore } from "@/store/useBookmarkStore"; +import { useHeaderButtonStore } from "@/store/useHeaderButtonStore"; import { useRouteCartStore } from "@/store/useRouteCartStore"; -import { distanceToString } from "@/util/locationUtil"; -import { normalizeHtmlBreaks } from "@/util/textNormalize"; + +import AddressLabel from "../common/AddressLabel"; +import HeaderButton from "../common/HeaderButton"; +import SightInfo from "./SightInfo"; +import SightStory from "./SightStory"; + +type TabType = "sightInfo" | "sightStory"; const SightDetailCard: React.FC = ({ selectedSight, sightDetail, isDetailLoading, - onClose, + handleHeaderBackPress, }) => { + const pagerRef = useRef(null); + const { user } = useAuthStore(); + const { insertRouteCartItem, removeRouteCartItem } = useRouteCartStore(); const routeCartItems = useRouteCartStore((state) => state.routeCartItems); - const audioMetadata = useAudioPlayerStore((state) => state.audioMetadata); - const setTemporarySightInfo = useAudioPlayerStore( - (state) => state.setTemporarySightInfo, - ); + const { setStorySpotBriefInfo } = useStoryStore(); + const { userBookmarkList } = useBookmarkStore(); + const { insertBookmark, removeBookmark } = useBookmark(); + const { + setButtonStyle, + setShowBackButton, + setShowCloseButton, + setOnBackPress, + setOnClosePress, + } = useHeaderButtonStore(); + + const [activeTab, setActiveTab] = useState("sightInfo"); + const handleTabPress = (tab: TabType) => { + setActiveTab(tab); + pagerRef.current?.setPage(tab === "sightInfo" ? 0 : 1); + }; - const isMyDocentPlaying = - sightDetail !== null && - audioMetadata?.id === sightToCustomAudioMetadata(sightDetail).id; - - const onPressDocent = (e: GestureResponderEvent) => { - e.stopPropagation(); - if (!sightDetail?.docentUrl) return; - if (isMyDocentPlaying) { - setTemporarySightInfo(); - } else { - setTemporarySightInfo(sightDetail); - } + useEffect(() => { + setButtonStyle("NONE"); + setShowBackButton(false); + setShowCloseButton(true); + setOnClosePress(() => { + handleHeaderBackPress(); + }); + }, [setShowBackButton, setShowCloseButton, handleHeaderBackPress]); + + //sight 조회 시 srotyId 저장 + useEffect(() => { + if (!selectedSight) return; + const sightLocation = { + longitude: String(selectedSight.longitude), + latitude: String(selectedSight.latitude), + }; + setStorySpotBriefInfo(sightLocation); + }, [setStorySpotBriefInfo, selectedSight?.latitude, selectedSight?.latitude]); + + const handlePageSelected = (e: { nativeEvent: { position: number } }) => { + const position = e.nativeEvent.position; + setActiveTab(position === 0 ? "sightInfo" : "sightStory"); }; if (!selectedSight) return null; - const checkData = (data: string | undefined | null) => { - const normalized = normalizeHtmlBreaks(data); - return normalized && normalized.trim() !== "" - ? normalized - : "데이터가 존재하지 않습니다"; - }; + const isBookmark = + userBookmarkList?.bookmarks.some((b) => b.sightId === selectedSight.id) ?? + false; const isInCart = routeCartItems.filter((routeCartItem) => { @@ -68,9 +92,13 @@ const SightDetailCard: React.FC = ({ return ( - + + + + + {selectedSight.title} - { e.stopPropagation(); if (isInCart) { @@ -95,112 +123,75 @@ const SightDetailCard: React.FC = ({ ) : ( )} - - + + { + e.stopPropagation(); + + if (!user) return; + + if (isBookmark) { + removeBookmark(selectedSight.id); + } else { + insertBookmark(selectedSight.id); + } + }} + > + {isBookmark ? ( + + ) : ( + + )} + + {isDetailLoading ? ( 상세 정보 로딩 중... ) : sightDetail ? ( - <> - - - {distanceToString(sightDetail.distance)} - + + + {sightDetail.theme} - - {checkData(sightDetail.address)} + - - {sightDetail.docentUrl && ( - - - - {isMyDocentPlaying ? "일시정지" : "도슨트 듣기"} - - - )} - -
- 소개 - {checkData(sightDetail.outl)} -
- -
- 방문정보 - - - - - 주소 - - {checkData(sightDetail.fullAddress)} - - - - - - 운영시간 - - {checkData(sightDetail.useTime)} - - - - - - 휴무일 - - {checkData(sightDetail.restDate)} - - - - - - 전화번호 - - {checkData(sightDetail.tel)} - - - - - - 입장료 - - {checkData(sightDetail.useFee)} - - - - - - 주차 가능 - - - {sightDetail.parking ? ( - - - {sightDetail.parking.includes("불가") ? "불가" : "가능"} - - - ) : ( - 데이터가 존재하지 않습니다 - )} - -
- + ) : null} + + handleTabPress("sightInfo")} + > + 정보 + + handleTabPress("sightStory")} + > + 이야기 + + + + + + - - 닫기 - + + + +
); }; @@ -208,18 +199,19 @@ const SightDetailCard: React.FC = ({ export default SightDetailCard; const Container = styled.View` - gap: 8px; - padding-horizontal: 10px; + gap: 12px; +`; + +const HeaderContainer = styled.View` + margin-bottom: 60px; `; -const HeaderRow = styled.View` +const SightHeaderContainer = styled.View` flex-direction: row; justify-content: space-between; align-items: center; -`; - -const IconButton = styled.TouchableOpacity` - padding: 4px; + margin-left: 16px; + margin-right: 16px; `; const SightTitle = styled.Text` @@ -229,33 +221,28 @@ const SightTitle = styled.Text` flex: 1; `; -const BasicInfoRow = styled.View` +const RouteAddButton = styled.TouchableOpacity` + padding: 4px; +`; +const BookMarkAddButton = styled.TouchableOpacity` + padding: 4px; +`; + +const BasicInfoWrapper = styled.View` flex-direction: row; align-items: center; gap: 8px; `; -const SightDistance = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.xs}px; - color: ${({ theme }) => theme.colors.main.primary}; -`; - const SightTheme = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.xs}px; + font-size: ${({ theme }) => theme.typography.fontSize.sm}px; color: ${({ theme }) => theme.colors.text.textTertiary}; `; -const SectionTitle = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.lg}px; - font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; - color: ${({ theme }) => theme.colors.text.textPrimary}; - margin-top: 16px; - margin-bottom: 16px; -`; - -const SightText = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.sm}px; - color: ${({ theme }) => theme.colors.text.textSecondary}; +const SightTopInfoWrapper = styled.View` + margin-left: 16px; + margin-right: 16px; + gap: 16px; `; const LoadingText = styled.Text` @@ -263,85 +250,43 @@ const LoadingText = styled.Text` color: ${({ theme }) => theme.colors.text.textTertiary}; `; -const CloseButton = styled.TouchableOpacity` - margin-top: 12px; - padding: 12px; - background-color: ${({ theme }) => theme.colors.grey.neutral100}; - border-radius: 8px; - align-items: center; -`; - -const CloseButtonText = styled.Text` - font-size: ${({ theme }) => theme.typography.fontSize.sm}px; - color: ${({ theme }) => theme.colors.text.textSecondary}; -`; - -const MainImage = styled.Image` +const SightImage = styled.Image` width: 100%; height: 200px; border-radius: 8px; - margin-bottom: 0; `; -const DocentButton = styled.TouchableOpacity` - flex-direction: row; +const TabButton = styled.TouchableOpacity<{ active: boolean }>` + flex: 1; align-items: center; - justify-content: center; - background-color: #f5f5f5; padding: 12px; - border-radius: 24px; - margin-bottom: 24px; - gap: 8px; -`; - -const DocentText = styled.Text` - font-size: 14px; - font-weight: 600; - color: #333; + border-bottom-width: 2px; + border-bottom-color: ${({ active }) => + active ? theme.colors.text.textPrimary : "transparent"}; `; -const InfoRow = styled.View` +const TabContainer = styled.View` flex-direction: row; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -`; - -const InfoLabelArea = styled.View` - flex-direction: row; - align-items: center; - gap: 8px; -`; - -const InfoLabel = styled.Text` - font-size: 14px; - color: #666; -`; - -const InfoValue = styled.Text` - font-size: 14px; - color: #000; - font-weight: 500; + border-bottom-width: 1px; + border-bottom-color: ${theme.colors.grey.neutral200}; `; -const Section = styled.View` - margin-bottom: 24px; +const TabText = styled.Text<{ active: boolean }>` + font-family: ${({ active }) => + active + ? theme.typography.fontFamily.semiBold + : theme.typography.fontFamily.regular}; + font-size: ${theme.typography.fontSize.sm}px; + color: ${({ active }) => + active ? theme.colors.text.textPrimary : theme.colors.text.textTertiary}; `; -const Badge = styled.View<{ type: "good" | "bad" }>` - padding: 4px 10px; - border-radius: 4px; - background-color: ${({ type }) => (type === "good" ? "#66BB6A" : "#EF5350")}; +const StyledPagerView = styled(PagerView)` + height: 360px; `; -const BadgeText = styled.Text` - color: #fff; - font-size: 12px; - font-weight: bold; -`; - -const DescriptionText = styled.Text` - font-size: 14px; - color: #444; - line-height: 22px; +const PageContainer = styled.View` + flex: 1; + width: 100%; + overflow: hidden; `; diff --git a/src/components/sight/SightInfo.tsx b/src/components/sight/SightInfo.tsx new file mode 100644 index 0000000..c2af93e --- /dev/null +++ b/src/components/sight/SightInfo.tsx @@ -0,0 +1,258 @@ +import { GestureResponderEvent } from "react-native"; + +import { + Banknote, + Clock, + Headphones, + Map, + ParkingCircle, + Phone, +} from "lucide-react-native"; +import styled from "styled-components/native"; + +import { SightDetailInfo } from "@/types/sight"; + +import { theme } from "@/styles/theme"; + +import { + sightToCustomAudioMetadata, + useAudioPlayerStore, +} from "@/store/useAudioPlayerStore"; +import { normalizeHtmlBreaks } from "@/util/textNormalize"; + +import Divider from "../story/Divider"; + +interface SightInfo { + isDetailLoading: boolean; + sightDetail: SightDetailInfo | null; +} + +const SightInfo: React.FC = ({ isDetailLoading, sightDetail }) => { + const checkData = (data: string | undefined | null) => { + const normalized = normalizeHtmlBreaks(data); + return normalized && normalized.trim() !== "" + ? normalized + : "데이터가 존재하지 않습니다"; + }; + const audioMetadata = useAudioPlayerStore((state) => state.audioMetadata); + const setTemporarySightInfo = useAudioPlayerStore( + (state) => state.setTemporarySightInfo + ); + const isMyDocentPlaying = + sightDetail !== null && + audioMetadata?.id === sightToCustomAudioMetadata(sightDetail).id; + + const onPressDocent = (e: GestureResponderEvent) => { + e.stopPropagation(); + if (!sightDetail?.docentUrl) return; + if (isMyDocentPlaying) { + setTemporarySightInfo(); + } else { + setTemporarySightInfo(sightDetail); + } + }; + + return ( + + + {sightDetail?.docentUrl && ( + + + + {isMyDocentPlaying ? "일시정지" : "도슨트 듣기"} + + + )} + + + {isDetailLoading ? ( + 상세 정보 로딩 중... + ) : sightDetail ? ( + + + 소개 + {checkData(sightDetail.overview)} + + + + + 방문정보 + + + + + 주소 + + + {checkData(sightDetail.fullAddress)} + + + + + + + 운영시간 + + + {checkData(sightDetail.useTime)} + + + + + + + 휴무일 + + + {checkData(sightDetail.restDate)} + + + + + + + 전화번호 + + + {checkData(sightDetail.tel)} + + + + + + + 입장료 + + + {checkData(sightDetail.useFee)} + + + + + + + 주차 가능 + + + {sightDetail.parking ? ( + + + {sightDetail.parking.includes("불가") ? "불가" : "가능"} + + + ) : ( + 데이터가 존재하지 않습니다 + )} + + + + ) : null} + + ); +}; + +const Container = styled.ScrollView``; + +const SightDeatilContainer = styled.View``; + +const DocentText = styled.Text` + font-size: 14px; + font-weight: 600; + color: #333; +`; + +const SightDocentWrapper = styled.View` + margin-left: 16px; + margin-right: 16px; + margin-top: 12px; +`; + +const SightInfoWrapper = styled.View` + margin-left: 16px; + margin-right: 16px; +`; + +const SectionTitle = styled.Text` + font-size: ${({ theme }) => theme.typography.fontSize.lg}px; + font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; + color: ${({ theme }) => theme.colors.text.textPrimary}; + margin-top: 8px; + margin-bottom: 20px; +`; + +const DescriptionText = styled.Text` + font-size: ${({ theme }) => theme.typography.fontSize.sm}px; + color: #444; + line-height: 22px; +`; + +const LoadingText = styled.Text``; + +const VisitorInfoWrapper = styled.View` + padding-left: 16px; + padding-right: 16px; + margin-bottom: 30px; +`; + +const InfoRow = styled.View` + flex-direction: row; + justify-content: space-between; + margin-bottom: 16px; +`; + +const InfoLabelArea = styled.View` + flex-direction: row; + gap: 8px; + padding: 1px; +`; + +const InfoLabel = styled.Text` + font-size: ${({ theme }) => theme.typography.fontSize.sm}px; + color: ${({ theme }) => theme.colors.text.textSecondary}; + font-weight: ${({ theme }) => theme.typography.fontWeight.regular}; +`; + +const InfoTextArea = styled.View` + width: 270px; +`; + +const InfoValue = styled.Text` + font-size: ${({ theme }) => theme.typography.fontSize.xs}px; + color: ${({ theme }) => theme.colors.text.textSecondary}; + font-weight: ${({ theme }) => theme.typography.fontWeight.medium}; +`; + +const Badge = styled.View<{ type: "good" | "bad" }>` + padding: 4px 10px; + border-radius: 4px; + background-color: ${({ type }) => + type === "good" ? theme.colors.alarm.success : theme.colors.alarm.error}; +`; + +const BadgeText = styled.Text` + color: ${({ theme }) => theme.colors.text.textWhite}; + font-size: ${({ theme }) => theme.typography.fontSize.xs}px; + font-weight: ${({ theme }) => theme.typography.fontWeight.bold}; +`; + +const DocentButton = styled.TouchableOpacity` + flex-direction: row; + align-items: center; + justify-content: center; + background-color: ${({ theme }) => theme.colors.background.background300}; + padding: 12px; + border-radius: 24px; + margin-bottom: 24px; + gap: 10px; +`; + +export default SightInfo; diff --git a/src/components/sight/SightStory.tsx b/src/components/sight/SightStory.tsx new file mode 100644 index 0000000..495c8aa --- /dev/null +++ b/src/components/sight/SightStory.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from "react"; + +import styled from "styled-components/native"; + +import { GetStoryRequest, StoryItem } from "@/types/storySpot"; + +import { getStoryInfo } from "@/api/getStoryApi"; +import { useStoryStore } from "@/store/story/useStoryStore"; +import { useSightStore } from "@/store/useSightStore"; + +import StoryCard from "../story/StoryCard"; + +const SightStory = () => { + const { selectedSight } = useSightStore(); + const { storySpotBriefInfo } = useStoryStore(); + const sightStoryId = storySpotBriefInfo?.spotId; + + const [storyDetails, setStoryDetails] = useState(); + const [loading, setLoading] = useState(false); + + const fetchStoryDetail = useCallback(async () => { + if (!sightStoryId || !selectedSight?.longitude || !selectedSight?.latitude) + return; + + const req: GetStoryRequest = { + storySpotId: sightStoryId, + query: { + query: { + longitude: String(selectedSight.longitude), + latitude: String(selectedSight.latitude), + locale: "KO" as const, + page: 0, + size: 1000, + sort: "createdAt,desc" as const, + }, + }, + }; + try { + const res = await getStoryInfo(req); + setStoryDetails(res.stories); + } catch (e) { + setStoryDetails([]); + } finally { + setLoading(false); + } + + setLoading(true); + }, [storySpotBriefInfo, selectedSight]); + + useEffect(() => { + fetchStoryDetail(); + }); + + return ( + + {loading ? 이야기 불러오는 중... : null} + + + + {storyDetails?.map((story, index) => ( + + ))} + + + + ); +}; + +export default SightStory; + +const Container = styled.View``; + +const StoryContentContainer = styled.View``; + +const StoryWrapper = styled.Pressable``; + +const InfoText = styled.Text` + padding: 12px 16px; +`; diff --git a/src/components/story/MainStoryHeader.tsx b/src/components/story/MainStoryHeader.tsx index 2a235d4..be60849 100644 --- a/src/components/story/MainStoryHeader.tsx +++ b/src/components/story/MainStoryHeader.tsx @@ -25,7 +25,7 @@ const MainStoryHeader = () => { {storyListInMap?.map((mapStory, index) => ( = ({ - + {storyId ? ( + + ) : null} {createdAt} diff --git a/src/components/story/StorySpotHeader.tsx b/src/components/story/StorySpotHeader.tsx index 2b9216c..91fa423 100644 --- a/src/components/story/StorySpotHeader.tsx +++ b/src/components/story/StorySpotHeader.tsx @@ -40,7 +40,7 @@ const StorySpotHeader = () => { {storyItems?.map((storyItem, index) => ( `/api/sight/curation/${curationId}`, }, + BOOKMARK: { + ADD: (sightId: string) => `/api/user/sight/${sightId}/bookmark`, + DELETE: (sightId: string) => `/api/user/sight/${sightId}/bookmark`, + GET_BOOKMARK: "/api/user/sight/bookmark", + }, }, ROUTE: { diff --git a/src/hooks/sight/useBookmark.ts b/src/hooks/sight/useBookmark.ts new file mode 100644 index 0000000..0d80939 --- /dev/null +++ b/src/hooks/sight/useBookmark.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; + +import { + getAddBookmark, + getBookmarkInfo, + getDeleteBookmark, +} from "@/api/sight/getSightBookMark"; +import { useAuthStore } from "@/store/useAuthStore"; +import { useBookmarkStore } from "@/store/useBookmarkStore"; + +export const useBookmark = () => { + const { setUserBookmarkList, setAddBookmark, setRemoveBookmark } = + useBookmarkStore(); + + const { user } = useAuthStore(); + + const memberId = user?.memberId; + + //북마크 조회 + const fetchBookmark = useCallback(async () => { + try { + const res = await getBookmarkInfo({ userId: memberId }); + setUserBookmarkList(res); + } catch (error) { + throw error; + } + }, [user, setUserBookmarkList]); + + //북마크 추가 + const insertBookmark = useCallback( + async (sightId: string) => { + if (!memberId) return; + const bookMark = { + sightId: sightId, + memberId: memberId, + }; + setAddBookmark(bookMark); + try { + await getAddBookmark(sightId, memberId); + } catch (error) { + throw error; + } + }, + [memberId, setUserBookmarkList, setAddBookmark] + ); + + //북마크 삭제 + const removeBookmark = useCallback( + async (sightId: string) => { + if (!memberId) return; + const bookMark = { + sightId: sightId, + memberId: memberId, + }; + setRemoveBookmark(bookMark); + + try { + await getDeleteBookmark(sightId, memberId); + } catch (error) { + throw error; + } + }, + [memberId, setUserBookmarkList, setRemoveBookmark] + ); + + return { + fetchBookmark, + insertBookmark, + removeBookmark, + }; +}; diff --git a/src/hooks/story/useStorySpotMap.ts b/src/hooks/story/useStorySpotMap.ts index 74ab677..1f1cbcd 100644 --- a/src/hooks/story/useStorySpotMap.ts +++ b/src/hooks/story/useStorySpotMap.ts @@ -91,7 +91,7 @@ export const useStorySpotMap = () => { setStoryInfo(storyRequest); moveToCustomPinLocation(mapRef, story.latitude, story.longitude); - setSelectedMarkerId(storySpotBriefInfo?.spotId); + setSelectedMarkerId(storyRequest?.storySpotId); }, []); //sight 마커선택 시 diff --git a/src/store/story/useStoryStore.ts b/src/store/story/useStoryStore.ts index 7be5385..a88105c 100644 --- a/src/store/story/useStoryStore.ts +++ b/src/store/story/useStoryStore.ts @@ -1,19 +1,20 @@ import { create } from "zustand"; import { - GetLocationSpotBriefInfoResponse as StorySpotInfoResponse, - GetMapStoriesRequest as GetStoriesInMapRequest, + GetLocationSpotBriefInfoResponse, + GetMapStoriesRequest, GetMapStoryResponse, - GetSearchTitleRequest as GetSearchStoryRequest, - GetSpotBriefInfoRequest as GetStorySpotInfoRequest, + GetSearchTitleRequest, + GetSpotBriefInfoRequest, GetSpotTotalInfoResponse, - GetStoryRequest as GetStoryMarkerInfoRequest, + GetStoryRequest, MapSpotInfoItem, MapSpotInfoList, - SearchSpotInfoResponse as SearchStoryInfoResponse, + SearchSpotInfoResponse, SpotInfoResponse, SpotTitleListResponse, StoryInfoResponse, + StoryItem, storySpots, StorySummaryResponse, } from "@/types/storySpot"; @@ -29,57 +30,43 @@ import { interface StoryStore { storyItems?: StoryItem[]; briefSpotInfo?: SpotInfoResponse; - mainStoryMapRequest?: GetStoriesInMapRequest; + mainStoryMapRequest?: GetMapStoriesRequest; spotTitleList?: SpotTitleListResponse; distance?: number; summaries?: StorySummaryResponse[]; storyListInMap?: StoryInfoResponse[]; - storySpotBriefInfo?: StorySpotInfoResponse; - newSpotLocation?: GetStorySpotInfoRequest[]; + storySpotBriefInfo?: GetLocationSpotBriefInfoResponse; + newSpotLocation?: GetSpotBriefInfoRequest[]; searchedStoryInfo?: storySpots[]; spotLocationInMap?: MapSpotInfoItem[]; + isLoading: boolean; + loading: boolean; setSearchStory: ( - param: GetSearchStoryRequest - ) => Promise; + param: GetSearchTitleRequest + ) => Promise; resetSearchStory: () => void; setStoryInfo: ( - param: GetStoryMarkerInfoRequest + param: GetStoryRequest ) => Promise; resetStoryInfo: () => void; setStoryListInMap: ( - param: GetStoriesInMapRequest + param: GetMapStoriesRequest ) => Promise; setStorySpotBriefInfo: ( - param: GetStorySpotInfoRequest - ) => Promise; + param: GetSpotBriefInfoRequest + ) => Promise; + resetStorySpotInfo: () => void; setStoryLocationInMap: ( - param: GetStoriesInMapRequest + param: GetMapStoriesRequest ) => Promise; } -interface StoryItem { - storyAuthor?: { - storyAuthorId?: number; - nickname?: string; - profileUrl?: string; - }; - - title?: string; - content?: string; - locale?: "KO" | "EN"; - storyConcept?: "TIP" | "EXPERIENCE" | "CULTURE" | "HISTORY" | "ETC"; - likeCount?: number; - createdAt?: string; - updatedAt?: string; - imageUrls?: string[]; -} - export const useStoryStore = create((set, get) => ({ briefSpotInfo: undefined, mainStoryMapRequest: undefined, @@ -92,10 +79,12 @@ export const useStoryStore = create((set, get) => ({ storySpotBriefInfo: undefined, spotLocationInMap: undefined, searchedStoryInfo: undefined, + isLoading: true, + loading: true, //마커 선택 시 이야기게시글 불러오기 setStoryInfo: async ( - param: GetStoryMarkerInfoRequest + param: GetStoryRequest ): Promise => { try { const response: GetSpotTotalInfoResponse = await getStoryInfo(param); @@ -126,7 +115,7 @@ export const useStoryStore = create((set, get) => ({ //지도 사각형 영역 내 이야기 게시글 조회 setStoryListInMap: async ( - param: GetStoriesInMapRequest + param: GetMapStoriesRequest ): Promise => { try { const response: GetMapStoryResponse = await getStoryListInMap(param); @@ -145,10 +134,10 @@ export const useStoryStore = create((set, get) => ({ //스팟 위치 정보 -> 관련 스팟 이름,id 가져오기 setStorySpotBriefInfo: async ( - param: GetStorySpotInfoRequest - ): Promise => { + param: GetSpotBriefInfoRequest + ): Promise => { try { - const response: StorySpotInfoResponse = + const response: GetLocationSpotBriefInfoResponse = await getStorySpotBriefInfo(param); set({ storySpotBriefInfo: response, @@ -156,6 +145,7 @@ export const useStoryStore = create((set, get) => ({ return response; } catch (error) { set({ storySpotBriefInfo: undefined }); + return undefined; } }, resetStorySpotInfo: () => { @@ -166,10 +156,10 @@ export const useStoryStore = create((set, get) => ({ //스토리 검색 setSearchStory: async ( - param: GetSearchStoryRequest - ): Promise => { + param: GetSearchTitleRequest + ): Promise => { try { - const response: SearchStoryInfoResponse = await getSearchStory(param); + const response: SearchSpotInfoResponse = await getSearchStory(param); set({ searchedStoryInfo: response.storySpots, }); @@ -186,7 +176,7 @@ export const useStoryStore = create((set, get) => ({ //지도 사각형 내 스팟 마커 조회 setStoryLocationInMap: async ( - param: GetStoriesInMapRequest + param: GetMapStoriesRequest ): Promise => { try { const response: MapSpotInfoList = await getStorySpotListInMap(param); @@ -200,6 +190,10 @@ export const useStoryStore = create((set, get) => ({ return undefined; } }, + + setStoryLoading: (loading: boolean) => { + set({ isLoading: loading }); + }, })); function formatDateArray(dateArray: number[] | string | undefined): string { diff --git a/src/store/useBookmarkStore.ts b/src/store/useBookmarkStore.ts new file mode 100644 index 0000000..db6b2c9 --- /dev/null +++ b/src/store/useBookmarkStore.ts @@ -0,0 +1,53 @@ +import { create } from "zustand"; + +import { BookmarkInfo, BookmarkInfoList } from "@/types/bookmark"; + +interface BookmarkState { + userBookmarkList: BookmarkInfoList | undefined; + + setUserBookmarkList: (list: BookmarkInfoList) => void; + setAddBookmark: (bookmark: BookmarkInfo) => void; + setRemoveBookmark: (bookmark: BookmarkInfo) => void; + resetUserBookmarkList: () => void; +} + +const initialState = { + userBookmarkList: undefined, +}; + +export const useBookmarkStore = create((set) => ({ + ...initialState, + + //로그인 시 북마크 전체 조회 + setUserBookmarkList: (list) => set({ userBookmarkList: list }), + + //북마크 추가 시 store에 리스트 업데이트 + setAddBookmark: (bookmark) => + set((state) => { + const current = state.userBookmarkList?.bookmarks ?? []; + + const exists = current.some((b) => b.sightId === bookmark.sightId); + if (exists) return state; + + return { + userBookmarkList: { + bookmarks: [bookmark, ...current], + }, + }; + }), + + //북마크 삭제 시 store에 리스트 업데이트 + setRemoveBookmark: (bookmark) => + set((state) => { + const current = state.userBookmarkList?.bookmarks ?? []; + + return { + userBookmarkList: { + bookmarks: current.filter((b) => b.sightId !== bookmark.sightId), + }, + }; + }), + + //로그아웃 시 북마크 삭제 + resetUserBookmarkList: () => set(initialState), +})); diff --git a/src/types/bookmark.ts b/src/types/bookmark.ts new file mode 100644 index 0000000..2ac1873 --- /dev/null +++ b/src/types/bookmark.ts @@ -0,0 +1,22 @@ +export interface BookmarkEditRequest { + id: number; + sightId: string; +} + +export interface BookmarkEditResponse { + isLiked: boolean; + sightId: string; +} + +export interface BookmarkInfoRequest { + userId?: number; +} + +export interface BookmarkInfoList { + bookmarks: BookmarkInfo[]; +} + +export interface BookmarkInfo { + sightId: string; + memberId: number; +} diff --git a/src/types/sight.ts b/src/types/sight.ts index 33cbbbb..6e2ba56 100644 --- a/src/types/sight.ts +++ b/src/types/sight.ts @@ -18,7 +18,8 @@ export interface SightMapInfoList { export interface SightDetailInfo { id: string; theme: string; // 관광지 테마 (문화시설, 자연관광지 등) - outl: string; // 관광지 개요/설명 + subTheme: string; + overview: string; // 관광지 개요/설명 title: string; // 관광지 이름 fullAddress: string; // 전체 주소 address: string; // 주소 요약 (구/동 단위) @@ -32,6 +33,8 @@ export interface SightDetailInfo { useFee: string; // 입장료 distance: number; // 현재 위치로부터 거리 (km 단위, 소수점 1자리) docentUrl: string; // 도슨트 오디오 URL + isBookmarked: boolean; + curationList: CurationItem[]; } export interface RectangleBoundsParams { @@ -59,7 +62,7 @@ export interface SightDetailCardProps { isDetailLoading: boolean; isInCart: boolean; onToggleRoute: () => void; - onClose: () => void; + handleHeaderBackPress: () => void; } export interface SearchSightParams { diff --git a/src/types/storySpot.ts b/src/types/storySpot.ts index 7079a4e..2397088 100644 --- a/src/types/storySpot.ts +++ b/src/types/storySpot.ts @@ -16,6 +16,23 @@ export interface GetMapStoryResponse { hasPrevious?: boolean; } +export interface StoryItem { + storyAuthor?: { + storyAuthorId?: number; + nickname?: string; + profileUrl?: string; + }; + + title?: string; + content?: string; + locale?: "KO" | "EN"; + storyConcept?: "TIP" | "EXPERIENCE" | "CULTURE" | "HISTORY" | "ETC"; + likeCount?: number; + createdAt?: string; + updatedAt?: string; + imageUrls?: string[]; +} + export interface GetLocationSpotBriefInfoResponse { spotId?: number; titles?: string[]; @@ -32,7 +49,7 @@ export interface SpotTitleListResponse { } export interface StoryAuthorResponse { - storyAuthorId?: number; + storyAuthorId: number; nickname?: string; profileUrl?: string; } @@ -69,10 +86,10 @@ export interface GetStoryRequest { page?: number; size?: number; sort?: - | "createdAt,desc" - | "createdAt,asc" - | "likeCount,desc" - | "likeCount,asc"; + | "createdAt,desc" + | "createdAt,asc" + | "likeCount,desc" + | "likeCount,asc"; }; }; }