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
24 changes: 12 additions & 12 deletions jobdri/src/app/credit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,30 @@ function CreditContent() {
const orderId = searchParams.get("orderId");
const amount = searchParams.get("amount");

// URL์— ๊ฒฐ์ œ ์ •๋ณด๊ฐ€ ์žˆ์œผ๋ฉด ์Šน์ธ ๋กœ์ง ์‹œ์ž‘
if (paymentKey && orderId && amount) {
const processConfirm = async () => {
setIsConfirming(true); // ํ™”๋ฉด ์ž ๊ธˆ (์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€)
setIsConfirming(true); // ํ™”๋ฉด ์ž ๊ธˆ

try {
await confirmPurchase(paymentKey, orderId, Number(amount));

alert("ํฌ๋ ˆ๋”ง ์ถฉ์ „์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!");
window.history.replaceState(null, "", window.location.pathname);
setIsConfirming(false);
setTimeout(() => {
alert("ํฌ๋ ˆ๋”ง ์ถฉ์ „์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!");
window.location.reload();
}, 100);
} catch (error) {
console.error("๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ:", error);
alert("๊ฒฐ์ œ ์Šน์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.");
} finally {
setIsConfirming(false);
// ์ƒˆ๋กœ๊ณ ์นจ ์‹œ ์ค‘๋ณต ํ˜ธ์ถœ ๋ฐฉ์ง€
router.replace("/credit");
alert("๊ฒฐ์ œ ์Šน์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.");
// ์‹คํŒจ ์‹œ์—๋„ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋‚ ๋ ค ์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€
window.history.replaceState(null, "", window.location.pathname);
}
};

processConfirm();
}
}, [searchParams, router]);
}, [searchParams]);
Comment on lines 40 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

๐Ÿ—„๏ธ Data Integrity & Integration | ๐ŸŸ  Major | โšก Quick win

Query params are cleared after confirmation, not before โ€” risks duplicate confirmPurchase calls.

The stated design ("clears them before confirmation") isn't what the code does: confirmPurchase runs first, and window.history.replaceState only clears paymentKey/orderId/amount afterward. If the user refreshes the page while the confirm request is still in flight (or the effect re-fires for any reason), the same query params are still present and processConfirm will run again with the same paymentKey, sending a duplicate confirmation request for the same payment.

๐Ÿ› Proposed fix
-import { useEffect, useState, Suspense } from "react";
+import { useEffect, useRef, useState, Suspense } from "react";
@@
 function CreditContent() {
   const [plans, setPlans] = useState<CreditPlan[]>([]);
   const [isConfirming, setIsConfirming] = useState(false); // ์Šน์ธ ์ค‘ ๋กœ๋”ฉ ์ƒํƒœ ์ถ”๊ฐ€
   const searchParams = useSearchParams();
   const router = useRouter(); // ๋ผ์šฐํ„ฐ ํ›… ์ถ”๊ฐ€
+  const hasProcessedPaymentRef = useRef(false);
@@
     if (paymentKey && orderId && amount) {
+    if (paymentKey && orderId && amount && !hasProcessedPaymentRef.current) {
+      hasProcessedPaymentRef.current = true;
       const processConfirm = async () => {
         setIsConfirming(true); // ํ™”๋ฉด ์ž ๊ธˆ
+        // Clear the query params immediately so a refresh/remount can't replay this confirmation.
+        window.history.replaceState(null, "", window.location.pathname);
 
         try {
           await confirmPurchase(paymentKey, orderId, Number(amount));
-          window.history.replaceState(null, "", window.location.pathname);
           setIsConfirming(false);
           setTimeout(() => {
             alert("ํฌ๋ ˆ๋”ง ์ถฉ์ „์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!");
             window.location.reload();
           }, 100);
         } catch (error) {
           console.error("๊ฒฐ์ œ ์Šน์ธ ์‹คํŒจ:", error);
           setIsConfirming(false);
           alert("๊ฒฐ์ œ ์Šน์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.");
-          // ์‹คํŒจ ์‹œ์—๋„ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋‚ ๋ ค ์ค‘๋ณต ์š”์ฒญ ๋ฐฉ์ง€
-          window.history.replaceState(null, "", window.location.pathname);
         }
       };
 
       processConfirm();
     }
๐Ÿค– 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 40 - 63, Update the confirmation
flow in the useEffect around processConfirm so window.history.replaceState
removes the payment query parameters before calling confirmPurchase. Keep the
captured paymentKey, orderId, and amount available for the in-flight request,
while ensuring refreshes or effect re-runs no longer see the original parameters
and trigger duplicate confirmations.


const basePricePerUnit =
plans.find((p) => p.planCode === "ONE_TIME")?.price ?? 2500;
Expand All @@ -82,9 +85,6 @@ function CreditContent() {
price={plan.price.toLocaleString()}
planCode={plan.planCode}
discountRate={calcDiscountRate(plan, basePricePerUnit)}
discountLabel={
calcDiscountRate(plan, basePricePerUnit) ? "ํ• ์ธ" : ""
}
/>
))}
</section>
Expand Down
97 changes: 79 additions & 18 deletions jobdri/src/components/common/cards/CreditCard.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
"use client";

import { useState } from "react";
import { useState, useEffect } from "react";
import type { HTMLAttributes } from "react";
import clsx from "clsx";
import { Button } from "@/components/common/buttons";
import ModalPurchase from "@/components/common/modal/ModalPurchase";
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> {
creditCount?: number;
creditLabel?: string;
price?: string;
currencyLabel?: string;
discountRate?: string;
discountLabel?: string;
buttonLabel?: string;
planCode: PlanCode;
onPurchase?: () => void;
Expand All @@ -27,38 +28,98 @@ export default function CreditCard({
planCode,
onPurchase,
className,
discountLabel,
creditLabel,
currencyLabel,
...articleProps
}: CreditCardProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);

const handlePurchaseClick = () => {
onPurchase?.();
setIsModalOpen(true);
};

const handleCloseModal = () => {
if (!isLoading) setIsModalOpen(false);
};

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

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("๊ฒฐ์ œ๋ฅผ ์ทจ์†Œํ•˜์˜€์Šต๋‹ˆ๋‹ค.");
} else {
console.error("๊ฒฐ์ œ ์ฐฝ ํ˜ธ์ถœ ์ค‘ ์˜ค๋ฅ˜:", tossError.message || error);
setToastMessage("๊ฒฐ์ œ ์ง„ํ–‰ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
}
} finally {
setIsLoading(false);
setIsModalOpen(false);
}
};
Comment on lines +55 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

๐Ÿ—„๏ธ Data Integrity & Integration | ๐ŸŸ  Major | โšก Quick win

Add a re-entrancy guard to handleConfirmPurchase.

Nothing prevents handleConfirmPurchase from being invoked again while a purchase is already in flight โ€” ModalCard's primary button isn't disabled based on isLoading (only its label text changes), and the handler itself doesn't check isLoading before starting. A rapid double-click before the first click's render commits can trigger two concurrent preparePurchase() + payment.requestPayment() calls, risking duplicate payment windows/charges.

๐Ÿ› Proposed fix
   const handleConfirmPurchase = async () => {
+    if (isLoading) return;
     setIsLoading(true);
     try {

Also applies to: 100-111

๐Ÿค– 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/common/cards/CreditCard.tsx` around lines 55 - 90,
Update handleConfirmPurchase to return immediately when isLoading is already
true, before setting loading state or calling preparePurchase, preventing
concurrent purchase requests from rapid repeated invocation. Preserve the
existing purchase flow and cleanup behavior for the first invocation.


return (
<>
{toastMessage && (
<div className="fixed top-10 left-1/2 z-[9999] -translate-x-1/2">
<Toast message={toastMessage} position="top" />
</div>
)}

{isModalOpen && (
<ModalPurchase
creditCount={creditCount}
price={price}
planCode={planCode}
onClose={() => setIsModalOpen(false)}
title="ํฌ๋ ˆ๋”ง์„ ์ถฉ์ „ํ• ๊นŒ์š”?"
/>
<ModalOverlay onClose={handleCloseModal}>
<ModalCard
onSecondaryClick={handleCloseModal}
onPrimaryClick={handleConfirmPurchase}
title="ํฌ๋ ˆ๋”ง์„ ์ถฉ์ „ํ• ๊นŒ์š”?"
description={`${creditCount} ํฌ๋ ˆ๋”ง์„ ์ถฉ์ „ํ•ฉ๋‹ˆ๋‹ค.`}
secondaryBtn="๋‹ซ๊ธฐ"
primaryBtn={isLoading ? "์ฒ˜๋ฆฌ ์ค‘..." : "์ถฉ์ „ํ•˜๊ธฐ"}
/>
</ModalOverlay>
)}
<article

{/* ํฌ๋ ˆ๋”ง ์นด๋“œ UI */}
<main
className={clsx(
"flex w-full flex-col items-center h-fit rounded-card bg-bg-contents-default px-8 pt-8 pb-7 shadow-card",
className,
)}
{...articleProps}
>
<div className="flex flex-1 flex-col justify-between gap-8 w-full">
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-4">
<div className="flex items-end justify-center gap-1">
<span className="text-center text-[32px] font-bold leading-[130%] text-text-neutral-title ">
{creditCount}
Expand Down Expand Up @@ -100,7 +161,7 @@ export default function CreditCard({
</p>
</div>
</div>
</article>
</main>
</>
);
}
2 changes: 1 addition & 1 deletion jobdri/src/components/common/credit/CreditTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const defaultRows: CreditRowData[] = Array.from({ length: 6 }, (_, i) => ({
export default function CreditTable({ rows = defaultRows }: CreditTableProps) {
return (
<section className="flex flex-col gap-4 w-full rounded-card-l border border-line-neutral-default">
<div className="flex flex-col overflow-hidden rounded-card-l border-t border-line-neutral-default">
<div className="flex flex-col overflow-hidden rounded-card-l">
<CreditHeader />
{rows.map((row, index) => (
<CreditRow
Expand Down
7 changes: 2 additions & 5 deletions jobdri/src/components/common/lnb/Lnb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { createPortal } from "react-dom";
import clsx from "clsx";
import { useRouter } from "next/navigation";
import { ModalNotice } from "@/components/common/modal";
import ModalNotice from "../modal/ModalNotice";
import { Toast, type ToastVariant } from "@/components/common/toast";
import {
AUTH_STORAGE_KEYS,
Expand Down Expand Up @@ -281,10 +281,7 @@ export default function Lnb({
);
};

window.addEventListener(
MOCK_APPLY_DELETED_EVENT,
handleMockApplyDeleted,
);
window.addEventListener(MOCK_APPLY_DELETED_EVENT, handleMockApplyDeleted);

return () => {
window.removeEventListener(
Expand Down
2 changes: 1 addition & 1 deletion jobdri/src/components/common/modal/ModalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ModalCard: React.FC<ModalCardProps> = ({
onErrorClick,
}) => {
return (
<div className="bg-white rounded-modal shadow-modal flex flex-col h-fil w-full max-w-[400px]">
<div className="bg-white rounded-modal shadow-modal flex flex-col h-full w-100">
<div className="flex flex-col justify-center items-start mx-7 mt-7">
<h3 className="text-t20-semibold text-text-neutral-title text-center mb-3">
{title}
Expand Down
8 changes: 6 additions & 2 deletions jobdri/src/components/common/modal/ModalOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { useEffect, type ReactNode } from "react";

interface ModalOverlayProps {
children: ReactNode;
onClose?: () => void;
}

export function ModalOverlay({ children }: ModalOverlayProps) {
export function ModalOverlay({ children, onClose }: ModalOverlayProps) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
Expand All @@ -15,7 +16,10 @@ export function ModalOverlay({ children }: ModalOverlayProps) {
}, []);

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity"
onClick={onClose}
>
<div className="relative z-[101]" onClick={(e) => e.stopPropagation()}>
{children}
</div>
Expand Down
124 changes: 0 additions & 124 deletions jobdri/src/components/common/modal/ModalPurchase.tsx

This file was deleted.

6 changes: 0 additions & 6 deletions jobdri/src/components/common/modal/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +0,0 @@
export { default as ModalInput } from "./ModalInput";
export { default as ModalFileUpload } from "./ModalFileUpload";
export { default as ModalLinkInputDemo } from "./ModalLinkInputDemo";
export { default as ModalNotice } from "./ModalNotice";
export { default as ModalPurchase } from "./ModalPurchase";
export { default as ModalAdd } from "./ModalAdd";