diff --git a/src/api/Myplace/myPlace.api.ts b/src/api/Myplace/myPlace.api.ts new file mode 100644 index 0000000..d7ad98f --- /dev/null +++ b/src/api/Myplace/myPlace.api.ts @@ -0,0 +1,80 @@ +import api from '@/api/api'; +import type { ApiResponse } from '@/types/api-response'; + +export interface SavePlaceRequest { + contentId: string; + regionName: string; + themeName: string; + cnctrLevel: number; + placeName: string; +} + +export interface SaveToggleResponse { + placeId: number; + type: 'LIKE' | 'SAVE'; + enabled: boolean; + changed: boolean; + likeCount: number; + message: string; + createdAt: string; + updatedAt: string; +} + +export interface SaveStatusData { + contentId: string; + save: boolean; +} + +export interface SavedPlaceItem { + cnctrLevel: number; + contentId: string; + placeName: string; + likeCount: number; + themeName: string; + savedAt: string; +} +export interface SavedPlacePage { + content: SavedPlaceItem[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + first: boolean; + last: boolean; + hasNext: boolean; + hasPrevious: boolean; +} + +//저장 ON +export async function savePlace(payload: SavePlaceRequest): Promise { + const res = await api.put('/my/places/save', payload); + return res.data; +} + +//저장 OFF +export async function unsavePlace(payload: { + contentId: string; + regionName?: string; + themeName?: string; + cnctrLevel?: number; +}) { + const res = await api.delete('/my/places/save', { + data: payload, + headers: { 'Content-Type': 'application/json' }, + }); + return res.data; +} + +//저장 여부 +export async function getSaveStatus(contentId: string): Promise { + const res = await api.get>('/my/places/save/status', { + params: { contentId }, + }); + return !!res.data?.data?.save; +} + +//내 저장 목록 +export async function getSavedPlaces(page = 0, size = 20): Promise { + const res = await api.get('/my/places', { params: { page, size } }); + return res.data; +} diff --git a/src/api/Myplace/saved.api.ts b/src/api/Myplace/saved.api.ts deleted file mode 100644 index 4ec77e9..0000000 --- a/src/api/Myplace/saved.api.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/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 deleted file mode 100644 index c8c3cea..0000000 --- a/src/api/Myplace/saveg.api.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/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 deleted file mode 100644 index cd72f90..0000000 --- a/src/api/Myplace/savep.api.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/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/Selector/region.api.ts b/src/api/Selector/region.api.ts new file mode 100644 index 0000000..69e7784 --- /dev/null +++ b/src/api/Selector/region.api.ts @@ -0,0 +1,22 @@ +import api from '@/api/api'; + +export interface SigunguDto { + sigunguCode: string; + sigunguName: string; +} +export interface AreaDto { + areaCode: string; + areaName: string; + sigunguList: SigunguDto[]; +} + +function unwrap(raw: any): T { + return raw && typeof raw.success === 'boolean' && 'data' in raw ? raw.data : raw; +} + +export async function fetchRegions(): Promise { + const res = await api.get('/places/regions'); + const data = unwrap(res.data); + if (!Array.isArray(data)) throw new Error('Unexpected response for /places/regions'); + return data; +} diff --git a/src/api/travel/places.api.ts b/src/api/travel/places.api.ts new file mode 100644 index 0000000..507f9da --- /dev/null +++ b/src/api/travel/places.api.ts @@ -0,0 +1,58 @@ +import api from '@/api/api'; + +export type SearchPlacesParams = { + areaCode?: number | string; + sigunguCode?: number | string; + cat1?: string; + cat2?: string; + contentTypeId?: number; + pageNo?: number; + numOfRows?: number; + arrange?: 'O' | 'Q' | 'R' | 'S'; + _type?: string; +}; + +export type PlaceDto = { + title: string; + contentid: string; + catName?: string; + areaName?: string; + firstimage?: string; + dist?: string; + likeCount?: number; + cnctrRate?: string | number; + quietnessLevel?: number; +}; + +function unwrap(raw: any): T { + return raw && typeof raw.success === 'boolean' && 'data' in raw ? raw.data : raw; +} +function toArray(raw: any): T[] { + if (Array.isArray(raw)) return raw; + if (Array.isArray(raw?.content)) return raw.content; + if (Array.isArray(raw?.items)) return raw.items; + if (raw && typeof raw === 'object') return [raw as T]; + return []; +} + +export async function searchPlaces(params: SearchPlacesParams): Promise { + const res = await api.get('/places', { params }); + return toArray(unwrap(res.data)); +} + +export function mapToCard(p: PlaceDto) { + const raw = p.firstimage || ''; + const imgUrl = + typeof raw === 'string' && raw.startsWith('http://') + ? raw.replace(/^http:\/\//, 'https://') + : raw || undefined; + + return { + id: p.contentid, + title: p.title, + theme: p.catName ?? '-', + likeCount: p.likeCount ?? 0, + imgUrl, + quietLevel: typeof p.quietnessLevel === 'number', + }; +} diff --git a/src/api/travel/travel.api.ts b/src/api/travel/travel.api.ts deleted file mode 100644 index 20d105e..0000000 --- a/src/api/travel/travel.api.ts +++ /dev/null @@ -1,80 +0,0 @@ -import api from "@/api/api"; -import type { ApiResponse } from "@/types/api-response"; -import { getThemeCodes, getRegionCodes } from "@/utils/ktoMapping"; -import type { AiTravelBody, GeneralTravelBody } from "@/types/kto"; - -export function buildAiTravelBody(params: { main: string; sub: string }): AiTravelBody { - const { cat1, cat2, cat3 } = getThemeCodes(params.main, params.sub); - return { cat1, cat2, cat3 }; -} - -export function buildGeneralTravelBody(params: { - region: string; - sigungu?: string; - main?: string; - sub?: string; -}): GeneralTravelBody { - const { areaCode, sigunguCode } = getRegionCodes(params.region, params.sigungu); - if (params.main && params.sub) { - const { cat1, cat2, cat3 } = getThemeCodes(params.main, params.sub); - return { areaCode, sigunguCode, cat1, cat2, cat3 }; - } - return { areaCode, sigunguCode }; -} - -function normalizeAxiosError(e: any): Error { - const status = e?.response?.status; - const respMsg = - e?.response?.data?.message || - e?.response?.data?.error || - e?.message; - - if (status) return new Error(`[${status}] ${respMsg ?? "Server error"}`); - if (e?.request) return new Error("Network error or no response from server"); - return new Error(respMsg ?? String(e)); -} - -export async function requestAiPlaces( - endpoint: string, - params: { main: string; sub: string } -): Promise { - const body = buildAiTravelBody(params); - try { - const { data } = await api.post>(endpoint, body); - return data.data; - } catch (e) { - throw normalizeAxiosError(e); - } -} - - -export async function requestGeneralPlaces( - endpoint: string, - params: { region: string; sigungu?: string; main?: string; sub?: string } -): Promise { - const body = buildGeneralTravelBody(params); - try { - const { data } = await api.post>(endpoint, body); - return data.data; - } catch (e) { - throw normalizeAxiosError(e); - } -} - -/** ======================== -import type { Place } from "@/types/place"; -async function example() { - // AI 여행지 - const aiResult = await requestAiPlaces("/ai/places", { - main: "자연", - sub: "자연관광지", - }); - // 일반 여행지 - const generalResult = await requestGeneralPlaces("/places/search", { - region: "서울특별시", // 별칭 허용 (regionMap에서 normalize) - sigungu: "종로구", - main: "자연", - sub: "자연관광지", - }); -} -*/ diff --git a/src/component/SideBar.tsx b/src/component/SideBar.tsx index 1586352..6fe2d65 100644 --- a/src/component/SideBar.tsx +++ b/src/component/SideBar.tsx @@ -22,7 +22,7 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' } return ( <>
@@ -54,7 +54,7 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' } AI 맞춤 여행지 탐색
-
diff --git a/src/component/common/Card/PlaceCard.tsx b/src/component/common/Card/PlaceCard.tsx index b669d13..dcc1f17 100644 --- a/src/component/common/Card/PlaceCard.tsx +++ b/src/component/common/Card/PlaceCard.tsx @@ -5,7 +5,7 @@ type PlaceCardProps = { imgUrl?: string; title: string; theme: string; - quietLevel: number; + quietLevel: number | boolean; likeCount: number; showRemoveButton?: boolean; onRemove?: () => void; diff --git a/src/component/selector/RegionSelector.tsx b/src/component/selector/RegionSelector.tsx index b2a450a..e8f3076 100644 --- a/src/component/selector/RegionSelector.tsx +++ b/src/component/selector/RegionSelector.tsx @@ -1,38 +1,99 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Selector from './Selector'; -import { regionMap, normalizeRegionName } from '@/constants/regionMap'; -import { getRegionCodes } from '@/utils/ktoMapping'; +import api from '@/api/api'; export type RegionSelectPayload = { region: string; sigungu?: string; - areaCode: number | string; - sigunguCode?: number | string; + areaCode: string | number; + sigunguCode?: string | number; }; -function buildRegionDataMap() { - const m: Record = {}; - for (const [region, info] of Object.entries(regionMap)) { - const subs = info.sigungu ? Object.keys(info.sigungu) : [region]; - m[region] = subs; - } - return m; +type SigunguDto = { sigunguCode: string | number; sigunguName: string }; +type AreaDto = { areaCode: string | number; areaName: string; sigunguList: SigunguDto[] }; + +function unwrap(raw: any): T { + return raw && typeof raw.success === 'boolean' && 'data' in raw ? raw.data : raw; } -const REGION_DATA_MAP: Record = buildRegionDataMap(); -const norm = (s: string) => normalizeRegionName(s.replace(/\u00A0/g, ' ').trim()); +const norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); export default function RegionSelector({ onChange, }: { onChange?: (payload: RegionSelectPayload) => void; }) { - const dataMap = useMemo(() => REGION_DATA_MAP, []); + const [dataMap, setDataMap] = useState>({}); + + const [codeMap, setCodeMap] = useState< + Record }> + >({}); + + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + (async () => { + try { + setLoading(true); + setErr(null); + + const res = await api.get('/places/regions'); + const list = unwrap(res.data); + + const dm: Record = {}; + const cm: Record< + string, + { areaCode: string | number; sigunguMap: Record } + > = {}; + + for (const area of list ?? []) { + const areaName = norm(area.areaName); + const sigs = (area.sigunguList ?? []).map((s) => norm(s.sigunguName)); + + dm[areaName] = sigs.length ? sigs : [areaName]; + const sigMap: Record = {}; + for (const s of area.sigunguList ?? []) { + sigMap[norm(s.sigunguName)] = s.sigunguCode; + } + cm[areaName] = { areaCode: area.areaCode, sigunguMap: sigMap }; + } + + if (!alive) return; + setDataMap(dm); + setCodeMap(cm); + } catch (e) { + if (!alive) return; + setErr('지역 목록을 불러오지 못했습니다.'); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, []); + + const initialMain = useMemo(() => { + if (dataMap['인천']) return '인천'; + const keys = Object.keys(dataMap); + return keys.length ? keys[0] : ''; + }, [dataMap]); + + if (loading) { + return ( +
불러오는 중…
+ ); + } + if (err) { + return
{err}
; + } return ( { const region = norm(main); - const sigungu = subs?.[0]; + const sigungu = norm(subs?.[0]); - const { areaCode, sigunguCode } = getRegionCodes(region, sigungu); + const area = codeMap[region]; + const areaCode = area?.areaCode ?? ''; + const sigunguCode = sigungu ? area?.sigunguMap?.[sigungu] : undefined; - onChange?.({ region, sigungu, areaCode, sigunguCode }); + onChange?.({ region, sigungu: sigungu || undefined, areaCode, sigunguCode }); }} /> ); diff --git a/src/component/selector/SortPillSelect.tsx b/src/component/selector/SortPillSelect.tsx new file mode 100644 index 0000000..5d6518d --- /dev/null +++ b/src/component/selector/SortPillSelect.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown } from 'react-feather'; + +export type Option = { value: T; label: string }; + +type Props = { + value: T; + options: Option[]; + onChange: (v: T) => void; + className?: string; + disabled?: boolean; + size?: 'sm' | 'md'; +}; + +export default function SortPillSelect({ + value, + options, + onChange, + className, + disabled, + size = 'md', +}: Props) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const menuRef = useRef(null); + const current = useMemo( + () => options.find((o) => o.value === value) ?? options[0], + [value, options], + ); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (!menuRef.current && !btnRef.current) return; + const target = e.target as Node; + if (menuRef.current?.contains(target) || btnRef.current?.contains(target)) return; + setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + btnRef.current?.focus(); + } + }; + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const [activeIdx, setActiveIdx] = useState(-1); + useEffect(() => { + if (!open) setActiveIdx(-1); + }, [open]); + + const pillSize = size === 'sm' ? 'h-7 px-2 text-caption2' : 'h-10 px-4 text-[15px]'; + + return ( +
+ + + {open && ( +
{ + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIdx((i) => (i + 1) % options.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIdx((i) => (i - 1 + options.length) % options.length); + } else if (e.key === 'Home') { + e.preventDefault(); + setActiveIdx(0); + } else if (e.key === 'End') { + e.preventDefault(); + setActiveIdx(options.length - 1); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const pick = options[activeIdx]; + if (pick) { + onChange(pick.value); + setOpen(false); + btnRef.current?.focus(); + } + } + }} + > + {options.map((opt, i) => { + const selected = opt.value === value; + const active = i === activeIdx; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/component/selector/TravelActivitySelector.tsx b/src/component/selector/TravelActivitySelector.tsx index 7eba231..d7df8a5 100644 --- a/src/component/selector/TravelActivitySelector.tsx +++ b/src/component/selector/TravelActivitySelector.tsx @@ -1,18 +1,102 @@ +import { useEffect, useMemo, useState } from 'react'; import SelectorMulti from './SelectorMulti'; -import { activityMap } from '@/constants/ActivityMap'; +import api from '@/api/api'; import { cn } from '@/utils/cn'; type Props = { className?: string; onChange?: (main: string, subs: string[]) => void; + onChangeCodes?: (cat1?: string, cat2?: string[]) => void; }; -export default function TravelActivitySelector({ className, onChange }: Props) { +type Cat2Dto = { cat2: string; cat2Name: string }; +type Cat1GroupDto = { cat1: string; cat1Name: string; cat2List: Cat2Dto[] }; + +function unwrap(raw: any): T { + return raw && typeof raw.success === 'boolean' && 'data' in raw ? raw.data : raw; +} + +const norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); + +export default function TravelActivitySelector({ className, onChange, onChangeCodes }: Props) { + const [, setGroups] = useState([]); + const [dataMap, setDataMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + + const [codeMap, setCodeMap] = useState< + Record }> + >({}); + + useEffect(() => { + let alive = true; + (async () => { + try { + setLoading(true); + setErr(null); + + const res = await api.get('/places/themes'); + const list = unwrap(res.data); + + const dm: Record = {}; + const cm: Record }> = {}; + + for (const g of list ?? []) { + const cat1Name = norm(g.cat1Name); + const cat2Names = (g.cat2List ?? []).map((c) => norm(c.cat2Name)); + + dm[cat1Name] = cat2Names.length ? cat2Names : [cat1Name]; + + const map: Record = {}; + for (const c of g.cat2List ?? []) { + map[norm(c.cat2Name)] = c.cat2; + } + cm[cat1Name] = { cat1: g.cat1, cat2ByName: map }; + } + + if (!alive) return; + setGroups(list ?? []); + setDataMap(dm); + setCodeMap(cm); + } catch (e) { + if (!alive) return; + setErr('테마 목록을 불러오지 못했습니다.'); + } finally { + if (alive) setLoading(false); + } + })(); + return () => { + alive = false; + }; + }, []); + + const initialMain = useMemo(() => { + if (dataMap['자연']) return '자연'; + const keys = Object.keys(dataMap); + return keys.length ? keys[0] : ''; + }, [dataMap]); + + if (loading) { + return ( +
+ 불러오는 중… +
+ ); + } + if (err) { + return ( +
+ {err} +
+ ); + } + if (!initialMain) return null; + return (
{ - console.log('선택된:', main, subs); - onChange?.(main, subs); + const m = norm(main); + const s = subs.map(norm); + onChange?.(m, s); + + const c1 = codeMap[m]?.cat1; + const c2 = subs.map((x) => codeMap[m]?.cat2ByName[norm(x)]).filter(Boolean) as string[]; + onChangeCodes?.(c1, c2); }} />
diff --git a/src/pages/ai/TravelSpotDetail.tsx b/src/pages/ai/TravelSpotDetail.tsx index bc9f826..8ac8504 100644 --- a/src/pages/ai/TravelSpotDetail.tsx +++ b/src/pages/ai/TravelSpotDetail.tsx @@ -13,6 +13,7 @@ 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 { savePlace, unsavePlace, getSaveStatus } from '@/api/Myplace/myPlace.api'; const TravelSpotDetail = () => { const navigate = useNavigate(); @@ -49,6 +50,7 @@ const TravelSpotDetail = () => { regionName: data.regionTag ?? '정보없음', themeName: data.themeName ?? '여행지', cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), }, }), ); @@ -81,8 +83,60 @@ const TravelSpotDetail = () => { } }; - const handleToggleBookmark = () => { - setBookmarked((prev) => !prev); + const handleToggleBookmark = async (e?: React.MouseEvent) => { + e?.preventDefault(); + e?.stopPropagation(); + + if (!data) return; + + // 미로그인: 로그인 후 다시 실행하도록 액션/리다이렉트 저장 + if (!isAuthed) { + const here = `${location.pathname}${location.search}${location.hash}`; + sessionStorage.setItem('postLoginRedirect', here); + sessionStorage.setItem( + 'postLoginAction', + JSON.stringify({ + kind: 'SAVE_PLACE', + contentId, + payload: { + regionName: data.regionTag ?? '정보없음', + themeName: data.themeName ?? '여행지', + cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), + }, + }), + ); + navigate( + `/login?redirect=${encodeURIComponent(here)}&action=save_place&cid=${encodeURIComponent( + contentId, + )}`, + { replace: true }, + ); + return; + } + + try { + if (bookmarked) { + await unsavePlace({ + contentId, + regionName: data?.regionTag ?? '정보없음', + themeName: data?.themeName ?? '여행지', + cnctrLevel: data?.serenity ?? 0, + }); + setBookmarked(false); + } else { + await savePlace({ + contentId, + regionName: data.regionTag ?? '정보없음', + themeName: data.themeName ?? '여행지', + cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), + }); + setBookmarked(true); + } + } catch (err: any) { + console.error('저장 처리 실패:', err?.message || err); + } }; function mapIntegratedToPlaceDetail(id: string, item: IntegratedPlace): PlaceDetail { @@ -160,6 +214,12 @@ const TravelSpotDetail = () => { } catch (e) { console.debug('좋아요 상태 조회 실패:', e); } + try { + const saved = await getSaveStatus(contentId); + if (alive) setBookmarked(!!saved); + } catch (e) { + console.debug('저장 상태 조회 실패:', e); + } } } } catch (e: any) { @@ -172,7 +232,7 @@ const TravelSpotDetail = () => { return () => { alive = false; }; - }, [contentId]); + }, [contentId, isAuthed]); return (
diff --git a/src/pages/explore/Filter.tsx b/src/pages/explore/Filter.tsx index 1993495..c4c82fc 100644 --- a/src/pages/explore/Filter.tsx +++ b/src/pages/explore/Filter.tsx @@ -1,63 +1,26 @@ -// src/pages/explore/Filter.tsx -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import Progressbar from '@/component/Progressbar'; -import RegionSelectorRaw from '@/component/selector/RegionSelector'; -import TravelActivitySelectorRaw from '@/component/selector/TravelActivitySelector'; import { ArrowLeft } from '@/assets'; -import { Button } from '@/component'; +import RegionStep from './RegionStep'; +import ThemeStep from './ThemeStep'; +import LoadingStep from './LoadingStep'; +import ResultsStep from './ResultStep'; +import type { Pair, Phase, Place } from './types'; -type Pair = { left?: string; right?: string }; -type Phase = 'select' | 'loading' | 'results'; - -function toPair(v: any): Pair { - if (!v) return {}; - if (typeof v === 'string') return { left: v }; - if (Array.isArray(v)) return { left: v[0], right: v[1] }; - if ('left' in v || 'right' in v) return { left: v.left, right: v.right }; - const left = - v?.sido ?? v?.group ?? v?.category ?? v?.parent ?? v?.main ?? v?.value ?? v?.key ?? v?.[0]; - const right = v?.sigungu ?? v?.theme ?? v?.sub ?? v?.child ?? v?.detail ?? v?.label ?? v?.[1]; - return { left, right }; -} - -type AdapterProps = { onSelect?: (v: Pair) => void }; - -const RegionSelector: React.FC = ({ onSelect }) => ( - onSelect?.(toPair(v)), - } as any)} - /> -); - -const TravelActivitySelector: React.FC = ({ onSelect }) => ( - onSelect?.(toPair(v)), - } as any)} - /> -); - -type Place = { - id: string | number; - name: string; - imageUrl?: string; - sido?: string; - sigungu?: string; - tags?: string[]; - address?: string; -}; - -export default function Filter() { +export default function FilterPage() { const navigate = useNavigate(); - const [step, setStep] = useState<1 | 2>(1); const [phase, setPhase] = useState('select'); const [region, setRegion] = useState({}); const [activity, setActivity] = useState({}); const [results, setResults] = useState([]); const [error, setError] = useState(null); + const [regionCodes, setRegionCodes] = useState<{ + areaCode?: string | number; + sigunguCode?: string | number; + }>({}); + const [activityCodes, setActivityCodes] = useState<{ cat1?: string; cat2?: string[] }>({}); useEffect(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -65,27 +28,29 @@ export default function Filter() { useEffect(() => { setActivity({}); + setActivityCodes({}); }, [region.left, region.right]); - const next = () => setStep(2); - const prev = () => setStep(1); - const progress = useMemo(() => { if (phase === 'results') return 1; return step === 1 ? 0.5 : 1; }, [phase, step]); - // ✅ 완료 → 항상 활성화, 무조건 Searching으로 이동 - function handleFinish() { - navigate('/explore/Searching', { state: { region, activity } }); - } + const handleFinish = () => { + navigate('/search/result', { + state: { + region: { areaCode: regionCodes.areaCode, sigunguCode: regionCodes.sigunguCode }, + activity: { cat1: activityCodes.cat1, cat2: activityCodes.cat2 }, + }, + }); + }; - function resetAndSelectAgain() { + const resetAndSelectAgain = () => { setPhase('select'); setStep(1); setResults([]); setError(null); - } + }; return (
@@ -98,6 +63,7 @@ export default function Filter() { 여행지 탐색
+
{/* 진행 바 */}
@@ -109,120 +75,34 @@ export default function Filter() { {phase === 'select' && ( <> {step === 1 ? ( - <> -

- 1단계: 지역을 골라주세요. -

-
- -
-
- -
- + setStep(2)} + /> ) : ( - <> -

- 2단계: 테마를 골라주세요. -

-
- -
-
-
- - -
-
- + setStep(1)} + onFinish={handleFinish} + /> )} )} - {phase === 'loading' && ( -
-
여행지를 불러오는 중…
-
- )} + {phase === 'loading' && } {phase === 'results' && ( - <> -

탐색 결과

- -
- {region.left && ( - - 지역: {region.left} - {region.right ? ` ${region.right}` : ''} - - )} - {activity.left && activity.right && ( - - 테마: {activity.left} / {activity.right} - - )} - -
- - {error && ( -
- {error} -
- )} - {!error && results.length === 0 && ( -
- 조건에 맞는 여행지가 없어요. 필터를 바꿔보세요. -
- )} - -
    - {results.map((p) => ( -
  • -
    - {p.imageUrl ? ( - {p.name} - ) : ( -
    - 이미지 없음 -
    - )} -
    -
    -
    {p.name}
    -
    - {(p.sido || p.sigungu) && `${p.sido ?? ''} ${p.sigungu ?? ''}`} -
    - {p.tags && p.tags.length > 0 && ( -
    - {p.tags.map((t) => ( - - #{t} - - ))} -
    - )} -
    -
  • - ))} -
- + )}
diff --git a/src/pages/explore/List.tsx b/src/pages/explore/List.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/explore/LoadingStep.tsx b/src/pages/explore/LoadingStep.tsx new file mode 100644 index 0000000..963d0b5 --- /dev/null +++ b/src/pages/explore/LoadingStep.tsx @@ -0,0 +1,7 @@ +export default function LoadingStep() { + return ( +
+
여행지를 불러오는 중…
+
+ ); +} diff --git a/src/pages/explore/RegionStep.tsx b/src/pages/explore/RegionStep.tsx new file mode 100644 index 0000000..1ccceb5 --- /dev/null +++ b/src/pages/explore/RegionStep.tsx @@ -0,0 +1,53 @@ +import { useCallback, useRef } from 'react'; +import RegionSelectorRaw from '@/component/selector/RegionSelector'; +import { Button } from '@/component'; +import type { Pair } from './types'; + +type RegionSelectPayload = { + region: string; + sigungu?: string; + areaCode: string | number; + sigunguCode?: string | number; +}; + +type Props = { + value: Pair; + onChange: (v: Pair) => void; + onChangeCodes?: (codes: { areaCode?: string | number; sigunguCode?: string | number }) => void; + onNext: () => void; +}; + +export default function RegionStep({ value, onChange, onChangeCodes, onNext }: Props) { + const lastKeyRef = useRef(''); + const canNext = Boolean(value.right); + const handleSelect = useCallback( + (p: RegionSelectPayload) => { + const left = p.region; + const right = p.sigungu; + const key = `${left}|${right ?? ''}`; + + if (key === lastKeyRef.current) return; + lastKeyRef.current = key; + + if (value.left !== left || value.right !== right) { + onChange({ left, right }); + } + onChangeCodes?.({ areaCode: p.areaCode, sigunguCode: p.sigunguCode }); + }, + [onChange, onChangeCodes, value.left, value.right], + ); + + return ( + <> +

1단계: 지역을 골라주세요.

+
+ +
+
+ +
+ + ); +} diff --git a/src/pages/explore/ResultStep.tsx b/src/pages/explore/ResultStep.tsx new file mode 100644 index 0000000..0c64a9b --- /dev/null +++ b/src/pages/explore/ResultStep.tsx @@ -0,0 +1,88 @@ +import { Button } from '@/component'; +import { type Pair, type Place } from './types'; + +type Props = { + region: Pair; + activity: Pair; + results: Place[]; + error: string | null; + onReset: () => void; +}; + +export default function ResultsStep({ region, activity, results, error, onReset }: Props) { + return ( + <> +

탐색 결과

+ +
+ {region.left && ( + + 지역: {region.left} + {region.right ? ` ${region.right}` : ''} + + )} + {activity.left && activity.right && ( + + 테마: {activity.left} / {activity.right} + + )} + +
+ + {error && ( +
+ {error} +
+ )} + {!error && results.length === 0 && ( +
+ 조건에 맞는 여행지가 없어요. 필터를 바꿔보세요. +
+ )} + +
    + {results.map((p) => ( +
  • +
    + {p.imageUrl ? ( + {p.name} + ) : ( +
    + 이미지 없음 +
    + )} +
    +
    +
    {p.name}
    +
    + {(p.sido || p.sigungu) && `${p.sido ?? ''} ${p.sigungu ?? ''}`} +
    + {p.tags && p.tags.length > 0 && ( +
    + {p.tags.map((t) => ( + + #{t} + + ))} +
    + )} +
    +
  • + ))} +
+ + ); +} diff --git a/src/pages/explore/Searching.tsx b/src/pages/explore/Searching.tsx index 9f7bb16..764cf0f 100644 --- a/src/pages/explore/Searching.tsx +++ b/src/pages/explore/Searching.tsx @@ -1,32 +1,23 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { Loader } from "@/component"; -import bubbleUrl from "@/image/Searching.svg"; -import personUrl from "@/image/Searching2.svg"; - -export default function Searching() { - const navigate = useNavigate(); - - useEffect(() => { - const t = setTimeout(() => { - navigate("/explore/travelsearch"); - }, 3000); - return () => clearTimeout(t); - }, [navigate]); +import { Loader } from '@/component'; +import bubbleUrl from '@/image/Searching.svg'; +import personUrl from '@/image/Searching2.svg'; +export default function SearchingPage() { return ( -
- - 당신의 취향에 맞는 여행지를 찾고 있습니다… - 검색 일러스트 +
+ + 당신의 취향에 맞는 여행지를 찾고 있습니다… + 검색 일러스트
); } diff --git a/src/pages/explore/ThemeStep.tsx b/src/pages/explore/ThemeStep.tsx new file mode 100644 index 0000000..8e9e6f4 --- /dev/null +++ b/src/pages/explore/ThemeStep.tsx @@ -0,0 +1,73 @@ +import { useCallback, useRef } from 'react'; +import TravelActivitySelectorRaw from '@/component/selector/TravelActivitySelector'; +import { Button } from '@/component'; +import type { Pair } from './types'; + +type Props = { + value: Pair; + onChange: (v: Pair) => void; + onChangeCodes?: (v: { cat1?: string; cat2?: string[] }) => void; + onPrev: () => void; + onFinish: () => void; + canFinish?: boolean; +}; + +export default function ThemeStep({ + canFinish, + value, + onChange, + onChangeCodes, + onPrev, + onFinish, +}: Props) { + const lastNameKey = useRef(''); + const lastCodeKey = useRef(''); + const enabled = typeof canFinish === 'boolean' ? canFinish : Boolean(value.left && value.right); + const handleNames = useCallback( + (main: string, subs: string[]) => { + const left = main; + const right = subs?.[0]; + const nextKey = `${left}|${right ?? ''}`; + + if (nextKey === lastNameKey.current) return; + if (value.left === left && value.right === right) { + lastNameKey.current = nextKey; + return; + } + + lastNameKey.current = nextKey; + onChange({ left, right }); + }, + [onChange, value.left, value.right], + ); + + const handleCodes = useCallback( + (cat1?: string, cat2?: string[]) => { + const key = `${cat1 ?? ''}|${(cat2 ?? []).join(',')}`; + if (key === lastCodeKey.current) return; + + lastCodeKey.current = key; + onChangeCodes?.({ cat1, cat2 }); + }, + [onChangeCodes], + ); + + return ( + <> +

2단계: 테마를 골라주세요.

+
+ +
+
+
+ + +
+
+ + ); +} diff --git a/src/pages/explore/types.ts b/src/pages/explore/types.ts new file mode 100644 index 0000000..35e6b4a --- /dev/null +++ b/src/pages/explore/types.ts @@ -0,0 +1,23 @@ +export type Pair = { left?: string; right?: string }; +export type Phase = 'select' | 'loading' | 'results'; + +export type Place = { + id: string | number; + name: string; + imageUrl?: string; + sido?: string; + sigungu?: string; + tags?: string[]; + address?: string; +}; + +export function toPair(v: any): Pair { + if (!v) return {}; + if (typeof v === 'string') return { left: v }; + if (Array.isArray(v)) return { left: v[0], right: v[1] }; + if ('left' in v || 'right' in v) return { left: v.left, right: v.right }; + const left = + v?.sido ?? v?.group ?? v?.category ?? v?.parent ?? v?.main ?? v?.value ?? v?.key ?? v?.[0]; + const right = v?.sigungu ?? v?.theme ?? v?.sub ?? v?.child ?? v?.detail ?? v?.label ?? v?.[1]; + return { left, right }; +} diff --git a/src/pages/home/MyPage.tsx b/src/pages/home/MyPage.tsx index 8225ace..1dc595c 100644 --- a/src/pages/home/MyPage.tsx +++ b/src/pages/home/MyPage.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import Header from "@/component/Header"; -import Sidebar from "@/component/SideBar"; -import api from "@/api/api"; -import type { ApiResponse } from "@/types/api-response"; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '@/component/Header'; +import Sidebar from '@/component/SideBar'; +import api from '@/api/api'; +import type { ApiResponse } from '@/types/api-response'; type Profile = { userId: number; @@ -17,7 +17,7 @@ export default function MyPage() { const [error, setError] = useState(null); const [profile, setProfile] = useState(null); - const [nickname, setNickname] = useState(""); + const [nickname, setNickname] = useState(''); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(null); @@ -28,11 +28,11 @@ export default function MyPage() { const handleLogout = () => { try { - localStorage.removeItem("accessToken"); - localStorage.removeItem("refreshToken"); - localStorage.removeItem("userId"); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userId'); } finally { - navigate("/", { replace: true }); + navigate('/', { replace: true }); } }; @@ -43,29 +43,29 @@ export default function MyPage() { setLoading(true); setError(null); try { - const resp = await api.get>("/my/profile/"); + const resp = await api.get>('/my/profile/'); const res = resp.data; if (!cancelled) { if (res?.success && res.data) { setProfile(res.data); - setNickname(res.data.nickname ?? ""); + setNickname(res.data.nickname ?? ''); } else { - setError((res as any)?.error?.message || "프로필 정보를 불러오지 못했습니다."); + setError((res as any)?.error?.message || '프로필 정보를 불러오지 못했습니다.'); } } } catch (err: any) { if (cancelled) return; const status = err?.response?.status as number | undefined; if (status === 401) { - localStorage.removeItem("accessToken"); - localStorage.removeItem("refreshToken"); - navigate("/", { replace: true }); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + navigate('/', { replace: true }); return; } setError( err?.response?.data?.error?.message || err?.message || - "프로필 조회 중 오류가 발생했습니다." + '프로필 조회 중 오류가 발생했습니다.', ); } finally { // eslint-disable-next-line @typescript-eslint/no-unused-expressions @@ -79,7 +79,7 @@ export default function MyPage() { }; }, [navigate]); - const email = profile?.email || ""; + const email = profile?.email || ''; const handleSaveNickname = async () => { if (saving) return; @@ -87,62 +87,67 @@ export default function MyPage() { const value = nickname.trim(); if (value.length < 2) { - setSaveMsg("닉네임은 2글자 이상이어야 합니다."); + setSaveMsg('닉네임은 2글자 이상이어야 합니다.'); return; } setSaving(true); try { - const resp = await api.patch>("/my/profile/nickname", { + const resp = await api.patch>('/my/profile/nickname', { nickname: value, }); const res = resp.data; if (res?.success && res.data) { setProfile((prev) => - prev ? { ...prev, nickname: res.data?.nickname ?? value } : res.data + prev ? { ...prev, nickname: res.data?.nickname ?? value } : res.data, ); setNickname(res.data?.nickname ?? value); - setSaveMsg("닉네임이 저장되었습니다."); + setSaveMsg('닉네임이 저장되었습니다.'); try { - localStorage.setItem("nickname", res.data?.nickname ?? value); + localStorage.setItem('nickname', res.data?.nickname ?? value); } catch {} } else { const msg = (res as any)?.error?.message || (res as any)?.message || - "닉네임 저장 중 문제가 발생했습니다."; + '닉네임 저장 중 문제가 발생했습니다.'; setSaveMsg(msg); } } catch (err: any) { const status = err?.response?.status as number | undefined; if (status === 401) { - localStorage.removeItem("accessToken"); - localStorage.removeItem("refreshToken"); - navigate("/", { replace: true }); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + navigate('/', { replace: true }); return; } if (status === 409) { - setSaveMsg("이미 사용 중인 닉네임입니다."); + setSaveMsg('이미 사용 중인 닉네임입니다.'); return; } setSaveMsg( err?.response?.data?.error?.message || err?.response?.data?.message || err?.message || - "닉네임 저장 중 오류가 발생했습니다." + '닉네임 저장 중 오류가 발생했습니다.', ); } finally { setSaving(false); } }; + const canSave = (() => { + const trimmed = nickname.trim(); + const prev = (profile?.nickname ?? '').trim(); + return !saving && trimmed.length >= 2 && trimmed !== prev; + })(); return ( -
+
-
+
{loading ? (

마이페이지 불러오는 중…

) : error ? ( @@ -151,21 +156,21 @@ export default function MyPage() { ) : ( <> -

- 안녕하세요 {nickname ? `${nickname}님` : "여행자님"}! +

+ 안녕하세요 {nickname ? `${nickname}님` : '여행자님'}!

-

{email || "이메일 정보 없음"}

+

{email || '이메일 정보 없음'}

@@ -173,9 +178,9 @@ export default function MyPage() { )}
-
+
-
- +
setNickname(e.target.value)} - className="w-full rounded-m border border-gray1 bg-white px-3 py-2 text-body1" + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave) handleSaveNickname(); + }} + className="rounded-m border-gray1 text-body1 h-10 w-full border bg-white px-3" placeholder="닉네임 입력" /> - {/* ✅ 수정 버튼 하나만 */}
- {saveMsg &&

{saveMsg}

} + {saveMsg &&

{saveMsg}

} +
+ {/*회원 탈퇴*/} +
+
+
+ + 더 이상 서비스를 사용하지 않으신가요? + + +
+
- -
); } diff --git a/src/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index 21d20e6..2788467 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -1,39 +1,47 @@ -// src/pages/MyTravelList.tsx import { useEffect, useMemo, useState } from 'react'; 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, unsavePlace, type SavedPlaceItem } from '@/api/Myplace/myPlace.api'; +import { useNavigate } from 'react-router-dom'; +import SortPillSelect, { type Option } from '@/component/selector/SortPillSelect'; const PAGE_SIZE = 20; -function clamp(n: number, min: number, max: number) { - return Math.max(min, Math.min(max, n)); -} - +type Row = SavedPlaceItem; +const arrangeOptions: Option<'O' | 'Q' | 'R' | 'S'>[] = [ + { value: 'O', label: '기본순' }, + { value: 'Q', label: '수정일순' }, + { value: 'R', label: '등록일순' }, + { value: 'S', label: '거리순' }, +]; const MyTravelList = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [page, setPage] = useState(0); const [last, setLast] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [q, setQ] = useState(''); + const [arrange, setArrange] = useState<'O' | 'Q' | 'R' | 'S'>('O'); const handleMenuClick = () => setIsSidebarOpen(true); const handleCloseSidebar = () => setIsSidebarOpen(false); + const navigate = useNavigate(); const loadPage = async (p: number, replace = false) => { if (loading) return; setLoading(true); setError(null); try { - const res = await getSavedPlaces({ page: p, size: PAGE_SIZE }); - setItems((prev) => (replace ? res.content : [...prev, ...res.content])); - setPage(res.page); - setLast(res.last); + const raw = (await getSavedPlaces(p, PAGE_SIZE)) as any; + const pageData = raw?.content ? raw : (raw?.data ?? {}); + const content = Array.isArray(pageData.content) ? pageData.content : []; + + setItems((prev) => (replace ? content : [...prev, ...content])); + setPage(pageData.page ?? p); + setLast(!!pageData.last); } catch (e) { console.error('[MyTravelList][getSavedPlaces]', e); setError('목록을 불러오는 중 문제가 발생했습니다.'); @@ -47,14 +55,15 @@ const MyTravelList = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleRemoveItem = async (contentId: string | number) => { + const handleRemoveItem = async (item: Row) => { const snapshot = items; - setItems((prev) => prev.filter((it) => it.contentId !== contentId)); + setItems((prev) => prev.filter((it) => it.contentId !== item.contentId)); try { - await unsavePlace(contentId); + await unsavePlace({ contentId: String(item.contentId) }); } catch (e) { console.error('[MyTravelList][unsavePlace]', e); - setItems(snapshot); // 롤백 + // 롤백 + setItems(snapshot); alert('삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); } }; @@ -63,58 +72,64 @@ const MyTravelList = () => { if (!q.trim()) return items; const kw = q.trim().toLowerCase(); return items.filter((it) => { - const title = `${it.regionName ?? ''} ${it.themeName ?? ''} ${it.contentId}`.toLowerCase(); - return title.includes(kw); + const hay = `${it.placeName ?? ''} ${it.themeName ?? ''} ${it.contentId}`.toLowerCase(); + return hay.includes(kw); }); }, [items, q]); return ( -
+
-
-
-
나의 여행지
+
+
+ 나의 여행지
-
+
setQ(e.target.value)} - className="w-full rounded-full bg-[#edf0e2] px-4 py-2 pl-10 text-sm text-black placeholder:text-[#7f8c6b] focus:outline-none" + className="bg-gray2 w-full rounded-full px-9 py-2 pl-10 text-sm text-black placeholder:text-[#7f8c6b] focus:outline-none" /> - + search
+ {/*정렬*/} +
+ +
{error && (
{error}
)} -
- {filtered.map((it) => { - const title = - it.regionName && it.themeName - ? `${it.regionName} · ${it.themeName}` - : it.regionName || it.themeName || String(it.contentId); +
+ {filtered.map((row) => { + const { contentId, themeName, likeCount, cnctrLevel } = row; - const likeCount = (it as any).likeCnt ?? (it as any).likedCnt ?? 0; - const quietLevel = clamp(Math.round(it.entrLevel ?? 3), 1, 5); + const title = row.placeName || String(contentId); return ( handleRemoveItem(it.contentId)} + onClick={() => navigate(`/place/${contentId}`)} + onRemove={() => handleRemoveItem(row)} /> ); })} diff --git a/src/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index 3f78181..43c85bb 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -1,69 +1,142 @@ - -import { useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; 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 SearchingPage from '../explore/Searching'; +import { searchPlaces, mapToCard, type PlaceDto } from '@/api/travel/places.api'; -type TravelItem = { - id: number; - title: string; - tranquil: boolean; - type: string; - likes: number; - imgUrl?: string; +type NavState = { + region?: { areaCode?: number | string; sigunguCode?: number | string }; + activity?: { cat1?: string; cat2?: string[] }; }; +const PAGE_SIZE = 20; + +export default function TravelSearch() { + const navigate = useNavigate(); + const { state } = useLocation(); + const navState = (state || {}) as NavState; -const TravelSearch = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [travelItems, setTravelItems] = useState( - () => TRAVEL_ITEMS.map((it) => ({ ...it })) - ); + const [q, setQ] = useState(''); + const [items, setItems] = useState[]>([]); + const [, setPageNo] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(false); + const [err, setErr] = useState(null); const handleMenuClick = () => setIsSidebarOpen(true); const handleCloseSidebar = () => setIsSidebarOpen(false); - const handleRemoveItem = (id: number) => - setTravelItems((prev) => prev.filter((item) => item.id !== id)); + useEffect(() => { + let alive = true; + (async () => { + setItems([]); + setPageNo(1); + setHasMore(true); + setErr(null); + + setInitialLoading(true); + try { + await loadPage(1, true); + } finally { + if (alive) setInitialLoading(false); + } + })(); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + navState?.region?.areaCode, + navState?.region?.sigunguCode, + navState?.activity?.cat1, + JSON.stringify(navState?.activity?.cat2 || []), + ]); + + async function loadPage(p: number, replace = false) { + if (loading || (!hasMore && !replace)) return; + if (!replace) setLoading(true); + setErr(null); + + try { + const params = { + areaCode: navState.region?.areaCode, + sigunguCode: navState.region?.sigunguCode, + cat1: navState.activity?.cat1, + cat2: navState.activity?.cat2?.[0], + pageNo: p, + numOfRows: PAGE_SIZE, + arrange: 'O' as const, + }; + const list: PlaceDto[] = await searchPlaces(params); + const mapped = list.map(mapToCard); + + setItems((prev) => (replace ? mapped : [...prev, ...mapped])); + setPageNo(p); + setHasMore(list.length >= PAGE_SIZE); + } catch { + setErr('여행지 목록을 불러오지 못했어요.'); + } finally { + if (!replace) setLoading(false); + } + } + + const filtered = useMemo(() => { + const kw = q.trim().toLowerCase(); + if (!kw) return items; + return items.filter((it) => it.title.toLowerCase().includes(kw)); + }, [items, q]); + + if (initialLoading) return ; return ( -
+
- -
-
-
여행지 탐색
+
+
+
+ 여행지 탐색 +
-
+ +
setQ(e.target.value)} + className="bg-gray2 placeholder:text-green1 w-full rounded-full px-4 py-2 pl-10 text-sm text-black focus:outline-none" /> - + search
-
- {travelItems.map((item) => ( + {err && ( +
{err}
+ )} + {!err && filtered.length === 0 && ( +
조건에 맞는 결과가 없어요.
+ )} + +
+ {filtered.map((it) => ( handleRemoveItem(item.id)} + key={String(it.id)} + title={it.title} + theme={it.theme} + likeCount={it.likeCount} + imgUrl={it.imgUrl} + quietLevel={it.quietLevel} + onClick={() => navigate(`/place/${it.id}`)} /> ))}
); -}; - -export default TravelSearch; \ No newline at end of file +} diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 1484c50..6e677fb 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -2,8 +2,6 @@ import { createBrowserRouter } from 'react-router-dom'; import HomePage from '@/pages/home/Homepage'; import MyTravelList from '@/pages/home/MyTravelList'; import MyPage from '@/pages/home/MyPage'; -import RegionSelector from '@/component/selector/RegionSelector'; -import TravelActivitySelector from '@/component/selector/TravelActivitySelector'; import CardTestPage from '@/examples/CardTest'; import AlertComponent from '@/examples/AlertTest'; import AiExploreMain from '@/pages/ai/MainAI'; @@ -15,7 +13,6 @@ import LoginPage from '@/pages/home/Login'; import KakaoCallbackPage from '@/pages/home/KakaoCallbackPage'; import Register2 from '@/pages/register/Register2'; import Register3 from '@/pages/register/Register3'; -import Searching from '@/pages/explore/Searching'; import TravelSearch from '@/pages/home/TravelSearch'; import RequireAuth from './RequireAuth'; @@ -59,14 +56,6 @@ export const router = createBrowserRouter([ path: 'result', element: , }, - { - path: 'searching', - element: , - }, - { - path: 'travelsearch', - element: , - }, ], }, { @@ -80,20 +69,16 @@ export const router = createBrowserRouter([ path: '/place/:contentId', element: , }, - { - path: '/region', - element: , - }, - { - path: '/activity', - element: , - }, { path: '/card', element: , }, { - path: '/explore/Filter', + path: '/search', element: , }, + { + path: '/search/result', + element: , + }, ]);