Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions src/api/Selector/region.api.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재윤님 혹시 지역이랑 테마 불러오는 API 연결 파일은 왜 이렇게 수정하셨나요?

Original file line number Diff line number Diff line change
@@ -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<T>(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(/(?<!:)\/{2,}/g, "/");
}

function resolveApiPrefix() {
const base: string = (api as any)?.defaults?.baseURL ?? "";
const envPrefix: string =
(import.meta as any)?.env?.VITE_API_PREFIX?.toString?.() || "/api";

try {
const url = new URL(base, "http://_dummy.origin");
const path = url.pathname || "";
if (path.split("/").includes("api")) return ""; // 이미 /api 세그먼트 존재
} catch {
if (typeof base === "string" && /(^|\/)api(\/|$)/.test(base)) return "";
}
return envPrefix;
}

export async function fetchRegions(): Promise<AreaDto[]> {
const res = await api.get('/places/regions');
const data = unwrap<AreaDto[]>(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<AreaDto[]> {
const res = await api.get<RegionsWire>(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 };
80 changes: 70 additions & 10 deletions src/api/Selector/theme.api.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeGroup[]> {
const { data } = await api.get<ThemeGroup[] | ApiResponse<ThemeGroup[]>>('/places/themes');
const payload = (data as any)?.data ?? data;
return payload as ThemeGroup[];
type ThemeGroupsWire =
| ThemeGroup[]
| ApiResponse<ThemeGroup[]>
| { result?: ThemeGroup[]; data?: ThemeGroup[] };
function joinUrl(...parts: (string | undefined | null)[]) {
const raw = parts.filter(Boolean).join("/");
return raw.replace(/(?<!:)\/{2,}/g, "/");
}

function resolveApiPrefix() {
const base: string = (api as any)?.defaults?.baseURL ?? "";
const envPrefix: string =
(import.meta as any)?.env?.VITE_API_PREFIX?.toString?.() || "/api";

try {
const url = new URL(base, "http://_dummy.origin");
const path = url.pathname || "";
if (path.split("/").includes("api")) return "";
} catch {
if (typeof base === "string" && /(^|\/)api(\/|$)/.test(base)) return "";
}
return envPrefix;
}

const API_PREFIX = resolveApiPrefix();
const THEMES_ENDPOINT = joinUrl(API_PREFIX || "", "places", "themes");



export async function getThemeGroups(
options?: { signal?: AbortSignal }
): Promise<ThemeGroup[]> {
const res: AxiosResponse<ThemeGroupsWire> = 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,
};
118 changes: 72 additions & 46 deletions src/component/selector/RegionSelector.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,105 +10,130 @@ export type RegionSelectPayload = {
sigunguCode?: string | number;
};

type SigunguDto = { sigunguCode: string | number; sigunguName: string };
type AreaDto = { areaCode: string | number; areaName: string; sigunguList: SigunguDto[] };

function unwrap<T>(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<Record<string, string[]>>({});

const [codeMap, setCodeMap] = useState<
Record<string, { areaCode: string | number; sigunguMap: Record<string, string | number> }>
>({});

const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(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<AreaDto[]>(res.data);
// 성공 시 에러 확실히 초기화
setErr(null);

const dm: Record<string, string[]> = {};
const cm: Record<
string,
{ areaCode: string | number; sigunguMap: Record<string, string | number> }
> = {};
const cm: Record<string, { areaCode: string | number; sigunguMap: Record<string, string | number> }> = {};

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<string, string | number> = {};
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('지역 목록을 불러오지 못했습니다.');
} 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 (
<div className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">불러오는 중…</div>
<div
className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600"
role="status"
aria-live="polite"
>
불러오는 중…
</div>
);
}

// ✅ 데이터가 있으면 에러 배너는 숨김 (취소로 인한 가짜 에러 방지)
const hasData = Object.keys(dataMap).length > 0;

if (err && !hasData) {
return (
<div className="rounded-lg bg-red-100 px-3 py-2 text-sm text-red-700" role="alert">
{err}
</div>
);
}
if (err) {
return <div className="rounded-lg bg-red-100 px-3 py-2 text-sm text-red-700">{err}</div>;

if (!hasData) {
return (
<div className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
표시할 지역이 없습니다.
</div>
);
}

return (
<Selector
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 });
Expand Down
Loading