From befa10bfc5108efd952934e28fa52e863385770d Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 00:27:23 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=EB=82=98=EC=9D=98=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=EC=A7=80=20=EC=A0=80=EC=9E=A5/=EC=82=AD=EC=A0=9C/?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Myplace/myPlace.api.ts | 78 +++++++++++++++++++++++++++++++ src/pages/ai/TravelSpotDetail.tsx | 63 +++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/api/Myplace/myPlace.api.ts diff --git a/src/api/Myplace/myPlace.api.ts b/src/api/Myplace/myPlace.api.ts new file mode 100644 index 0000000..3d635fa --- /dev/null +++ b/src/api/Myplace/myPlace.api.ts @@ -0,0 +1,78 @@ +import api from '@/api/api'; +import type { ApiResponse } from '@/types/api-response'; + +export interface SavePlaceRequest { + contentId: string; + regionName: string; + themeName: string; + cnctrLevel: number; +} + +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; + 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/pages/ai/TravelSpotDetail.tsx b/src/pages/ai/TravelSpotDetail.tsx index bc9f826..28d4c5d 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(); @@ -81,8 +82,58 @@ 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, + }, + }), + ); + 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, + }); + setBookmarked(true); + } + } catch (err: any) { + console.error('저장 처리 실패:', err?.message || err); + } }; function mapIntegratedToPlaceDetail(id: string, item: IntegratedPlace): PlaceDetail { @@ -160,6 +211,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 +229,7 @@ const TravelSpotDetail = () => { return () => { alive = false; }; - }, [contentId]); + }, [contentId, isAuthed]); return (
From 2ed7a4043ef8908c1e4019529414f55bbfcc531b Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 01:04:31 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=ED=8B=80=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=EB=82=98=EC=9D=98=20=EC=97=AC=ED=96=89?= =?UTF-8?q?=EC=A7=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/home/MyTravelList.tsx | 51 +++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index 21d20e6..462e8a8 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -1,21 +1,24 @@ -// 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, + type SavedPlacePage, + type SavePlaceRequest, +} from '@/api/Myplace/myPlace.api'; +import { useNavigate } from 'react-router-dom'; const PAGE_SIZE = 20; -function clamp(n: number, min: number, max: number) { - return Math.max(min, Math.min(max, n)); -} +type Row = SavedPlaceItem & { regionName?: string }; 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); @@ -24,16 +27,20 @@ const MyTravelList = () => { 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 +54,21 @@ 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); + const payload: SavePlaceRequest = { + contentId: String(item.contentId), + regionName: item.regionName ?? '정보없음', + themeName: item.themeName ?? '여행지', + cnctrLevel: typeof item.cnctrLevel === 'number' ? item.cnctrLevel : 0, + }; + await unsavePlace(payload); } catch (e) { console.error('[MyTravelList][unsavePlace]', e); - setItems(snapshot); // 롤백 + // 롤백 + setItems(snapshot); alert('삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); } }; @@ -102,8 +116,8 @@ const MyTravelList = () => { ? `${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); + const likeCount = it.likeCount; + const quietLevel = it.cnctrLevel; return ( { imgUrl={undefined} quietLevel={quietLevel} showRemoveButton - onRemove={() => handleRemoveItem(it.contentId)} + onClick={() => navigate(`/place/${it.contentId}`)} + onRemove={() => handleRemoveItem(it)} /> ); })} From d10edf90e39e1d4444bc3b38744ed78162b69e0f Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 01:57:57 +0900 Subject: [PATCH 03/13] =?UTF-8?q?style:=20=EB=82=98=EC=9D=98=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=EC=A7=80=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/home/MyTravelList.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index 462e8a8..08ea062 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -83,24 +83,24 @@ const MyTravelList = () => { }, [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
@@ -109,7 +109,7 @@ const MyTravelList = () => {
{error}
)} -
+
{filtered.map((it) => { const title = it.regionName && it.themeName From f090d8e05d7d973631032e070cd6a1ce9e7b9f5e Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 03:03:48 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=ED=83=80=EC=9D=B4=ED=8B=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Myplace/myPlace.api.ts | 2 ++ src/pages/ai/TravelSpotDetail.tsx | 3 +++ src/pages/home/MyTravelList.tsx | 42 ++++++++++--------------------- 3 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/api/Myplace/myPlace.api.ts b/src/api/Myplace/myPlace.api.ts index 3d635fa..d7ad98f 100644 --- a/src/api/Myplace/myPlace.api.ts +++ b/src/api/Myplace/myPlace.api.ts @@ -6,6 +6,7 @@ export interface SavePlaceRequest { regionName: string; themeName: string; cnctrLevel: number; + placeName: string; } export interface SaveToggleResponse { @@ -27,6 +28,7 @@ export interface SaveStatusData { export interface SavedPlaceItem { cnctrLevel: number; contentId: string; + placeName: string; likeCount: number; themeName: string; savedAt: string; diff --git a/src/pages/ai/TravelSpotDetail.tsx b/src/pages/ai/TravelSpotDetail.tsx index 28d4c5d..8ac8504 100644 --- a/src/pages/ai/TravelSpotDetail.tsx +++ b/src/pages/ai/TravelSpotDetail.tsx @@ -50,6 +50,7 @@ const TravelSpotDetail = () => { regionName: data.regionTag ?? '정보없음', themeName: data.themeName ?? '여행지', cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), }, }), ); @@ -101,6 +102,7 @@ const TravelSpotDetail = () => { regionName: data.regionTag ?? '정보없음', themeName: data.themeName ?? '여행지', cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), }, }), ); @@ -128,6 +130,7 @@ const TravelSpotDetail = () => { regionName: data.regionTag ?? '정보없음', themeName: data.themeName ?? '여행지', cnctrLevel: data.serenity ?? 0, + placeName: data.name || String(contentId), }); setBookmarked(true); } diff --git a/src/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index 08ea062..d400803 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -3,18 +3,12 @@ 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, - unsavePlace, - type SavedPlaceItem, - type SavedPlacePage, - type SavePlaceRequest, -} from '@/api/Myplace/myPlace.api'; +import { getSavedPlaces, unsavePlace, type SavedPlaceItem } from '@/api/Myplace/myPlace.api'; import { useNavigate } from 'react-router-dom'; const PAGE_SIZE = 20; -type Row = SavedPlaceItem & { regionName?: string }; +type Row = SavedPlaceItem; const MyTravelList = () => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -58,13 +52,7 @@ const MyTravelList = () => { const snapshot = items; setItems((prev) => prev.filter((it) => it.contentId !== item.contentId)); try { - const payload: SavePlaceRequest = { - contentId: String(item.contentId), - regionName: item.regionName ?? '정보없음', - themeName: item.themeName ?? '여행지', - cnctrLevel: typeof item.cnctrLevel === 'number' ? item.cnctrLevel : 0, - }; - await unsavePlace(payload); + await unsavePlace({ contentId: String(item.contentId) }); } catch (e) { console.error('[MyTravelList][unsavePlace]', e); // 롤백 @@ -77,8 +65,8 @@ 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]); @@ -110,26 +98,22 @@ const MyTravelList = () => { )}
- {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.likeCount; - const quietLevel = it.cnctrLevel; + const title = row.placeName || String(contentId); return ( navigate(`/place/${it.contentId}`)} - onRemove={() => handleRemoveItem(it)} + onClick={() => navigate(`/place/${contentId}`)} + onRemove={() => handleRemoveItem(row)} /> ); })} From 266143f0c78d828f263e4112497e3a0de402fcb8 Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 03:58:25 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Myplace/saved.api.ts | 23 ++-- src/api/Myplace/saveg.api.ts | 31 +++-- src/api/Myplace/savep.api.ts | 40 +++---- src/api/travel/travel.api.ts | 113 ++++++------------ src/pages/explore/Filter.tsx | 191 +++++------------------------- src/pages/explore/List.tsx | 0 src/pages/explore/LoadingStep.tsx | 7 ++ src/pages/explore/RegionStep.tsx | 30 +++++ src/pages/explore/ResultStep.tsx | 88 ++++++++++++++ src/pages/explore/ThemeStep.tsx | 36 ++++++ src/pages/explore/types.ts | 23 ++++ src/routes/router.tsx | 10 +- 12 files changed, 294 insertions(+), 298 deletions(-) delete mode 100644 src/pages/explore/List.tsx create mode 100644 src/pages/explore/LoadingStep.tsx create mode 100644 src/pages/explore/RegionStep.tsx create mode 100644 src/pages/explore/ResultStep.tsx create mode 100644 src/pages/explore/ThemeStep.tsx create mode 100644 src/pages/explore/types.ts diff --git a/src/api/Myplace/saved.api.ts b/src/api/Myplace/saved.api.ts index 4ec77e9..d20d2c5 100644 --- a/src/api/Myplace/saved.api.ts +++ b/src/api/Myplace/saved.api.ts @@ -1,22 +1,19 @@ -import api from "../api"; -import type { ApiResponse } from "@/types/api-response"; +import api from '../api'; +import type { ApiResponse } from '@/types/api-response'; export type PlaceSaveResult = { placeId: number; typeId?: number; - liked: boolean; - changed: boolean; + liked: boolean; + changed: boolean; memo?: string | null; - createdAt: string; - updatedAt: string; + createdAt: string; + updatedAt: string; }; -export async function unsavePlace( - contentId: string | number -): Promise { - const { data } = await api.delete>( - "/my/places/save", - { params: { contentId: String(contentId) } } - ); +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..74fbb0d 100644 --- a/src/api/Myplace/saveg.api.ts +++ b/src/api/Myplace/saveg.api.ts @@ -1,14 +1,13 @@ -import api from "../api"; -import type { ApiResponse } from "@/types/api-response"; - +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; + contentId: string | number; + themeName?: string; + regionName?: string; + entrLevel?: number; + likedCnt?: number; + savedAt: string; }; export type SavedPlacesPage = { @@ -23,12 +22,12 @@ export type SavedPlacesPage = { 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 } } - ); +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..534a3d8 100644 --- a/src/api/Myplace/savep.api.ts +++ b/src/api/Myplace/savep.api.ts @@ -1,22 +1,22 @@ -import api from "@/api/api"; +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 = { @@ -36,15 +36,15 @@ type ApiEnvelope = { function unwrapResponse(raw: T | ApiEnvelope): T { const anyRaw = raw as any; - if (anyRaw && typeof anyRaw === "object" && "data" in anyRaw && anyRaw.data) { + 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("서버 응답 형식이 예상과 다릅니다."); + if (!res || typeof res.placedId !== 'number') { + throw new Error('서버 응답 형식이 예상과 다릅니다.'); } } @@ -56,22 +56,20 @@ export async function savePlace(payload: SaveMyPlaceRequest): Promise>( - "/my/places/save", + '/my/places/save', body, - { headers: { "Content-Type": "application/json" } } + { 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 || "네트워크 오류 또는 서버 에러가 발생했습니다."); -} + } 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/travel.api.ts b/src/api/travel/travel.api.ts index 20d105e..13bbbc5 100644 --- a/src/api/travel/travel.api.ts +++ b/src/api/travel/travel.api.ts @@ -1,80 +1,43 @@ -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)); +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 !== ''), + ); } - -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); +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 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: "자연관광지", +export async function fetchPlaces(params: PlacesQuery = {}): Promise { + const { data } = await api.get>(ENDPOINT, { + params: prune(params), }); + return unwrap(data); } -*/ diff --git a/src/pages/explore/Filter.tsx b/src/pages/explore/Filter.tsx index 1993495..8282f69 100644 --- a/src/pages/explore/Filter.tsx +++ b/src/pages/explore/Filter.tsx @@ -1,57 +1,15 @@ -// 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({}); @@ -67,25 +25,21 @@ export default function Filter() { setActivity({}); }, [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() { + const handleFinish = () => { navigate('/explore/Searching', { state: { region, activity } }); - } + }; - function resetAndSelectAgain() { + const resetAndSelectAgain = () => { setPhase('select'); setStep(1); setResults([]); setError(null); - } + }; return (
@@ -98,6 +52,7 @@ export default function Filter() { 여행지 탐색
+
{/* 진행 바 */}
@@ -109,120 +64,28 @@ 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..dab8bb3 --- /dev/null +++ b/src/pages/explore/RegionStep.tsx @@ -0,0 +1,30 @@ +import RegionSelectorRaw from '@/component/selector/RegionSelector'; +import { Button } from '@/component'; +import { type Pair, toPair } from './types'; + +type Props = { + value: Pair; + onChange: (v: Pair) => void; + onNext: () => void; +}; + +export default function RegionStep({ value, onChange, onNext }: Props) { + return ( + <> +

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

+
+ onChange(toPair(v)), + value, + } as any)} + /> +
+
+ +
+ + ); +} 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/ThemeStep.tsx b/src/pages/explore/ThemeStep.tsx new file mode 100644 index 0000000..0529df8 --- /dev/null +++ b/src/pages/explore/ThemeStep.tsx @@ -0,0 +1,36 @@ +import TravelActivitySelectorRaw from '@/component/selector/TravelActivitySelector'; +import { Button } from '@/component'; +import { type Pair, toPair } from './types'; + +type Props = { + value: Pair; + onChange: (v: Pair) => void; + onPrev: () => void; + onFinish: () => void; +}; + +export default function ThemeStep({ value, onChange, onPrev, onFinish }: Props) { + return ( + <> +

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

+
+ onChange(toPair(v)), + value, + } as any)} + /> +
+
+
+ + +
+
+ + ); +} 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/routes/router.tsx b/src/routes/router.tsx index 1484c50..7a8df27 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -80,20 +80,12 @@ export const router = createBrowserRouter([ path: '/place/:contentId', element: , }, - { - path: '/region', - element: , - }, - { - path: '/activity', - element: , - }, { path: '/card', element: , }, { - path: '/explore/Filter', + path: '/search', element: , }, ]); From 67335e012d2b7e07d56cad69ed7d45fe731855fd Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 04:17:02 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20=EC=85=80=EB=A0=89=ED=84=B0=20API?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Myplace/saved.api.ts | 19 ---- src/api/Myplace/saved.ts | 22 ---- src/api/Myplace/saveg.api.ts | 33 ------ src/api/Myplace/saveg.ts | 34 ------ src/api/Myplace/savep.api.ts | 75 ------------- src/api/Myplace/savep.ts | 77 ------------- src/api/Selector/region.api.ts | 22 ++++ src/component/selector/RegionSelector.tsx | 101 ++++++++++++++---- .../selector/TravelActivitySelector.tsx | 97 ++++++++++++++++- 9 files changed, 196 insertions(+), 284 deletions(-) delete mode 100644 src/api/Myplace/saved.api.ts delete mode 100644 src/api/Myplace/saved.ts delete mode 100644 src/api/Myplace/saveg.api.ts delete mode 100644 src/api/Myplace/saveg.ts delete mode 100644 src/api/Myplace/savep.api.ts delete mode 100644 src/api/Myplace/savep.ts create mode 100644 src/api/Selector/region.api.ts diff --git a/src/api/Myplace/saved.api.ts b/src/api/Myplace/saved.api.ts deleted file mode 100644 index d20d2c5..0000000 --- a/src/api/Myplace/saved.api.ts +++ /dev/null @@ -1,19 +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 74fbb0d..0000000 --- a/src/api/Myplace/saveg.api.ts +++ /dev/null @@ -1,33 +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 534a3d8..0000000 --- a/src/api/Myplace/savep.api.ts +++ /dev/null @@ -1,75 +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/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/TravelActivitySelector.tsx b/src/component/selector/TravelActivitySelector.tsx index 7eba231..a528481 100644 --- a/src/component/selector/TravelActivitySelector.tsx +++ b/src/component/selector/TravelActivitySelector.tsx @@ -1,5 +1,6 @@ +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 = { @@ -7,12 +8,94 @@ type Props = { onChange?: (main: string, subs: string[]) => void; }; +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 }: Props) { + const [groups, 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); + onChange?.(norm(main), subs.map(norm)); + + // 만약 이후에 코드가 필요하면 codeMap으로 역조회 가능: + // const cat1Code = codeMap[norm(main)]?.cat1; + // const cat2Codes = subs.map((s) => codeMap[norm(main)]?.cat2ByName[norm(s)]); + // console.log(cat1Code, cat2Codes); }} />
From 1589e49d27924913efaad7f687243b1dd997000a Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 05:13:47 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=EC=A7=80=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/travel/places.api.ts | 58 ++++++++ src/api/travel/travel.api.ts | 43 ------ .../selector/TravelActivitySelector.tsx | 14 +- src/pages/explore/Filter.tsx | 21 ++- src/pages/explore/RegionStep.tsx | 41 ++++-- src/pages/explore/Searching.tsx | 55 +++---- src/pages/explore/ThemeStep.tsx | 55 +++++-- src/pages/home/TravelSearch.tsx | 135 ++++++++++++++---- src/routes/router.tsx | 12 +- 9 files changed, 302 insertions(+), 132 deletions(-) create mode 100644 src/api/travel/places.api.ts delete mode 100644 src/api/travel/travel.api.ts diff --git a/src/api/travel/places.api.ts b/src/api/travel/places.api.ts new file mode 100644 index 0000000..178d462 --- /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' ? p.quietnessLevel : 3, // 없으면 3 + }; +} diff --git a/src/api/travel/travel.api.ts b/src/api/travel/travel.api.ts deleted file mode 100644 index 13bbbc5..0000000 --- a/src/api/travel/travel.api.ts +++ /dev/null @@ -1,43 +0,0 @@ -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/component/selector/TravelActivitySelector.tsx b/src/component/selector/TravelActivitySelector.tsx index a528481..2b24c0e 100644 --- a/src/component/selector/TravelActivitySelector.tsx +++ b/src/component/selector/TravelActivitySelector.tsx @@ -6,6 +6,7 @@ import { cn } from '@/utils/cn'; type Props = { className?: string; onChange?: (main: string, subs: string[]) => void; + onChangeCodes?: (cat1?: string, cat2?: string[]) => void; }; type Cat2Dto = { cat2: string; cat2Name: string }; @@ -17,7 +18,7 @@ function unwrap(raw: any): T { const norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); -export default function TravelActivitySelector({ className, onChange }: Props) { +export default function TravelActivitySelector({ className, onChange, onChangeCodes }: Props) { const [groups, setGroups] = useState([]); const [dataMap, setDataMap] = useState>({}); const [loading, setLoading] = useState(true); @@ -105,12 +106,13 @@ export default function TravelActivitySelector({ className, onChange }: Props) { borderColor: 'border-red2', }} onSelect={(main, subs) => { - onChange?.(norm(main), subs.map(norm)); + const m = norm(main); + const s = subs.map(norm); + onChange?.(m, s); - // 만약 이후에 코드가 필요하면 codeMap으로 역조회 가능: - // const cat1Code = codeMap[norm(main)]?.cat1; - // const cat2Codes = subs.map((s) => codeMap[norm(main)]?.cat2ByName[norm(s)]); - // console.log(cat1Code, cat2Codes); + 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/explore/Filter.tsx b/src/pages/explore/Filter.tsx index 8282f69..c4c82fc 100644 --- a/src/pages/explore/Filter.tsx +++ b/src/pages/explore/Filter.tsx @@ -16,6 +16,11 @@ export default function FilterPage() { 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' }); @@ -23,6 +28,7 @@ export default function FilterPage() { useEffect(() => { setActivity({}); + setActivityCodes({}); }, [region.left, region.right]); const progress = useMemo(() => { @@ -31,7 +37,12 @@ export default function FilterPage() { }, [phase, step]); const handleFinish = () => { - navigate('/explore/Searching', { state: { region, activity } }); + navigate('/search/result', { + state: { + region: { areaCode: regionCodes.areaCode, sigunguCode: regionCodes.sigunguCode }, + activity: { cat1: activityCodes.cat1, cat2: activityCodes.cat2 }, + }, + }); }; const resetAndSelectAgain = () => { @@ -64,11 +75,17 @@ export default function FilterPage() { {phase === 'select' && ( <> {step === 1 ? ( - setStep(2)} /> + setStep(2)} + /> ) : ( setStep(1)} onFinish={handleFinish} /> diff --git a/src/pages/explore/RegionStep.tsx b/src/pages/explore/RegionStep.tsx index dab8bb3..1ccceb5 100644 --- a/src/pages/explore/RegionStep.tsx +++ b/src/pages/explore/RegionStep.tsx @@ -1,27 +1,50 @@ +import { useCallback, useRef } from 'react'; import RegionSelectorRaw from '@/component/selector/RegionSelector'; import { Button } from '@/component'; -import { type Pair, toPair } from './types'; +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, onNext }: Props) { +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단계: 지역을 골라주세요.

- onChange(toPair(v)), - value, - } as any)} - /> +
-
diff --git a/src/pages/explore/Searching.tsx b/src/pages/explore/Searching.tsx index 9f7bb16..3f0c405 100644 --- a/src/pages/explore/Searching.tsx +++ b/src/pages/explore/Searching.tsx @@ -1,32 +1,37 @@ -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"; +import { Loader } from '@/component'; +import bubbleUrl from '@/image/Searching.svg'; +import personUrl from '@/image/Searching2.svg'; -export default function Searching() { - const navigate = useNavigate(); +type Props = { + open: boolean; + text?: string; +}; - useEffect(() => { - const t = setTimeout(() => { - navigate("/explore/travelsearch"); - }, 3000); - return () => clearTimeout(t); - }, [navigate]); +export default function SearchingOverlay({ + open, + text = '당신의 취향에 맞는 여행지를 찾고 있습니다…', +}: Props) { + if (!open) return null; return ( -
- - 당신의 취향에 맞는 여행지를 찾고 있습니다… - 검색 일러스트 +
+ + {text} + 검색 일러스트
); } diff --git a/src/pages/explore/ThemeStep.tsx b/src/pages/explore/ThemeStep.tsx index 0529df8..8e9e6f4 100644 --- a/src/pages/explore/ThemeStep.tsx +++ b/src/pages/explore/ThemeStep.tsx @@ -1,32 +1,69 @@ +import { useCallback, useRef } from 'react'; import TravelActivitySelectorRaw from '@/component/selector/TravelActivitySelector'; import { Button } from '@/component'; -import { type Pair, toPair } from './types'; +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({ value, onChange, onPrev, onFinish }: Props) { +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단계: 테마를 골라주세요.

- onChange(toPair(v)), - value, - } as any)} - /> +
-
diff --git a/src/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index 3f78181..f89c0af 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -1,69 +1,144 @@ - -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 SearchingOverlay 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[] }; // cat2 다중이면 첫 값만 사용 }; +const PAGE_SIZE = 20; + const TravelSearch = () => { + const navigate = useNavigate(); + const { state } = useLocation(); + const navState = (state || {}) as NavState; + const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [travelItems, setTravelItems] = useState( - () => TRAVEL_ITEMS.map((it) => ({ ...it })) - ); + const [q, setQ] = useState(''); + const [items, setItems] = useState[]>([]); + const [pageNo, setPageNo] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = 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)); + // 최초/필터 변경 시 리스트 초기화 후 1페이지 로드 + useEffect(() => { + setItems([]); + setPageNo(1); + setHasMore(true); + // 즉시 첫 페이지 + void loadPage(1, true); + // 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; + 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 (e) { + setErr('여행지 목록을 불러오지 못했어요.'); + } finally { + 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]); return (
+ +
-
-
-
여행지 탐색
+
+
+
여행지 탐색
+
setQ(e.target.value)} + className="bg-green0 placeholder:text-green-muted w-full rounded-full px-4 py-2 pl-10 text-sm text-black focus:outline-none" /> - + search
+ {err && ( +
{err}
+ )} + + {!loading && !err && filtered.length === 0 && ( +
조건에 맞는 결과가 없어요.
+ )} +
- {travelItems.map((item) => ( + {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}`)} /> ))}
+ +
+ {hasMore && ( + + )} +
); }; -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 7a8df27..a624e9c 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -59,14 +59,6 @@ export const router = createBrowserRouter([ path: 'result', element: , }, - { - path: 'searching', - element: , - }, - { - path: 'travelsearch', - element: , - }, ], }, { @@ -88,4 +80,8 @@ export const router = createBrowserRouter([ path: '/search', element: , }, + { + path: '/search/result', + element: , + }, ]); From 3b13000ec7a8ef6180f4f6b3db3102bf9686566d Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 05:43:04 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/travel/places.api.ts | 2 +- src/component/SideBar.tsx | 2 +- src/pages/explore/Searching.tsx | 20 ++------ src/pages/home/TravelSearch.tsx | 82 ++++++++++++++++----------------- 4 files changed, 45 insertions(+), 61 deletions(-) diff --git a/src/api/travel/places.api.ts b/src/api/travel/places.api.ts index 178d462..3f9f97e 100644 --- a/src/api/travel/places.api.ts +++ b/src/api/travel/places.api.ts @@ -53,6 +53,6 @@ export function mapToCard(p: PlaceDto) { theme: p.catName ?? '-', likeCount: p.likeCount ?? 0, imgUrl, - quietLevel: typeof p.quietnessLevel === 'number' ? p.quietnessLevel : 3, // 없으면 3 + quietLevel: typeof p.quietnessLevel === 'number', // 없으면 3 }; } diff --git a/src/component/SideBar.tsx b/src/component/SideBar.tsx index 1586352..a8cff32 100644 --- a/src/component/SideBar.tsx +++ b/src/component/SideBar.tsx @@ -54,7 +54,7 @@ const Sidebar: React.FC = ({ isOpen, onClose, position = 'right' } AI 맞춤 여행지 탐색
-
diff --git a/src/pages/explore/Searching.tsx b/src/pages/explore/Searching.tsx index 3f0c405..764cf0f 100644 --- a/src/pages/explore/Searching.tsx +++ b/src/pages/explore/Searching.tsx @@ -2,27 +2,13 @@ import { Loader } from '@/component'; import bubbleUrl from '@/image/Searching.svg'; import personUrl from '@/image/Searching2.svg'; -type Props = { - open: boolean; - text?: string; -}; - -export default function SearchingOverlay({ - open, - text = '당신의 취향에 맞는 여행지를 찾고 있습니다…', -}: Props) { - if (!open) return null; - +export default function SearchingPage() { return ( -
+
{text} diff --git a/src/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index f89c0af..00da0c0 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -4,17 +4,16 @@ import Header from '@/component/Header'; import Sidebar from '@/component/SideBar'; import SearchIcon from '@/image/Search.svg'; import PlaceCard from '@/component/common/Card/PlaceCard'; -import SearchingOverlay from '../explore/Searching'; +import SearchingPage from '../explore/Searching'; import { searchPlaces, mapToCard, type PlaceDto } from '@/api/travel/places.api'; type NavState = { region?: { areaCode?: number | string; sigunguCode?: number | string }; - activity?: { cat1?: string; cat2?: string[] }; // cat2 다중이면 첫 값만 사용 + activity?: { cat1?: string; cat2?: string[] }; }; - const PAGE_SIZE = 20; -const TravelSearch = () => { +export default function TravelSearch() { const navigate = useNavigate(); const { state } = useLocation(); const navState = (state || {}) as NavState; @@ -25,18 +24,30 @@ const TravelSearch = () => { const [pageNo, 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); - // 최초/필터 변경 시 리스트 초기화 후 1페이지 로드 useEffect(() => { - setItems([]); - setPageNo(1); - setHasMore(true); - // 즉시 첫 페이지 - void loadPage(1, true); + 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, @@ -47,14 +58,15 @@ const TravelSearch = () => { async function loadPage(p: number, replace = false) { if (loading || (!hasMore && !replace)) return; - setLoading(true); + 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], // 서버가 단일만 받을 때 + cat2: navState.activity?.cat2?.[0], pageNo: p, numOfRows: PAGE_SIZE, arrange: 'O' as const, @@ -65,10 +77,10 @@ const TravelSearch = () => { setItems((prev) => (replace ? mapped : [...prev, ...mapped])); setPageNo(p); setHasMore(list.length >= PAGE_SIZE); - } catch (e) { + } catch { setErr('여행지 목록을 불러오지 못했어요.'); } finally { - setLoading(false); + if (!replace) setLoading(false); } } @@ -78,27 +90,28 @@ const TravelSearch = () => { return items.filter((it) => it.title.toLowerCase().includes(kw)); }, [items, q]); - return ( -
- + if (initialLoading) return ; + return ( +
- -
-
-
여행지 탐색
+
+
+
+ 여행지 탐색 +
-
+
setQ(e.target.value)} - className="bg-green0 placeholder:text-green-muted w-full rounded-full px-4 py-2 pl-10 text-sm text-black focus:outline-none" + className="bg-gray2 placeholder:text-green1 w-full rounded-full px-4 py-2 pl-10 text-sm text-black focus:outline-none" /> - + search
@@ -106,12 +119,11 @@ const TravelSearch = () => { {err && (
{err}
)} - - {!loading && !err && filtered.length === 0 && ( + {!err && filtered.length === 0 && (
조건에 맞는 결과가 없어요.
)} -
+
{filtered.map((it) => ( { /> ))}
- -
- {hasMore && ( - - )} -
); -}; - -export default TravelSearch; +} From f537c1e89c607ee2913fd3bd342a22826fe0805b Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 05:50:57 +0900 Subject: [PATCH 09/13] =?UTF-8?q?style:=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component/SideBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component/SideBar.tsx b/src/component/SideBar.tsx index a8cff32..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 ( <>
From f1d7c484c856952e7c3a393465cb73bdb1fab335 Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 06:09:22 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/home/MyPage.tsx | 130 +++++++++++++++++++++----------------- 1 file changed, 71 insertions(+), 59 deletions(-) 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}

} +
+ {/*회원 탈퇴*/} +
+
+
+ + 더 이상 서비스를 사용하지 않으신가요? + + +
+
- -
); } From 1d824e028777c1c3c9c989d45db9eb2e0c789bb2 Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 06:22:32 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20=EC=A0=95=EB=A0=AC=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/component/selector/SortPillSelect.tsx | 159 ++++++++++++++++++++++ src/pages/home/MyTravelList.tsx | 18 ++- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/component/selector/SortPillSelect.tsx 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/pages/home/MyTravelList.tsx b/src/pages/home/MyTravelList.tsx index d400803..2788467 100644 --- a/src/pages/home/MyTravelList.tsx +++ b/src/pages/home/MyTravelList.tsx @@ -5,11 +5,17 @@ import SearchIcon from '@/image/Search.svg'; import PlaceCard from '@/component/common/Card/PlaceCard'; 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; 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([]); @@ -18,6 +24,7 @@ const MyTravelList = () => { 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); @@ -92,6 +99,15 @@ const MyTravelList = () => { search
+ {/*정렬*/} +
+ +
{error && (
{error}
From f16b1df1691c542ef4fa3023dc0bcbcf7be93771 Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 06:32:35 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/travel/places.api.ts | 2 +- src/component/common/Card/PlaceCard.tsx | 2 +- src/pages/home/TravelSearch.tsx | 2 +- src/routes/router.tsx | 3 --- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/api/travel/places.api.ts b/src/api/travel/places.api.ts index 3f9f97e..89c7ec6 100644 --- a/src/api/travel/places.api.ts +++ b/src/api/travel/places.api.ts @@ -53,6 +53,6 @@ export function mapToCard(p: PlaceDto) { theme: p.catName ?? '-', likeCount: p.likeCount ?? 0, imgUrl, - quietLevel: typeof p.quietnessLevel === 'number', // 없으면 3 + quietLevel: p.quietnessLevel, }; } 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/pages/home/TravelSearch.tsx b/src/pages/home/TravelSearch.tsx index 00da0c0..43c85bb 100644 --- a/src/pages/home/TravelSearch.tsx +++ b/src/pages/home/TravelSearch.tsx @@ -21,7 +21,7 @@ export default function TravelSearch() { const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [q, setQ] = useState(''); const [items, setItems] = useState[]>([]); - const [pageNo, setPageNo] = useState(1); + const [, setPageNo] = useState(1); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(false); diff --git a/src/routes/router.tsx b/src/routes/router.tsx index a624e9c..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'; From a05d127b15a7b64487467b243c1aa52f11512847 Mon Sep 17 00:00:00 2001 From: sispo3314 Date: Fri, 19 Sep 2025 06:35:14 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/travel/places.api.ts | 2 +- src/component/selector/TravelActivitySelector.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/travel/places.api.ts b/src/api/travel/places.api.ts index 89c7ec6..507f9da 100644 --- a/src/api/travel/places.api.ts +++ b/src/api/travel/places.api.ts @@ -53,6 +53,6 @@ export function mapToCard(p: PlaceDto) { theme: p.catName ?? '-', likeCount: p.likeCount ?? 0, imgUrl, - quietLevel: p.quietnessLevel, + quietLevel: typeof p.quietnessLevel === 'number', }; } diff --git a/src/component/selector/TravelActivitySelector.tsx b/src/component/selector/TravelActivitySelector.tsx index 2b24c0e..d7df8a5 100644 --- a/src/component/selector/TravelActivitySelector.tsx +++ b/src/component/selector/TravelActivitySelector.tsx @@ -19,7 +19,7 @@ function unwrap(raw: any): T { const norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); export default function TravelActivitySelector({ className, onChange, onChangeCodes }: Props) { - const [groups, setGroups] = useState([]); + const [, setGroups] = useState([]); const [dataMap, setDataMap] = useState>({}); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null);