지정판매소 주소·지도 연동과 관련 설정을 반영
지정판매소 등록/수정/목록에 카카오 주소 검색 및 지도 연동 컴포넌트를 적용하고, 관련 모델·SQL 스크립트·테스트 설정을 함께 정리해 기능 동작 기반을 맞췄다. Made-with: Cursor
This commit is contained in:
126
app/Views/components/kakao_address_search.php
Normal file
126
app/Views/components/kakao_address_search.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var string $buttonId 주소 검색 버튼 id */
|
||||
$buttonId = $buttonId ?? 'btn-kakao-postcode';
|
||||
/** @var string $zipName 우편번호 input name */
|
||||
$zipName = $zipName ?? 'ds_zip';
|
||||
/** @var string $roadName 도로명 input name */
|
||||
$roadName = $roadName ?? 'ds_addr';
|
||||
/** @var string $jibunName 지번 input name */
|
||||
$jibunName = $jibunName ?? 'ds_addr_jibun';
|
||||
/** @var string $sidoFieldName 카카오 시·도 → hidden name (비우면 미설정) */
|
||||
$sidoFieldName = $sidoFieldName ?? '';
|
||||
/** @var string $sigunguFieldName 카카오 시·군·구 → hidden name */
|
||||
$sigunguFieldName = $sigunguFieldName ?? '';
|
||||
/** @var string $detailFieldName 상세주소 input name (건물명 등, 비우면 미사용) */
|
||||
$detailFieldName = $detailFieldName ?? '';
|
||||
/**
|
||||
* @var array{lg_sido?: string, lg_gugun?: string}|null $tenantScope 지자체 관할 검사(비우면 미검사)
|
||||
*/
|
||||
$tenantScope = $tenantScope ?? null;
|
||||
/** @var bool $roadBaseOnly true면 도로명에 건물명 괄호 미부착(상세로 이전) */
|
||||
$roadBaseOnly = ! empty($roadBaseOnly);
|
||||
?>
|
||||
<script src="https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var btnId = <?= json_encode($buttonId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var zipName = <?= json_encode($zipName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var roadName = <?= json_encode($roadName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var jibunName = <?= json_encode($jibunName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var sidoFieldName = <?= json_encode($sidoFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var sigunguFieldName = <?= json_encode($sigunguFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var detailFieldName = <?= json_encode($detailFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var roadBaseOnly = <?= $roadBaseOnly ? 'true' : 'false' ?>;
|
||||
var tenantScope = <?= json_encode($tenantScope ?? (object) [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
|
||||
function compactStr(s) {
|
||||
return String(s || '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function tokenMatches(needle, primary, blob) {
|
||||
var n = compactStr(needle);
|
||||
if (!n) return true;
|
||||
var b = compactStr(blob);
|
||||
if (b.indexOf(n) !== -1) return true;
|
||||
var p = compactStr(primary);
|
||||
if (p.indexOf(n) !== -1) return true;
|
||||
if (n.indexOf(p) !== -1 && p) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function addressAllowedByTenant(data) {
|
||||
var lgSido = tenantScope && tenantScope.lg_sido ? String(tenantScope.lg_sido) : '';
|
||||
var lgGugun = tenantScope && tenantScope.lg_gugun ? String(tenantScope.lg_gugun) : '';
|
||||
if (!lgSido && !lgGugun) return true;
|
||||
var sido = data.sido || '';
|
||||
var sigungu = data.sigungu || '';
|
||||
var road = data.roadAddress || '';
|
||||
var jibun = data.jibunAddress || '';
|
||||
var zip = data.zonecode || '';
|
||||
var blob = sido + ' ' + sigungu + ' ' + road + ' ' + jibun + ' ' + zip;
|
||||
if (lgSido && !tokenMatches(lgSido, sido, blob)) return false;
|
||||
if (lgGugun && !tokenMatches(lgGugun, sigungu, blob)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function bind() {
|
||||
var btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
var form = btn.closest('form');
|
||||
if (!form) return;
|
||||
|
||||
function field(n) {
|
||||
return form.querySelector('[name="' + n + '"]');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
if (typeof daum === 'undefined' || !daum.Postcode) {
|
||||
window.alert('주소 검색 스크립트를 불러오지 못했습니다. 네트워크를 확인해 주세요.');
|
||||
return;
|
||||
}
|
||||
new daum.Postcode({
|
||||
oncomplete: function (data) {
|
||||
if (!addressAllowedByTenant(data)) {
|
||||
window.alert('작업 중인 지자체 관할이 아닌 주소입니다. 해당 시·구 주소를 검색해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
var zipEl = field(zipName);
|
||||
var roadEl = field(roadName);
|
||||
var jibunEl = field(jibunName);
|
||||
if (zipEl) zipEl.value = data.zonecode || '';
|
||||
|
||||
var roadAddr = data.roadAddress || '';
|
||||
if (!roadBaseOnly && data.buildingName !== '') {
|
||||
roadAddr += (roadAddr !== '' ? ' (' + data.buildingName + ')' : data.buildingName);
|
||||
}
|
||||
if (roadEl) roadEl.value = roadAddr;
|
||||
|
||||
if (jibunEl) jibunEl.value = data.jibunAddress || '';
|
||||
|
||||
if (sidoFieldName) {
|
||||
var sidoEl = field(sidoFieldName);
|
||||
if (sidoEl) sidoEl.value = data.sido || '';
|
||||
}
|
||||
if (sigunguFieldName) {
|
||||
var sigEl = field(sigunguFieldName);
|
||||
if (sigEl) sigEl.value = data.sigungu || '';
|
||||
}
|
||||
if (detailFieldName && roadBaseOnly) {
|
||||
var detEl = field(detailFieldName);
|
||||
if (detEl) detEl.value = data.buildingName || '';
|
||||
}
|
||||
}
|
||||
}).open();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind);
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
47
app/Views/components/kakao_map_link_button.php
Normal file
47
app/Views/components/kakao_map_link_button.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @var string $buttonId 버튼 id (폼마다 고유) */
|
||||
$buttonId = $buttonId ?? 'btn-kakao-map-open';
|
||||
/** @var string $label 버튼 텍스트 */
|
||||
$label = $label ?? '지도';
|
||||
?>
|
||||
<button type="button" id="<?= esc($buttonId, 'attr') ?>" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0" title="카카오맵에서 이 주소 검색"><?= esc($label) ?></button>
|
||||
<script>
|
||||
(function () {
|
||||
var bid = <?= json_encode($buttonId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
function bind() {
|
||||
var btn = document.getElementById(bid);
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var form = btn.closest('form');
|
||||
if (!form) return;
|
||||
function val(name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? String(el.value || '').trim() : '';
|
||||
}
|
||||
var road = val('ds_addr');
|
||||
var jibun = val('ds_addr_jibun');
|
||||
var detail = val('ds_addr_detail');
|
||||
var q = road || jibun;
|
||||
if (detail) {
|
||||
q = q ? (q + ' ' + detail) : detail;
|
||||
}
|
||||
if (!q) {
|
||||
window.alert('주소 검색으로 도로명·지번을 먼저 입력한 뒤 지도를 열 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
if (typeof window.openDesignatedShopKakaoMap === 'function') {
|
||||
window.openDesignatedShopKakaoMap(q);
|
||||
return;
|
||||
}
|
||||
window.open('https://map.kakao.com/link/search/' + encodeURIComponent(q), '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bind);
|
||||
} else {
|
||||
bind();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
153
app/Views/components/kakao_map_modal.php
Normal file
153
app/Views/components/kakao_map_modal.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$key = trim((string) ($kakaoJavascriptKey ?? ''));
|
||||
?>
|
||||
<div id="kakao-map-modal" class="hidden fixed inset-0 z-[300] flex items-center justify-center p-4" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="kakao-map-modal-title">
|
||||
<div class="absolute inset-0 bg-black/50" id="kakao-map-modal-backdrop"></div>
|
||||
<div class="relative z-[301] w-full max-w-2xl max-h-[90vh] flex flex-col rounded border border-gray-300 bg-white shadow-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||
<span id="kakao-map-modal-title" class="text-sm font-bold text-gray-800">위치</span>
|
||||
<button type="button" id="kakao-map-modal-close" class="text-gray-600 hover:text-gray-900 text-xl leading-none px-1" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div id="kakao-map-modal-container" class="w-full bg-gray-100" style="min-height: 380px; height: 50vh;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var APP_KEY = <?= json_encode($key, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
var modal = document.getElementById('kakao-map-modal');
|
||||
var backdrop = document.getElementById('kakao-map-modal-backdrop');
|
||||
var btnClose = document.getElementById('kakao-map-modal-close');
|
||||
var mapContainer = document.getElementById('kakao-map-modal-container');
|
||||
var mapInstance = null;
|
||||
var markerInstance = null;
|
||||
var scriptLoading = false;
|
||||
var pendingAfterLoad = [];
|
||||
|
||||
function hideModal() {
|
||||
if (!modal) return;
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function runPending() {
|
||||
var q = pendingAfterLoad;
|
||||
pendingAfterLoad = [];
|
||||
q.forEach(function (fn) {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureScript(cb) {
|
||||
if (!APP_KEY) {
|
||||
window.alert('카카오맵 JavaScript 키가 설정되지 않았습니다. .env에 kakao.javascriptKey를 설정해 주세요. (Kakao Developers → 앱 키 → JavaScript 키)');
|
||||
return;
|
||||
}
|
||||
if (typeof kakao !== 'undefined' && kakao.maps) {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
pendingAfterLoad.push(cb);
|
||||
if (scriptLoading) {
|
||||
return;
|
||||
}
|
||||
scriptLoading = true;
|
||||
var s = document.createElement('script');
|
||||
s.charset = 'UTF-8';
|
||||
s.async = true;
|
||||
// 동적 삽입 시 autoload=false 후 kakao.maps.load() 필수 (카카오 웹 가이드)
|
||||
s.src = 'https://dapi.kakao.com/v2/maps/sdk.js?appkey=' + encodeURIComponent(APP_KEY) + '&libraries=services&autoload=false';
|
||||
s.onload = function () {
|
||||
scriptLoading = false;
|
||||
if (typeof kakao === 'undefined' || !kakao.maps || typeof kakao.maps.load !== 'function') {
|
||||
pendingAfterLoad = [];
|
||||
window.alert(
|
||||
'카카오맵 API를 불러올 수 없습니다.\n\n' +
|
||||
'Kakao Developers → 내 애플리케이션 → 해당 앱 → 「제품 설정」에서 「Kakao Map」(지도) / 로컬 API를 사용 설정으로 켜 주세요.\n' +
|
||||
'(비활성 시 서버에서 OPEN_MAP_AND_LOCAL 오류가 납니다.)\n\n' +
|
||||
'또한 플랫폼(Web)에 이 사이트 주소(예: http://localhost:8080)가 등록되어 있어야 합니다.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
kakao.maps.load(function () {
|
||||
runPending();
|
||||
});
|
||||
};
|
||||
s.onerror = function () {
|
||||
scriptLoading = false;
|
||||
pendingAfterLoad = [];
|
||||
window.alert(
|
||||
'카카오맵 스크립트를 불러오지 못했습니다.\n\n' +
|
||||
'• 네트워크·차단(광고 차단) 확인\n' +
|
||||
'• Kakao Developers → 제품 설정에서 「Kakao Map」활성화\n' +
|
||||
'• 플랫폼(Web)에 접속 중인 URL 등록'
|
||||
);
|
||||
};
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
if (btnClose) {
|
||||
btnClose.addEventListener('click', hideModal);
|
||||
}
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', hideModal);
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape' || !modal || modal.classList.contains('hidden')) {
|
||||
return;
|
||||
}
|
||||
hideModal();
|
||||
});
|
||||
|
||||
window.openDesignatedShopKakaoMap = function (addressQuery) {
|
||||
var q = String(addressQuery || '').trim();
|
||||
if (!q) {
|
||||
window.alert('주소가 없습니다.');
|
||||
return;
|
||||
}
|
||||
ensureScript(function () {
|
||||
if (typeof kakao === 'undefined' || !kakao.maps || !kakao.maps.services) {
|
||||
window.alert('카카오맵을 초기화할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
var geocoder = new kakao.maps.services.Geocoder();
|
||||
geocoder.addressSearch(q, function (result, status) {
|
||||
if (status !== kakao.maps.services.Status.OK || !result || !result[0]) {
|
||||
window.alert('주소를 지도에서 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
var coords = new kakao.maps.LatLng(result[0].y, result[0].x);
|
||||
showModal();
|
||||
if (!mapInstance) {
|
||||
mapInstance = new kakao.maps.Map(mapContainer, {
|
||||
center: coords,
|
||||
level: 3
|
||||
});
|
||||
} else {
|
||||
mapInstance.setCenter(coords);
|
||||
mapInstance.setLevel(3);
|
||||
}
|
||||
if (markerInstance) {
|
||||
markerInstance.setMap(null);
|
||||
}
|
||||
markerInstance = new kakao.maps.Marker({ position: coords, map: mapInstance });
|
||||
setTimeout(function () {
|
||||
if (mapInstance) {
|
||||
mapInstance.relayout();
|
||||
mapInstance.setCenter(coords);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user