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 { embedPostcode } from '../lib/postcode';
|
||||
|
||||
const COMPANY_DOMAIN = 'awesomedev.dev';
|
||||
|
||||
@ -31,6 +32,22 @@ export default function MyProfilePage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = 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(() => {
|
||||
let cancelled = false;
|
||||
@ -44,7 +61,9 @@ export default function MyProfilePage() {
|
||||
// 수정 가능한 필드만 폼 상태로 옮긴다
|
||||
setForm({
|
||||
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,
|
||||
emergencyPhone: p.emergencyPhone,
|
||||
});
|
||||
@ -78,7 +97,7 @@ export default function MyProfilePage() {
|
||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||
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.major.trim()) return '전공을 입력해 주세요.';
|
||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||
@ -99,12 +118,13 @@ export default function MyProfilePage() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { addressBase, addressDetail, ...fields } = form;
|
||||
const res = await client.put('/profile/me', {
|
||||
...form,
|
||||
...fields,
|
||||
name: form.name.trim(),
|
||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
address: [addressBase.trim(), addressDetail.trim()].filter(Boolean).join(', '),
|
||||
school: form.school.trim(),
|
||||
major: form.major.trim(),
|
||||
emergencyName: form.emergencyName.trim(),
|
||||
@ -112,7 +132,12 @@ export default function MyProfilePage() {
|
||||
emergencyPhone: form.emergencyPhone.trim(),
|
||||
});
|
||||
setSaved('저장했어요! ✅');
|
||||
setForm((prev) => ({ ...prev, companyEmailLocal: res.data.companyEmailLocal }));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
companyEmailLocal: res.data.companyEmailLocal,
|
||||
addressBase: res.data.address, // 합쳐진 최종 주소를 다시 기본 칸에
|
||||
addressDetail: '',
|
||||
}));
|
||||
} catch (err) {
|
||||
// 서버가 담아 준 사유(중복 이메일 등)를 그대로 보여준다
|
||||
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||
@ -131,7 +156,7 @@ export default function MyProfilePage() {
|
||||
<h1>내 정보</h1>
|
||||
<p>
|
||||
가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요.
|
||||
경리 서류에 쓰이니 <strong>주소는 시/군/구부터 상세주소까지</strong> 적어 주세요.
|
||||
주소는 <strong>[주소찾기]로 검색</strong>하고 동·호수만 상세주소에 적어 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -166,8 +191,39 @@ export default function MyProfilePage() {
|
||||
|
||||
<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="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="major" value={form.major} onChange={set} />
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
// 이 파일이 하는 일: 회원가입 폼. 로그인 정보 + 인적사항(개인정보)을 입력받아
|
||||
// POST /api/auth/signup으로 보내고, 성공하면 로그인 페이지로 안내한다.
|
||||
// 로그인 전에도 접근할 수 있는 페이지라서 Layout(상단 메뉴) 바깥에 렌더링된다.
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import client from '../api/client';
|
||||
import { embedPostcode } from '../lib/postcode';
|
||||
|
||||
const TRACKS = [
|
||||
{ value: 'DEV', label: '개발' },
|
||||
@ -18,7 +19,10 @@ const COMPANY_DOMAIN = 'awesomedev.dev'; // 회사 이메일 도메인은 고정
|
||||
// "규칙"이 아니라 "그때그때 읽기 쉬운 쪽"을 고른다.
|
||||
const EMPTY = {
|
||||
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: '',
|
||||
privacyConsent: false, // 개인정보 수집·이용 동의 — 체크해야만 가입 가능
|
||||
};
|
||||
@ -61,6 +65,25 @@ export default function SignupPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = 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) {
|
||||
return <Navigate to="/" replace />;
|
||||
@ -88,7 +111,7 @@ export default function SignupPage() {
|
||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||
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.major.trim()) return '전공을 입력해 주세요.';
|
||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||
@ -117,7 +140,8 @@ export default function SignupPage() {
|
||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||
birthDate: form.birthDate, // "2007-03-15" — 백엔드가 LocalDate로 변환
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
// 기본주소 + 상세주소를 합쳐 하나의 주소로 보낸다 (상세가 없으면 기본만)
|
||||
address: [form.addressBase, form.addressDetail.trim()].filter(Boolean).join(', '),
|
||||
school: form.school.trim(),
|
||||
major: form.major.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="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="major" value={form.major} onChange={set} />
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user