From fd33fefb263cbea4806c1425c962e2349c3f33b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Wed, 29 Jul 2026 17:59:00 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EB=B3=80=ED=99=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 66 +++++++++++++------ .../components/common/cards/CreditCard.tsx | 45 +++++-------- .../src/components/login/EmailLoginScreen.tsx | 53 ++++++++++++--- jobdri/src/lib/api/credit.ts | 38 ++++++----- 4 files changed, 128 insertions(+), 74 deletions(-) diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index f9740ed..e2e96ac 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -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"; @@ -22,9 +22,9 @@ function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { function CreditContent() { const [plans, setPlans] = useState([]); - const [isConfirming, setIsConfirming] = useState(false); // 승인 중 로딩 상태 추가 + const [isConfirming, setIsConfirming] = useState(false); const searchParams = useSearchParams(); - const router = useRouter(); // 라우터 훅 추가 + const router = useRouter(); useEffect(() => { fetchCreditPlans() @@ -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]); @@ -69,11 +94,10 @@ function CreditContent() { <> - {/* 결제 승인 중일 때 화면을 덮는 로딩 UI */} {isConfirming && (
- 결제를 안전하게 승인하고 있습니다... + 결제 결과를 확인하고 있습니다...
)} diff --git a/jobdri/src/components/common/cards/CreditCard.tsx b/jobdri/src/components/common/cards/CreditCard.tsx index 08ddb57..2bccdc4 100644 --- a/jobdri/src/components/common/cards/CreditCard.tsx +++ b/jobdri/src/components/common/cards/CreditCard.tsx @@ -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 { @@ -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); } diff --git a/jobdri/src/components/login/EmailLoginScreen.tsx b/jobdri/src/components/login/EmailLoginScreen.tsx index 4eb096d..14132ff 100644 --- a/jobdri/src/components/login/EmailLoginScreen.tsx +++ b/jobdri/src/components/login/EmailLoginScreen.tsx @@ -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} /> @@ -142,11 +147,10 @@ function InputMain({ ); } -interface AuthSingleLineProps - extends Omit< - InputHTMLAttributes, - "value" | "onChange" | "disabled" | "className" - > { +interface AuthSingleLineProps extends Omit< + InputHTMLAttributes, + "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>([]); + // 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>, ) => { setter(value); + setEmailTouched(false); 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) => { 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)} /> setPasswordTouched(true)} /> @@ -744,7 +771,13 @@ export default function EmailLoginScreen() { hasSignupEmailValidationError || Boolean(signupErrorMessage) } - error={signupErrorMessage || undefined} + error={ + signupErrorMessage + ? signupErrorMessage + : hasSignupEmailValidationError + ? "이메일 형식이 맞지 않습니다." + : undefined + } className="self-stretch" gapClassName={authInputGapClass} labelClassName={authInputLabelClass} @@ -766,6 +799,7 @@ export default function EmailLoginScreen() { gapClassName={authInputGapClass} labelClassName={authInputLabelClass} onChange={handlePasswordChange} + onBlur={() => setPasswordTouched(true)} /> setPasswordConfirmTouched(true)} /> diff --git a/jobdri/src/lib/api/credit.ts b/jobdri/src/lib/api/credit.ts index 091ca0c..69278fd 100644 --- a/jobdri/src/lib/api/credit.ts +++ b/jobdri/src/lib/api/credit.ts @@ -49,9 +49,7 @@ export async function fetchCreditBalance({ export async function fetchCreditTransactions( type?: TransactionType, ): Promise { - 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 { - 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 { +// 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(); }