diff --git a/src/api/Selector/region.api.ts b/src/api/Selector/region.api.ts index 69e7784..c4f0ee1 100644 --- a/src/api/Selector/region.api.ts +++ b/src/api/Selector/region.api.ts @@ -1,22 +1,73 @@ -import api from '@/api/api'; +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; +type RegionsWire = + | AreaDto[] + | { data?: AreaDto[]; result?: AreaDto[] } + | { success?: boolean; data?: AreaDto[] }; +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; +const API_PREFIX = resolveApiPrefix(); +const REGIONS_ENDPOINT = joinUrl(API_PREFIX || "", "places", "regions"); + + +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"); + } + + 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} />
);