feat: 주소 입력을 카카오 우편번호 검색으로 — 주소 오타·누락 원천 차단
- lib/postcode.js: 다음(카카오) 우편번호 서비스 지연 로딩 + embed 방식 (팝업은 모바일에서 차단될 수 있어 폼 안에 펼치는 embed 채택, 학습 주석 포함) - 가입·내 정보 페이지: 기본주소는 [주소찾기] 검색으로만 채워지는 읽기 전용, 상세주소(동·호수)만 직접 입력 → 저장 시 '[우편번호] 도로명주소, 상세' 로 합침 - 검색 결과: 우편번호 + 표준 도로명주소 + 건물명 자동 입력 - 배경: 학생 2명의 가입 주소가 시/군/구 누락 등 불완전했음 — '사용자의 실수는 실수할 수 없는 UI로 막는다' 운영 검증: 버튼 클릭 → 카카오 위젯(postcode.map.kakao.com) embed 로드 확인. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f557d7a189
commit
57a334545a
61
frontend/src/lib/postcode.js
Normal file
61
frontend/src/lib/postcode.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* 이 파일이 하는 일:
|
||||||
|
* 다음(카카오) 우편번호 서비스를 불러와 주소 검색 UI를 띄운다.
|
||||||
|
* 회원가입·내 정보의 [주소찾기] 버튼이 이걸 쓴다.
|
||||||
|
*
|
||||||
|
* 학습 포인트 ① — 왜 직접 입력 대신 주소 검색인가?
|
||||||
|
* 주소는 "형식이 정해진 데이터"다. 손으로 치게 하면 시/군/구 누락, 오타, 옛 지번주소가
|
||||||
|
* 뒤섞여 들어온다(실제로 그랬다). 검색으로 받으면 항상 표준 도로명주소 + 우편번호가 들어온다.
|
||||||
|
* — 사용자의 실수는 사용자를 탓할 게 아니라 실수할 수 없는 UI로 막는 것이다.
|
||||||
|
*
|
||||||
|
* 학습 포인트 ② — 외부 스크립트 지연 로딩(lazy loading).
|
||||||
|
* 이 스크립트는 주소찾기를 누르는 순간에만 필요하다. 앱 시작 때 미리 받으면
|
||||||
|
* 모든 페이지가 그만큼 느려진다. 그래서 버튼을 눌렀을 때 <script>를 만들어 붙이고,
|
||||||
|
* 한 번 받았으면 Promise를 재사용한다(두 번 다운로드하지 않는다).
|
||||||
|
*
|
||||||
|
* 학습 포인트 ③ — 팝업이 아닌 embed 방식.
|
||||||
|
* window.open 팝업은 모바일 브라우저가 차단하기도 한다. embed는 우리가 준 div 안에
|
||||||
|
* 검색 화면을 그려 넣는 방식이라 차단당할 게 없고, 모바일에서도 자연스럽다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCRIPT_SRC = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
|
||||||
|
|
||||||
|
let scriptPromise = null;
|
||||||
|
|
||||||
|
function loadScript() {
|
||||||
|
if (!scriptPromise) {
|
||||||
|
scriptPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = SCRIPT_SRC;
|
||||||
|
script.onload = () => resolve(window.daum);
|
||||||
|
script.onerror = () => {
|
||||||
|
scriptPromise = null; // 실패하면 다음 클릭 때 다시 시도할 수 있게 초기화
|
||||||
|
reject(new Error('우편번호 서비스 로딩 실패'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return scriptPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주소 검색 화면을 container(div) 안에 펼친다.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container 검색 UI를 그릴 div
|
||||||
|
* @param {(address: string) => void} onSelect 사용자가 주소를 고르면 완성된 주소 문자열을 받는다
|
||||||
|
* @returns {Promise<void>} 스크립트 로딩 실패 시 reject — 호출한 쪽에서 안내를 띄운다
|
||||||
|
*/
|
||||||
|
export async function embedPostcode(container, onSelect) {
|
||||||
|
const daum = await loadScript();
|
||||||
|
container.innerHTML = ''; // 두 번째 열 때 이전 화면이 겹치지 않게 비운다
|
||||||
|
new daum.Postcode({
|
||||||
|
oncomplete: (data) => {
|
||||||
|
// 도로명주소를 기본으로, 건물명이 있으면 괄호로 붙인다. 우편번호는 맨 앞에.
|
||||||
|
// 예: "[06978] 서울 동작구 상도로17길 14-2 (OO빌딩)"
|
||||||
|
const building = data.buildingName ? ` (${data.buildingName})` : '';
|
||||||
|
onSelect(`[${data.zonecode}] ${data.roadAddress}${building}`);
|
||||||
|
},
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}).embed(container);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
// 이 파일이 하는 일: "내 정보" 화면 — 가입 때 적은 인적사항을 확인하고 직접 고친다.
|
// 이 파일이 하는 일: "내 정보" 화면 — 가입 때 적은 인적사항을 확인하고 직접 고친다.
|
||||||
// (주소 이사, 연락처 변경, 이메일 아이디 오타 교정 등. 아이디·트랙은 여기서 못 바꾼다.)
|
// (주소 이사, 연락처 변경, 이메일 아이디 오타 교정 등. 아이디·트랙은 여기서 못 바꾼다.)
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
|
import { embedPostcode } from '../lib/postcode';
|
||||||
|
|
||||||
const COMPANY_DOMAIN = 'awesomedev.dev';
|
const COMPANY_DOMAIN = 'awesomedev.dev';
|
||||||
|
|
||||||
@ -31,6 +32,22 @@ export default function MyProfilePage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saved, setSaved] = useState('');
|
const [saved, setSaved] = useState('');
|
||||||
|
// 주소찾기 — SignupPage와 같은 패턴 (열림 상태 + embed 대상 div + effect에서 그리기)
|
||||||
|
const [postcodeOpen, setPostcodeOpen] = useState(false);
|
||||||
|
const postcodeBoxRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postcodeOpen || !postcodeBoxRef.current) return;
|
||||||
|
embedPostcode(postcodeBoxRef.current, (address) => {
|
||||||
|
setForm((prev) => ({ ...prev, addressBase: address, addressDetail: '' }));
|
||||||
|
setSaved('');
|
||||||
|
setPostcodeOpen(false);
|
||||||
|
requestAnimationFrame(() => document.getElementById('pf-addressDetail')?.focus());
|
||||||
|
}).catch(() => {
|
||||||
|
setPostcodeOpen(false);
|
||||||
|
setError('주소 검색을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
}, [postcodeOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -44,7 +61,9 @@ export default function MyProfilePage() {
|
|||||||
// 수정 가능한 필드만 폼 상태로 옮긴다
|
// 수정 가능한 필드만 폼 상태로 옮긴다
|
||||||
setForm({
|
setForm({
|
||||||
name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate,
|
name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate,
|
||||||
phone: p.phone, address: p.address, school: p.school, major: p.major,
|
phone: p.phone, school: p.school, major: p.major,
|
||||||
|
// 저장된 주소 전체를 기본주소 칸에 보여준다. 바꾸려면 주소찾기로 다시 고른다.
|
||||||
|
addressBase: p.address, addressDetail: '',
|
||||||
emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation,
|
emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation,
|
||||||
emergencyPhone: p.emergencyPhone,
|
emergencyPhone: p.emergencyPhone,
|
||||||
});
|
});
|
||||||
@ -78,7 +97,7 @@ export default function MyProfilePage() {
|
|||||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||||
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
||||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
||||||
if (!form.address.trim()) return '주소를 입력해 주세요. (시/군/구부터 상세주소까지)';
|
if (!form.addressBase.trim()) return '[주소찾기] 버튼으로 주소를 선택해 주세요.';
|
||||||
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
||||||
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
||||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||||
@ -99,12 +118,13 @@ export default function MyProfilePage() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const { addressBase, addressDetail, ...fields } = form;
|
||||||
const res = await client.put('/profile/me', {
|
const res = await client.put('/profile/me', {
|
||||||
...form,
|
...fields,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
address: form.address.trim(),
|
address: [addressBase.trim(), addressDetail.trim()].filter(Boolean).join(', '),
|
||||||
school: form.school.trim(),
|
school: form.school.trim(),
|
||||||
major: form.major.trim(),
|
major: form.major.trim(),
|
||||||
emergencyName: form.emergencyName.trim(),
|
emergencyName: form.emergencyName.trim(),
|
||||||
@ -112,7 +132,12 @@ export default function MyProfilePage() {
|
|||||||
emergencyPhone: form.emergencyPhone.trim(),
|
emergencyPhone: form.emergencyPhone.trim(),
|
||||||
});
|
});
|
||||||
setSaved('저장했어요! ✅');
|
setSaved('저장했어요! ✅');
|
||||||
setForm((prev) => ({ ...prev, companyEmailLocal: res.data.companyEmailLocal }));
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
companyEmailLocal: res.data.companyEmailLocal,
|
||||||
|
addressBase: res.data.address, // 합쳐진 최종 주소를 다시 기본 칸에
|
||||||
|
addressDetail: '',
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 서버가 담아 준 사유(중복 이메일 등)를 그대로 보여준다
|
// 서버가 담아 준 사유(중복 이메일 등)를 그대로 보여준다
|
||||||
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
|
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||||
@ -131,7 +156,7 @@ export default function MyProfilePage() {
|
|||||||
<h1>내 정보</h1>
|
<h1>내 정보</h1>
|
||||||
<p>
|
<p>
|
||||||
가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요.
|
가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요.
|
||||||
경리 서류에 쓰이니 <strong>주소는 시/군/구부터 상세주소까지</strong> 적어 주세요.
|
주소는 <strong>[주소찾기]로 검색</strong>하고 동·호수만 상세주소에 적어 주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -166,8 +191,39 @@ export default function MyProfilePage() {
|
|||||||
|
|
||||||
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
||||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} />
|
<Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} />
|
||||||
<Field label="주소 (시/군/구부터 상세주소까지)" k="address"
|
{/* 주소 — 검색으로만 채워지는 읽기 전용 기본주소 + 직접 입력하는 상세주소 */}
|
||||||
placeholder="예: 서울 동작구 상도로17길 14-2, 501호" value={form.address} onChange={set} />
|
<div className="field">
|
||||||
|
<label htmlFor="pf-addressBase">주소</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
id="pf-addressBase"
|
||||||
|
className="input"
|
||||||
|
value={form.addressBase}
|
||||||
|
readOnly
|
||||||
|
placeholder="[주소찾기]를 눌러 주세요"
|
||||||
|
onClick={() => setPostcodeOpen(true)}
|
||||||
|
style={{ flex: 1, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn" onClick={() => setPostcodeOpen((v) => !v)}>
|
||||||
|
주소찾기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{postcodeOpen && (
|
||||||
|
<div
|
||||||
|
ref={postcodeBoxRef}
|
||||||
|
style={{ height: 420, marginTop: 8, border: '1px solid var(--line)',
|
||||||
|
borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="pf-addressDetail"
|
||||||
|
className="input"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
value={form.addressDetail}
|
||||||
|
onChange={(e) => set('addressDetail', e.target.value)}
|
||||||
|
placeholder="상세주소 (동·호수 등) — 주소를 다시 고르면 여기에 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Field label="학교" k="school" value={form.school} onChange={set} />
|
<Field label="학교" k="school" value={form.school} onChange={set} />
|
||||||
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
// 이 파일이 하는 일: 회원가입 폼. 로그인 정보 + 인적사항(개인정보)을 입력받아
|
// 이 파일이 하는 일: 회원가입 폼. 로그인 정보 + 인적사항(개인정보)을 입력받아
|
||||||
// POST /api/auth/signup으로 보내고, 성공하면 로그인 페이지로 안내한다.
|
// POST /api/auth/signup으로 보내고, 성공하면 로그인 페이지로 안내한다.
|
||||||
// 로그인 전에도 접근할 수 있는 페이지라서 Layout(상단 메뉴) 바깥에 렌더링된다.
|
// 로그인 전에도 접근할 수 있는 페이지라서 Layout(상단 메뉴) 바깥에 렌더링된다.
|
||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, Link, Navigate } from 'react-router-dom';
|
import { useNavigate, Link, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
|
import { embedPostcode } from '../lib/postcode';
|
||||||
|
|
||||||
const TRACKS = [
|
const TRACKS = [
|
||||||
{ value: 'DEV', label: '개발' },
|
{ value: 'DEV', label: '개발' },
|
||||||
@ -18,7 +19,10 @@ const COMPANY_DOMAIN = 'awesomedev.dev'; // 회사 이메일 도메인은 고정
|
|||||||
// "규칙"이 아니라 "그때그때 읽기 쉬운 쪽"을 고른다.
|
// "규칙"이 아니라 "그때그때 읽기 쉬운 쪽"을 고른다.
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
|
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
|
||||||
companyEmailLocal: '', birthDate: '', phone: '', address: '', school: '', major: '',
|
companyEmailLocal: '', birthDate: '', phone: '', school: '', major: '',
|
||||||
|
// 주소는 두 조각 — 기본주소는 주소찾기(검색)로만 채워지고, 상세주소만 직접 친다.
|
||||||
|
// 손으로 치던 시절엔 시/군/구 누락·오타가 실제로 들어왔다. lib/postcode.js 참고.
|
||||||
|
addressBase: '', addressDetail: '',
|
||||||
emergencyName: '', emergencyRelation: '', emergencyPhone: '',
|
emergencyName: '', emergencyRelation: '', emergencyPhone: '',
|
||||||
privacyConsent: false, // 개인정보 수집·이용 동의 — 체크해야만 가입 가능
|
privacyConsent: false, // 개인정보 수집·이용 동의 — 체크해야만 가입 가능
|
||||||
};
|
};
|
||||||
@ -61,6 +65,25 @@ export default function SignupPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
// 주소찾기(다음 우편번호 서비스) — 열림 상태와 검색 UI를 그릴 div
|
||||||
|
const [postcodeOpen, setPostcodeOpen] = useState(false);
|
||||||
|
const postcodeBoxRef = useRef(null);
|
||||||
|
|
||||||
|
// 열림 상태가 되면(div가 렌더된 뒤) 그 안에 검색 화면을 그린다.
|
||||||
|
// 학습 포인트: embed 대상 div는 렌더가 끝나야 존재한다 — 그래서 클릭 핸들러가 아니라
|
||||||
|
// useEffect에서 그린다 (setState → 렌더 → effect 순서를 이용).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!postcodeOpen || !postcodeBoxRef.current) return;
|
||||||
|
embedPostcode(postcodeBoxRef.current, (address) => {
|
||||||
|
setForm((prev) => ({ ...prev, addressBase: address }));
|
||||||
|
setPostcodeOpen(false);
|
||||||
|
// 주소를 골랐으면 자연스럽게 상세주소 입력으로 이동
|
||||||
|
requestAnimationFrame(() => document.getElementById('su-addressDetail')?.focus());
|
||||||
|
}).catch(() => {
|
||||||
|
setPostcodeOpen(false);
|
||||||
|
setError('주소 검색을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
}, [postcodeOpen]);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
@ -88,7 +111,7 @@ export default function SignupPage() {
|
|||||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||||
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
||||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
||||||
if (!form.address.trim()) return '주소를 입력해 주세요.';
|
if (!form.addressBase) return '[주소찾기] 버튼으로 주소를 선택해 주세요.';
|
||||||
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
||||||
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
||||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||||
@ -117,7 +140,8 @@ export default function SignupPage() {
|
|||||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||||
birthDate: form.birthDate, // "2007-03-15" — 백엔드가 LocalDate로 변환
|
birthDate: form.birthDate, // "2007-03-15" — 백엔드가 LocalDate로 변환
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
address: form.address.trim(),
|
// 기본주소 + 상세주소를 합쳐 하나의 주소로 보낸다 (상세가 없으면 기본만)
|
||||||
|
address: [form.addressBase, form.addressDetail.trim()].filter(Boolean).join(', '),
|
||||||
school: form.school.trim(),
|
school: form.school.trim(),
|
||||||
major: form.major.trim(),
|
major: form.major.trim(),
|
||||||
emergencyName: form.emergencyName.trim(),
|
emergencyName: form.emergencyName.trim(),
|
||||||
@ -215,7 +239,39 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
||||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" value={form.phone} onChange={set} />
|
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" value={form.phone} onChange={set} />
|
||||||
<Field label="주소" k="address" placeholder="시/군/구 이하" value={form.address} onChange={set} />
|
{/* 주소 — 직접 입력 대신 검색으로. 기본주소는 읽기 전용(실수 원천 차단) */}
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="su-addressBase">주소</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
id="su-addressBase"
|
||||||
|
className="input"
|
||||||
|
value={form.addressBase}
|
||||||
|
readOnly
|
||||||
|
placeholder="[주소찾기]를 눌러 주세요"
|
||||||
|
onClick={() => setPostcodeOpen(true)}
|
||||||
|
style={{ flex: 1, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn" onClick={() => setPostcodeOpen((v) => !v)}>
|
||||||
|
주소찾기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{postcodeOpen && (
|
||||||
|
<div
|
||||||
|
ref={postcodeBoxRef}
|
||||||
|
style={{ height: 420, marginTop: 8, border: '1px solid var(--line)',
|
||||||
|
borderRadius: 10, overflow: 'hidden' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id="su-addressDetail"
|
||||||
|
className="input"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
value={form.addressDetail}
|
||||||
|
onChange={(e) => set('addressDetail', e.target.value)}
|
||||||
|
placeholder="상세주소 (동·호수 등, 없으면 비워두세요)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Field label="학교" k="school" placeholder="미림마이스터고등학교" value={form.school} onChange={set} />
|
<Field label="학교" k="school" placeholder="미림마이스터고등학교" value={form.school} onChange={set} />
|
||||||
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user