대민서비스 물재이용 랜딩 페이지 수요처등록 우편번호 검색 팝업창 추가
This commit is contained in:
@@ -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<T>(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<string[]>([]);
|
||||
const [items, setItems] = useState<AddrItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(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(
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
{/* overlay */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/40"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* panel wrapper */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div
|
||||
className="relative w-full max-w-[520px] rounded-2xl bg-white shadow-[0_20px_60px_rgba(0,0,0,0.18)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="우편번호 검색"
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-7 pt-6 pb-4">
|
||||
<h2 className="text-[22px] font-extrabold tracking-tight text-slate-900">
|
||||
우편번호 검색
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-9 w-9 place-items-center rounded-full hover:bg-slate-100"
|
||||
aria-label="닫기"
|
||||
>
|
||||
<span className="text-[18px] leading-none">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* search input */}
|
||||
<div className="px-7 pb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => 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(" ")}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 grid h-9 w-9 place-items-center rounded-full hover:bg-slate-100 text-[#01ACE5]"
|
||||
aria-label="검색"
|
||||
onClick={() => setQ((v) => v.trim())}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M21 21l-4.3-4.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* suggestions dropdown */}
|
||||
{showSuggest && (
|
||||
<div className="absolute left-0 right-0 top-[calc(100%+6px)] z-10 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-[14px] hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
// ✅ 추천 클릭 → 입력값 채우고 드롭다운 닫기 + 포커스 유지
|
||||
setQ(s);
|
||||
setSuggestions([]);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<span className="truncate text-slate-800">{s}</span>
|
||||
<span className="ml-4 text-slate-400">↵</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* results */}
|
||||
<div className="px-7 pb-7">
|
||||
<div className="mb-3 text-[13px] text-slate-500">
|
||||
{q.trim()
|
||||
? loading
|
||||
? "검색 중…"
|
||||
: `총 ${total.toLocaleString()}개의 검색결과`
|
||||
: "주소를 입력하면 검색결과가 표시됩니다."}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] overflow-y-auto pr-1">
|
||||
{!loading && q.trim() && items.length === 0 && (
|
||||
<div className="rounded-lg border border-slate-200 p-4 text-[14px] text-slate-600">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={`${it.zipNo}-${it.roadAddr}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(it)}
|
||||
className={[
|
||||
"w-full rounded-lg border p-4 text-left transition",
|
||||
"border-slate-200 hover:border-sky-300 hover:bg-sky-50/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="text-[20px] font-extrabold tracking-tight text-slate-900">
|
||||
{it.zipNo}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-start gap-2">
|
||||
<span className="w-[52px] shrink-0 mt-[2px] inline-flex items-center justify-center rounded-full border border-sky-400 px-2 py-[2px] text-[12px] font-semibold text-sky-600">
|
||||
도로명
|
||||
</span>
|
||||
<p className="text-[14px] leading-6 text-slate-700">
|
||||
{it.roadAddr}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<span className="w-[52px] shrink-0 mt-[2px] inline-flex items-center justify-center rounded-full border border-[#6DBB4F] px-2 py-[2px] text-[12px] font-semibold text-[#6DBB4F]">
|
||||
지번
|
||||
</span>
|
||||
<p className="text-[14px] leading-6 text-slate-700">
|
||||
{it.jibunAddr}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{/* 입력(우측) */}
|
||||
<div className="col-span-12 lg:col-span-10 px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-10 px-6 py-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +146,25 @@ function SelectBox<T extends string>({
|
||||
}
|
||||
|
||||
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<SidoValue>("");
|
||||
const [sigungu, setSigungu] = useState<string>("");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<FormRow label="회사명" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
placeholder="회사명을 입력해주세요."
|
||||
/>
|
||||
<TextInput value={company} onChange={setCompany} placeholder="회사명을 입력해주세요." />
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
@@ -234,11 +246,7 @@ export default function DemandPlaceRegisterPage() {
|
||||
|
||||
<FormRow label="사업자등록번호" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={bizNo}
|
||||
onChange={setBizNo}
|
||||
placeholder="사업자 등록번호를 입력해주세요."
|
||||
/>
|
||||
<TextInput value={bizNo} onChange={setBizNo} placeholder="사업자 등록번호를 입력해주세요." />
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
@@ -278,21 +286,24 @@ export default function DemandPlaceRegisterPage() {
|
||||
<div className="w-[180px]">
|
||||
<TextInput value={zip} onChange={setZip} placeholder="우편번호" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alert("우편번호 검색(샘플)")}
|
||||
className="h-11 rounded bg-[#888] px-4 text-base text-white hover:bg-neutral-700"
|
||||
onClick={() => setOpen(true)}
|
||||
className="h-11 rounded-md bg-[#888] px-4 text-sm text-white hover:brightness-95"
|
||||
>
|
||||
우편번호 검색
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ✅ 선택된 도로명주소가 자동으로 들어감 */}
|
||||
<TextInput value={addr1} onChange={setAddr1} placeholder="주소" />
|
||||
<TextInput
|
||||
value={addr2}
|
||||
onChange={setAddr2}
|
||||
placeholder="상세 주소를 입력하세요."
|
||||
/>
|
||||
|
||||
{/* ✅ 상세주소는 사용자 입력 */}
|
||||
<TextInput value={addr2} onChange={setAddr2} placeholder="상세 주소를 입력하세요." />
|
||||
|
||||
{/* (선택) 지번주소도 보여주고 싶으면 */}
|
||||
{/* {jibun ? <p className="text-xs text-neutral-500">지번: {jibun}</p> : null} */}
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
@@ -300,11 +311,7 @@ export default function DemandPlaceRegisterPage() {
|
||||
|
||||
<FormRow label="전화번호" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={tel}
|
||||
onChange={setTel}
|
||||
placeholder="전화번호를 입력하세요."
|
||||
/>
|
||||
<TextInput value={tel} onChange={setTel} placeholder="전화번호를 입력하세요." />
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
@@ -312,12 +319,7 @@ export default function DemandPlaceRegisterPage() {
|
||||
|
||||
<FormRow label="비밀번호" required>
|
||||
<div className="max-w-[420px]">
|
||||
<TextInput
|
||||
value={pw}
|
||||
onChange={setPw}
|
||||
placeholder="비밀번호를 입력하세요."
|
||||
type="password"
|
||||
/>
|
||||
<TextInput value={pw} onChange={setPw} placeholder="비밀번호를 입력하세요." type="password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
@@ -345,6 +347,9 @@ export default function DemandPlaceRegisterPage() {
|
||||
수요처 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ✅ 모달 */}
|
||||
<PostcodeModal open={open} onClose={() => setOpen(false)} onSelect={handleSelect} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user