From 8d0a79063e60ac1f20f91df8ae699bff78dffa43 Mon Sep 17 00:00:00 2001 From: LGS Date: Mon, 23 Feb 2026 14:19:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EB=AF=BC=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=AC=BC=EC=9E=AC=EC=9D=B4=EC=9A=A9=20=EB=9E=9C=EB=94=A9=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=9A=94=EC=B2=98?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=9A=B0=ED=8E=B8=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=ED=8C=9D=EC=97=85=EC=B0=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reuse/_components/PostcodeModal.tsx | 298 ++++++++++++++++++ .../app/(landing)/reuse/apply/place/page.tsx | 77 ++--- apps/dm_new/app/api/postcode/route.ts | 60 ++++ 3 files changed, 399 insertions(+), 36 deletions(-) create mode 100644 apps/dm_new/app/(landing)/reuse/_components/PostcodeModal.tsx create mode 100644 apps/dm_new/app/api/postcode/route.ts diff --git a/apps/dm_new/app/(landing)/reuse/_components/PostcodeModal.tsx b/apps/dm_new/app/(landing)/reuse/_components/PostcodeModal.tsx new file mode 100644 index 000000000..83fe82aea --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/_components/PostcodeModal.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +export type AddrItem = { + zipNo: string; // 우편번호 + roadAddr: string; // 도로명주소(전체 문장) + jibunAddr: string; // 지번주소 +}; + +type Props = { + open: boolean; + onClose: () => void; + onSelect: (item: AddrItem) => void; +}; + +type PostcodeApiResponse = { + totalCount?: number; + items?: AddrItem[]; +}; + +function useDebouncedValue(value: T, delay = 250) { + const [v, setV] = useState(value); + useEffect(() => { + const t = setTimeout(() => setV(value), delay); + return () => clearTimeout(t); + }, [value, delay]); + return v; +} + +// A: 추천은 roadAddr 전체 문장 +const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); + +const makeSuggestionsA = (list: AddrItem[], keyword: string, limit = 7) => { + const key = normalize(keyword); + + const roadAddrs = list + .map((x) => x.roadAddr) + .filter((v): v is string => typeof v === "string" && v.trim().length > 0) + .map(normalize); + + const uniq = Array.from(new Set(roadAddrs)); + + // 입력어 포함 우선 + 짧은 주소 우선 + uniq.sort((a, b) => { + const ai = a.includes(key) ? 0 : 1; + const bi = b.includes(key) ? 0 : 1; + if (ai !== bi) return ai - bi; + return a.length - b.length; + }); + + return uniq.slice(0, limit); +}; + +export default function PostcodeModal({ open, onClose, onSelect }: Props) { + const [mounted, setMounted] = useState(false); + + const [q, setQ] = useState(""); + const dq = useDebouncedValue(q, 250); + + const [suggestions, setSuggestions] = useState([]); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => setMounted(true), []); + + // open 시: body scroll lock + focus + ESC + useEffect(() => { + if (!open) return; + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + setTimeout(() => inputRef.current?.focus(), 50); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + + return () => { + document.body.style.overflow = prevOverflow; + window.removeEventListener("keydown", onKeyDown); + }; + }, [open, onClose]); + + // 검색/추천/리스트 갱신 + useEffect(() => { + if (!open) return; + + const keyword = dq.trim(); + + if (!keyword) { + setSuggestions([]); + setItems([]); + setTotal(0); + setLoading(false); + return; + } + + let alive = true; + + (async () => { + setLoading(true); + try { + const res = await fetch(`/api/postcode?query=${encodeURIComponent(keyword)}&page=1`, { + cache: "no-store", + }); + + const data = (await res.json()) as PostcodeApiResponse; + + if (!alive) return; + + const list: AddrItem[] = data.items ?? []; + const totalCount: number = data.totalCount ?? 0; + + setItems(list); + setTotal(totalCount); + + // ✅ A: roadAddr 전체 문장 추천 + const sug = makeSuggestionsA(list, keyword, 7); + setSuggestions(sug); + } catch { + if (!alive) return; + setItems([]); + setTotal(0); + setSuggestions([]); + } finally { + if (alive) setLoading(false); + } + })(); + + return () => { + alive = false; + }; + }, [dq, open]); + + const showSuggest = open && q.trim().length > 0 && suggestions.length > 0; + + if (!mounted || !open) return null; + + return createPortal( +
+ {/* overlay */} + +
+ + {/* search input */} +
+
+ setQ(e.target.value)} + placeholder="주소검색" + className={[ + "w-full rounded-md border px-4 py-3 pr-12 text-[14px] outline-none", + "border-slate-300 focus:border-sky-500 focus:ring-2 focus:ring-sky-200", + ].join(" ")} + /> + + + + {/* suggestions dropdown */} + {showSuggest && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+
+ + {/* results */} +
+
+ {q.trim() + ? loading + ? "검색 중…" + : `총 ${total.toLocaleString()}개의 검색결과` + : "주소를 입력하면 검색결과가 표시됩니다."} +
+ +
+ {!loading && q.trim() && items.length === 0 && ( +
+ 검색 결과가 없습니다. +
+ )} + +
+ {items.map((it) => ( + + ))} +
+
+
+ + + , + document.body + ); +} \ No newline at end of file diff --git a/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx index 2deb0334e..b1976a2e8 100644 --- a/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx +++ b/apps/dm_new/app/(landing)/reuse/apply/place/page.tsx @@ -1,8 +1,11 @@ // apps/dm/app/(landing)/reuse/apply/place/page.tsx "use client"; +import React from "react"; import { useMemo, useState } from "react"; import SubPageHeader from "../../_components/SubPageHeader"; +import PostcodeModal from "../../_components/PostcodeModal"; +import type { AddrItem } from "../../_components/PostcodeModal"; type Option = { value: string; label: string }; type SidoValue = "" | "seoul" | "busan" | "daegu" | "incheon" | "gyeonggi"; @@ -75,9 +78,7 @@ function FormRow({ {/* 입력(우측) */} -
- {children} -
+
{children}
); } @@ -145,6 +146,25 @@ function SelectBox({ } export default function DemandPlaceRegisterPage() { + /** ✅ 모달 + 주소 상태 (반드시 컴포넌트 내부!) */ + const [open, setOpen] = useState(false); + + // 모달에서 선택한 값은 이 3개에 반영 (기존 UI zip/addr1/addr2와 맞춤) + const [zip, setZip] = useState(""); + const [addr1, setAddr1] = useState(""); // 도로명 주소 넣을 곳 + const [addr2, setAddr2] = useState(""); // 상세주소(사용자 입력) + + // (필요하면 유지) 지번도 따로 보관하고 싶을 때 + const [jibun, setJibun] = useState(""); + + const handleSelect = (it: AddrItem) => { + setZip(it.zipNo); + setAddr1(it.roadAddr); // ✅ 주소칸에 도로명 자동 입력 + setJibun(it.jibunAddr); // ✅ 지번도 상태로 보관(원하면 UI에 표시) + setOpen(false); + }; + + /** 기존 폼 상태들 */ const [sido, setSido] = useState(""); const [sigungu, setSigungu] = useState(""); @@ -153,10 +173,6 @@ export default function DemandPlaceRegisterPage() { const [workerNo, setWorkerNo] = useState(""); const [noWorkerNo, setNoWorkerNo] = useState(false); - const [zip, setZip] = useState(""); - const [addr1, setAddr1] = useState(""); - const [addr2, setAddr2] = useState(""); - const [tel, setTel] = useState(""); const [pw, setPw] = useState(""); const [pw2, setPw2] = useState(""); @@ -222,11 +238,7 @@ export default function DemandPlaceRegisterPage() {
- +
@@ -234,11 +246,7 @@ export default function DemandPlaceRegisterPage() {
- +
@@ -278,21 +286,24 @@ export default function DemandPlaceRegisterPage() {
+ + {/* ✅ 선택된 도로명주소가 자동으로 들어감 */} - + + {/* ✅ 상세주소는 사용자 입력 */} + + + {/* (선택) 지번주소도 보여주고 싶으면 */} + {/* {jibun ?

지번: {jibun}

: null} */} @@ -300,11 +311,7 @@ export default function DemandPlaceRegisterPage() {
- +
@@ -312,12 +319,7 @@ export default function DemandPlaceRegisterPage() {
- +
@@ -345,6 +347,9 @@ export default function DemandPlaceRegisterPage() { 수요처 등록 + + {/* ✅ 모달 */} + setOpen(false)} onSelect={handleSelect} /> ); -} +} \ No newline at end of file diff --git a/apps/dm_new/app/api/postcode/route.ts b/apps/dm_new/app/api/postcode/route.ts new file mode 100644 index 000000000..e00399234 --- /dev/null +++ b/apps/dm_new/app/api/postcode/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const query = (searchParams.get("query") ?? "").trim(); + const page = Number(searchParams.get("page") ?? "1"); + + if (!query) { + return NextResponse.json({ totalCount: 0, items: [] }); + } + + const confmKey = process.env.JUSO_API_KEY; + + // ✅ 키 없으면 UI 개발용 더미 + if (!confmKey) { + return NextResponse.json({ + totalCount: 3, + items: [ + { + zipNo: "06236", + roadAddr: "서울특별시 강남구 테헤란로 152, 강남파이낸스센터 12층", + jibunAddr: "서울특별시 강남구 역삼동 737-10 강남파이낸스센터", + }, + { + zipNo: "06180", + roadAddr: "서울특별시 강남구 테헤란로 231, 센터필드 WEST동 3층", + jibunAddr: "서울특별시 강남구 역삼동 824-11 센터필드 WEST동", + }, + { + zipNo: "06142", + roadAddr: "서울특별시 강남구 테헤란로 521, 삼성동 코엑스인터컨티넨탈호텔 별관", + jibunAddr: "서울특별시 강남구 삼성동 159-1 코엑스인터컨티넨탈호텔", + }, + ], + }); + } + + // ✅ 행안부 주소검색 API(JSON) + const url = new URL("https://business.juso.go.kr/addrlink/addrLinkApi.do"); + url.searchParams.set("confmKey", confmKey); + url.searchParams.set("currentPage", String(page)); + url.searchParams.set("countPerPage", "20"); + url.searchParams.set("keyword", query); + url.searchParams.set("resultType", "json"); + + const r = await fetch(url.toString(), { cache: "no-store" }); + const j = await r.json(); + + const common = j?.results?.common; + const totalCount = Number(common?.totalCount ?? 0); + const list = j?.results?.juso ?? []; + + const items = list.map((x: any) => ({ + zipNo: x.zipNo, + roadAddr: x.roadAddr, + jibunAddr: x.jibunAddr, + })); + + return NextResponse.json({ totalCount, items }); +} \ No newline at end of file