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
3 changes: 2 additions & 1 deletion jobdri/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions jobdri/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 43 additions & 9 deletions jobdri/src/app/credit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
"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";
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;
Expand All @@ -31,6 +33,17 @@ function CreditContent() {
number | null
>(null);
const searchParams = useSearchParams();
const [toastMessage, setToastMessage] = useState<string | null>(null);
const router = useRouter();

useEffect(() => {
if (toastMessage) {
const timer = setTimeout(() => {
setToastMessage(null);
}, 3000);
return () => clearTimeout(timer);
}
}, [toastMessage]);

useEffect(() => {
fetchCreditPlans()
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -80,7 +103,6 @@ function CreditContent() {
}
};

// 동기적 setState 호출 경고(Cascading renders)를 방지하기 위해 Promise로 감싸서 실행
Promise.resolve().then(() => {
setIsConfirming(true);
pollStatus();
Expand All @@ -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;

Expand Down
34 changes: 34 additions & 0 deletions jobdri/src/app/credit/payment-cancel/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen w-full items-center justify-center bg-[#F5F6F9]">
<div className="text-h24-bold text-text-neutral-title animate-pulse">
결제를 취소하고 있습니다...
</div>
</div>
);
}

export default function PaymentCancelPage() {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center bg-[#F5F6F9]">
로딩 중...
</div>
}
>
<PaymentCancelContent />
</Suspense>
);
}
44 changes: 44 additions & 0 deletions jobdri/src/app/credit/payment-result/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen w-full items-center justify-center bg-[#F5F6F9]">
<div className="text-h24-bold text-text-neutral-title animate-pulse">
결제 결과를 처리하고 있습니다...
</div>
</div>
);
}

export default function PaymentResultPage() {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center bg-[#F5F6F9]">
로딩 중...
</div>
}
>
<PaymentResultContent />
</Suspense>
);
}
27 changes: 12 additions & 15 deletions jobdri/src/components/common/cards/CreditCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};

Expand Down
9 changes: 6 additions & 3 deletions jobdri/src/components/common/lnb/Lnb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type LnbRecentItem,
type LnbNavItem,
} from "./LnbShared";
import { useCreditStore } from "@/lib/store/useCreditStore";

interface LnbProps {
initialActiveItem?: LnbItemKey;
Expand Down Expand Up @@ -90,7 +91,7 @@ export default function Lnb({
const emailInitial = getEmailInitial(displayEmail);

// States
const [creditCount, setCreditCount] = useState<number>(0);
const { creditCount, setCreditCount } = useCreditStore();
const [isFold, setIsFold] = useState(false);
const [showComingSoonModal, setShowComingSoonModal] = useState(false);
const [activeItem, setActiveItem] = useState<LnbItemKey | undefined>(
Expand Down Expand Up @@ -297,7 +298,7 @@ export default function Lnb({
fetchCreditBalance({ redirectOnUnauthorized: false })
.then(setCreditCount)
.catch(() => {});
}, [disableCreditFetch]);
}, []);

// Handlers
const handleToggleFold = () => setIsFold((prev) => !prev);
Expand Down Expand Up @@ -405,7 +406,9 @@ export default function Lnb({
<ModalNotice
type="alertModal"
title="아직 준비중인 서비스입니다"
description={"더 나은 서비스를 위해 노력하고 있습니다!\n조금만 기다려 주세요"}
description={
"더 나은 서비스를 위해 노력하고 있습니다!\n조금만 기다려 주세요"
}
onClose={() => setShowComingSoonModal(false)}
primaryAction={{
label: "확인",
Expand Down
49 changes: 21 additions & 28 deletions jobdri/src/lib/api/credit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,14 @@ export async function fetchCreditPlans(): Promise<CreditPlan[]> {
}

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<PreparePaymentResult> {
Expand All @@ -105,27 +104,21 @@ 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 }),
// });
// 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();
}

Expand Down
Loading