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.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..aad97b3 100644 --- a/src/api/Myplace/savep.api.ts +++ b/src/api/Myplace/savep.api.ts @@ -1,77 +1,137 @@ -import api from "@/api/api"; +// src/api/Myplace/savep.api.ts +import api from "@/api/api"; export type SaveMyPlaceRequest = { - contentId: string; - regionName: string; - themeName: string; - cnctrLevel: number; + contentId: string | number; + + // 코드 기반(권장) + areaCode?: number | string; + sigunguCode?: number | string; + cat1?: string; + cat2?: string; + + cnctrLevel?: number; + + // 레거시(문자명) — 백엔드가 아직 받을 수도 있어 optional로 유지 + regionName?: string; + themeName?: string; }; -export type SaveMyPlaceResponse = { - placedId: number; - type: string; - like: boolean; +export type SaveMyPlaceData = { + placeId: number; + type: string; // "CREATED" | "UPDATED" | "UNCHANGED" 등 백 정의에 맞춰 들어옴 enabled: boolean; changed: boolean; - likeCount: number; - message: string; - createdAt: string; - updatedAt: string; + likeCount?: number; + message?: string; // 백에서 사유 메시지 내려줄 수 있음 + createdAt?: string; + updatedAt?: string; }; -type ApiEnvelope = { - success?: boolean; - data?: T; - message?: string; - code?: string | number; +type Wrapped = { + success: boolean; + data: T | null; error?: { - code?: string | number; + code?: number | string; status?: number; message?: string; path?: string; - timestamp?: string | number; - detail?: string; - }; + detail?: unknown; + } | null; }; -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; +class AppError extends Error { + code: number | string; + status?: number; + raw?: unknown; + constructor(msg: string, code: number | string, status?: number, raw?: unknown) { + super(msg); + this.name = "AppError"; + this.code = code; + this.status = status; + this.raw = raw; } - return raw as T; } -function assertResponseShape(res: any): asserts res is SaveMyPlaceResponse { - if (!res || typeof res.placedId !== "number") { - throw new Error("서버 응답 형식이 예상과 다릅니다."); +function safeParseJson(x: any) { + try { + if (typeof x === "string") return JSON.parse(x); + return x; + } catch { + return x; } } -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 || "네트워크 오류 또는 서버 에러가 발생했습니다."); +function throwNormalizedError(e: any): never { + const resp = e?.response; + const data = resp?.data; + const err = data?.error ?? data; + + const status = resp?.status ?? err?.status ?? 0; + const code = err?.code ?? status ?? "UNKNOWN"; + const msg = + err?.message || + data?.message || + e?.message || + "요청 처리 중 오류가 발생했습니다."; + + if (resp) { + console.debug("[saveMyPlace][HTTP ERROR]", { + method: resp.config?.method, + url: resp.config?.url, + status, + requestBody: safeParseJson(resp.config?.data), + responseBody: data, + }); + } + throw new AppError(msg, code, status, e); } + +export async function saveMyPlace(body: SaveMyPlaceRequest): Promise { + const payload: SaveMyPlaceRequest = { + ...body, + contentId: String(body.contentId), + }; + + try { + console.debug("[saveMyPlace][REQUEST]", { + method: "PUT", + url: "/my/places/save", + payload, + }); + + const { data, status, config } = await api.put>("my/places/save", payload, { + headers: { "Content-Type": "application/json" }, + }); + + console.debug("[saveMyPlace][RESPONSE]", { + status, + url: config?.url, + raw: data, + }); + + // 비래핑 + if ((data as any)?.placeId !== undefined) { + return data as SaveMyPlaceData; + } + + // 래핑 + const wrapped = data as Wrapped; + if (typeof wrapped?.success === "boolean") { + if (wrapped.success && wrapped.data) return wrapped.data; + + const em = wrapped?.error?.message || "요청이 실패했습니다."; + const ec = wrapped?.error?.code ?? status ?? 400; + const es = wrapped?.error?.status ?? status; + throw new AppError(em, ec, es, wrapped?.error); + } + + throw new AppError("알 수 없는 응답 형식입니다.", "UNEXPECTED_RESPONSE", status, data); + } catch (e) { + throwNormalizedError(e); + } } -export default { savePlace }; +// 기존 import 경로 호환 +export { saveMyPlace as savePlace }; +export { AppError }; 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..33332b1 --- /dev/null +++ b/src/api/travel/place.api.ts @@ -0,0 +1,65 @@ +// src/api/travel/place.api.ts +import api from '@/api/api'; + +export type PlaceListItem = { + contentId: string | number; + title: string; + imageUrl?: string; + likeCount?: number; + areaCode?: number | string; + sigunguCode?: number | string; + cat1?: string; + cat2?: string; + tranquil?: boolean; + // ... 필요한 필드들 +}; + +export type PlacesQuery = { + page?: number; + size?: number; + + // === 필터 === + cat1?: string | null; + cat2?: string[] | null; // 다중 cat2 허용 + areaCode?: number | string; + sigunguCode?: number | string; + tranquil?: boolean; + keywords?: string; +}; + +export async function fetchPlaces(query: PlacesQuery = {}) { + // 서버가 cat2 키를 'cat2List'로 받는다면 아래 키만 바꾸면 됨. + // const CAT2_KEY = 'cat2List'; + const CAT2_KEY = 'cat2'; + + const params: Record = { ...query }; + + if (!query.cat1) delete params.cat1; + if (!query.cat2 || !query.cat2.length) { + delete params.cat2; + } else { + // axios 기본 직렬화는 배열에 [] 붙이거나 인덱싱할 수 있어 호환 이슈 발생 + // → URLSearchParams로 'cat2=...&cat2=...' 형태 강제 + } + + // 커스텀 직렬화: cat2 배열은 동일 키 반복으로 직렬화 + const res = await api.get('/places', { + params, + paramsSerializer: (p) => { + const usp = new URLSearchParams(); + Object.entries(p).forEach(([k, v]) => { + if (v === undefined || v === null || v === '') return; + if (k === 'cat2' && Array.isArray(v)) { + v.forEach((vv) => usp.append(CAT2_KEY, String(vv))); + } else if (Array.isArray(v)) { + v.forEach((vv) => usp.append(k, String(vv))); + } else { + usp.append(k, String(v)); + } + }); + return usp.toString(); + }, + }); + + return res.data; +} diff --git a/src/component/selector/Selector.tsx b/src/component/selector/Selector.tsx index 14f2c67..698ed36 100644 --- a/src/component/selector/Selector.tsx +++ b/src/component/selector/Selector.tsx @@ -1,10 +1,19 @@ -import { useEffect, useState } from 'react'; +// src/component/Selector.tsx +import { useEffect, useMemo, useState } from 'react'; +import { getThemeGroups, type ThemeGroup } from '@/api/Selector/theme.api'; -export type SelectorProps = { - dataMap: Record; +export type OnSelectExtra = { + // 선택 코드 & 라벨 동시 제공 (mode='theme' 때 자동 세팅) + cat1?: string | null; + cat2?: string[]; + cat1Name?: string | null; + cat2Names?: string[]; +}; + +type BaseProps = { initialMain: string; - onSelect?: (main: string, subs: string[]) => void; - initialSubs?: string[]; + initialSubs?: string[]; // 단일 선택이라 첫 번째만 반영 + onSelect?: (main: string, subs: string[], extra?: OnSelectExtra) => void; colorScheme?: { leftBase?: string; leftItem?: string; @@ -15,11 +24,26 @@ export type SelectorProps = { }; }; +// 기존 사용: dataMap을 직접 주입 +type ManualMode = BaseProps & { + mode?: 'manual'; + dataMap: Record; +}; + +// 새 사용: theme API에서 자동 로드 +type ThemeMode = BaseProps & { + mode: 'theme'; + dataMap?: never; +}; + +export type SelectorProps = ManualMode | ThemeMode; + const Selector = ({ - dataMap, + mode = 'manual', + dataMap: dataMapProp, initialMain, - onSelect, initialSubs, + onSelect, colorScheme = {}, }: SelectorProps) => { const { @@ -31,50 +55,113 @@ const Selector = ({ borderColor = 'border-green3', } = colorScheme; + const [loading, setLoading] = useState(mode === 'theme'); + const [groups, setGroups] = useState([]); + const [dataMap, setDataMap] = useState>( + mode === 'manual' ? (dataMapProp as Record) : {}, + ); + + // theme 모드면 API 로드 + useEffect(() => { + if (mode !== 'theme') return; + (async () => { + try { + setLoading(true); + const gs = await getThemeGroups(); + setGroups(gs); + const map: Record = {}; + gs.forEach((g) => (map[g.cat1Name] = g.cat2List.map((c) => c.cat2Name))); + setDataMap(map); + } catch (e) { + console.error('[Selector][theme][load error]', e); + setGroups([]); + setDataMap({}); + } finally { + setLoading(false); + } + })(); + }, [mode]); + + // manual 모드에서 외부 dataMap이 바뀌면 반영 + useEffect(() => { + if (mode === 'manual' && dataMapProp) setDataMap(dataMapProp); + }, [mode, dataMapProp]); + + const firstMain = useMemo(() => Object.keys(dataMap)[0] ?? initialMain, [dataMap, initialMain]); + const [selectedMain, setSelectedMain] = useState(initialMain); const [selectedSub, setSelectedSub] = useState( initialSubs && initialSubs.length ? initialSubs[0] : null, ); + // dataMap 변화 시 초기값 정합성 보정 + useEffect(() => { + if (!dataMap[selectedMain]) { + const fallback = dataMap[initialMain] ? initialMain : firstMain; + setSelectedMain(fallback); + const sub = initialSubs?.[0]; + setSelectedSub(sub && dataMap[fallback]?.includes(sub) ? sub : null); + } else if (selectedSub && !dataMap[selectedMain]?.includes(selectedSub)) { + setSelectedSub(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataMap, firstMain]); + const changeMain = (next: string) => { setSelectedMain(next); - setSelectedSub(null); // 메인 카테고리 변경 시 서브 선택 초기화 + setSelectedSub(null); }; const chooseSub = (sub: string) => { - setSelectedSub((prev) => (prev === sub ? null : sub)); // 단일 선택, 같은 것 클릭시 해제 + setSelectedSub((prev) => (prev === sub ? null : sub)); }; - useEffect(() => setSelectedMain(initialMain), [initialMain]); - + // onSelect payload 구성(코드 포함) useEffect(() => { - if (initialSubs !== undefined) { - setSelectedSub(initialSubs.length ? initialSubs[0] : null); - } - }, [initialSubs]); + if (!onSelect) return; + const names = selectedSub ? [selectedSub] : []; + if (mode === 'theme') { + const g = groups.find((gg) => gg.cat1Name === selectedMain); + const cat1 = g?.cat1 ?? null; + const cat2Names = names; + const cat2 = + g?.cat2List + ?.filter((c) => cat2Names.includes(c.cat2Name)) + .map((c) => c.cat2) ?? []; - useEffect(() => { - onSelect?.(selectedMain, selectedSub ? [selectedSub] : []); - }, [onSelect, selectedMain, selectedSub]); + onSelect(selectedMain, names, { + cat1, + cat2, + cat1Name: g?.cat1Name ?? null, + cat2Names, + }); + } else { + onSelect(selectedMain, names); + } + }, [mode, groups, onSelect, selectedMain, selectedSub]); return (
- {Object.keys(dataMap).map((main) => ( - - ))} + {loading &&
불러오는 중…
} + {!loading && + Object.keys(dataMap).map((main) => ( + + ))}
- {dataMap[selectedMain]?.length ? ( + {loading ? ( +
+ ) : dataMap[selectedMain]?.length ? ( dataMap[selectedMain].map((sub) => { const active = selectedSub === sub; return ( diff --git a/src/component/selector/SelectorMulti.tsx b/src/component/selector/SelectorMulti.tsx index 061235a..3d23380 100644 --- a/src/component/selector/SelectorMulti.tsx +++ b/src/component/selector/SelectorMulti.tsx @@ -1,11 +1,18 @@ -import { useEffect, useState } from 'react'; +// src/component/SelectorMulti.tsx +import { useEffect, useMemo, useState } from 'react'; +import { getThemeGroups, type ThemeGroup } from '@/api/Selector/theme.api'; -export type SelectorMultiProps = { - dataMap: Record; - initialMain: string; - onSelect?: (main: string, subs: string[]) => void; - initialSubs?: string[]; +export type OnSelectExtra = { + cat1?: string | null; + cat2?: string[]; // 단일 선택이지만 호환 위해 배열 유지([code] 또는 []) + cat1Name?: string | null; + cat2Names?: string[]; // 단일 선택이지만 호환 위해 배열 유지([name] 또는 []) +}; +type BaseProps = { + initialMain: string; + initialSubs?: string[]; // 단일 선택 모드에서도 첫 번째 값만 사용 + onSelect?: (main: string, subs: string[], extra?: OnSelectExtra) => void; colorScheme?: { leftBase?: string; leftItem?: string; @@ -16,11 +23,24 @@ export type SelectorMultiProps = { }; }; +type ManualMode = BaseProps & { + mode?: 'manual'; + dataMap: Record; +}; + +type ThemeMode = BaseProps & { + mode: 'theme'; + dataMap?: never; +}; + +export type SelectorMultiProps = ManualMode | ThemeMode; + const SelectorMulti = ({ - dataMap, + mode = 'manual', + dataMap: dataMapProp, initialMain, - onSelect, initialSubs, + onSelect, colorScheme = {}, }: SelectorMultiProps) => { const { @@ -32,56 +52,132 @@ const SelectorMulti = ({ borderColor = 'border-green3', } = colorScheme; - const [selectedMain, setSelectedMain] = useState(initialMain); + const [loading, setLoading] = useState(mode === 'theme'); + const [groups, setGroups] = useState([]); + const [dataMap, setDataMap] = useState>( + mode === 'manual' ? (dataMapProp as Record) : {}, + ); + + // ── 테마 모드: 원격 그룹 불러오기 + useEffect(() => { + if (mode !== 'theme') return; + (async () => { + try { + setLoading(true); + const gs = await getThemeGroups(); + setGroups(gs); + const map: Record = {}; + gs.forEach((g) => (map[g.cat1Name] = g.cat2List.map((c) => c.cat2Name))); + setDataMap(map); + } catch (e) { + console.error('[SelectorMulti][theme][load error]', e); + setGroups([]); + setDataMap({}); + } finally { + setLoading(false); + } + })(); + }, [mode]); - const [selectedSubs, setSelectedSubs] = useState( - initialSubs && initialSubs.length ? [initialSubs[0]] : [] + // ── 매뉴얼 모드: 외부 dataMap 갱신 반영 + useEffect(() => { + if (mode === 'manual' && dataMapProp) setDataMap(dataMapProp); + }, [mode, dataMapProp]); + + const firstMain = useMemo(() => Object.keys(dataMap)[0] ?? initialMain, [dataMap, initialMain]); + + // ── 선택 상태(단일 선택) + const [selectedMain, setSelectedMain] = useState(initialMain); + const [selectedSub, setSelectedSub] = useState( + initialSubs && initialSubs.length ? initialSubs[0] : null, ); + // ── dataMap 변동 시 선택값 유효성 보정 + useEffect(() => { + // 메인 유효성 체크 + let nextMain = selectedMain; + if (!dataMap[selectedMain]) { + nextMain = dataMap[initialMain] ? initialMain : firstMain; + setSelectedMain(nextMain); + } + + // 서브 유효성 체크 (단일) + const subs = dataMap[nextMain] ?? []; + if (!selectedSub || !subs.includes(selectedSub)) { + // initialSubs 중 현재 메인에서 유효한 첫 값, 없으면 null + const candidate = + (initialSubs ?? []).find((s) => subs.includes(s)) ?? null; + setSelectedSub(candidate); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataMap, firstMain]); + + // ── 메인 변경 시 서브 리셋 const changeMain = (next: string) => { setSelectedMain(next); - setSelectedSubs([]); + setSelectedSub(null); // 메인 바꾸면 서브 초기화 }; + // ── 서브 단일 토글(같은 것 클릭 시 해제) const toggleSub = (sub: string) => { - setSelectedSubs((prev) => (prev[0] === sub ? [] : [sub])); + setSelectedSub((prev) => (prev === sub ? null : sub)); }; + // ── 상위로 변경 알림 useEffect(() => { - setSelectedMain(initialMain); - }, [initialMain]); + if (!onSelect) return; - useEffect(() => { - if (initialSubs !== undefined) { - setSelectedSubs(initialSubs.length ? [initialSubs[0]] : []); - } - }, [initialSubs]); + const subsArr = selectedSub ? [selectedSub] : []; - useEffect(() => { - onSelect?.(selectedMain, selectedSubs); - }, [selectedMain, selectedSubs, onSelect]); + if (mode === 'theme') { + const g = groups.find((gg) => gg.cat1Name === selectedMain); + const picked = selectedSub + ? g?.cat2List.find((c) => c.cat2Name === selectedSub) + : undefined; + + const cat1 = g?.cat1 ?? null; + const cat2 = picked?.cat2 ? [picked.cat2] : []; + const cat1Name = g?.cat1Name ?? null; + const cat2Names = selectedSub ? [selectedSub] : []; + + onSelect(selectedMain, subsArr, { + cat1, + cat2, + cat1Name, + cat2Names, + }); + } else { + onSelect(selectedMain, subsArr); + } + }, [mode, groups, onSelect, selectedMain, selectedSub]); return (
+ {/* 메인 리스트 */}
- {Object.keys(dataMap).map((main) => ( - - ))} + {loading &&
불러오는 중…
} + {!loading && + Object.keys(dataMap).map((main) => ( + + ))}
+ {/* 서브 리스트(단일 선택) */}
- {dataMap[selectedMain]?.length ? ( + {loading ? ( +
+ ) : (dataMap[selectedMain]?.length ?? 0) > 0 ? ( dataMap[selectedMain].map((sub) => { - const active = selectedSubs[0] === sub; + const active = selectedSub === sub; return ( + ))} +
+ +
+ {loading ? ( +
+ ) : dataMap[selectedMain]?.length ? ( + dataMap[selectedMain].map((sub) => { + const active = selectedSubs.includes(sub); + return ( + + ); + }) + ) : ( +
+ 해당 항목 없음 +
+ )} +
); -} +}; + +export default SelectorMulti; diff --git a/src/constants/themeCatMap.ts b/src/constants/themeCatMap.ts index 1ebda8d..2890c8c 100644 --- a/src/constants/themeCatMap.ts +++ b/src/constants/themeCatMap.ts @@ -2,10 +2,10 @@ import type { ThemeCatMap } from "@/types/kto"; export const themeCatMap: ThemeCatMap = { - // "자연>자연관광지": { cat1: "A01", cat2: "A0101" }, - //"자연>산": { cat1: "A01", cat2: "A0101", cat3: "A01010XXX" }, - // "역사>고궁": { cat1: "A02", cat2: "A0201" }, - // "체험>농촌체험": { cat1: "A03", cat2: "A0301" }, - // "쇼핑>전통시장": { cat1: "A04", cat2: "A0401" }, - // "음식>한식": { cat1: "A05", cat2: "A0502" }, + "자연>자연관광지": { cat1: "A01", cat2: "A0101" }, + "자연>산": { cat1: "A01", cat2: "A0101", cat3: "A01010XXX" }, + "역사>고궁": { cat1: "A02", cat2: "A0201" }, + "체험>농촌체험": { cat1: "A03", cat2: "A0301" }, + "쇼핑>전통시장": { cat1: "A04", cat2: "A0401" }, + "음식>한식": { cat1: "A05", cat2: "A0502" }, }; diff --git a/src/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index 3f78181..6655925 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -1,31 +1,214 @@ - -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'; +// src/pages/TravelSearch.tsx +import { useEffect, useMemo, useState } from "react"; +import Header from "@/component/Header"; +import Sidebar from "@/component/SideBar"; +import PlaceCard from "@/component/common/Card/PlaceCard"; +import { saveMyPlace as savePlace } from "@/api/Myplace/savep.api"; +import { fetchPlaces, type PlaceListItem, type PlacesQuery } from "@/api/travel/place.api"; +import { useAIExploreStore } from "@/stores/useAIExploreStore"; type TravelItem = { - id: number; + id: number | string; title: string; - tranquil: boolean; - type: string; + tranquil: boolean; // 조용함 여부 + type: string; // "cat1/cat2" 등 테마명 likes: number; imgUrl?: string; + region?: string; + regionBadge?: string; }; +const PLACEHOLDER_IMG = "/image/placeholder.png"; + +// URL을 https로 보정 +function toHttps(u?: string): string | undefined { + if (!u) return undefined; + const s = String(u).trim(); + if (!s) return undefined; + if (s.startsWith("//")) return "https:" + s; + if (s.startsWith("http://")) return "https://" + s.slice("http://".length); + return s; +} + +// 안전하게 깊은 경로 접근 +function getNested(obj: any, path: string): any { + return path.split(".").reduce((o, k) => (o && o[k] != null ? o[k] : undefined), obj); +} + +// 다양한 형태(url/src/img…)에서 이미지 URL 뽑기 +function normalizeUrlValue(v: any): string | undefined { + if (!v) return undefined; + if (Array.isArray(v)) { + for (const it of v) { + const u = normalizeUrlValue(it); + if (u) return u; + } + return undefined; + } + if (typeof v === "object") { + const keys = ["url", "src", "img", "path", "imgpath", "originimgurl", "smallimageurl"]; + for (const k of keys) { + if (v[k]) return String(v[k]); + } + return undefined; + } + if (typeof v === "string") return v; + return undefined; +} + +// 백 응답에서 이미지 후보값들 추려서 https URL로 확정 +function resolveImageUrl(p: any): string | undefined { + const rawCandidates = [ + p.imgUrl, + p.imageUrl, + p.thumbnail, + p.thumbUrl, + p.mainImage, + p.pictureUrl, + p.firstImage, + p.firstimage, + p.firstImage2, + p.firstimage2, + p.smallimageurl, + p.originimgurl, + getNested(p, "repPhoto.photoid.imgpath"), + getNested(p, "repPhoto.photoid_thumbnail.imgpath"), + getNested(p, "images.0.url"), + getNested(p, "photos.0.url"), + ].filter((x) => x != null); + + for (const cand of rawCandidates) { + const raw = normalizeUrlValue(cand); + const httpsed = toHttps(raw); + if (httpsed) return httpsed; + } + return undefined; +} + +// 플레이스홀더 부착 +function withPlaceholder(u?: string): string { + return u && typeof u === "string" && u.trim() ? u : PLACEHOLDER_IMG; +} + const TravelSearch = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [travelItems, setTravelItems] = useState( - () => TRAVEL_ITEMS.map((it) => ({ ...it })) - ); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [savingId, setSavingId] = useState(null); const handleMenuClick = () => setIsSidebarOpen(true); const handleCloseSidebar = () => setIsSidebarOpen(false); - const handleRemoveItem = (id: number) => - setTravelItems((prev) => prev.filter((item) => item.id !== id)); + // 🔥 핵심: ThemeSelect에서 저장한 테마 코드(cat1/cat2[]) 읽기 + const themeCodes = useAIExploreStore((s) => s.themeCodes); // { cat1: string|null, cat2: string[] } + const theme = useAIExploreStore((s) => s.theme); // { main, subs } (배지 표기용) + + const cat1 = themeCodes?.cat1 ?? undefined; + const cat2 = themeCodes?.cat2 && themeCodes.cat2.length ? themeCodes.cat2 : undefined; + + // 상단 배지(선택된 테마 표시) + const themeBadge = useMemo(() => { + if (!theme?.main) return undefined; + const subs = (theme?.subs ?? []).join(", "); + return subs ? `${theme.main} · ${subs}` : theme.main; + }, [theme]); + + // 백엔드 PlaceListItem -> 화면용 TravelItem 매핑 + function mapToTravelItem(p: PlaceListItem): TravelItem { + const id = (p as any).contentId ?? Math.random().toString(36).slice(2); + const type = [(p as any).cat1, (p as any).cat2].filter(Boolean).join("/") || "기타"; + + // 혼잡도 → 조용함 추정 + let tranquil = false; + const rate = (p as any).cnctrRate; + if (rate !== undefined && rate !== null) { + const n = Number(rate); + if (!Number.isNaN(n)) tranquil = n >= 4; + else tranquil = String(rate).toLowerCase().includes("low"); + } + + const resolved = resolveImageUrl(p); + const imgUrl = withPlaceholder(resolved); + + const areaName = (p as any).areaName; + const sigunguName = (p as any).sigunguName; + const region = + (p as any).regionName || + (areaName && sigunguName ? `${areaName} ${sigunguName}` : areaName) || + undefined; + + const likeCount = + (p as any).likeCount != null ? Number((p as any).likeCount) : 0; + + return { + id, + title: (p as any).title ?? "(제목 없음)", + tranquil, + type, + likes: Number.isFinite(likeCount) ? likeCount : 0, + imgUrl, + region, + regionBadge: region, + }; + } + + async function load(query: PlacesQuery = {}) { + setLoading(true); + setLoadError(null); + try { + const list = await fetchPlaces(query); + setItems(list.map(mapToTravelItem)); + } catch (e: any) { + console.error("[TravelSearch][fetchPlaces][error]", { + message: e?.message, + code: e?.code, + raw: e?.raw, + }); + setLoadError(e?.message ?? "여행지 목록을 불러오지 못했습니다."); + } finally { + setLoading(false); + } + } + + // 최초 로드 + 테마 코드 변경 시 재조회 + useEffect(() => { + const q: PlacesQuery = { + page: 0, + size: 20, + cat1, + cat2, // 배열이면 /places?cat2=a&cat2=b… 로 직렬화되도록 api에서 처리 + }; + void load(q); + // cat2 배열 비교를 위해 문자열화 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cat1, JSON.stringify(cat2)]); + + const handleSaveItem = async (item: TravelItem) => { + if (savingId === item.id) return; + try { + setSavingId(item.id); + + const themeName = item.type || "기타"; + const regionName = item.region || item.regionBadge || "미지정"; + + const payload = { + contentId: item.id, + cnctrLevel: item.tranquil ? 5 : 3, + themeName, + regionName, + }; + + const res = await savePlace(payload); + console.log("[TravelSearch][savePlace][ok]", res); + alert(`저장 완료! (placeId=${res.placeId}, changed=${res.changed})`); + } catch (e: any) { + console.error("[TravelSearch][savePlace][error]", e); + alert(e?.message ?? "저장 중 오류가 발생했습니다."); + } finally { + setSavingId(null); + } + }; return (
@@ -35,35 +218,76 @@ const TravelSearch = () => {
여행지 탐색
-
-
- - - search - + {themeBadge && ( +
+ + 필터 + | + {themeBadge} + +
+ )}
-
- {travelItems.map((item) => ( - handleRemoveItem(item.id)} - /> - ))} -
+ {loading &&
불러오는 중…
} + {loadError && ( +
+ {loadError} + +
+ )} + + {!loading && !loadError && ( +
+ {items.map((item) => { + const isSaving = savingId === item.id; + return ( + + ); + })} + {items.length === 0 && ( +
+ 표시할 여행지가 없습니다. +
+ )} +
+ )}
); }; -export default TravelSearch; \ No newline at end of file +export default TravelSearch;