Files
frontend/apps/dm/app/(landing)/reuse/_components/PostcodeModal.tsx
T

298 lines
9.6 KiB
TypeScript

"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
);
}