From 1c4bab9016b8f003f9331bff65479b9a031d6832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9C=A4=EC=84=9C?= Date: Fri, 31 Jul 2026 18:36:32 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20=ED=86=A0=EC=8A=A4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/package.json | 3 +- jobdri/pnpm-lock.yaml | 26 ++++++++++ jobdri/src/app/credit/page.tsx | 52 +++++++++++++++---- jobdri/src/app/credit/payment-cancel/page.tsx | 34 ++++++++++++ jobdri/src/app/credit/payment-result/page.tsx | 44 ++++++++++++++++ .../components/common/cards/CreditCard.tsx | 27 +++++----- jobdri/src/components/common/lnb/Lnb.tsx | 9 ++-- jobdri/src/lib/api/credit.ts | 49 ++++++++--------- jobdri/src/lib/store/useCreditStore.ts | 11 ++++ 9 files changed, 199 insertions(+), 56 deletions(-) create mode 100644 jobdri/src/app/credit/payment-cancel/page.tsx create mode 100644 jobdri/src/app/credit/payment-result/page.tsx create mode 100644 jobdri/src/lib/store/useCreditStore.ts diff --git a/jobdri/package.json b/jobdri/package.json index 2d4e213..f4a33d7 100644 --- a/jobdri/package.json +++ b/jobdri/package.json @@ -26,7 +26,8 @@ "lottie-react": "^2.4.1", "next": "16.2.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.14" }, "devDependencies": { "@svgr/webpack": "^8.1.0", diff --git a/jobdri/pnpm-lock.yaml b/jobdri/pnpm-lock.yaml index 26239c8..8893b03 100644 --- a/jobdri/pnpm-lock.yaml +++ b/jobdri/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.14)(react@19.2.4) devDependencies: '@svgr/webpack': specifier: ^8.1.0 @@ -2832,6 +2835,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -6007,3 +6028,8 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index b6ce236..335c2f4 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, Suspense } from "react"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import { CreditCard } from "@/components/common/cards"; import Useage from "@/components/common/credit/Useage"; import CouponRegistrationModal from "@/components/common/credit/CouponRegistrationModal"; @@ -9,12 +9,14 @@ import { fetchCreditPlans, checkPaymentStatus, type CreditPlan, + fetchCreditBalance, } from "@/lib/api/credit"; import Lnb from "@/components/common/lnb/Lnb"; import PageHeader from "@/components/common/PageHeader"; import { BusinessFooter } from "@/components/common/footer"; -import { Button } from "@/components/common/buttons"; import { Toast } from "@/components/common/toast"; +import { useCreditStore } from "@/lib/store/useCreditStore"; +import { Button } from "@/components/common/buttons"; function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { const original = basePricePerUnit * plan.creditAmount; @@ -31,6 +33,17 @@ function CreditContent() { number | null >(null); const searchParams = useSearchParams(); + const [toastMessage, setToastMessage] = useState(null); + const router = useRouter(); + + useEffect(() => { + if (toastMessage) { + const timer = setTimeout(() => { + setToastMessage(null); + }, 3000); + return () => clearTimeout(timer); + } + }, [toastMessage]); useEffect(() => { fetchCreditPlans() @@ -39,14 +52,14 @@ function CreditContent() { }, []); useEffect(() => { - const orderId = searchParams.get("orderId"); + const paymentId = searchParams.get("paymentId"); - if (orderId) { + if (paymentId) { let isPolling = true; const pollStatus = async () => { try { - const response = await checkPaymentStatus(orderId); + const response = await checkPaymentStatus(paymentId); // API 타입이 지정되지 않은 경우를 대비한 타입 단언 const data = response as unknown as { status?: string; @@ -58,13 +71,23 @@ function CreditContent() { isPolling = false; window.history.replaceState(null, "", window.location.pathname); setIsConfirming(false); - alert("크레딧 충전이 완료되었습니다!"); - window.location.reload(); + + // 서버에서 최신 잔액 조회 + fetchCreditBalance() + .then((latestBalance) => { + useCreditStore.getState().setCreditCount(latestBalance); + setToastMessage("크레딧 충전이 완료되었습니다."); + }) + .catch((err) => { + console.error("잔액 갱신 실패:", err); + alert("결제는 완료되었으나 잔액 동기화에 실패했습니다."); + window.location.reload(); + }); } else if (status === "FAILED") { isPolling = false; window.history.replaceState(null, "", window.location.pathname); setIsConfirming(false); - alert("결제에 실패했거나 취소되었습니다."); + setToastMessage("결제에 실패했거나 취소되었습니다."); } else { // PENDING, PROCESSING, UNKNOWN 상태일 경우 계속 폴링 if (isPolling) { @@ -80,7 +103,6 @@ function CreditContent() { } }; - // 동기적 setState 호출 경고(Cascading renders)를 방지하기 위해 Promise로 감싸서 실행 Promise.resolve().then(() => { setIsConfirming(true); pollStatus(); @@ -93,6 +115,18 @@ function CreditContent() { } }, [searchParams]); + useEffect(() => { + const isCanceled = searchParams.get("cancel"); + + if (isCanceled === "true") { + setTimeout(() => { + setToastMessage("결제가 취소되었습니다."); + }, 100); + + window.history.replaceState(null, "", window.location.pathname); + } + }, [searchParams]); + useEffect(() => { if (couponToastCreditAmount === null) return; diff --git a/jobdri/src/app/credit/payment-cancel/page.tsx b/jobdri/src/app/credit/payment-cancel/page.tsx new file mode 100644 index 0000000..edff84a --- /dev/null +++ b/jobdri/src/app/credit/payment-cancel/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, Suspense } from "react"; +import { useRouter } from "next/navigation"; + +function PaymentCancelContent() { + const router = useRouter(); + + useEffect(() => { + router.replace(`/credit?cancel=true`); + }, [router]); + + return ( +
+
+ 결제를 취소하고 있습니다... +
+
+ ); +} + +export default function PaymentCancelPage() { + return ( + + 로딩 중... + + } + > + + + ); +} diff --git a/jobdri/src/app/credit/payment-result/page.tsx b/jobdri/src/app/credit/payment-result/page.tsx new file mode 100644 index 0000000..528fbe5 --- /dev/null +++ b/jobdri/src/app/credit/payment-result/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +function PaymentResultContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const orderNo = + searchParams.get("orderNo") || + searchParams.get("paymentId") || + searchParams.get("orderId"); + + if (orderNo) { + router.replace(`/credit?orderId=${orderNo}`); + } else { + router.replace(`/credit?cancel=true`); + } + }, [router, searchParams]); + + return ( +
+
+ 결제 결과를 처리하고 있습니다... +
+
+ ); +} + +export default function PaymentResultPage() { + return ( + + 로딩 중... + + } + > + + + ); +} diff --git a/jobdri/src/components/common/cards/CreditCard.tsx b/jobdri/src/components/common/cards/CreditCard.tsx index 2bccdc4..2aed4c2 100644 --- a/jobdri/src/components/common/cards/CreditCard.tsx +++ b/jobdri/src/components/common/cards/CreditCard.tsx @@ -54,27 +54,24 @@ export default function CreditCard({ const handleConfirmPurchase = async () => { setIsLoading(true); try { - // 결제 준비 API 호출 - const response = await preparePurchase(planCode); + // 결제 준비 + const paymentData = 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); + // 토스페이 결제창(checkoutPage)으로 리다이렉트 + if (paymentData.checkoutPage) { + window.location.assign(paymentData.checkoutPage); } else { throw new Error("결제 페이지 URL을 찾을 수 없습니다."); } } catch (error: unknown) { - console.error("결제 준비 중 오류:", error); - setToastMessage("결제 진행 중 오류가 발생했습니다."); - setIsLoading(false); + console.error("결제 진행 중 오류:", error); + const errorMessage = + error instanceof Error + ? error.message + : "결제 진행 중 오류가 발생했습니다."; + setToastMessage(errorMessage); setIsModalOpen(false); + setIsLoading(false); } }; diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 5bc6f39..1924c98 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -38,6 +38,7 @@ import { type LnbRecentItem, type LnbNavItem, } from "./LnbShared"; +import { useCreditStore } from "@/lib/store/useCreditStore"; interface LnbProps { initialActiveItem?: LnbItemKey; @@ -90,7 +91,7 @@ export default function Lnb({ const emailInitial = getEmailInitial(displayEmail); // States - const [creditCount, setCreditCount] = useState(0); + const { creditCount, setCreditCount } = useCreditStore(); const [isFold, setIsFold] = useState(false); const [showComingSoonModal, setShowComingSoonModal] = useState(false); const [activeItem, setActiveItem] = useState( @@ -297,7 +298,7 @@ export default function Lnb({ fetchCreditBalance({ redirectOnUnauthorized: false }) .then(setCreditCount) .catch(() => {}); - }, [disableCreditFetch]); + }, []); // Handlers const handleToggleFold = () => setIsFold((prev) => !prev); @@ -405,7 +406,9 @@ export default function Lnb({ setShowComingSoonModal(false)} primaryAction={{ label: "확인", diff --git a/jobdri/src/lib/api/credit.ts b/jobdri/src/lib/api/credit.ts index 054aa36..29998c2 100644 --- a/jobdri/src/lib/api/credit.ts +++ b/jobdri/src/lib/api/credit.ts @@ -80,15 +80,14 @@ export async function fetchCreditPlans(): Promise { } export interface PreparePaymentResult { - paymentId: number; - orderId: string; - orderName: string; - amount: number; - creditAmount: number; - clientKey: string; - customerEmail: string; + orderId: number; // JobDri 내부 결제 DB ID + orderName: string; // 주문명 + amount: number; // 결제 금액 + creditAmount: number; // 충전될 크레딧 수량 + checkoutPage: string; // 토스페이 결제창 URL (리다이렉트용) } +// 토스 결제 준비 API export async function preparePurchase( planCode: PlanCode, ): Promise { @@ -105,27 +104,21 @@ 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 }), -// }); -// checkResponse(response, "결제 승인에 실패했습니다."); -// } - -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"); +// 결제 상태 확인 API (폴링 또는 복귀 페이지에서 사용) +export async function checkPaymentStatus(orderId: string | number) { + const response = await fetch( + `${API_BASE_URL}/api/payments/orders/${orderId}`, + { + method: "GET", + headers: getAuthHeaders(), + }, + ); + + if (!response.ok) { + console.warn(`결제 상태 조회 지연 중... (상태코드: ${response.status})`); + return { result: { status: "UNKNOWN" } }; + } + return response.json(); } diff --git a/jobdri/src/lib/store/useCreditStore.ts b/jobdri/src/lib/store/useCreditStore.ts new file mode 100644 index 0000000..a8a6c48 --- /dev/null +++ b/jobdri/src/lib/store/useCreditStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface CreditState { + creditCount: number; // 현재 크레딧 잔액 + setCreditCount: (count: number) => void; // 크레딧 업데이트 함수 +} + +export const useCreditStore = create((set) => ({ + creditCount: 0, + setCreditCount: (count) => set({ creditCount: count }), +}));