feat: 가맹점 주소 검색을 다음 우편번호 서비스로
기획 MCH_002_p ③. 버튼을 누른 순간 스크립트를 한 번만 받아 창을 연다 — 자주 열리는 화면이 아니라 모든 페이지가 이 30KB를 지고 다닐 이유가 없다. 도로명/지번은 사용자가 고른 표기를 그대로 쓰고, 우편번호(frcsPostZip)까지 함께 채운다. 좌표는 이 서비스가 주지 않는다. 카카오 지도 지오코더가 필요하고 앱 키가 아직 없어, NEXT_PUBLIC_KAKAO_MAP_KEY가 설정되면 자동으로 동작하고 없으면 비운 채 진행한다. Co-Authored-By: Claude Opus 5
@59f15bf328185b74f22d50c7b26c854457aa22e5
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
... | ... | @@ -1,6 +1,12 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 3 |
+import {
|
|
| 4 |
+ useActionState, |
|
| 5 |
+ useEffect, |
|
| 6 |
+ useRef, |
|
| 7 |
+ useState, |
|
| 8 |
+ useTransition, |
|
| 9 |
+} from 'react'; |
|
| 4 | 10 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 11 |
import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
| 6 | 12 |
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
... | ... | @@ -25,6 +31,7 @@ |
| 25 | 31 |
OWNER_MAX_LENGTH, |
| 26 | 32 |
} from '@/lib/domain/merchant-form'; |
| 27 | 33 |
import { saveMerchantAction } from '../_actions';
|
| 34 |
+import { openPostcodeSearch } from './postcode';
|
|
| 28 | 35 |
import styles from './merchant-form.module.scss'; |
| 29 | 36 |
|
| 30 | 37 |
interface MerchantFormModalProps {
|
... | ... | @@ -48,6 +55,7 @@ |
| 48 | 55 |
const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? ''); |
| 49 | 56 |
const [latitude, setLatitude] = useState(merchant?.latitude ?? ''); |
| 50 | 57 |
const [longitude, setLongitude] = useState(merchant?.longitude ?? ''); |
| 58 |
+ const [isSearching, startSearching] = useTransition(); |
|
| 51 | 59 |
|
| 52 | 60 |
useEffect(() => {
|
| 53 | 61 |
if (state.status === 'success') {
|
... | ... | @@ -61,16 +69,29 @@ |
| 61 | 69 |
|
| 62 | 70 |
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
|
| 63 | 71 |
|
| 64 |
- // TODO(기획): 주소 검색 서비스가 정해지지 않았다. 연동 전까지 직접 입력으로 받는다. |
|
| 72 |
+ /** 기획 ③ 주소 검색 연동 — 고른 주소로 우편번호·좌표까지 채운다. */ |
|
| 65 | 73 |
function searchAddress() {
|
| 66 |
- const picked = window.prompt('주소를 입력하세요.', address);
|
|
| 67 |
- if (picked === null) {
|
|
| 68 |
- return; |
|
| 69 |
- } |
|
| 70 |
- setAddress(picked.trim()); |
|
| 71 |
- setPostalCode('');
|
|
| 72 |
- setLatitude('');
|
|
| 73 |
- setLongitude('');
|
|
| 74 |
+ startSearching(async () => {
|
|
| 75 |
+ let picked; |
|
| 76 |
+ try {
|
|
| 77 |
+ picked = await openPostcodeSearch(); |
|
| 78 |
+ } catch {
|
|
| 79 |
+ showToast({
|
|
| 80 |
+ variant: 'danger', |
|
| 81 |
+ message: '주소 검색을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.', |
|
| 82 |
+ }); |
|
| 83 |
+ return; |
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ if (!picked) {
|
|
| 87 |
+ return; |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ setAddress(picked.address); |
|
| 91 |
+ setPostalCode(picked.zonecode); |
|
| 92 |
+ setLatitude(picked.latitude); |
|
| 93 |
+ setLongitude(picked.longitude); |
|
| 94 |
+ }); |
|
| 74 | 95 |
} |
| 75 | 96 |
|
| 76 | 97 |
return ( |
... | ... | @@ -213,7 +234,8 @@ |
| 213 | 234 |
<FoxButton |
| 214 | 235 |
type="secondary" |
| 215 | 236 |
size="md" |
| 216 |
- label="주소 검색" |
|
| 237 |
+ label={isSearching ? '검색 중...' : '주소 검색'}
|
|
| 238 |
+ disabled={isSearching}
|
|
| 217 | 239 |
onAction={searchAddress}
|
| 218 | 240 |
/> |
| 219 | 241 |
</div> |
+++ app/(protected)/(basic)/system/merchants/_components/postcode.ts
... | ... | @@ -0,0 +1,169 @@ |
| 1 | +/** | |
| 2 | + * 다음(카카오) 우편번호 서비스 연동 — 기획 MCH_002_p ③. | |
| 3 | + * | |
| 4 | + * 스크립트를 문서에 미리 넣지 않고 버튼을 누른 순간 한 번만 받는다. 가맹점 등록은 자주 열리는 | |
| 5 | + * 화면이 아니라 모든 페이지가 이 30KB를 지고 다닐 이유가 없다. | |
| 6 | + * | |
| 7 | + * 좌표는 이 서비스가 주지 않는다 — 주소만 준다. 위도·경도는 카카오 지도 SDK의 지오코더가 | |
| 8 | + * 필요하고 앱 키를 요구하는데 아직 발급되지 않았다(`NEXT_PUBLIC_KAKAO_MAP_KEY`). 키가 설정되면 | |
| 9 | + * `geocode()`가 자동으로 동작하고, 없으면 좌표를 비운 채로 진행한다. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const POSTCODE_SRC = | |
| 13 | + 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js'; | |
| 14 | +const KAKAO_SDK_SRC = 'https://dapi.kakao.com/v2/maps/sdk.js'; | |
| 15 | + | |
| 16 | +export type PostcodeResult = { | |
| 17 | + /** 우편번호 5자리. */ | |
| 18 | + zonecode: string; | |
| 19 | + /** 사용자가 고른 표기의 주소(도로명 또는 지번). */ | |
| 20 | + address: string; | |
| 21 | + latitude: string; | |
| 22 | + longitude: string; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +type DaumPostcodeData = { | |
| 26 | + zonecode?: string; | |
| 27 | + address?: string; | |
| 28 | + roadAddress?: string; | |
| 29 | + jibunAddress?: string; | |
| 30 | + userSelectedType?: string; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +declare global { | |
| 34 | + interface Window { | |
| 35 | + daum?: { | |
| 36 | + Postcode: new (options: { | |
| 37 | + oncomplete: (data: DaumPostcodeData) => void; | |
| 38 | + onclose?: () => void; | |
| 39 | + }) => { open: () => void }; | |
| 40 | + }; | |
| 41 | + kakao?: { | |
| 42 | + maps: { | |
| 43 | + load: (callback: () => void) => void; | |
| 44 | + services?: { | |
| 45 | + Geocoder: new () => { | |
| 46 | + addressSearch: ( | |
| 47 | + address: string, | |
| 48 | + callback: ( | |
| 49 | + result: Array<{ x: string; y: string }>, | |
| 50 | + status: string | |
| 51 | + ) => void | |
| 52 | + ) => void; | |
| 53 | + }; | |
| 54 | + Status: { OK: string }; | |
| 55 | + }; | |
| 56 | + }; | |
| 57 | + }; | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +const loaded = new Map<string, Promise<void>>(); | |
| 62 | + | |
| 63 | +/** 같은 주소의 스크립트는 한 번만 받는다 — 두 번 넣으면 전역이 다시 초기화된다. */ | |
| 64 | +function loadScript(src: string): Promise<void> { | |
| 65 | + const cached = loaded.get(src); | |
| 66 | + if (cached) { | |
| 67 | + return cached; | |
| 68 | + } | |
| 69 | + | |
| 70 | + const pending = new Promise<void>((resolve, reject) => { | |
| 71 | + const script = document.createElement('script'); | |
| 72 | + script.src = src; | |
| 73 | + script.async = true; | |
| 74 | + script.onload = () => resolve(); | |
| 75 | + script.onerror = () => reject(new Error(`스크립트를 불러오지 못했습니다: ${src}`)); | |
| 76 | + document.head.appendChild(script); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + loaded.set(src, pending); | |
| 80 | + return pending; | |
| 81 | +} | |
| 82 | + | |
| 83 | +/** 주소 → 좌표. 앱 키가 없거나 찾지 못하면 빈 값을 돌려준다(주소 입력은 계속 진행된다). */ | |
| 84 | +async function geocode(address: string): Promise<{ lat: string; lng: string }> { | |
| 85 | + const key = process.env.NEXT_PUBLIC_KAKAO_MAP_KEY; | |
| 86 | + if (!key) { | |
| 87 | + return { lat: '', lng: '' }; | |
| 88 | + } | |
| 89 | + | |
| 90 | + try { | |
| 91 | + await loadScript( | |
| 92 | + `${KAKAO_SDK_SRC}?appkey=${encodeURIComponent(key)}&autoload=false&libraries=services` | |
| 93 | + ); | |
| 94 | + } catch { | |
| 95 | + return { lat: '', lng: '' }; | |
| 96 | + } | |
| 97 | + | |
| 98 | + return new Promise((resolve) => { | |
| 99 | + const maps = window.kakao?.maps; | |
| 100 | + if (!maps) { | |
| 101 | + resolve({ lat: '', lng: '' }); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + | |
| 105 | + maps.load(() => { | |
| 106 | + const services = window.kakao?.maps.services; | |
| 107 | + if (!services) { | |
| 108 | + resolve({ lat: '', lng: '' }); | |
| 109 | + return; | |
| 110 | + } | |
| 111 | + | |
| 112 | + new services.Geocoder().addressSearch(address, (result, status) => { | |
| 113 | + const first = status === services.Status.OK ? result[0] : undefined; | |
| 114 | + // 카카오는 x가 경도, y가 위도다. | |
| 115 | + resolve({ lat: first?.y ?? '', lng: first?.x ?? '' }); | |
| 116 | + }); | |
| 117 | + }); | |
| 118 | + }); | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * 우편번호 창을 열고 고른 주소를 돌려준다. 사용자가 닫으면 `null`이다. | |
| 123 | + */ | |
| 124 | +export async function openPostcodeSearch(): Promise<PostcodeResult | null> { | |
| 125 | + await loadScript(POSTCODE_SRC); | |
| 126 | + | |
| 127 | + const picked = await new Promise<DaumPostcodeData | null>((resolve) => { | |
| 128 | + const Postcode = window.daum?.Postcode; | |
| 129 | + if (!Postcode) { | |
| 130 | + resolve(null); | |
| 131 | + return; | |
| 132 | + } | |
| 133 | + | |
| 134 | + let completed = false; | |
| 135 | + new Postcode({ | |
| 136 | + oncomplete: (data) => { | |
| 137 | + completed = true; | |
| 138 | + resolve(data); | |
| 139 | + }, | |
| 140 | + // 고르지 않고 닫으면 oncomplete가 오지 않는다 — 그때 null로 끝낸다. | |
| 141 | + onclose: () => { | |
| 142 | + if (!completed) { | |
| 143 | + resolve(null); | |
| 144 | + } | |
| 145 | + }, | |
| 146 | + }).open(); | |
| 147 | + }); | |
| 148 | + | |
| 149 | + if (!picked) { | |
| 150 | + return null; | |
| 151 | + } | |
| 152 | + | |
| 153 | + // 사용자가 고른 표기를 그대로 쓴다(도로명 R / 지번 J). | |
| 154 | + const address = | |
| 155 | + (picked.userSelectedType === 'J' | |
| 156 | + ? picked.jibunAddress | |
| 157 | + : picked.roadAddress) || | |
| 158 | + picked.address || | |
| 159 | + ''; | |
| 160 | + | |
| 161 | + const { lat, lng } = await geocode(address); | |
| 162 | + | |
| 163 | + return { | |
| 164 | + zonecode: picked.zonecode ?? '', | |
| 165 | + address, | |
| 166 | + latitude: lat, | |
| 167 | + longitude: lng, | |
| 168 | + }; | |
| 169 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?