dm_new 우편번호 검색
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
fetchPostcode,
|
||||
parsePostcodeInput,
|
||||
type AddrItem,
|
||||
type PostcodeSearchResult,
|
||||
} from "@/app/store/sdmMng/postcode-service";
|
||||
|
||||
export type { AddrItem } from "@/app/store/sdmMng/postcode-service";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (item: AddrItem) => void;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function PostcodeModal({ open, onClose, onSelect }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const dq = useDebouncedValue(q, 250);
|
||||
|
||||
const [items, setItems] = useState<AddrItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchMessage, setSearchMessage] = useState("");
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
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) {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
setSearchMessage("");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parsePostcodeInput(keyword, 1);
|
||||
if (!parsed.ok) {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
setSearchMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
let alive = true;
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setSearchMessage("");
|
||||
try {
|
||||
const result: PostcodeSearchResult = await fetchPostcode(parsed.value);
|
||||
if (!alive) return;
|
||||
|
||||
setItems(result.items);
|
||||
setTotal(result.totalCount);
|
||||
} catch {
|
||||
if (!alive) return;
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setSearchMessage("우편번호 검색 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [dq, open]);
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/40"
|
||||
aria-label="닫기"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<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="우편번호 검색"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="px-7 pb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="예) 테헤란로 152-1"
|
||||
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
|
||||
<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">
|
||||
이후 다른 API 연동 예정
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-7 pb-7">
|
||||
<div className="mb-3 text-[13px] text-slate-500">
|
||||
{q.trim()
|
||||
? loading
|
||||
? "검색 중..."
|
||||
: searchMessage || `총 ${total.toLocaleString()}개의 검색결과`
|
||||
: "도로명과 건물번호를 입력하세요. 예) 테헤란로 152-1"}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] overflow-y-auto pr-1">
|
||||
{!loading && q.trim() && items.length === 0 && !searchMessage && (
|
||||
<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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user