diff --git a/src/api/Myplace/saved.api.ts b/src/api/Myplace/saved.api.ts index 4ec77e9..b46e95f 100644 --- a/src/api/Myplace/saved.api.ts +++ b/src/api/Myplace/saved.api.ts @@ -1,22 +1,52 @@ + import api from "../api"; import type { ApiResponse } from "@/types/api-response"; export type PlaceSaveResult = { - placeId: number; - typeId?: number; - liked: boolean; - changed: boolean; - memo?: string | null; - createdAt: string; - updatedAt: string; + placedId: number; + type: string; + enabled: boolean; + changed: boolean; + likeCount: number; + message: string; + createdAt: string; + updatedAt: string; +}; +export type PlaceActionRequestDto = { + contentId: string | number; + regionName: string; + themeName: string; + cnctrLevel: number; + enabled?: boolean; + action?: "UNSAVE" | "SAVE"; }; +function unwrap(raw: T | ApiResponse): T { + const any = raw as any; + return any && typeof any === "object" && "data" in any && any.data != null + ? (any.data as T) + : (raw as T); +} + export async function unsavePlace( - contentId: string | number + dto: PlaceActionRequestDto ): Promise { - const { data } = await api.delete>( + const body: PlaceActionRequestDto = { + ...dto, + cnctrLevel: Number(dto.cnctrLevel), + enabled: dto.enabled ?? false, + action: dto.action ?? "UNSAVE", + }; + + const { data } = await api.delete | PlaceSaveResult>( "/my/places/save", - { params: { contentId: String(contentId) } } + { + data: body, + headers: { "Content-Type": "application/json" }, + } ); - return data.data; + + return unwrap(data); } + +export default { unsavePlace }; diff --git a/src/api/Myplace/saved.ts b/src/api/Myplace/saved.ts deleted file mode 100644 index 4ec77e9..0000000 --- a/src/api/Myplace/saved.ts +++ /dev/null @@ -1,22 +0,0 @@ -import api from "../api"; -import type { ApiResponse } from "@/types/api-response"; - -export type PlaceSaveResult = { - placeId: number; - typeId?: number; - liked: boolean; - changed: boolean; - memo?: string | null; - createdAt: string; - updatedAt: string; -}; - -export async function unsavePlace( - contentId: string | number -): Promise { - const { data } = await api.delete>( - "/my/places/save", - { params: { contentId: String(contentId) } } - ); - return data.data; -} diff --git a/src/api/Myplace/saveg.api.ts b/src/api/Myplace/saveg.api.ts index c8c3cea..5f4e791 100644 --- a/src/api/Myplace/saveg.api.ts +++ b/src/api/Myplace/saveg.api.ts @@ -1,14 +1,90 @@ import api from "../api"; import type { ApiResponse } from "@/types/api-response"; +export type SavedPlaceSnapshot = { + contentId: string | number; + title?: string; + imageUrl?: string; + areaName?: string; + sigunguName?: string; + regionName?: string; +}; + +const SNAP_KEY = "st_saved_place_snapshot"; + +function loadSnapshots(): Record { + try { + const raw = localStorage.getItem(SNAP_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} +function saveSnapshots(map: Record) { + try { + localStorage.setItem(SNAP_KEY, JSON.stringify(map)); + } catch { + + } +} + +export function rememberSavedPlaceSnapshot(s: SavedPlaceSnapshot) { + const map = loadSnapshots(); + const key = String(s.contentId); + map[key] = { ...(map[key] || {}), ...s, contentId: key }; + saveSnapshots(map); +} + +export function forgetSavedPlaceSnapshot(contentId: string | number) { + const map = loadSnapshots(); + delete map[String(contentId)]; + saveSnapshots(map); +} + +export type SavedPlaceRaw = { + cnctrLevel?: number; + entrLevel?: number; + contentId: string | number; + likeCount?: number; + likedCnt?: number; + themeName?: string; + regionName?: string; // 있으면 사용 + areaName?: string; // 서버가 주면 사용 + sigunguName?: string; // 서버가 주면 사용 + imageUrl?: string; // 서버가 주면 사용 + firstImage?: string; // 서버가 주면 사용 + title?: string; // 서버가 주면 사용 + name?: string; // 서버가 주면 사용 + placeTitle?: string; // 서버가 주면 사용 + savedAt: string | number[]; +}; + +export type SavedPlacesPageRaw = { + content: SavedPlaceRaw[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + first: boolean; + last: boolean; + hasNext?: boolean; + hasPrevious?: boolean; +}; export type SavedPlace = { - contentId: string | number; - themeName?: string; - regionName?: string; - entrLevel?: number; - likedCnt?: number; - savedAt: string; + contentId: string | number; + title?: string; + imageUrl?: string; + areaName?: string; + sigunguName?: string; + themeName?: string; + regionName?: string; + entrLevel?: number; + likedCnt?: number; + likeCount?: number; + cnctrLevel?: number; + savedAt: string; }; export type SavedPlacesPage = { @@ -23,12 +99,103 @@ export type SavedPlacesPage = { hasPrevious?: boolean; }; +function toIsoFromArray(a: any): string { + if (!Array.isArray(a) || a.length < 3) return new Date().toISOString(); + const [y, m, d, hh = 0, mm = 0, ss = 0, ns = 0] = a; + const ms = Math.floor((Number(ns) || 0) / 1e6); + return new Date(y, (m ?? 1) - 1, d ?? 1, hh, mm, ss, ms).toISOString(); +} + +function unwrap(raw: T | ApiResponse): T { + const any = raw as any; + return any && typeof any === "object" && "data" in any ? (any.data as T) : (raw as T); +} + +function normalizeOne(raw: SavedPlaceRaw, snap?: SavedPlaceSnapshot): SavedPlace { + const title = + raw.title ?? + raw.name ?? + raw.placeTitle ?? + snap?.title; + + const imageUrl = + raw.imageUrl ?? + raw.firstImage ?? + snap?.imageUrl; + + const areaName = raw.areaName ?? snap?.areaName; + const sigunguName = raw.sigunguName ?? snap?.sigunguName; + + const entrLevel = + typeof raw.entrLevel === "number" + ? raw.entrLevel + : typeof raw.cnctrLevel === "number" + ? raw.cnctrLevel + : undefined; + + const likedCnt = + typeof raw.likedCnt === "number" + ? raw.likedCnt + : typeof raw.likeCount === "number" + ? raw.likeCount + : undefined; + + const regionName = + raw.regionName ?? + snap?.regionName ?? + (areaName || sigunguName ? [areaName, sigunguName].filter(Boolean).join(" ") : undefined); + + let savedAt: string; + if (Array.isArray(raw.savedAt)) savedAt = toIsoFromArray(raw.savedAt); + else if (typeof raw.savedAt === "string") savedAt = new Date(raw.savedAt).toISOString(); + else savedAt = new Date().toISOString(); + + return { + contentId: raw.contentId, + title, + imageUrl, + areaName, + sigunguName, + themeName: raw.themeName, + regionName, + entrLevel, + likedCnt, + likeCount: raw.likeCount, + cnctrLevel: raw.cnctrLevel, + savedAt, + }; +} + +function normalizePage(raw: SavedPlacesPageRaw): SavedPlacesPage { + const snaps = loadSnapshots(); + const content = (raw.content || []).map((r) => + normalizeOne(r, snaps[String(r.contentId)]) + ); + return { + content, + page: Number(raw.page ?? 0), + size: Number(raw.size ?? content.length), + totalElements: Number(raw.totalElements ?? content.length), + totalPages: Number(raw.totalPages ?? 1), + first: Boolean(raw.first ?? true), + last: Boolean(raw.last ?? true), + hasNext: raw.hasNext, + hasPrevious: raw.hasPrevious, + }; +} export async function getSavedPlaces( { page = 0, size = 20 }: { page?: number; size?: number } = {} ): Promise { - const { data } = await api.get>( + const { data } = await api.get | SavedPlacesPageRaw>( "/my/places", { params: { page, size } } ); - return data.data; + const payload = unwrap(data); + return normalizePage(payload); } + +export default { + getSavedPlaces, + rememberSavedPlaceSnapshot, + forgetSavedPlaceSnapshot, +}; diff --git a/src/api/Myplace/saveg.ts b/src/api/Myplace/saveg.ts deleted file mode 100644 index c8c3cea..0000000 --- a/src/api/Myplace/saveg.ts +++ /dev/null @@ -1,34 +0,0 @@ -import api from "../api"; -import type { ApiResponse } from "@/types/api-response"; - - -export type SavedPlace = { - contentId: string | number; - themeName?: string; - regionName?: string; - entrLevel?: number; - likedCnt?: number; - savedAt: string; -}; - -export type SavedPlacesPage = { - content: SavedPlace[]; - page: number; - size: number; - totalElements: number; - totalPages: number; - first: boolean; - last: boolean; - hasNext?: boolean; - hasPrevious?: boolean; -}; - -export async function getSavedPlaces( - { page = 0, size = 20 }: { page?: number; size?: number } = {} -): Promise { - const { data } = await api.get>( - "/my/places", - { params: { page, size } } - ); - return data.data; -} diff --git a/src/api/Myplace/savep.api.ts b/src/api/Myplace/savep.api.ts index cd72f90..6a7c6b0 100644 --- a/src/api/Myplace/savep.api.ts +++ b/src/api/Myplace/savep.api.ts @@ -1,22 +1,23 @@ +// src/api/Myplace/savep.api.ts import api from "@/api/api"; export type SaveMyPlaceRequest = { - contentId: string; - regionName: string; + contentId: string; + regionName: string; themeName: string; - cnctrLevel: number; + cnctrLevel: number; }; export type SaveMyPlaceResponse = { placedId: number; - type: string; + type: string; like: boolean; enabled: boolean; changed: boolean; likeCount: number; - message: string; - createdAt: string; - updatedAt: string; + message: string; // ← 클라이언트에서 한글로 덮어씀 + createdAt: string; + updatedAt: string; }; type ApiEnvelope = { @@ -42,12 +43,6 @@ function unwrapResponse(raw: T | ApiEnvelope): T { return raw as T; } -function assertResponseShape(res: any): asserts res is SaveMyPlaceResponse { - if (!res || typeof res.placedId !== "number") { - throw new Error("서버 응답 형식이 예상과 다릅니다."); - } -} - export async function savePlace(payload: SaveMyPlaceRequest): Promise { try { const body: SaveMyPlaceRequest = { @@ -62,16 +57,24 @@ export async function savePlace(payload: SaveMyPlaceRequest): Promise(data); - assertResponseShape(unwrapped); - return unwrapped; - } catch (err: any) { - console.error("[savePlace][raw error]", err?.response || err); - const serverMsg = - err?.response?.data?.message || - err?.response?.data?.error?.message || - err?.message; - throw new Error(serverMsg || "네트워크 오류 또는 서버 에러가 발생했습니다."); -} + + // ✅ 표시/반환 메시지를 한글로 통일 + const msg = "저장되었습니다."; + const final: SaveMyPlaceResponse = { ...unwrapped, message: msg }; + + if (typeof window !== "undefined" && typeof window.alert === "function") { + window.alert(msg); + } + + return final; + } catch (err: any) { + console.error("[savePlace][raw error]", err?.response || err); + const serverMsg = + err?.response?.data?.message || + err?.response?.data?.error?.message || + err?.message; + throw new Error(serverMsg || "네트워크 오류 또는 서버 에러가 발생했습니다."); + } } export default { savePlace }; diff --git a/src/api/Myplace/savep.ts b/src/api/Myplace/savep.ts deleted file mode 100644 index cd72f90..0000000 --- a/src/api/Myplace/savep.ts +++ /dev/null @@ -1,77 +0,0 @@ -import api from "@/api/api"; - -export type SaveMyPlaceRequest = { - contentId: string; - regionName: string; - themeName: string; - cnctrLevel: number; -}; - -export type SaveMyPlaceResponse = { - placedId: number; - type: string; - like: boolean; - enabled: boolean; - changed: boolean; - likeCount: number; - message: string; - createdAt: string; - updatedAt: string; -}; - -type ApiEnvelope = { - success?: boolean; - data?: T; - message?: string; - code?: string | number; - error?: { - code?: string | number; - status?: number; - message?: string; - path?: string; - timestamp?: string | number; - detail?: string; - }; -}; - -function unwrapResponse(raw: T | ApiEnvelope): T { - const anyRaw = raw as any; - if (anyRaw && typeof anyRaw === "object" && "data" in anyRaw && anyRaw.data) { - return anyRaw.data as T; - } - return raw as T; -} - -function assertResponseShape(res: any): asserts res is SaveMyPlaceResponse { - if (!res || typeof res.placedId !== "number") { - throw new Error("서버 응답 형식이 예상과 다릅니다."); - } -} - -export async function savePlace(payload: SaveMyPlaceRequest): Promise { - try { - const body: SaveMyPlaceRequest = { - ...payload, - cnctrLevel: Number(payload.cnctrLevel), - }; - - const { data } = await api.put>( - "/my/places/save", - body, - { headers: { "Content-Type": "application/json" } } - ); - - const unwrapped = unwrapResponse(data); - assertResponseShape(unwrapped); - return unwrapped; - } catch (err: any) { - console.error("[savePlace][raw error]", err?.response || err); - const serverMsg = - err?.response?.data?.message || - err?.response?.data?.error?.message || - err?.message; - throw new Error(serverMsg || "네트워크 오류 또는 서버 에러가 발생했습니다."); -} -} - -export default { savePlace }; diff --git a/src/api/travel/place.api.ts b/src/api/travel/place.api.ts new file mode 100644 index 0000000..624a3ac --- /dev/null +++ b/src/api/travel/place.api.ts @@ -0,0 +1,43 @@ +import api from "@/api/api"; +import type { ApiResponse } from "@/types/api-response"; + +const ENDPOINT = "/places"; +export type PlaceListItem = { + title: string; + contentId: string | number; + cat1?: string; + cat2?: string; + dist?: number; + cnctrRate?: string | number; + [k: string]: any; +}; +export type PlacesQuery = { + areaCode?: number; + sigunguCode?: number; + cat1?: string; + cat2?: string; + contentTypeId?: number; + pageNo?: number; + numOfRows?: number; + _type?: string; + arrange?: string; +}; +type MaybeWrapped = ApiResponse | T; + +function prune>(obj: T) { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== "") + ); +} +function unwrap(payload: MaybeWrapped): T { + if (payload && typeof payload === "object" && "data" in (payload as any)) { + return (payload as any).data as T; + } + return payload as T; +} +export async function fetchPlaces(params: PlacesQuery = {}): Promise { + const { data } = await api.get>(ENDPOINT, { + params: prune(params), + }); + return unwrap(data); +} diff --git a/src/constants/themeMap.ts b/src/constants/themeMap.ts new file mode 100644 index 0000000..a0c9c61 --- /dev/null +++ b/src/constants/themeMap.ts @@ -0,0 +1,58 @@ +// 코드→라벨 매핑 +export const CAT1_LABEL: Record = { + A01: "자연관광지", + A02: "문화시설", + A03: "축제/공연/행사", + A04: "여행코스", + A05: "레포츠", + B02: "숙박", + A08: "쇼핑", + A07: "음식", +}; + +export const CAT2_LABEL: Record = { + A0101: "자연관광지", + A0102: "관광자원", + A0201: "역사관광지", + A0202: "문화시설", + A0203: "전시/미술관", + A0204: "공연장", + A0301: "축제", + A0302: "공연/행사", + A0401: "가족코스", + A0402: "나홀로코스", + A0403: "힐링코스", + A0404: "도보코스", + A0405: "캠핑코스", + A0502: "스포츠", + A0503: "수상레저", + A0505: "자전거", + A0507: "등산", + B0201: "호텔", + B0202: "콘도/리조트", + B0203: "펜션", + B0204: "민박", + B0205: "게스트하우스", + A0801: "쇼핑", + A0701: "한식", + A0702: "서양식", + A0703: "일식", + A0704: "중식", + A0705: "아시아식", + A0706: "카페/디저트", +}; + +// 원본 raw에서 라벨 추출 +export function displayThemeLabel(raw: any): string { + const c2 = (raw?.cat2 ?? raw?.CAT2 ?? "").toString().trim(); + if (c2 && CAT2_LABEL[c2]) return CAT2_LABEL[c2]; + + const c1 = (raw?.cat1 ?? raw?.CAT1 ?? "").toString().trim(); + if (c1 && CAT1_LABEL[c1]) return CAT1_LABEL[c1]; + + if (raw?.cat2Name) return String(raw.cat2Name); + if (raw?.cat1Name) return String(raw.cat1Name); + if (raw?.themeName) return String(raw.themeName); + + return "기타"; +} diff --git a/src/pages/ai/TravelSpotDetail.tsx b/src/pages/ai/TravelSpotDetail.tsx index bc9f826..eaf34cf 100644 --- a/src/pages/ai/TravelSpotDetail.tsx +++ b/src/pages/ai/TravelSpotDetail.tsx @@ -12,28 +12,29 @@ import type { PlaceDetail } from '@/types/Detail'; import { getPlaceDetail, type IntegratedPlace } from '@/api/Detail/detail.api'; import { useEffect, useState } from 'react'; import { Badge, Image, Loader, ParkingTable } from '@/component'; -import { likePlace, unlikePlace, getLikeStatus } from '@/api/like/like.api'; + +import { likePlace, unlikePlace } from '@/api/like/like.api'; +import { savePlace, type SaveMyPlaceRequest } from '@/api/Myplace/savep.api'; +import { unsavePlace, type PlaceActionRequestDto } from '@/api/Myplace/saved.api'; +import { rememberSavedPlaceSnapshot } from '@/api/Myplace/saveg.api'; const TravelSpotDetail = () => { const navigate = useNavigate(); const location = useLocation(); const { contentId = '' } = useParams<{ contentId: string }>(); const formatCount = (n: number, cap = 999) => (n > cap ? `${cap}+` : `${n}`); - const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [errMsg, setErrMsg] = useState(null); - const [liked, setLiked] = useState(false); const [likeCount, setLikeCount] = useState(0); const [bookmarked, setBookmarked] = useState(false); - const isAuthed = !!localStorage.getItem('accessToken'); + const [saving, setSaving] = useState(false); const handleToggleLike = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); - if (!data) return; if (!isAuthed) { @@ -76,15 +77,51 @@ const TravelSpotDetail = () => { setLikeCount((c) => c + 1); } setLiked((prev) => !prev); - } catch (err: any) { - console.error('좋아요 처리 실패:', err?.message || err); + } catch { } }; - - const handleToggleBookmark = () => { - setBookmarked((prev) => !prev); + const handleToggleBookmark = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + if (!data || !contentId || saving) return; + const prev = bookmarked; + setBookmarked(!prev); + setSaving(true); + try { + if (!prev) { + const payload: SaveMyPlaceRequest = { + contentId, + regionName: data.regionTag || '정보없음', + themeName: data.themeName || '여행지', + cnctrLevel: data.serenity >= 0 ? data.serenity : 0, + }; + await savePlace(payload); + const parts = (data.address || '').trim().split(/\s+/); + rememberSavedPlaceSnapshot({ + contentId: payload.contentId, + title: data.name, + imageUrl: data.thumbnail || undefined, + areaName: parts[0], + sigunguName: parts[1], + regionName: data.regionTag, + }); + } else { + const dto: PlaceActionRequestDto = { + contentId, + regionName: data.regionTag || '정보없음', + themeName: data.themeName || '여행지', + cnctrLevel: data.serenity >= 0 ? data.serenity : 0, + action: 'UNSAVE', + enabled: false, + }; + await unsavePlace(dto); + } + } catch { + setBookmarked(prev); + } finally { + setSaving(false); + } }; - function mapIntegratedToPlaceDetail(id: string, item: IntegratedPlace): PlaceDetail { const normalize = (u?: string | null) => { const s = (u ?? '').trim(); @@ -95,11 +132,9 @@ const TravelSpotDetail = () => { const thumbnail = normalize(item.placeImageUrl) || ''; const address = item.placeAddress ?? ''; const description = item.introduction ?? ''; - const regionTag = item.region ?? '정보없음'; const themeName = item.themeName ?? '여행지'; const serenity = item.tranquilityLevel ?? -1; - const parkings = item.nearbyParkingLots?.map((p) => ({ id: p.prkId, @@ -120,10 +155,7 @@ const TravelSpotDetail = () => { regionTag, themeName, serenity, - extra: { - aiSummary: item.aiTipSummary ?? undefined, - parkings, - }, + extra: { aiSummary: item.aiTipSummary ?? undefined, parkings }, }; } useEffect(() => { @@ -139,14 +171,12 @@ const TravelSpotDetail = () => { setErrMsg(null); const item = await getPlaceDetail(contentId); if (!alive) return; - if (!item) { setErrMsg('해당 여행지 정보를 찾을 수 없습니다.'); setData(null); } else { const mapped = mapIntegratedToPlaceDetail(contentId, item); setData(mapped); - //좋아요/북마크 초기값 세팅 setLiked(mapped.liked); setLikeCount(mapped.likeCount); setBookmarked(mapped.bookmarked); @@ -173,7 +203,6 @@ const TravelSpotDetail = () => { alive = false; }; }, [contentId]); - return (
@@ -196,69 +225,51 @@ const TravelSpotDetail = () => { ) : ( <>
- {/*썸네일*/}
{data.thumbnail ? ( - {data.name} + {data.name} ) : ( )}
- {/*이름, 주소*/}
{data.name}
{data.address}
- - {/*좋아요, 저장*/}
+ aria-label={`좋아요 ${likeCount}개`}> {formatCount(likeCount)} - -
- {/*태그 뱃지 영역*/}
- - {data.regionTag} - - - {data.themeName} - + {data.regionTag} + {data.themeName} {data.serenity === -1 ? ( - - 정보없음 - + 정보없음 ) : ( - - 한적함 - + 한적함 )}
- {/*소개...*/}
소개
{data.description}
- - {/*강릉시 한정 정보*/} - {/*AI 꿀팁 요약*/} {data.extra?.aiSummary && (
@@ -268,7 +279,6 @@ const TravelSpotDetail = () => {
{data.extra.aiSummary}
)} - {/*주차장 정보*/} {data.extra?.parkings && ( )} @@ -278,5 +288,4 @@ const TravelSpotDetail = () => {
); }; - export default TravelSpotDetail; diff --git a/src/pages/explore/Searching.tsx b/src/pages/explore/Searching.tsx index 9f7bb16..c18ecb5 100644 --- a/src/pages/explore/Searching.tsx +++ b/src/pages/explore/Searching.tsx @@ -9,7 +9,7 @@ export default function Searching() { useEffect(() => { const t = setTimeout(() => { - navigate("/explore/travelsearch"); + navigate("/travelsearch"); }, 3000); return () => clearTimeout(t); }, [navigate]); diff --git a/src/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index 21d20e6..8d3da4d 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -4,14 +4,23 @@ import Header from '@/component/Header'; import Sidebar from '@/component/SideBar'; import SearchIcon from '@/image/Search.svg'; import PlaceCard from '@/component/common/Card/PlaceCard'; -import { getSavedPlaces, type SavedPlace } from '@/api/Myplace/saveg.api'; -import { unsavePlace } from '@/api/Myplace/saved.api'; +import { + getSavedPlaces, + type SavedPlace, + forgetSavedPlaceSnapshot, +} from '@/api/Myplace/saveg.api'; +import { unsavePlace, type PlaceActionRequestDto } from '@/api/Myplace/saved.api'; const PAGE_SIZE = 20; function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } +function toCnctrLevel(it: SavedPlace): number { + if (typeof (it as any).cnctrLevel === 'number') return (it as any).cnctrLevel; + if (typeof it.entrLevel === 'number') return it.entrLevel; + return 3; +} const MyTravelList = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -44,27 +53,49 @@ const MyTravelList = () => { useEffect(() => { loadPage(0, true); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleRemoveItem = async (contentId: string | number) => { + const handleRemoveItem = async (it: SavedPlace) => { const snapshot = items; - setItems((prev) => prev.filter((it) => it.contentId !== contentId)); + + setItems((prev) => prev.filter((p) => p.contentId !== it.contentId)); try { - await unsavePlace(contentId); + const dto: PlaceActionRequestDto = { + contentId: String(it.contentId), + regionName: it.regionName || '기타', + themeName: it.themeName || '기타', + cnctrLevel: toCnctrLevel(it), + action: 'UNSAVE', + enabled: false, + }; + await unsavePlace(dto); + forgetSavedPlaceSnapshot(it.contentId); + await loadPage(0, true); } catch (e) { console.error('[MyTravelList][unsavePlace]', e); - setItems(snapshot); // 롤백 + setItems(snapshot); alert('삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); } }; - const filtered = useMemo(() => { - if (!q.trim()) return items; + const visible = useMemo(() => { + const base = items.filter((it: any) => { + if (typeof it.enabled === 'boolean') return it.enabled; + if (typeof it.like === 'boolean') return it.like; + return true; + }); + if (!q.trim()) return base; const kw = q.trim().toLowerCase(); - return items.filter((it) => { - const title = `${it.regionName ?? ''} ${it.themeName ?? ''} ${it.contentId}`.toLowerCase(); - return title.includes(kw); + return base.filter((it) => { + const displayTitle = + (it as any).title || + (it as any).name || + (it as any).placeTitle || + (it.regionName && it.themeName ? `${it.regionName} · ${it.themeName}` : undefined) || + it.regionName || + it.themeName || + String(it.contentId); + return displayTitle.toLowerCase().includes(kw); }); }, [items, q]); @@ -96,25 +127,34 @@ const MyTravelList = () => { )}
- {filtered.map((it) => { - const title = - it.regionName && it.themeName - ? `${it.regionName} · ${it.themeName}` - : it.regionName || it.themeName || String(it.contentId); - - const likeCount = (it as any).likeCnt ?? (it as any).likedCnt ?? 0; - const quietLevel = clamp(Math.round(it.entrLevel ?? 3), 1, 5); + {visible.map((it) => { + const displayTitle = + (it as any).title || + (it as any).name || + (it as any).placeTitle || + (it.regionName && it.themeName ? `${it.regionName} · ${it.themeName}` : undefined) || + it.regionName || + it.themeName || + String(it.contentId); + + const imgUrl = (it as any).imageUrl ?? (it as any).firstImage ?? undefined; + const likeCount = (it as any).likedCnt ?? (it as any).likeCount ?? 0; + const quietLevel = clamp( + Math.round((it as any).entrLevel ?? (it as any).cnctrLevel ?? 3), + 1, + 5 + ); return ( handleRemoveItem(it.contentId)} + onRemove={() => handleRemoveItem(it)} /> ); })} diff --git a/src/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index 3f78181..8930a7a 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -1,31 +1,113 @@ - -import { useState } from 'react'; -import Header from '@/component/Header'; -import Sidebar from '@/component/SideBar'; -import SearchIcon from '@/image/Search.svg'; -import { TRAVEL_ITEMS } from '@/constants/TravelSearch'; -import PlaceCard from '@/component/common/Card/PlaceCard'; +import { useEffect, useState, type KeyboardEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import Header from "@/component/Header"; +import Sidebar from "@/component/SideBar"; +import PlaceCard from "@/component/common/Card/PlaceCard"; +import { fetchPlaces, type PlaceListItem } from "@/api/travel/place.api"; +import { displayThemeLabel } from "@/constants/themeMap"; type TravelItem = { - id: number; + id: string; title: string; tranquil: boolean; - type: string; + type: string; likes: number; imgUrl?: string; + _raw?: PlaceListItem; +}; +const DETAIL_ROUTE = (contentId: string) => `/place/${encodeURIComponent(contentId)}`; +function pickContentId(raw: any): string | null { + const cid = + raw?.contentId ?? + raw?.contentid ?? + raw?.CONTENT_ID ?? + raw?.id ?? + null; + if (cid === null || cid === undefined) return null; + const s = String(cid).trim(); + return s.length > 0 && s !== "0" ? s : null; // '0' 같은 잘못된 값 방지 +} +const rateToQuietLevel = (rate: unknown) => { + const n = Number(rate); + if (!Number.isFinite(n)) return 3; + const table: Record = { 0: 5, 1: 4, 2: 3, 3: 2 }; + return table[n] ?? 3; +}; +const mapPlaceToTravelItem = (p: PlaceListItem): TravelItem | null => { + const raw: any = p as any; + const cid = pickContentId(raw); + if (!cid) { + console.warn("[TravelSearch] contentId 없음 → 제외됨. raw:", raw); + return null; + } + const quiet = rateToQuietLevel(raw.cnctrRate); + const title = raw.title ?? "이름 미상"; + const img = + raw.firstimage ?? + raw.firstImage ?? + raw.imageUrl ?? + raw.imgUrl ?? + undefined; + return { + id: cid, + title, + tranquil: quiet >= 4, + type: displayThemeLabel(raw), + likes: Number(raw.likeCount ?? 0), + imgUrl: img, + _raw: p, + }; }; - const TravelSearch = () => { + const navigate = useNavigate(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [travelItems, setTravelItems] = useState( - () => TRAVEL_ITEMS.map((it) => ({ ...it })) - ); - + const [travelItems, setTravelItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const handleMenuClick = () => setIsSidebarOpen(true); const handleCloseSidebar = () => setIsSidebarOpen(false); + const load = async () => { + setLoading(true); + setError(null); + try { + const list = await fetchPlaces({ + pageNo: 1, + numOfRows: 20, + _type: "json", + arrange: "O", + }); - const handleRemoveItem = (id: number) => - setTravelItems((prev) => prev.filter((item) => item.id !== id)); + const mapped = list + .map(mapPlaceToTravelItem) + .filter((x): x is TravelItem => x !== null); + if (mapped.length !== list.length) { + console.warn( + `[TravelSearch] 총 ${list.length}개 중 ${list.length - mapped.length}개는 contentId 누락으로 제외` + ); + } + setTravelItems(mapped); + } catch (e: any) { + console.error("[TravelSearch][fetchPlaces][error]", e); + setError(e?.message || "목록을 불러오지 못했습니다."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openDetail = (item: TravelItem) => { + navigate(DETAIL_ROUTE(item.id)); + }; + + const handleCardKey = (e: KeyboardEvent, item: TravelItem) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openDetail(item); + } + }; return (
@@ -36,34 +118,41 @@ const TravelSearch = () => {
여행지 탐색
-
- - - search - -
+ + {error &&
{error}
}
{travelItems.map((item) => ( - handleRemoveItem(item.id)} - /> + role="button" + tabIndex={0} + aria-label={`여행지 상세로 이동: ${item.title}`} + data-content-id={item.id} + onClick={() => openDetail(item)} + onKeyDown={(e) => handleCardKey(e, item)} + className="relative rounded-lg transition shadow-sm outline-none focus:ring-2 focus:ring-green3 cursor-pointer hover:shadow-md" + > + +
))} + + {!loading && !error && travelItems.length === 0 && ( +
+ 표시할 여행지가 없습니다. +
+ )}
); }; -export default TravelSearch; \ No newline at end of file +export default TravelSearch; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 1484c50..29e1f00 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -63,10 +63,7 @@ export const router = createBrowserRouter([ path: 'searching', element: , }, - { - path: 'travelsearch', - element: , - }, + ], }, { @@ -80,6 +77,10 @@ export const router = createBrowserRouter([ path: '/place/:contentId', element: , }, + { + path: 'travelsearch', + element: , + }, { path: '/region', element: , diff --git a/src/utils/ktoMapping.ts b/src/utils/ktoMapping.ts index 371a3dc..992ae88 100644 --- a/src/utils/ktoMapping.ts +++ b/src/utils/ktoMapping.ts @@ -1,4 +1,3 @@ -// src/utils/ktoMapping.ts import { themeCatMap } from "@/constants/themeCatMap"; import { regionMap, normalizeRegionName } from "@/constants/regionMap"; import type { ThemeCodes, RegionCodes } from "@/types/kto";