-
Notifications
You must be signed in to change notification settings - Fork 1
Feat: 입력 필드 상태 변화 수정 #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: 입력 필드 상태 변화 수정 #116
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,7 @@ interface AuthInputProps { | |
| placeholder?: string; | ||
| value?: string; | ||
| onChange?: (value: string) => void; | ||
| onBlur?: () => void; | ||
| inputType?: HTMLInputTypeAttribute; | ||
| name?: string; | ||
| autoComplete?: string; | ||
|
|
@@ -72,6 +73,7 @@ function InputMain({ | |
| placeholder, | ||
| value = "", | ||
| onChange, | ||
| onBlur, | ||
| inputType, | ||
| name, | ||
| autoComplete, | ||
|
|
@@ -128,7 +130,10 @@ function InputMain({ | |
| value={value} | ||
| onChange={(event) => onChange?.(event.target.value)} | ||
| onFocus={() => setFocused(true)} | ||
| onBlur={() => setFocused(false)} | ||
| onBlur={() => { | ||
| setFocused(false); | ||
| onBlur?.(); | ||
| }} | ||
| disabled={disabled} | ||
| /> | ||
| </div> | ||
|
|
@@ -142,11 +147,10 @@ function InputMain({ | |
| ); | ||
| } | ||
|
|
||
| interface AuthSingleLineProps | ||
| extends Omit< | ||
| InputHTMLAttributes<HTMLInputElement>, | ||
| "value" | "onChange" | "disabled" | "className" | ||
| > { | ||
| interface AuthSingleLineProps extends Omit< | ||
| InputHTMLAttributes<HTMLInputElement>, | ||
| "value" | "onChange" | "disabled" | "className" | ||
| > { | ||
| value?: string; | ||
| onChange?: (value: string) => void; | ||
| disabled?: boolean; | ||
|
|
@@ -257,11 +261,20 @@ export default function EmailLoginScreen() { | |
| useState(false); | ||
| const verificationInputRefs = useRef<Array<HTMLInputElement | null>>([]); | ||
|
|
||
| // toched 상태 | ||
| const [emailTouched, setEmailTouched] = useState(false); | ||
| const [passwordTouched, setPasswordTouched] = useState(false); | ||
| const [passwordConfirmTouched, setPasswordConfirmTouched] = useState(false); | ||
|
|
||
| const isLoginReady = email.length > 0 && password.length > 0; | ||
| const hasSignupEmailValidationError = | ||
| authMode === "signup" && email.length > 0 && !emailPattern.test(email); | ||
| authMode === "signup" && | ||
| emailTouched && | ||
| email.length > 0 && | ||
| !emailPattern.test(email); | ||
| const hasPasswordMaxLengthError = password.length > 20; | ||
| const hasPasswordValidationError = | ||
| passwordTouched && | ||
| password.length > 0 && | ||
| !hasPasswordMaxLengthError && | ||
| !passwordPattern.test(password); | ||
|
|
@@ -271,7 +284,9 @@ export default function EmailLoginScreen() { | |
| ? passwordValidationMessage | ||
| : undefined; | ||
| const hasPasswordMismatchError = | ||
| passwordConfirm.length > 0 && passwordConfirm !== password; | ||
| passwordConfirmTouched && | ||
| passwordConfirm.length > 0 && | ||
| passwordConfirm !== password; | ||
| const isSignupFilled = | ||
| email.length > 0 && password.length > 0 && passwordConfirm.length > 0; | ||
| const isSignupReady = | ||
|
|
@@ -305,6 +320,7 @@ export default function EmailLoginScreen() { | |
| setter: Dispatch<SetStateAction<string>>, | ||
| ) => { | ||
| setter(value); | ||
| setEmailTouched(false); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Make touched resets field-specific.
Pass the corresponding touched setter into the shared helper, or reset each field’s touched state in its dedicated handler. Also applies to: 333-333 🤖 Prompt for AI Agents |
||
| setLoginError(false); | ||
| setLoginErrorMessage(loginValidationErrorMessage); | ||
| setSignupErrorMessage(""); | ||
|
|
@@ -314,6 +330,7 @@ export default function EmailLoginScreen() { | |
|
|
||
| const handlePasswordChange = (value: string) => { | ||
| handleInputChange(value, setPassword); | ||
| setPasswordConfirmTouched(false); | ||
|
|
||
| if (value.length === 0) { | ||
| setPasswordConfirm(""); | ||
|
|
@@ -322,6 +339,7 @@ export default function EmailLoginScreen() { | |
|
|
||
| const handlePasswordConfirmChange = (value: string) => { | ||
| handleInputChange(value, setPasswordConfirm); | ||
| setPasswordConfirmTouched(false); | ||
| }; | ||
|
|
||
| const focusVerificationInput = (index: number) => { | ||
|
|
@@ -459,6 +477,10 @@ export default function EmailLoginScreen() { | |
| const handleSignupSubmit = async (event: FormEvent<HTMLFormElement>) => { | ||
| event.preventDefault(); | ||
|
|
||
| setEmailTouched(true); | ||
| setPasswordTouched(true); | ||
| setPasswordConfirmTouched(true); | ||
|
|
||
| if (isSignupSubmitting || !isSignupReady || !emailPattern.test(email)) { | ||
| return; | ||
| } | ||
|
|
@@ -534,6 +556,9 @@ export default function EmailLoginScreen() { | |
| setPasswordConfirm(""); | ||
| setVerificationCode([...initialVerificationCode]); | ||
| setHasVerificationError(false); | ||
| setEmailTouched(false); | ||
| setPasswordTouched(false); | ||
| setPasswordConfirmTouched(false); | ||
| hideCreditTooltip(); | ||
| }; | ||
|
|
||
|
|
@@ -674,6 +699,7 @@ export default function EmailLoginScreen() { | |
| onChange={(value) => | ||
| handleInputChange(value, setEmail) | ||
| } | ||
| onBlur={() => setEmailTouched(true)} | ||
| /> | ||
| <InputMain | ||
| name="password" | ||
|
|
@@ -686,6 +712,7 @@ export default function EmailLoginScreen() { | |
| error={loginError ? loginErrorMessage : undefined} | ||
| className="self-stretch" | ||
| onChange={handlePasswordChange} | ||
| onBlur={() => setPasswordTouched(true)} | ||
| /> | ||
| </div> | ||
|
|
||
|
|
@@ -744,7 +771,13 @@ export default function EmailLoginScreen() { | |
| hasSignupEmailValidationError || | ||
| Boolean(signupErrorMessage) | ||
| } | ||
| error={signupErrorMessage || undefined} | ||
| error={ | ||
| signupErrorMessage | ||
| ? signupErrorMessage | ||
| : hasSignupEmailValidationError | ||
| ? "이메일 형식이 맞지 않습니다." | ||
| : undefined | ||
| } | ||
|
Comment on lines
+774
to
+780
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Wire blur handling for both signup fields. The signup email error is gated by Add: + onBlur={() => setEmailTouched(true)}and restore: - // onBlur={() => setPasswordConfirmTouched(true)}
+ onBlur={() => setPasswordConfirmTouched(true)}Also applies to: 824-824 🤖 Prompt for AI Agents |
||
| className="self-stretch" | ||
| gapClassName={authInputGapClass} | ||
| labelClassName={authInputLabelClass} | ||
|
|
@@ -766,6 +799,7 @@ export default function EmailLoginScreen() { | |
| gapClassName={authInputGapClass} | ||
| labelClassName={authInputLabelClass} | ||
| onChange={handlePasswordChange} | ||
| onBlur={() => setPasswordTouched(true)} | ||
| /> | ||
| <InputMain | ||
| label="비밀번호 확인" | ||
|
|
@@ -787,6 +821,7 @@ export default function EmailLoginScreen() { | |
| gapClassName={authInputGapClass} | ||
| labelClassName={authInputLabelClass} | ||
| onChange={handlePasswordConfirmChange} | ||
| // onBlur={() => setPasswordConfirmTouched(true)} | ||
| /> | ||
| </div> | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -49,9 +49,7 @@ export async function fetchCreditBalance({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function fetchCreditTransactions( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type?: TransactionType, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<CreditTransaction[]> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = new URL( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `${API_BASE_URL}/api/payments/credits/me/transactions`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = new URL(`${API_BASE_URL}/api/payments/credits/me/transactions`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (type) url.searchParams.set("type", type); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(url.toString(), { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -107,18 +105,26 @@ export async function preparePurchase( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function confirmPurchase( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| paymentKey: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| orderId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| amount: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(`${API_BASE_URL}/api/payments/confirm`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: "POST", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...getAuthHeaders(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Content-Type": "application/json", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ paymentKey, orderId, amount }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // export async function confirmPurchase( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // paymentKey: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // orderId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // amount: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // const response = await fetch(`${API_BASE_URL}/api/payments/confirm`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // method: "POST", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // ...getAuthHeaders(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // "Content-Type": "application/json", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // body: JSON.stringify({ paymentKey, orderId, amount }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // checkResponse(response, "결제 승인에 실패했습니다."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function checkPaymentStatus(orderId: string) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(`/api/payments/orders/${orderId}`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: "GET", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checkResponse(response, "결제 승인에 실패했습니다."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) throw new Error("Failed to fetch payment status"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response.json(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+124
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Both callers use
📍 Affects 3 files
🤖 Prompt for AI Agents🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
The relative path resolves against the Next.js app origin instead of the API host, and without 🔧 Proposed fix-export async function checkPaymentStatus(orderId: string) {
- const response = await fetch(`/api/payments/orders/${orderId}`, {
- method: "GET",
- });
- if (!response.ok) throw new Error("Failed to fetch payment status");
- return response.json();
-}
+export type PaymentStatus =
+ | "PENDING"
+ | "PROCESSING"
+ | "COMPLETED"
+ | "FAILED";
+
+export async function checkPaymentStatus(
+ orderId: string,
+): Promise<{ status: PaymentStatus }> {
+ const response = await fetch(
+ `${API_BASE_URL}/api/payments/orders/${encodeURIComponent(orderId)}`,
+ {
+ method: "GET",
+ headers: getAuthHeaders(),
+ },
+ );
+ checkResponse(response, "결제 상태 조회에 실패했습니다.");
+ const { result }: ApiResponse<{ status: PaymentStatus }> =
+ await response.json();
+ return result;
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Polling is not cancellable and never terminates.
Two defects in this loop:
isPolling; the already-scheduledsetTimeout(Line 65) is never cleared, andpollStatusdoes not re-checkisPollingafterawait. After unmount/searchParamschange, the in-flight or scheduled call still runssetIsConfirming,alert, andwindow.location.reload().PENDING/PROCESSING, the loop polls every 2s forever, with the blocking overlay up indefinitely.🐛 Proposed fix: cancellable, bounded polling
if (orderId) { let isPolling = true; + let timer: ReturnType<typeof setTimeout> | undefined; + let attempts = 0; + const MAX_ATTEMPTS = 30; // ~60s + + const finish = (message: string, reload = false) => { + isPolling = false; + window.history.replaceState(null, "", window.location.pathname); + setIsConfirming(false); + alert(message); + if (reload) window.location.reload(); + }; const pollStatus = async () => { try { const response = await checkPaymentStatus(orderId); + if (!isPolling) return; // API 타입이 지정되지 않은 경우를 대비한 타입 단언 const data = response as unknown as { status?: string; result?: { status?: string }; }; const status = data.status || data.result?.status; if (status === "COMPLETED") { - isPolling = false; - window.history.replaceState(null, "", window.location.pathname); - setIsConfirming(false); - alert("크레딧 충전이 완료되었습니다!"); - window.location.reload(); + finish("크레딧 충전이 완료되었습니다!", true); } else if (status === "FAILED") { - isPolling = false; - window.history.replaceState(null, "", window.location.pathname); - setIsConfirming(false); - alert("결제에 실패했거나 취소되었습니다."); + finish("결제에 실패했거나 취소되었습니다."); + } else if (++attempts >= MAX_ATTEMPTS) { + finish("결제 결과 확인이 지연되고 있습니다. 잠시 후 다시 확인해 주세요."); } else { // PENDING, PROCESSING, UNKNOWN 상태일 경우 계속 폴링 - if (isPolling) { - setTimeout(pollStatus, 2000); // 2초 주기로 폴링 - } + timer = setTimeout(pollStatus, 2000); // 2초 주기로 폴링 } } catch (error: unknown) { + if (!isPolling) return; console.error("결제 상태 조회 실패:", error); - isPolling = false; - window.history.replaceState(null, "", window.location.pathname); - setIsConfirming(false); - alert("결제 상태를 확인하는 중 오류가 발생했습니다."); + finish("결제 상태를 확인하는 중 오류가 발생했습니다."); } }; @@ // 폴링 중단 return () => { isPolling = false; + if (timer) clearTimeout(timer); }; }📝 Committable suggestion
🤖 Prompt for AI Agents