From d11155fff36ad6400427901338b5d9e72f2d465c Mon Sep 17 00:00:00 2001 From: "jaeyoonjung(rexyoon)" <137620925+rexyoon@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:10:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=97=AC=ED=96=89=EC=A7=80=20=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Selector/region.api.ts | 88 +++++++++- src/api/Selector/theme.api.ts | 80 +++++++-- src/component/selector/RegionSelector.tsx | 118 ++++++++----- .../selector/TravelActivitySelector.tsx | 162 +++++++++++------- 4 files changed, 325 insertions(+), 123 deletions(-) diff --git a/src/api/Selector/region.api.ts b/src/api/Selector/region.api.ts index 69e7784..1c742d9 100644 --- a/src/api/Selector/region.api.ts +++ b/src/api/Selector/region.api.ts @@ -1,22 +1,94 @@ -import api from '@/api/api'; +// src/api/region.api.ts +import api from "@/api/api"; + +/** + * Swagger: GET /api/places/regions + * "지역코드별로 그룹핑된 모든 지역 목록을 조회합니다." + */ 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; +type RegionsWire = + | AreaDto[] + | { data?: AreaDto[]; result?: AreaDto[] } + | { success?: boolean; data?: AreaDto[] }; + +/** URL 파트 안전 합치기 (http[s]:// 보존, 중복 슬래시 제거) */ +function joinUrl(...parts: (string | undefined | null)[]) { + const raw = parts.filter(Boolean).join("/"); + return raw.replace(/(? { - 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; +/** baseURL 안에 이미 "/api"가 포함되어 있으면 프리픽스는 비움 */ +function resolveApiPrefix() { + const base: string = (api as any)?.defaults?.baseURL ?? ""; + const envPrefix: string = + (import.meta as any)?.env?.VITE_API_PREFIX?.toString?.() || "/api"; + + try { + // URL로 파싱해 path만 비교 (상대 baseURL일 수도 있으니 실패해도 무시) + const url = new URL(base, "http://_dummy.origin"); + const path = url.pathname || ""; + if (path.split("/").includes("api")) return ""; // 이미 /api 세그먼트 존재 + } catch { + // baseURL이 절대 URL이 아니라 상대 경로일 때 + if (typeof base === "string" && /(^|\/)api(\/|$)/.test(base)) return ""; + } + return envPrefix; } + +// ---- 여기서 최종 엔드포인트 결정 ---- +const API_PREFIX = resolveApiPrefix(); +// 요청 URL을 "절대 경로(/...)"가 아니라 "상대 경로(places/...)"로 만들어 +// baseURL이 '.../api'인 경우에도 중복 없이 붙도록 처리 +const REGIONS_ENDPOINT = joinUrl(API_PREFIX || "", "places", "regions"); + +/** + * 전체 지역(시/도 → 시군구) 목록 조회 + * + * @param options.signal - 요청 취소용 AbortSignal + * @returns AreaDto[] + */ +export async function fetchRegions( + options?: { signal?: AbortSignal } +): Promise { + const res = await api.get(REGIONS_ENDPOINT, { + signal: options?.signal, + }); + + const wire = res.data as any; + + // 다양한 래핑 케이스를 방어적으로 언랩 + const payload: unknown = Array.isArray(wire) + ? wire + : wire?.data ?? wire?.result ?? wire; + + if (!Array.isArray(payload)) { + throw new Error("Invalid response format from /places/regions"); + } + + // (선택) 보기 좋은 정렬: areaName, sigunguName 기준 + const normalized: AreaDto[] = (payload as AreaDto[]) + .map((area) => ({ + ...area, + sigunguList: Array.isArray(area.sigunguList) + ? [...area.sigunguList].sort((a, b) => + a.sigunguName.localeCompare(b.sigunguName) + ) + : [], + })) + .sort((a, b) => a.areaName.localeCompare(b.areaName)); + + return normalized; +} + +export default { fetchRegions }; diff --git a/src/api/Selector/theme.api.ts b/src/api/Selector/theme.api.ts index fd99046..5f7fc2b 100644 --- a/src/api/Selector/theme.api.ts +++ b/src/api/Selector/theme.api.ts @@ -1,18 +1,78 @@ -import api from '../api'; -import type { ApiResponse } from '@/types/api-response'; +import api from "@/api/api"; +import type { AxiosResponse } from "axios"; +import type { ApiResponse } from "@/types/api-response"; export interface ThemeCat2 { - cat2: string; - cat2Name: string; + cat2: string; + cat2Name: string; } + export interface ThemeGroup { - cat1: string; - cat1Name: string; + cat1: string; + cat1Name: string; cat2List: ThemeCat2[]; } -export async function getThemeGroups(): Promise { - const { data } = await api.get>('/places/themes'); - const payload = (data as any)?.data ?? data; - return payload as ThemeGroup[]; +type ThemeGroupsWire = + | ThemeGroup[] + | ApiResponse + | { result?: ThemeGroup[]; data?: ThemeGroup[] }; +function joinUrl(...parts: (string | undefined | null)[]) { + const raw = parts.filter(Boolean).join("/"); + return raw.replace(/(? { + const res: AxiosResponse = await api.get(THEMES_ENDPOINT, { + signal: options?.signal, + }); + + const wire = res.data as any; + + const payload: unknown = Array.isArray(wire) + ? wire + : wire?.data ?? wire?.result ?? wire; + + if (!Array.isArray(payload)) { + throw new Error("Invalid response format from /places/themes"); + } + + const normalized: ThemeGroup[] = (payload as ThemeGroup[]) + .map((group) => ({ + ...group, + cat2List: Array.isArray(group.cat2List) + ? [...group.cat2List].sort((a, b) => + a.cat2Name.localeCompare(b.cat2Name) + ) + : [], + })) + .sort((a, b) => a.cat1Name.localeCompare(b.cat1Name)); + + return normalized; +} + +export default { + getThemeGroups, +}; diff --git a/src/component/selector/RegionSelector.tsx b/src/component/selector/RegionSelector.tsx index e8f3076..2809e7f 100644 --- a/src/component/selector/RegionSelector.tsx +++ b/src/component/selector/RegionSelector.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; -import Selector from './Selector'; -import api from '@/api/api'; +// src/component/selector/RegionSelector.tsx +import { useEffect, useMemo, useState } from "react"; +import Selector from "./Selector"; +import { fetchRegions, type AreaDto } from "@/api/Selector/region.api"; export type RegionSelectPayload = { region: string; @@ -9,50 +10,38 @@ export type RegionSelectPayload = { sigunguCode?: string | number; }; -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 norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); +const norm = (s?: string | null) => (s ?? "").replace(/\u00A0/g, " ").trim(); export default function RegionSelector({ onChange, -}: { - onChange?: (payload: RegionSelectPayload) => void; -}) { +}: { onChange?: (payload: RegionSelectPayload) => void }) { const [dataMap, setDataMap] = useState>({}); - const [codeMap, setCodeMap] = useState< Record }> >({}); - const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); useEffect(() => { - let alive = true; + const controller = new AbortController(); + (async () => { + setLoading(true); + setErr(null); try { - setLoading(true); - setErr(null); + const list: AreaDto[] = await fetchRegions({ signal: controller.signal }); - const res = await api.get('/places/regions'); - const list = unwrap(res.data); + // 성공 시 에러 확실히 초기화 + setErr(null); const dm: Record = {}; - const cm: Record< - string, - { areaCode: string | number; sigunguMap: Record } - > = {}; + const cm: 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; @@ -60,34 +49,71 @@ export default function RegionSelector({ cm[areaName] = { areaCode: area.areaCode, sigunguMap: sigMap }; } - if (!alive) return; setDataMap(dm); setCodeMap(cm); - } catch (e) { - if (!alive) return; - setErr('지역 목록을 불러오지 못했습니다.'); + } catch (e: any) { + // ⚠️ 이 effect의 요청이 취소돼서 난 에러라면 무시 + if (controller.signal.aborted) return; + + // axios 취소 패턴들 — 인터셉터가 UNKNOWN으로 바꿔도 걸러지게 넓게 체크 + const msg = String(e?.message || ""); + const code = String(e?.code || ""); + if ( + e?.name === "AbortError" || + e?.__CANCEL__ === true || + code === "ERR_CANCELED" || + code === "CANCELED" || + /abort|cancell?ed/i.test(msg) + ) { + return; + } + + console.error("[RegionSelector] load error:", e); + setErr(e?.message || "네트워크 오류 또는 서버 에러가 발생했습니다."); } finally { - if (alive) setLoading(false); + setLoading(false); } })(); - return () => { - alive = false; - }; + + return () => controller.abort(); }, []); const initialMain = useMemo(() => { - if (dataMap['인천']) return '인천'; + if (dataMap["인천"]) return "인천"; const keys = Object.keys(dataMap); - return keys.length ? keys[0] : ''; + return keys.length ? keys[0] : ""; }, [dataMap]); + // 로딩 UI if (loading) { return ( -
불러오는 중…
+
+ 불러오는 중… +
+ ); + } + + // ✅ 데이터가 있으면 에러 배너는 숨김 (취소로 인한 가짜 에러 방지) + const hasData = Object.keys(dataMap).length > 0; + + if (err && !hasData) { + return ( +
+ {err} +
); } - if (err) { - return
{err}
; + + if (!hasData) { + return ( +
+ 표시할 지역이 없습니다. +
+ ); } return ( @@ -95,19 +121,19 @@ export default function RegionSelector({ dataMap={dataMap} initialMain={initialMain} colorScheme={{ - leftBase: 'bg-orange text-black', - leftItem: 'text-black', - leftActive: 'bg-[#ffebd9] text-black', - rightItem: 'text-black', - rightActive: 'bg-orange text-black', - borderColor: 'border-orange', + leftBase: "bg-orange text-black", + leftItem: "text-black", + leftActive: "bg-[#ffebd9] text-black", + rightItem: "text-black", + rightActive: "bg-orange text-black", + borderColor: "border-orange", }} onSelect={(main, subs) => { const region = norm(main); const sigungu = norm(subs?.[0]); const area = codeMap[region]; - const areaCode = area?.areaCode ?? ''; + const areaCode = area?.areaCode ?? ""; const sigunguCode = sigungu ? area?.sigunguMap?.[sigungu] : undefined; onChange?.({ region, sigungu: sigungu || undefined, areaCode, sigunguCode }); diff --git a/src/component/selector/TravelActivitySelector.tsx b/src/component/selector/TravelActivitySelector.tsx index d7df8a5..95a160a 100644 --- a/src/component/selector/TravelActivitySelector.tsx +++ b/src/component/selector/TravelActivitySelector.tsx @@ -1,25 +1,40 @@ -import { useEffect, useMemo, useState } from 'react'; -import SelectorMulti from './SelectorMulti'; -import api from '@/api/api'; -import { cn } from '@/utils/cn'; +import { useEffect, useMemo, useState, useCallback } from "react"; +import SelectorMulti from "./SelectorMulti"; +import { cn } from "@/utils/cn"; +import { getThemeGroups, type ThemeGroup } from "@/api/Selector/theme.api"; type Props = { className?: string; onChange?: (main: string, subs: string[]) => void; onChangeCodes?: (cat1?: string, cat2?: string[]) => void; + onReadyChange?: ( + ready: boolean, + payload: { main: string; subs: string[]; cat1?: string; cat2?: string[] } + ) => void; }; -type Cat2Dto = { cat2: string; cat2Name: string }; -type Cat1GroupDto = { cat1: string; cat1Name: string; cat2List: Cat2Dto[] }; +const norm = (s?: string | null) => (s ?? "").replace(/\u00A0/g, " ").trim(); +const arrEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((v, i) => v === b[i]); -function unwrap(raw: any): T { - return raw && typeof raw.success === 'boolean' && 'data' in raw ? raw.data : raw; +function isIgnorableCancel(err: any): boolean { + const msg = String(err?.message || ""); + const code = String(err?.code || ""); + return ( + err?.name === "AbortError" || + err?.__CANCEL__ === true || + code === "ERR_CANCELED" || + code === "CANCELED" || + /abort|cancell?ed/i.test(msg) + ); } -const norm = (s?: string | null) => (s ?? '').replace(/\u00A0/g, ' ').trim(); - -export default function TravelActivitySelector({ className, onChange, onChangeCodes }: Props) { - const [, setGroups] = useState([]); +export default function TravelActivitySelector({ + className, + onChange, + onChangeCodes, + onReadyChange, +}: Props) { const [dataMap, setDataMap] = useState>({}); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -28,92 +43,121 @@ export default function TravelActivitySelector({ className, onChange, onChangeCo Record }> >({}); + const [selectedMain, setSelectedMain] = useState(""); + const [selectedSubs, setSelectedSubs] = useState([]); + useEffect(() => { - let alive = true; + const controller = new AbortController(); (async () => { + setLoading(true); + setErr(null); try { - setLoading(true); + const groups: ThemeGroup[] = await getThemeGroups({ signal: controller.signal }); 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 ?? []) { + for (const g of groups ?? []) { const cat1Name = norm(g.cat1Name); const cat2Names = (g.cat2List ?? []).map((c) => norm(c.cat2Name)); - - dm[cat1Name] = cat2Names.length ? cat2Names : [cat1Name]; - + dm[cat1Name] = cat2Names; const map: Record = {}; - for (const c of g.cat2List ?? []) { - map[norm(c.cat2Name)] = c.cat2; - } + 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('테마 목록을 불러오지 못했습니다.'); + } catch (e: any) { + if (controller.signal.aborted || isIgnorableCancel(e)) return; + console.error("[TravelActivitySelector] load error:", e); + setErr(e?.message || "테마 목록을 불러오지 못했습니다."); } finally { - if (alive) setLoading(false); + setLoading(false); } })(); - return () => { - alive = false; - }; + return () => controller.abort(); }, []); const initialMain = useMemo(() => { - if (dataMap['자연']) return '자연'; + if (dataMap["자연"]) return "자연"; const keys = Object.keys(dataMap); - return keys.length ? keys[0] : ''; + return keys.length ? keys[0] : ""; }, [dataMap]); + const colorScheme = useMemo( + () => ({ + leftBase: "bg-red2 text-black text-caption4", + leftItem: "text-black text-caption4", + leftActive: "bg-pink text-black text-caption4", + rightItem: "text-black", + rightActive: "bg-red2 text-black text-caption4", + borderColor: "border-red2", + }), + [] + ); + const handleSelect = useCallback( + (main: string, subs: string[]) => { + const m = norm(main); + const s = subs.map(norm); + + const sameMain = m === selectedMain; + const sameSubs = arrEqual(s, selectedSubs); + if (sameMain && sameSubs) return; + + setSelectedMain((prev) => (prev === m ? prev : m)); + setSelectedSubs((prev) => (arrEqual(prev, s) ? prev : s)); + + if (!sameMain || !sameSubs) { + onChange?.(m, s); + const c1 = codeMap[m]?.cat1; + const c2 = s.map((x) => codeMap[m]?.cat2ByName[norm(x)]).filter(Boolean) as string[]; + onChangeCodes?.(c1, c2); + } + }, + [selectedMain, selectedSubs, onChange, onChangeCodes, codeMap] + ); + + useEffect(() => { + const main = selectedMain; + const subs = selectedSubs; + const c1 = main ? codeMap[main]?.cat1 : undefined; + const c2 = main + ? (subs.map((x) => codeMap[main]?.cat2ByName[norm(x)]).filter(Boolean) as string[]) + : []; + const ready = !!main && subs.length > 0; + onReadyChange?.(ready, { main, subs, cat1: c1, cat2: c2 }); + }, [selectedMain, selectedSubs, codeMap, onReadyChange]); + if (loading) { return ( -
+
불러오는 중…
); } - if (err) { + const hasData = Object.keys(dataMap).length > 0; + if (err && !hasData) { return ( -
+
{err}
); } - if (!initialMain) return null; + if (!initialMain) { + return ( +
+ 표시할 테마가 없습니다. +
+ ); + } return ( -
+
{ - 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); - }} + colorScheme={colorScheme} + onSelect={handleSelect} />
); From bafd91d4a1620e5c32e357fd7db09f74eae06013 Mon Sep 17 00:00:00 2001 From: "jaeyoonjung(rexyoon)" <137620925+rexyoon@users.noreply.github.com> Date: Thu, 16 Oct 2025 23:13:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=97=AC=ED=96=89=EC=A7=80=20=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/Selector/region.api.ts | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/api/Selector/region.api.ts b/src/api/Selector/region.api.ts index 1c742d9..c4f0ee1 100644 --- a/src/api/Selector/region.api.ts +++ b/src/api/Selector/region.api.ts @@ -1,11 +1,5 @@ -// src/api/region.api.ts import api from "@/api/api"; -/** - * Swagger: GET /api/places/regions - * "지역코드별로 그룹핑된 모든 지역 목록을 조회합니다." - */ - export interface SigunguDto { sigunguCode: string; sigunguName: string; @@ -21,43 +15,30 @@ type RegionsWire = | AreaDto[] | { data?: AreaDto[]; result?: AreaDto[] } | { success?: boolean; data?: AreaDto[] }; - -/** URL 파트 안전 합치기 (http[s]:// 보존, 중복 슬래시 제거) */ function joinUrl(...parts: (string | undefined | null)[]) { const raw = parts.filter(Boolean).join("/"); return raw.replace(/(? { @@ -67,7 +48,6 @@ export async function fetchRegions( const wire = res.data as any; - // 다양한 래핑 케이스를 방어적으로 언랩 const payload: unknown = Array.isArray(wire) ? wire : wire?.data ?? wire?.result ?? wire; @@ -76,7 +56,6 @@ export async function fetchRegions( throw new Error("Invalid response format from /places/regions"); } - // (선택) 보기 좋은 정렬: areaName, sigunguName 기준 const normalized: AreaDto[] = (payload as AreaDto[]) .map((area) => ({ ...area,