Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions jobdri/src/app/credit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CreditCard } from "@/components/common/cards";
import Useage from "@/components/common/credit/Useage";
import {
fetchCreditPlans,
confirmPurchase,
checkPaymentStatus,
type CreditPlan,
} from "@/lib/api/credit";
import Lnb from "@/components/common/lnb/Lnb";
Expand All @@ -22,9 +22,9 @@ function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string {

function CreditContent() {
const [plans, setPlans] = useState<CreditPlan[]>([]);
const [isConfirming, setIsConfirming] = useState(false); // 승인 중 로딩 상태 추가
const [isConfirming, setIsConfirming] = useState(false);
const searchParams = useSearchParams();
const router = useRouter(); // 라우터 훅 추가
const router = useRouter();

useEffect(() => {
fetchCreditPlans()
Expand All @@ -33,32 +33,57 @@ function CreditContent() {
}, []);

useEffect(() => {
const paymentKey = searchParams.get("paymentKey");
const orderId = searchParams.get("orderId");
const amount = searchParams.get("amount");

if (paymentKey && orderId && amount) {
const processConfirm = async () => {
setIsConfirming(true); // 화면 잠금
if (orderId) {
let isPolling = true;

const pollStatus = async () => {
try {
await confirmPurchase(paymentKey, orderId, Number(amount));
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
setTimeout(() => {
const response = await checkPaymentStatus(orderId);
// 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();
}, 100);
} catch (error) {
console.error("결제 승인 실패:", error);
setIsConfirming(false);
alert("결제 승인에 실패했습니다. 다시 시도해 주세요.");
// 실패 시에도 쿼리 파라미터를 날려 중복 요청 방지
} else if (status === "FAILED") {
isPolling = false;
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
alert("결제에 실패했거나 취소되었습니다.");
} else {
// PENDING, PROCESSING, UNKNOWN 상태일 경우 계속 폴링
if (isPolling) {
setTimeout(pollStatus, 2000); // 2초 주기로 폴링
}
}
} catch (error: unknown) {
console.error("결제 상태 조회 실패:", error);
isPolling = false;
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
alert("결제 상태를 확인하는 중 오류가 발생했습니다.");
}
};

processConfirm();
// 동기적 setState 호출 경고(Cascading renders)를 방지하기 위해 Promise로 감싸서 실행
Promise.resolve().then(() => {
setIsConfirming(true);
pollStatus();
});

// 폴링 중단
return () => {
isPolling = false;
};
}
}, [searchParams]);
Comment on lines +38 to 88

Copy link
Copy Markdown

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:

  1. The cleanup only flips isPolling; the already-scheduled setTimeout (Line 65) is never cleared, and pollStatus does not re-check isPolling after await. After unmount/searchParams change, the in-flight or scheduled call still runs setIsConfirming, alert, and window.location.reload().
  2. If the backend keeps returning 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (orderId) {
let isPolling = true;
const pollStatus = async () => {
try {
await confirmPurchase(paymentKey, orderId, Number(amount));
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
setTimeout(() => {
const response = await checkPaymentStatus(orderId);
// 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();
}, 100);
} catch (error) {
console.error("결제 승인 실패:", error);
setIsConfirming(false);
alert("결제 승인에 실패했습니다. 다시 시도해 주세요.");
// 실패 시에도 쿼리 파라미터를 날려 중복 요청 방지
} else if (status === "FAILED") {
isPolling = false;
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
alert("결제에 실패했거나 취소되었습니다.");
} else {
// PENDING, PROCESSING, UNKNOWN 상태일 경우 계속 폴링
if (isPolling) {
setTimeout(pollStatus, 2000); // 2초 주기로 폴링
}
}
} catch (error: unknown) {
console.error("결제 상태 조회 실패:", error);
isPolling = false;
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
alert("결제 상태를 확인하는 중 오류가 발생했습니다.");
}
};
processConfirm();
// 동기적 setState 호출 경고(Cascading renders)를 방지하기 위해 Promise로 감싸서 실행
Promise.resolve().then(() => {
setIsConfirming(true);
pollStatus();
});
// 폴링 중단
return () => {
isPolling = false;
};
}
}, [searchParams]);
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") {
finish("크레딧 충전이 완료되었습니다!", true);
} else if (status === "FAILED") {
finish("결제에 실패했거나 취소되었습니다.");
} else if (++attempts >= MAX_ATTEMPTS) {
finish("결제 결과 확인이 지연되고 있습니다. 잠시 후 다시 확인해 주세요.");
} else {
// PENDING, PROCESSING, UNKNOWN 상태일 경우 계속 폴링
timer = setTimeout(pollStatus, 2000); // 2초 주기로 폴링
}
} catch (error: unknown) {
if (!isPolling) return;
console.error("결제 상태 조회 실패:", error);
finish("결제 상태를 확인하는 중 오류가 발생했습니다.");
}
};
// 동기적 setState 호출 경고(Cascading renders)를 방지하기 위해 Promise로 감싸서 실행
Promise.resolve().then(() => {
setIsConfirming(true);
pollStatus();
});
// 폴링 중단
return () => {
isPolling = false;
if (timer) clearTimeout(timer);
};
}
}, [searchParams]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/app/credit/page.tsx` around lines 38 - 88, Update the polling flow
in the useEffect around pollStatus to track and clear the active timeout during
cleanup, and re-check the cancellation flag after each awaited
checkPaymentStatus call before performing state updates, alerts, history
changes, or reloads. Add a finite polling limit for PENDING, PROCESSING, and
unknown statuses; when the limit is reached, stop polling, remove the URL order
state, clear confirmation, and report that payment status could not be
confirmed. Ensure all terminal and error paths clear any scheduled timeout.


Expand All @@ -69,11 +94,10 @@ function CreditContent() {
<>
<PageHeader />

{/* 결제 승인 중일 때 화면을 덮는 로딩 UI */}
{isConfirming && (
<div className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="text-white text-h24-bold animate-pulse">
결제를 안전하게 승인하고 있습니다...
결제 결과를 확인하고 있습니다...
</div>
</div>
)}
Expand Down
45 changes: 17 additions & 28 deletions jobdri/src/components/common/cards/CreditCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { ModalCard } from "../modal/ModalCard";
import { ModalOverlay } from "../modal/ModalOverlay";
import type { PlanCode } from "@/lib/api/credit";
import { preparePurchase } from "@/lib/api/credit";
import { loadTossPayments, ANONYMOUS } from "@tosspayments/tosspayments-sdk";
import { Toast } from "../toast";

interface CreditCardProps extends HTMLAttributes<HTMLElement> {
Expand Down Expand Up @@ -55,35 +54,25 @@ export default function CreditCard({
const handleConfirmPurchase = async () => {
setIsLoading(true);
try {
const { clientKey, orderId, orderName, amount, customerEmail } =
await preparePurchase(planCode);

const tossPayments = await loadTossPayments(clientKey);
const payment = tossPayments.payment({ customerKey: ANONYMOUS });

await payment.requestPayment({
method: "CARD",
amount: { currency: "KRW", value: amount },
orderId,
orderName,
customerEmail,
successUrl: `${window.location.origin}/credit`,
failUrl: `${window.location.origin}/credit/fail`,
card: {
easyPay: "TOSSPAY",
flowMode: "DIRECT",
},
});
} catch (error: unknown) {
const tossError = error as { code?: string; message?: string };

if (tossError.code === "USER_CANCEL") {
setToastMessage("결제를 취소하였습니다.");
// 결제 준비 API 호출
const response = await preparePurchase(planCode);

// 타입스크립트 에러 방지를 위한 타입 단언
const data = response as unknown as {
checkoutPage?: string;
result?: { checkoutPage?: string };
};
const checkoutPage = data.checkoutPage || data.result?.checkoutPage;

if (checkoutPage) {
// 백엔드에서 생성한 토스페이 결제 페이지로 이동
window.location.assign(checkoutPage);
} else {
console.error("결제 창 호출 중 오류:", tossError.message || error);
setToastMessage("결제 진행 중 오류가 발생했습니다.");
throw new Error("결제 페이지 URL을 찾을 수 없습니다.");
}
} finally {
} catch (error: unknown) {
console.error("결제 준비 중 오류:", error);
setToastMessage("결제 진행 중 오류가 발생했습니다.");
setIsLoading(false);
setIsModalOpen(false);
}
Expand Down
53 changes: 44 additions & 9 deletions jobdri/src/components/login/EmailLoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface AuthInputProps {
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
inputType?: HTMLInputTypeAttribute;
name?: string;
autoComplete?: string;
Expand All @@ -72,6 +73,7 @@ function InputMain({
placeholder,
value = "",
onChange,
onBlur,
inputType,
name,
autoComplete,
Expand Down Expand Up @@ -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>
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 =
Expand Down Expand Up @@ -305,6 +320,7 @@ export default function EmailLoginScreen() {
setter: Dispatch<SetStateAction<string>>,
) => {
setter(value);
setEmailTouched(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make touched resets field-specific.

handleInputChange is shared by email, password, and password-confirm inputs, but Line 323 always clears emailTouched. Typing in password or confirmation therefore hides the email error. handlePasswordChange also resets only passwordConfirmTouched, so passwordTouched remains true while the user corrects the password.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/login/EmailLoginScreen.tsx` at line 323, Make
touched-state resets field-specific in handleInputChange and
handlePasswordChange: ensure email input changes reset only emailTouched,
password input changes reset passwordTouched, and confirmation input changes
reset passwordConfirmTouched. Pass the appropriate setter through the shared
helper or perform the resets in each dedicated handler, without clearing
unrelated fields.

setLoginError(false);
setLoginErrorMessage(loginValidationErrorMessage);
setSignupErrorMessage("");
Expand All @@ -314,6 +330,7 @@ export default function EmailLoginScreen() {

const handlePasswordChange = (value: string) => {
handleInputChange(value, setPassword);
setPasswordConfirmTouched(false);

if (value.length === 0) {
setPasswordConfirm("");
Expand All @@ -322,6 +339,7 @@ export default function EmailLoginScreen() {

const handlePasswordConfirmChange = (value: string) => {
handleInputChange(value, setPasswordConfirm);
setPasswordConfirmTouched(false);
};

const focusVerificationInput = (index: number) => {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -534,6 +556,9 @@ export default function EmailLoginScreen() {
setPasswordConfirm("");
setVerificationCode([...initialVerificationCode]);
setHasVerificationError(false);
setEmailTouched(false);
setPasswordTouched(false);
setPasswordConfirmTouched(false);
hideCreditTooltip();
};

Expand Down Expand Up @@ -674,6 +699,7 @@ export default function EmailLoginScreen() {
onChange={(value) =>
handleInputChange(value, setEmail)
}
onBlur={() => setEmailTouched(true)}
/>
<InputMain
name="password"
Expand All @@ -686,6 +712,7 @@ export default function EmailLoginScreen() {
error={loginError ? loginErrorMessage : undefined}
className="self-stretch"
onChange={handlePasswordChange}
onBlur={() => setPasswordTouched(true)}
/>
</div>

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 emailTouched, but the signup email input has no onBlur, so format errors appear only after submit. The password-confirm onBlur is commented out, so mismatch errors likewise remain hidden until submit.

Add:

+ onBlur={() => setEmailTouched(true)}

and restore:

- // onBlur={() => setPasswordConfirmTouched(true)}
+ onBlur={() => setPasswordConfirmTouched(true)}

Also applies to: 824-824

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/components/login/EmailLoginScreen.tsx` around lines 774 - 780,
Wire blur handling for both signup fields: add an onBlur handler to the signup
email input that marks the email as touched, and restore the commented
password-confirmation onBlur handler so it marks that field as touched. Update
the signup form inputs near the signupErrorMessage logic and preserve the
existing validation/error conditions.

className="self-stretch"
gapClassName={authInputGapClass}
labelClassName={authInputLabelClass}
Expand All @@ -766,6 +799,7 @@ export default function EmailLoginScreen() {
gapClassName={authInputGapClass}
labelClassName={authInputLabelClass}
onChange={handlePasswordChange}
onBlur={() => setPasswordTouched(true)}
/>
<InputMain
label="비밀번호 확인"
Expand All @@ -787,6 +821,7 @@ export default function EmailLoginScreen() {
gapClassName={authInputGapClass}
labelClassName={authInputLabelClass}
onChange={handlePasswordConfirmChange}
// onBlur={() => setPasswordConfirmTouched(true)}
/>
</div>

Expand Down
38 changes: 22 additions & 16 deletions jobdri/src/lib/api/credit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Both callers use as unknown as casts because the credit API layer doesn't declare its response shapes. Fix the contracts at the source instead of asserting at each call site.

  • jobdri/src/lib/api/credit.ts#L124-L129: give checkPaymentStatus an explicit return type and unwrap result; add checkoutPage to PreparePaymentResult.
  • jobdri/src/components/common/cards/CreditCard.tsx#L57-L72: drop the cast and the unreachable data.result?.checkoutPage fallback, reading response.checkoutPage.
  • jobdri/src/app/credit/page.tsx#L45-L49: drop the cast and read response.status directly.
📍 Affects 3 files
  • jobdri/src/lib/api/credit.ts#L124-L129 (this comment)
  • jobdri/src/components/common/cards/CreditCard.tsx#L57-L72
  • jobdri/src/app/credit/page.tsx#L45-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/credit.ts` around lines 124 - 129, Update
jobdri/src/lib/api/credit.ts lines 124-129 by giving checkPaymentStatus an
explicit response type and returning the unwrapped result; extend
PreparePaymentResult with checkoutPage. In
jobdri/src/components/common/cards/CreditCard.tsx lines 57-72, remove the cast
and fallback, and read response.checkoutPage directly. In
jobdri/src/app/credit/page.tsx lines 45-49, remove the cast and read
response.status directly.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

checkPaymentStatus breaks the conventions every other call in this file follows: missing API_BASE_URL, auth headers, and checkResponse.

The relative path resolves against the Next.js app origin instead of the API host, and without getAuthHeaders() a user-scoped order lookup will 401. Bypassing checkResponse also skips handleUnauthorized() on 401, so the polling loop in jobdri/src/app/credit/page.tsx (Lines 68-74) surfaces a generic Korean alert instead of redirecting to login. Also unwrap result and type the return so callers don't need as unknown as assertions.

🔧 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
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;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobdri/src/lib/api/credit.ts` around lines 124 - 129, Update
checkPaymentStatus to follow the file’s established API request pattern: build
the URL with API_BASE_URL, include getAuthHeaders(), and pass the response
through checkResponse so unauthorized responses invoke handleUnauthorized().
Unwrap the response’s result field and declare the returned payment-status type,
allowing callers to use it without casts.

}