계정 관리 이메일/전화번호 실시간 validation 및 오류 표시 기능 추가

- 이메일/전화번호 형식 및 중복 검증 기능 추가
- 실시간 validation 오류 메시지 표시
- 입력 중이거나 오류 발생 시 추가 버튼 비활성화
- 공백 trim 처리하여 중복 검사 정확도 향상
- validation 오류 시 편집 모드 유지하여 수정 가능
- 빈 입력란 blur 시 자동 제거
- 삭제 버튼 항상 활성화
- 다국어 지원 (한국어/영어)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-11-21 15:56:11 +09:00
parent f58ecbf45e
commit bc8f587405
4 changed files with 297 additions and 46 deletions

View File

@@ -97,7 +97,21 @@ export const UserLoginAuthInfoWrap = ({
const handleAddEmail = () => { const handleAddEmail = () => {
// 현재 편집 중인 항목을 읽기전용으로 고정 // 현재 편집 중인 항목을 읽기전용으로 고정
if (editableEmailIndex >= 0) { if (editableEmailIndex >= 0) {
setReadOnlyEmails(prev => new Set([...prev, editableEmailIndex])); const currentEmail = newEmails[editableEmailIndex];
// 유효성 검사: 값이 있고, 형식이 올바르고, 중복이 아닐 때만 readOnly로 변경
if (currentEmail && currentEmail.trim() && isValidEmail(currentEmail)) {
// 중복 검사 - trim하여 비교
const trimmedEmail = currentEmail.trim();
const allEmails = [
...(authMethodData?.emails?.map(e => e.content?.trim()) || []),
...newEmails.map(e => e.trim()).filter((_, i) => i !== editableEmailIndex)
].filter(e => e); // 빈 문자열 제거
if (!allEmails.includes(trimmedEmail)) {
setReadOnlyEmails(prev => new Set([...prev, editableEmailIndex]));
}
}
} }
// 새로운 편집 가능한 항목 추가 // 새로운 편집 가능한 항목 추가
@@ -108,7 +122,21 @@ export const UserLoginAuthInfoWrap = ({
const handleAddPhone = () => { const handleAddPhone = () => {
// 현재 편집 중인 항목을 읽기전용으로 고정 // 현재 편집 중인 항목을 읽기전용으로 고정
if (editablePhoneIndex >= 0) { if (editablePhoneIndex >= 0) {
setReadOnlyPhones(prev => new Set([...prev, editablePhoneIndex])); const currentPhone = newPhones[editablePhoneIndex];
// 유효성 검사: 값이 있고, 형식이 올바르고, 중복이 아닐 때만 readOnly로 변경
if (currentPhone && currentPhone.trim() && isValidPhone(currentPhone)) {
// 중복 검사 - trim하여 비교
const trimmedPhone = currentPhone.trim();
const allPhones = [
...(authMethodData?.phones?.map(p => p.content?.trim()) || []),
...newPhones.map(p => p.trim()).filter((_, i) => i !== editablePhoneIndex)
].filter(p => p); // 빈 문자열 제거
if (!allPhones.includes(trimmedPhone)) {
setReadOnlyPhones(prev => new Set([...prev, editablePhoneIndex]));
}
}
} }
// 새로운 편집 가능한 항목 추가 // 새로운 편집 가능한 항목 추가
@@ -193,43 +221,112 @@ export const UserLoginAuthInfoWrap = ({
return phoneRegex.test(phone); return phoneRegex.test(phone);
}; };
// 이메일 오류 메시지 반환
const getEmailError = (index: number, email: string): string | null => {
// 값이 없으면 오류 표시 안 함
if (!email || !email.trim()) return null;
// 형식 검증
if (!isValidEmail(email)) {
return t('account.invalidEmailFormat') || '이메일 형식이 올바르지 않습니다.';
}
// 중복 검증
const allEmails = [
...(authMethodData?.emails?.map(e => e.content) || []),
...newEmails
];
const currentEmailWithoutSelf = allEmails.filter((e, i) => {
const emailIndex = i - (authMethodData?.emails?.length || 0);
return e.trim() && emailIndex !== index;
});
if (currentEmailWithoutSelf.includes(email)) {
return t('account.duplicateEmail') || '중복된 이메일입니다.';
}
return null;
};
// 전화번호 오류 메시지 반환
const getPhoneError = (index: number, phone: string): string | null => {
// 값이 없으면 오류 표시 안 함
if (!phone || !phone.trim()) return null;
// 형식 검증
if (!isValidPhone(phone)) {
return t('account.invalidPhoneFormat') || '전화번호 형식이 올바르지 않습니다. (010으로 시작하는 11자리)';
}
// 중복 검증
const allPhones = [
...(authMethodData?.phones?.map(p => p.content) || []),
...newPhones
];
const currentPhoneWithoutSelf = allPhones.filter((p, i) => {
const phoneIndex = i - (authMethodData?.phones?.length || 0);
return p.trim() && phoneIndex !== index;
});
if (currentPhoneWithoutSelf.includes(phone)) {
return t('account.duplicatePhone') || '중복된 전화번호입니다.';
}
return null;
};
// 전체 validation 오류 확인 (이메일 + 전화번호)
const hasAnyValidationError = () => {
// 이메일 오류 확인
const hasEmailError = newEmails.some((email, index) => {
if (!email || !email.trim()) return false;
return getEmailError(index, email) !== null;
});
// 전화번호 오류 확인
const hasPhoneError = newPhones.some((phone, index) => {
if (!phone || !phone.trim()) return false;
return getPhoneError(index, phone) !== null;
});
return hasEmailError || hasPhoneError;
};
// 이메일 추가 버튼 활성화 조건 // 이메일 추가 버튼 활성화 조건
const isEmailAddButtonEnabled = () => { const isEmailAddButtonEnabled = () => {
// 이메일 또는 전화번호 입력 중이면 비활성화
if (editableEmailIndex >= 0 || editablePhoneIndex >= 0) return false;
if (newEmails.length === 0) return true; // 처음은 활성화 if (newEmails.length === 0) return true; // 처음은 활성화
const lastEmailIndex = newEmails.length - 1; const lastEmailIndex = newEmails.length - 1;
const lastEmail = newEmails[lastEmailIndex]; const lastEmail = newEmails[lastEmailIndex];
// 마지막 항목이 편집 가능하고, 유효한 형식이며, 중복이 없으면 활성화 // 이 없으면 활성화
return lastEmailIndex >= editableEmailIndex && if (!lastEmail || !lastEmail.trim()) return false;
lastEmail &&
lastEmail.trim() && // 이메일 또는 전화번호에 validation 오류가 있으면 비활성화
isValidEmail(lastEmail) && return !hasAnyValidationError();
!hasDuplicateEmail();
}; };
// 전화번호 추가 버튼 활성화 조건 // 전화번호 추가 버튼 활성화 조건
const isPhoneAddButtonEnabled = () => { const isPhoneAddButtonEnabled = () => {
// 이메일 또는 전화번호 입력 중이면 비활성화
if (editableEmailIndex >= 0 || editablePhoneIndex >= 0) return false;
if (newPhones.length === 0) return true; // 처음은 활성화 if (newPhones.length === 0) return true; // 처음은 활성화
const lastPhoneIndex = newPhones.length - 1; const lastPhoneIndex = newPhones.length - 1;
const lastPhone = newPhones[lastPhoneIndex]; const lastPhone = newPhones[lastPhoneIndex];
// 마지막 항목이 편집 가능하고, 유효한 형식이며, 중복이 없으면 활성화 // 이 없으면 활성화
return lastPhoneIndex >= editablePhoneIndex && if (!lastPhone || !lastPhone.trim()) return false;
lastPhone &&
lastPhone.trim() && // 이메일 또는 전화번호에 validation 오류가 있으면 비활성화
isValidPhone(lastPhone) && return !hasAnyValidationError();
!hasDuplicatePhone();
}; };
// 삭제 버튼 활성화 조건 (전체 항목이 1개만 남으면 비활성화) // 삭제 버튼 활성화 조건
const isDeleteButtonEnabled = () => { const isDeleteButtonEnabled = () => {
const totalEmailCount = (authMethodData?.emails?.length || 0) + newEmails.length; return true; // 항상 활성화
const totalPhoneCount = (authMethodData?.phones?.length || 0) + newPhones.length;
const totalCount = totalEmailCount + totalPhoneCount;
return totalCount > 1;
}; };
// 중복 이메일 검증 // 중복 이메일 검증
@@ -442,13 +539,25 @@ export const UserLoginAuthInfoWrap = ({
value={displayValue} value={displayValue}
placeholder="example@domain.com" placeholder="example@domain.com"
onChange={(e) => handleNewEmailChange(index, e.target.value)} onChange={(e) => handleNewEmailChange(index, e.target.value)}
onFocus={() => setEditableEmailIndex(index)} onFocus={(e) => {
onBlur={() => { if (isReadOnly) {
if (email && isValidEmail(email)) { e.target.blur();
setEditableEmailIndex(-1); } else {
setEditableEmailIndex(index);
} }
}} }}
onBlur={() => {
// 값이 없거나 공백만 있으면 입력란 제거
if (!email || !email.trim()) {
handleRemoveNewEmail(index);
} else if (!getEmailError(index, email)) {
// validation 오류가 없을 때만 편집 모드 해제
setEditableEmailIndex(-1);
}
// validation 오류가 있으면 편집 모드 유지 (편집 가능 상태)
}}
readOnly={isReadOnly} readOnly={isReadOnly}
style={isReadOnly ? { cursor: 'default' } : undefined}
/> />
<button <button
className="icon-btn minus" className="icon-btn minus"
@@ -460,6 +569,12 @@ export const UserLoginAuthInfoWrap = ({
</div> </div>
); );
})} })}
{(() => {
const firstError = newEmails.map((email, index) => getEmailError(index, email)).find(error => error);
return firstError ? (
<div className="error-message"><p>{firstError}</p></div>
) : null;
})()}
</div> </div>
<div className="group"> <div className="group">
@@ -501,13 +616,25 @@ export const UserLoginAuthInfoWrap = ({
value={displayValue} value={displayValue}
placeholder={t('account.enterPhoneNumber')} placeholder={t('account.enterPhoneNumber')}
onChange={(e) => handleNewPhoneChange(index, e.target.value)} onChange={(e) => handleNewPhoneChange(index, e.target.value)}
onFocus={() => setEditablePhoneIndex(index)} onFocus={(e) => {
onBlur={() => { if (isReadOnly) {
if (phone && isValidPhone(phone)) { e.target.blur();
setEditablePhoneIndex(-1); } else {
setEditablePhoneIndex(index);
} }
}} }}
onBlur={() => {
// 값이 없거나 공백만 있으면 입력란 제거
if (!phone || !phone.trim()) {
handleRemoveNewPhone(index);
} else if (!getPhoneError(index, phone)) {
// validation 오류가 없을 때만 편집 모드 해제
setEditablePhoneIndex(-1);
}
// validation 오류가 있으면 편집 모드 유지 (편집 가능 상태)
}}
readOnly={isReadOnly} readOnly={isReadOnly}
style={isReadOnly ? { cursor: 'default' } : undefined}
/> />
<button <button
className="icon-btn minus" className="icon-btn minus"
@@ -519,6 +646,12 @@ export const UserLoginAuthInfoWrap = ({
</div> </div>
); );
})} })}
{(() => {
const firstError = newPhones.map((phone, index) => getPhoneError(index, phone)).find(error => error);
return firstError ? (
<div className="error-message"><p>{firstError}</p></div>
) : null;
})()}
<div className="notice-bar">{t('account.tabChangeResetNotice')}</div> <div className="notice-bar">{t('account.tabChangeResetNotice')}</div>
</div> </div>
</div> </div>

View File

@@ -368,6 +368,10 @@
"passwordNoRepeatingCharacters": "Password cannot contain the same character 3 times in a row.", "passwordNoRepeatingCharacters": "Password cannot contain the same character 3 times in a row.",
"passwordNoConsecutiveSequence": "Password cannot contain 3 or more consecutive characters or numbers.", "passwordNoConsecutiveSequence": "Password cannot contain 3 or more consecutive characters or numbers.",
"passwordNoKeyboardSequence": "Password cannot contain 3 consecutive characters from keyboard layout.", "passwordNoKeyboardSequence": "Password cannot contain 3 consecutive characters from keyboard layout.",
"invalidEmailFormat": "Invalid email format.",
"duplicateEmail": "Duplicate email address.",
"invalidPhoneFormat": "Invalid phone number format. (11 digits starting with 010)",
"duplicatePhone": "Duplicate phone number.",
"errors": { "errors": {
"invalidPassword": "Incorrect password.", "invalidPassword": "Incorrect password.",
"updatePasswordFail": "Password update failed.", "updatePasswordFail": "Password update failed.",

View File

@@ -368,6 +368,10 @@
"passwordNoRepeatingCharacters": "동일한 문자를 3번 연속으로 사용할 수 없습니다.", "passwordNoRepeatingCharacters": "동일한 문자를 3번 연속으로 사용할 수 없습니다.",
"passwordNoConsecutiveSequence": "연속된 문자 또는 숫자를 3자리 이상 사용할 수 없습니다.", "passwordNoConsecutiveSequence": "연속된 문자 또는 숫자를 3자리 이상 사용할 수 없습니다.",
"passwordNoKeyboardSequence": "키보드 배열 순서로 3자리 연속 사용할 수 없습니다.", "passwordNoKeyboardSequence": "키보드 배열 순서로 3자리 연속 사용할 수 없습니다.",
"invalidEmailFormat": "이메일 형식이 올바르지 않습니다.",
"duplicateEmail": "중복된 이메일입니다.",
"invalidPhoneFormat": "전화번호 형식이 올바르지 않습니다. (010으로 시작하는 11자리)",
"duplicatePhone": "중복된 전화번호입니다.",
"errors": { "errors": {
"invalidPassword": "패스워드가 틀립니다.", "invalidPassword": "패스워드가 틀립니다.",
"updatePasswordFail": "패스워드 업데이트 실패입니다.", "updatePasswordFail": "패스워드 업데이트 실패입니다.",

View File

@@ -110,7 +110,18 @@ export const UserAddAccountPage = () => {
const handleAddEmail = () => { const handleAddEmail = () => {
// 현재 편집 중인 항목을 읽기전용으로 고정 // 현재 편집 중인 항목을 읽기전용으로 고정
if (editableEmailIndex >= 0) { if (editableEmailIndex >= 0) {
setReadOnlyEmails(prev => new Set([...prev, editableEmailIndex])); const currentEmail = newEmails[editableEmailIndex];
// 유효성 검사: 값이 있고, 형식이 올바르고, 중복이 아닐 때만 readOnly로 변경
if (currentEmail && currentEmail.trim() && isValidEmail(currentEmail)) {
// 중복 검사 - trim하여 비교
const trimmedEmail = currentEmail.trim();
const allEmails = newEmails.map(e => e.trim()).filter((_, i) => i !== editableEmailIndex).filter(e => e);
if (!allEmails.includes(trimmedEmail)) {
setReadOnlyEmails(prev => new Set([...prev, editableEmailIndex]));
}
}
} }
// 새로운 편집 가능한 항목 추가 // 새로운 편집 가능한 항목 추가
@@ -121,7 +132,18 @@ export const UserAddAccountPage = () => {
const handleAddPhone = () => { const handleAddPhone = () => {
// 현재 편집 중인 항목을 읽기전용으로 고정 // 현재 편집 중인 항목을 읽기전용으로 고정
if (editablePhoneIndex >= 0) { if (editablePhoneIndex >= 0) {
setReadOnlyPhones(prev => new Set([...prev, editablePhoneIndex])); const currentPhone = newPhones[editablePhoneIndex];
// 유효성 검사: 값이 있고, 형식이 올바르고, 중복이 아닐 때만 readOnly로 변경
if (currentPhone && currentPhone.trim() && isValidPhone(currentPhone)) {
// 중복 검사 - trim하여 비교
const trimmedPhone = currentPhone.trim();
const allPhones = newPhones.map(p => p.trim()).filter((_, i) => i !== editablePhoneIndex).filter(p => p);
if (!allPhones.includes(trimmedPhone)) {
setReadOnlyPhones(prev => new Set([...prev, editablePhoneIndex]));
}
}
} }
// 새로운 편집 가능한 항목 추가 // 새로운 편집 가능한 항목 추가
@@ -251,32 +273,99 @@ export const UserAddAccountPage = () => {
return phoneRegex.test(phone); return phoneRegex.test(phone);
}; };
// 이메일 오류 메시지 반환
const getEmailError = (index: number, email: string): string | null => {
// 값이 없으면 오류 표시 안 함
if (!email || !email.trim()) return null;
// 형식 검증
if (!isValidEmail(email)) {
return t('account.invalidEmailFormat') || '이메일 형식이 올바르지 않습니다.';
}
// 중복 검증
const allEmails = newEmails;
const currentEmailWithoutSelf = allEmails.filter((e, i) => {
return e.trim() && i !== index;
});
if (currentEmailWithoutSelf.includes(email)) {
return t('account.duplicateEmail') || '중복된 이메일입니다.';
}
return null;
};
// 전화번호 오류 메시지 반환
const getPhoneError = (index: number, phone: string): string | null => {
// 값이 없으면 오류 표시 안 함
if (!phone || !phone.trim()) return null;
// 형식 검증
if (!isValidPhone(phone)) {
return t('account.invalidPhoneFormat') || '전화번호 형식이 올바르지 않습니다. (010으로 시작하는 11자리)';
}
// 중복 검증
const allPhones = newPhones;
const currentPhoneWithoutSelf = allPhones.filter((p, i) => {
return p.trim() && i !== index;
});
if (currentPhoneWithoutSelf.includes(phone)) {
return t('account.duplicatePhone') || '중복된 전화번호입니다.';
}
return null;
};
// 전체 validation 오류 확인 (이메일 + 전화번호)
const hasAnyValidationError = () => {
// 이메일 오류 확인
const hasEmailError = newEmails.some((email, index) => {
if (!email || !email.trim()) return false;
return getEmailError(index, email) !== null;
});
// 전화번호 오류 확인
const hasPhoneError = newPhones.some((phone, index) => {
if (!phone || !phone.trim()) return false;
return getPhoneError(index, phone) !== null;
});
return hasEmailError || hasPhoneError;
};
// 이메일 추가 버튼 활성화 조건 // 이메일 추가 버튼 활성화 조건
const isEmailAddButtonEnabled = () => { const isEmailAddButtonEnabled = () => {
if (newEmails.length === 0) return true; // 이메일 또는 전화번호 입력 중이면 비활성화
if (editableEmailIndex >= 0 || editablePhoneIndex >= 0) return false;
if (newEmails.length === 0) return true; // 처음은 활성화
const lastEmailIndex = newEmails.length - 1; const lastEmailIndex = newEmails.length - 1;
const lastEmail = newEmails[lastEmailIndex]; const lastEmail = newEmails[lastEmailIndex];
return lastEmailIndex >= editableEmailIndex && // 값이 없으면 비활성화
lastEmail && if (!lastEmail || !lastEmail.trim()) return false;
lastEmail.trim() &&
isValidEmail(lastEmail) && // 이메일 또는 전화번호에 validation 오류가 있으면 비활성화
!hasDuplicateEmail(); return !hasAnyValidationError();
}; };
// 전화번호 추가 버튼 활성화 조건 // 전화번호 추가 버튼 활성화 조건
const isPhoneAddButtonEnabled = () => { const isPhoneAddButtonEnabled = () => {
if (newPhones.length === 0) return true; // 이메일 또는 전화번호 입력 중이면 비활성화
if (editableEmailIndex >= 0 || editablePhoneIndex >= 0) return false;
if (newPhones.length === 0) return true; // 처음은 활성화
const lastPhoneIndex = newPhones.length - 1; const lastPhoneIndex = newPhones.length - 1;
const lastPhone = newPhones[lastPhoneIndex]; const lastPhone = newPhones[lastPhoneIndex];
return lastPhoneIndex >= editablePhoneIndex && // 값이 없으면 비활성화
lastPhone && if (!lastPhone || !lastPhone.trim()) return false;
lastPhone.trim() &&
isValidPhone(lastPhone) && // 이메일 또는 전화번호에 validation 오류가 있으면 비활성화
!hasDuplicatePhone(); return !hasAnyValidationError();
}; };
// 중복 검증 // 중복 검증
@@ -294,8 +383,7 @@ export const UserAddAccountPage = () => {
// 삭제 버튼 활성화 조건 // 삭제 버튼 활성화 조건
const isDeleteButtonEnabled = () => { const isDeleteButtonEnabled = () => {
const totalCount = newEmails.length + newPhones.length; return true; // 항상 활성화
return totalCount > 1;
}; };
// 저장 버튼 활성화 조건 체크 // 저장 버튼 활성화 조건 체크
@@ -562,9 +650,14 @@ export const UserAddAccountPage = () => {
setEditableEmailIndex(index); setEditableEmailIndex(index);
}} }}
onBlur={() => { onBlur={() => {
if (email && isValidEmail(email)) { // 값이 없거나 공백만 있으면 입력란 제거
if (!email || !email.trim()) {
handleRemoveNewEmail(index);
} else if (!getEmailError(index, email)) {
// validation 오류가 없을 때만 편집 모드 해제
setEditableEmailIndex(-1); setEditableEmailIndex(-1);
} }
// validation 오류가 있으면 편집 모드 유지 (편집 가능 상태)
}} }}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
@@ -578,6 +671,12 @@ export const UserAddAccountPage = () => {
</div> </div>
); );
})} })}
{(() => {
const firstError = newEmails.map((email, index) => getEmailError(index, email)).find(error => error);
return firstError ? (
<div className="error-message"><p>{firstError}</p></div>
) : null;
})()}
</div> </div>
<div className="ua-group"> <div className="ua-group">
@@ -610,9 +709,14 @@ export const UserAddAccountPage = () => {
setEditablePhoneIndex(index); setEditablePhoneIndex(index);
}} }}
onBlur={() => { onBlur={() => {
if (phone && isValidPhone(phone)) { // 값이 없거나 공백만 있으면 입력란 제거
if (!phone || !phone.trim()) {
handleRemoveNewPhone(index);
} else if (!getPhoneError(index, phone)) {
// validation 오류가 없을 때만 편집 모드 해제
setEditablePhoneIndex(-1); setEditablePhoneIndex(-1);
} }
// validation 오류가 있으면 편집 모드 유지 (편집 가능 상태)
}} }}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
@@ -626,6 +730,12 @@ export const UserAddAccountPage = () => {
</div> </div>
); );
})} })}
{(() => {
const firstError = newPhones.map((phone, index) => getPhoneError(index, phone)).find(error => error);
return firstError ? (
<div className="error-message" style={{ marginTop: '10px' }}><p>{firstError}</p></div>
) : null;
})()}
</div> </div>
</div> </div>