- 이름,이메일,계좌번호 등 마스킹정책 적용

- 권한 체크 오 기입 수정
- 다국어 누락 부분 수정
This commit is contained in:
HyeonJongKim
2025-11-17 19:14:56 +09:00
parent fd5333e4a2
commit 4e1baffb13
13 changed files with 400 additions and 67 deletions

View File

@@ -6,6 +6,7 @@ import { CashReceiptPurposeType } from '../model/types';
import { PatternFormat } from 'react-number-format'; import { PatternFormat } from 'react-number-format';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware'; import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
import { MaskedNameInput, MaskedEmailInput, MaskedPhoneInput } from '@/shared/ui/masked-input';
export interface CashReceiptHandWrittenIssuanceStep1Props { export interface CashReceiptHandWrittenIssuanceStep1Props {
businessNumber?: string; businessNumber?: string;
@@ -95,11 +96,10 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
<div className="issue-row"> <div className="issue-row">
<div className="issue-label">{t('transaction.fields.buyer')}</div> <div className="issue-label">{t('transaction.fields.buyer')}</div>
<div className="issue-field"> <div className="issue-field">
<input <MaskedNameInput
type="text" value={buyerName || ''}
onChange={setBuyerName}
placeholder={t('transaction.handWrittenIssuance.buyerNamePlaceholder')} placeholder={t('transaction.handWrittenIssuance.buyerNamePlaceholder')}
value={buyerName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setBuyerName(e.target.value)}
onFocus={handleInputFocus} onFocus={handleInputFocus}
/> />
</div> </div>
@@ -120,11 +120,10 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
<div className="issue-row"> <div className="issue-row">
<div className="issue-label">{t('account.emailAddress')}</div> <div className="issue-label">{t('account.emailAddress')}</div>
<div className="issue-field"> <div className="issue-field">
<input <MaskedEmailInput
type="email" value={email || ''}
onChange={setEmail}
placeholder={t('transaction.handWrittenIssuance.emailPlaceholder')} placeholder={t('transaction.handWrittenIssuance.emailPlaceholder')}
value={email}
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
onFocus={handleInputFocus} onFocus={handleInputFocus}
/> />
</div> </div>
@@ -132,14 +131,12 @@ export const CashReceiptHandWrittenIssuanceStep1 = ({
<div className="issue-row" style={keyboardAwarePadding}> <div className="issue-row" style={keyboardAwarePadding}>
<div className="issue-label">{t('account.phoneNumber')}</div> <div className="issue-label">{t('account.phoneNumber')}</div>
<div className="issue-field"> <div className="issue-field">
<PatternFormat <MaskedPhoneInput
value={phoneNumber || ''}
onChange={setPhoneNumber}
placeholder={t('transaction.handWrittenIssuance.phoneNumberPlaceholder')} placeholder={t('transaction.handWrittenIssuance.phoneNumberPlaceholder')}
value={phoneNumber}
valueIsNumericString
format="###########"
onChange={(e: ChangeEvent<HTMLInputElement>) => setPhoneNumber(e.target.value)}
onFocus={handleInputFocus} onFocus={handleInputFocus}
></PatternFormat> />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -774,7 +774,23 @@
"billing": { "billing": {
"title": "Billing", "title": "Billing",
"detailTitle": "Billing Details", "detailTitle": "Billing Details",
"charge": "Billing Charge" "charge": "Billing Charge",
"paymentInfoInput": "Enter Payment Information",
"billKey": "Bill Key",
"productAmount": "Product Amount",
"paymentRequestDate": "Payment Request Date",
"selectDate": "Select Date",
"installmentMonth": "Installment Months",
"lumpSum": "Lump Sum",
"months": "{{count}} Months",
"chargeRequest": "Request Payment",
"billKeyRequired": "Bill key is required.",
"productNameRequired": "Product name is required.",
"productAmountRequired": "Product amount is required.",
"productAmountMinimum": "Product amount must be greater than 0.",
"orderNumberRequired": "Order number is required.",
"buyerNameRequired": "Buyer name is required.",
"chargeSuccess": "Payment request was successful."
}, },
"payment": { "payment": {
"title": "Payment Management", "title": "Payment Management",

View File

@@ -774,7 +774,23 @@
"billing": { "billing": {
"title": "빌링", "title": "빌링",
"detailTitle": "빌링 상세", "detailTitle": "빌링 상세",
"charge": "빌링 청구" "charge": "빌링 청구",
"paymentInfoInput": "결제 정보 입력",
"billKey": "빌키",
"productAmount": "상품금액",
"paymentRequestDate": "결제 요청일자",
"selectDate": "날짜 선택",
"installmentMonth": "할부 개월",
"lumpSum": "일시불",
"months": "{{count}}개월",
"chargeRequest": "결제 신청",
"billKeyRequired": "빌키는 필수 입력 항목입니다.",
"productNameRequired": "상품명은 필수 입력 항목입니다.",
"productAmountRequired": "상품금액은 필수 입력 항목입니다.",
"productAmountMinimum": "상품금액은 0보다 커야 합니다.",
"orderNumberRequired": "주문번호는 필수 입력 항목입니다.",
"buyerNameRequired": "구매자명은 필수 입력 항목입니다.",
"chargeSuccess": "결제 신청을 성공하였습니다."
}, },
"payment": { "payment": {
"title": "결제 관리", "title": "결제 관리",

View File

@@ -11,11 +11,11 @@ import {
import { useExtensionAccountHolderSearchRequestMutation } from '@/entities/additional-service/api/account-holder-search/use-extension-account-holder-search-reqeust-mutation'; import { useExtensionAccountHolderSearchRequestMutation } from '@/entities/additional-service/api/account-holder-search/use-extension-account-holder-search-reqeust-mutation';
import { ExtensionAccountHolderSearchRequestParams, ExtensionAccountHolderSearchRequestResponse } from '@/entities/additional-service/model/account-holder-search/types'; import { ExtensionAccountHolderSearchRequestParams, ExtensionAccountHolderSearchRequestResponse } from '@/entities/additional-service/model/account-holder-search/types';
import { useStore } from '@/shared/model/store'; import { useStore } from '@/shared/model/store';
import { NumericFormat } from 'react-number-format';
import { snackBar } from '@/shared/lib'; import { snackBar } from '@/shared/lib';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { showAlert } from '@/widgets/show-alert'; import { showAlert } from '@/widgets/show-alert';
import { AccountHolderSearchDetail } from '@/entities/additional-service/ui/account-holder-search/detail/account-holder-search-detail'; import { AccountHolderSearchDetail } from '@/entities/additional-service/ui/account-holder-search/detail/account-holder-search-detail';
import { MaskedAccountNumberInput } from '@/shared/ui/masked-input';
export const AccountHolderSearchRequestPage = () => { export const AccountHolderSearchRequestPage = () => {
const { navigate } = useNavigate(); const { navigate } = useNavigate();
@@ -145,18 +145,11 @@ export const AccountHolderSearchRequestPage = () => {
<div className="billing-row"> <div className="billing-row">
<div className="billing-label">{t('transaction.fields.accountNo')}</div> <div className="billing-label">{t('transaction.fields.accountNo')}</div>
<div className="billing-field"> <div className="billing-field">
<NumericFormat <MaskedAccountNumberInput
value={formData.accountNo} value={formData.accountNo}
valueIsNumericString placeholder=''
allowNegative={false} onChange={(value) => setFormData({ ...formData, accountNo: value })}
decimalScale={0} maxLength={14}
isAllowed={(values) => {
const { value } = values;
return !value || value.length <= 14;
}}
onValueChange={(values) => {
setFormData({ ...formData, accountNo: values.value });
}}
/> />
</div> </div>
</div> </div>

View File

@@ -11,7 +11,6 @@ import {
} from '@/widgets/sub-layout/use-sub-layout'; } from '@/widgets/sub-layout/use-sub-layout';
import { useStore } from '@/shared/model/store'; import { useStore } from '@/shared/model/store';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PatternFormat } from 'react-number-format';
import { overlay } from 'overlay-kit'; import { overlay } from 'overlay-kit';
import { Dialog } from '@/shared/ui/dialogs/dialog'; import { Dialog } from '@/shared/ui/dialogs/dialog';
import { QnaSaveParams, QnaSaveResponse } from '@/entities/support/model/types'; import { QnaSaveParams, QnaSaveResponse } from '@/entities/support/model/types';
@@ -19,6 +18,7 @@ import { checkGrant } from '@/shared/lib/check-grant';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware'; import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
import { snackBar } from '@/shared/lib'; import { snackBar } from '@/shared/lib';
import { showAlert } from '@/widgets/show-alert'; import { showAlert } from '@/widgets/show-alert';
import { MaskedNameInput, MaskedPhoneInput, MaskedEmailInput } from '@/shared/ui/masked-input';
export enum QnaRegisterPropsName { export enum QnaRegisterPropsName {
Mid = 'Mid', Mid = 'Mid',
@@ -189,11 +189,10 @@ export const QnaRegisterPage = () => {
{t('support.qna.formLabels.requesterName')} <span className="red">{t('support.qna.formLabels.required')}</span> {t('support.qna.formLabels.requesterName')} <span className="red">{t('support.qna.formLabels.required')}</span>
</div> </div>
<div className="inq-control"> <div className="inq-control">
<input <MaskedNameInput
type="text"
value={requestName} value={requestName}
required={true} placeholder=''
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputValue(e, QnaRegisterPropsName.RequestName)} onChange={setRequestName}
onFocus={handleInputFocus} onFocus={handleInputFocus}
/> />
</div> </div>
@@ -203,24 +202,21 @@ export const QnaRegisterPage = () => {
{t('support.qna.formLabels.phoneNumber')} <span className="red">{t('support.qna.formLabels.required')}</span> {t('support.qna.formLabels.phoneNumber')} <span className="red">{t('support.qna.formLabels.required')}</span>
</div> </div>
<div className="inq-control"> <div className="inq-control">
<PatternFormat <MaskedPhoneInput
placeholder={t('support.qna.formLabels.phonePlaceholder')} placeholder={t('support.qna.formLabels.phonePlaceholder')}
value={requestTel} value={requestTel}
valueIsNumericString onChange={setRequestTel}
format="###########"
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputValue(e, QnaRegisterPropsName.RequestTel)}
onFocus={handleInputFocus} onFocus={handleInputFocus}
></PatternFormat> />
</div> </div>
</div> </div>
<div className="inq-field"> <div className="inq-field">
<div className="inq-label">{t('support.qna.formLabels.emailAddress')}</div> <div className="inq-label">{t('support.qna.formLabels.emailAddress')}</div>
<div className="inq-control"> <div className="inq-control">
<input <MaskedEmailInput
type="email"
placeholder={t('support.qna.formLabels.emailPlaceholder')} placeholder={t('support.qna.formLabels.emailPlaceholder')}
value={requestEmail} value={requestEmail}
onChange={(e: ChangeEvent<HTMLInputElement>) => setInputValue(e, QnaRegisterPropsName.RequestEmail)} onChange={setRequestEmail}
onFocus={handleInputFocus} onFocus={handleInputFocus}
/> />
</div> </div>

View File

@@ -19,6 +19,7 @@ import { notiBar, snackBar } from '@/shared/lib';
import { BillingChargeParams, BillingChargeResponse } from '@/entities/transaction/model/types'; import { BillingChargeParams, BillingChargeResponse } from '@/entities/transaction/model/types';
import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware'; import { useKeyboardAware } from '@/shared/lib/hooks/use-keyboard-aware';
import { checkGrant } from '@/shared/lib/check-grant'; import { checkGrant } from '@/shared/lib/check-grant';
import { MaskedNameInput } from '@/shared/ui/masked-input';
const menuId = 34; const menuId = 34;
export const BillingChargePage = () => { export const BillingChargePage = () => {
@@ -56,23 +57,23 @@ export const BillingChargePage = () => {
const onClickToBillingCharge = () => { const onClickToBillingCharge = () => {
if(checkGrant(menuId, 'W')){ if(checkGrant(menuId, 'W')){
if (!billKey) { if (!billKey) {
showAlert('빌키는 필수 입력 항목입니다.'); showAlert(t('billing.billKeyRequired'));
return; return;
} }
else if (!productName) { else if (!productName) {
showAlert('상품명은 필수 입력 항목입니다.'); showAlert(t('billing.productNameRequired'));
} }
else if (!productAmount) { else if (!productAmount) {
showAlert('상품금액은 필수 입력 항목입니다.'); showAlert(t('billing.productAmountRequired'));
} }
else if (productAmount <= 0) { else if (productAmount <= 0) {
showAlert('상품금액은 0보다 커야 합니다.'); showAlert(t('billing.productAmountMinimum'));
} }
else if (!orderNumber) { else if (!orderNumber) {
showAlert('주문번호는 필수 입력 항목입니다.'); showAlert(t('billing.orderNumberRequired'));
} }
else if (!buyerName) { else if (!buyerName) {
showAlert('구매자명은 필수 입력 항목입니다.'); showAlert(t('billing.buyerNameRequired'));
} }
let params: BillingChargeParams = { let params: BillingChargeParams = {
@@ -85,7 +86,7 @@ export const BillingChargePage = () => {
installmentMonth: installmentMonth installmentMonth: installmentMonth
}; };
billingCharge(params).then((rs: BillingChargeResponse) => { billingCharge(params).then((rs: BillingChargeResponse) => {
snackBar('결제 신청을 성공하였습니다.', function () { snackBar(t('billing.chargeSuccess'), function () {
navigate(PATHS.transaction.billing.list); navigate(PATHS.transaction.billing.list);
}, 3000); }, 3000);
@@ -98,8 +99,8 @@ export const BillingChargePage = () => {
} }
else{ else{
showAlert(t('common.nopermission')); showAlert(t('common.nopermission'));
} }
}; };
const onChangeBillKey = (value: string) => { const onChangeBillKey = (value: string) => {
@@ -116,13 +117,13 @@ export const BillingChargePage = () => {
<option <option
key={`key-installment`} key={`key-installment`}
value='' value=''
></option> >{t('common.select')}</option>
); );
rs.push( rs.push(
<option <option
key={`key-installment-0`} key={`key-installment-0`}
value='00' value='00'
></option> >{t('billing.lumpSum')}</option>
); );
for (let i = 2; i <= 33; i++) { for (let i = 2; i <= 33; i++) {
let val = (i < 10) ? '0' + i : '' + i; let val = (i < 10) ? '0' + i : '' + i;
@@ -130,7 +131,7 @@ export const BillingChargePage = () => {
<option <option
key={`key-installment-${i}`} key={`key-installment-${i}`}
value={val} value={val}
>{i}</option> >{t('billing.months', { count: i })}</option>
); );
}; };
return rs; return rs;
@@ -142,10 +143,10 @@ export const BillingChargePage = () => {
<div className="tab-content"> <div className="tab-content">
<div className="tab-pane sub active"> <div className="tab-pane sub active">
<div className="option-list"> <div className="option-list">
<div className="billing-title"> </div> <div className="billing-title">{t('billing.paymentInfoInput')}</div>
<div className="billing-form"> <div className="billing-form">
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> <span>*</span></div> <div className="billing-label">{t('billing.billKey')} <span>*</span></div>
<div className="billing-field"> <div className="billing-field">
<input <input
type="text" type="text"
@@ -156,7 +157,7 @@ export const BillingChargePage = () => {
</div> </div>
</div> </div>
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> <span>*</span></div> <div className="billing-label">{t('transaction.fields.productName')} <span>*</span></div>
<div className="billing-field"> <div className="billing-field">
<input <input
type="text" type="text"
@@ -167,7 +168,7 @@ export const BillingChargePage = () => {
</div> </div>
</div> </div>
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> <span>*</span></div> <div className="billing-label">{t('billing.productAmount')} <span>*</span></div>
<div className="billing-field"> <div className="billing-field">
<NumericFormat <NumericFormat
value={productAmount} value={productAmount}
@@ -179,7 +180,7 @@ export const BillingChargePage = () => {
</div> </div>
</div> </div>
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> <span>*</span></div> <div className="billing-label">{t('transaction.fields.orderNumber')} <span>*</span></div>
<div className="billing-field"> <div className="billing-field">
<input <input
type="text" type="text"
@@ -190,24 +191,24 @@ export const BillingChargePage = () => {
</div> </div>
</div> </div>
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> <span>*</span></div> <div className="billing-label">{t('transaction.fields.buyer')} <span>*</span></div>
<div className="billing-field"> <div className="billing-field">
<input <MaskedNameInput
type="text"
value={buyerName} value={buyerName}
onChange={(e: ChangeEvent<HTMLInputElement>) => setBuyerName(e.target.value)} placeholder=''
onChange={setBuyerName}
onFocus={handleInputFocus} onFocus={handleInputFocus}
/> />
</div> </div>
</div> </div>
<div className="billing-row"> <div className="billing-row">
<div className="billing-label"> </div> <div className="billing-label">{t('billing.paymentRequestDate')}</div>
<div className="billing-field"> <div className="billing-field">
<div className="input-wrapper date wid-100"> <div className="input-wrapper date wid-100">
<input <input
type="text" type="text"
placeholder="날짜 선택" placeholder={t('billing.selectDate')}
value={paymentRequestDate} value={paymentRequestDate}
readOnly={true} readOnly={true}
/> />
@@ -226,7 +227,7 @@ export const BillingChargePage = () => {
</div> </div>
<div className="billing-row" style={keyboardAwarePadding}> <div className="billing-row" style={keyboardAwarePadding}>
<div className="billing-label"> </div> <div className="billing-label">{t('billing.installmentMonth')}</div>
<div className="billing-field"> <div className="billing-field">
<select <select
value={installmentMonth} value={installmentMonth}
@@ -242,7 +243,7 @@ export const BillingChargePage = () => {
<button <button
className="btn-50 btn-blue flex-1" className="btn-50 btn-blue flex-1"
onClick={() => onClickToBillingCharge()} onClick={() => onClickToBillingCharge()}
> </button> >{t('billing.chargeRequest')}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -247,7 +247,7 @@ export const CashReceiptListPage = () => {
setTransactionType(val); setTransactionType(val);
}; };
const onClickToNavigate = () => { const onClickToNavigate = () => {
if(checkGrant(menuId, 'X')){ if(checkGrant(menuId, 'W')){
navigate(PATHS.transaction.cashReceipt.handWrittenIssuance); navigate(PATHS.transaction.cashReceipt.handWrittenIssuance);
} }
else{ else{

View File

@@ -0,0 +1,66 @@
/**
* 마스킹 유틸리티 함수
* 개인정보 보호를 위한 데이터 마스킹 처리
*/
/**
* 이름 마스킹
* - 1글자: 그대로 표시
* - 2글자: 첫 글자 + * (홍*, j*)
* - 3글자: 첫 글자 + * + 마지막 글자 (홍*동, j*n)
* - 4글자 이상: 첫 글자 + *** + 마지막 글자 (선**녀, j***n)
*/
export const getMaskedName = (name?: string): string => {
if (!name) return '';
const length = name.length;
if (length <= 1) return name;
if (length === 2) return name[0] + '*';
if (length === 3) return name[0] + '*' + name[2];
const firstChar = name[0];
const lastChar = name[length - 1];
const maskedLength = length - 2;
const masked = '*'.repeat(maskedLength);
return firstChar + masked + lastChar;
};
/**
* 전화번호 마스킹
* - 마지막 4자리 마스킹
* - 예: 0101234****
*/
export const getMaskedPhoneNumber = (phone?: string): string => {
if (!phone) return '';
if (phone.length <= 7) return phone;
const visiblePart = phone.slice(0, -4);
return visiblePart + '****';
};
/**
* 이메일 마스킹
* - 앞 2자리만 표시, @ 이전 부분 마스킹
* - 예: te**@nicepay.co.kr
*/
export const getMaskedEmail = (email?: string): string => {
if (!email) return '';
const atIndex = email.indexOf('@');
if (atIndex === -1 || atIndex <= 2) return email;
const visiblePart = email.slice(0, 2);
const domainPart = email.slice(atIndex);
const maskedLength = atIndex - 2;
const masked = '*'.repeat(maskedLength);
return visiblePart + masked + domainPart;
};
/**
* 계좌번호 마스킹
* - 마지막 5자리만 표시
* - 예: *********74018
*/
export const getMaskedAccountNumber = (accountNo?: string): string => {
if (!accountNo) return '';
if (accountNo.length <= 5) return accountNo;
const visiblePart = accountNo.slice(-5);
const maskedLength = accountNo.length - 5;
const masked = '*'.repeat(maskedLength);
return masked + visiblePart;
};

View File

@@ -0,0 +1,8 @@
export { MaskedNameInput } from './masked-name-input';
export { MaskedPhoneInput } from './masked-phone-input';
export { MaskedEmailInput } from './masked-email-input';
export { MaskedAccountNumberInput } from './masked-account-number-input';
export type { MaskedNameInputProps } from './masked-name-input';
export type { MaskedPhoneInputProps } from './masked-phone-input';
export type { MaskedEmailInputProps } from './masked-email-input';
export type { MaskedAccountNumberInputProps } from './masked-account-number-input';

View File

@@ -0,0 +1,54 @@
import { ChangeEvent } from 'react';
import { getMaskedAccountNumber } from '@/shared/lib/masking-utils';
export interface MaskedAccountNumberInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
maxLength?: number;
}
export const MaskedAccountNumberInput = ({
value,
onChange,
placeholder = '*********74018',
onFocus,
className = '',
maxLength = 14
}: MaskedAccountNumberInputProps) => {
return (
<input
type="tel"
className={className}
value={getMaskedAccountNumber(value)}
placeholder={placeholder}
onKeyDown={(e) => {
// 백스페이스 처리
if (e.key === 'Backspace') {
e.preventDefault();
onChange(value.slice(0, -1));
}
// 숫자 입력 처리
else if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
if (value.length < maxLength) {
onChange(value + e.key);
}
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
// Android 등에서 한글 키보드로 입력 시 대비
const input = e.target.value;
const digitsOnly = input.replace(/\*/g, '').replace(/[^0-9]/g, '');
if (digitsOnly.length <= maxLength) {
onChange(digitsOnly);
}
}}
inputMode="numeric"
pattern="[0-9]*"
onFocus={onFocus}
/>
);
};

View File

@@ -0,0 +1,61 @@
import { ChangeEvent } from 'react';
import { getMaskedEmail } from '@/shared/lib/masking-utils';
export interface MaskedEmailInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
isValid?: (value: string) => boolean;
}
export const MaskedEmailInput = ({
value,
onChange,
placeholder = 'te**@nicepay.co.kr',
onFocus,
className = '',
isValid
}: MaskedEmailInputProps) => {
const validationClass = isValid && value ? (isValid(value) ? '' : 'error') : '';
return (
<input
type="text"
className={`${className} ${validationClass}`.trim()}
value={getMaskedEmail(value)}
placeholder={placeholder}
onKeyDown={(e) => {
// 백스페이스 처리
if (e.key === 'Backspace') {
e.preventDefault();
onChange(value.slice(0, -1));
}
// 영문, 숫자, @, ., - 등 이메일에 허용되는 문자만 입력 가능
else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
if (/^[a-zA-Z0-9@._-]$/.test(e.key)) {
e.preventDefault();
onChange(value + e.key);
} else {
e.preventDefault();
}
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
// 복사/붙여넣기 등 특수 입력 대비
const input = e.target.value;
const filteredInput = input.replace(/[^a-zA-Z0-9@._-]/g, '');
const atIndex = filteredInput.indexOf('@');
if (atIndex !== -1) {
const beforeAt = filteredInput.slice(0, atIndex).replace(/\*/g, '');
const afterAt = filteredInput.slice(atIndex);
onChange(beforeAt + afterAt);
} else {
onChange(filteredInput.replace(/\*/g, ''));
}
}}
onFocus={onFocus}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { ChangeEvent, useState } from 'react';
import { getMaskedName } from '@/shared/lib/masking-utils';
export interface MaskedNameInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
}
export const MaskedNameInput = ({
value,
onChange,
placeholder = '홍*동',
onFocus,
className = ''
}: MaskedNameInputProps) => {
const [isComposing, setIsComposing] = useState<boolean>(false);
return (
<input
type="text"
className={className}
value={isComposing ? value : getMaskedName(value)}
placeholder={placeholder}
onKeyDown={(e) => {
// 스페이스바 입력 차단
if (e.key === ' ') {
e.preventDefault();
}
// 백스페이스 처리 (composition 중이 아닐 때만)
else if (e.key === 'Backspace' && !isComposing) {
e.preventDefault();
onChange(value.slice(0, -1));
}
// 영문자 입력 처리 (composition 중이 아닐 때만)
else if (!isComposing && e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
if (/^[a-zA-Z]$/.test(e.key)) {
e.preventDefault();
onChange(value + e.key);
}
// 한글이 아닌 다른 문자는 차단
else if (!/^[ㄱ-ㅎㅏ-ㅣ가-힣]$/.test(e.key)) {
e.preventDefault();
}
}
}}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e: any) => {
setIsComposing(false);
// 스페이스 제거 후 저장
const valueWithoutSpace = e.target.value.replace(/\s/g, '');
onChange(valueWithoutSpace);
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (isComposing) {
// composition 중에는 스페이스 제거
const valueWithoutSpace = e.target.value.replace(/\s/g, '');
onChange(valueWithoutSpace);
}
}}
onFocus={onFocus}
/>
);
};

View File

@@ -0,0 +1,59 @@
import { ChangeEvent } from 'react';
import { getMaskedPhoneNumber } from '@/shared/lib/masking-utils';
export interface MaskedPhoneInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
maxLength?: number;
isValid?: (value: string) => boolean;
}
export const MaskedPhoneInput = ({
value,
onChange,
placeholder = '0101234****',
onFocus,
className = '',
maxLength = 11,
isValid
}: MaskedPhoneInputProps) => {
const validationClass = isValid && value ? (isValid(value) ? '' : 'error') : '';
return (
<input
type="tel"
className={`${className} ${validationClass}`.trim()}
value={getMaskedPhoneNumber(value)}
placeholder={placeholder}
onKeyDown={(e) => {
// 백스페이스 처리
if (e.key === 'Backspace') {
e.preventDefault();
onChange(value.slice(0, -1));
}
// 숫자 입력 처리
else if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
if (value.length < maxLength) {
onChange(value + e.key);
}
}
}}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
// Android 등에서 한글 키보드로 입력 시 대비
const input = e.target.value;
const digitsOnly = input.replace(/\*/g, '').replace(/[^0-9]/g, '');
if (digitsOnly.length <= maxLength) {
onChange(digitsOnly);
}
}}
inputMode="numeric"
pattern="[0-9]*"
maxLength={15}
onFocus={onFocus}
/>
);
};