From e2bacd2c0d396c201fde5d12da69c34e6eedb403 Mon Sep 17 00:00:00 2001 From: minnngo Date: Wed, 29 Jul 2026 17:45:19 +0900 Subject: [PATCH 1/5] =?UTF-8?q?Feat:=20=EC=BF=A0=ED=8F=B0=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 22 ++++- jobdri/src/components/common/PageHeader.tsx | 2 +- .../common/buttons/ButtonCtaModal.tsx | 2 +- .../common/credit/CouponRegistrationModal.tsx | 84 +++++++++++++++++++ .../common/input/InputTextAreaAutoGrowS.tsx | 27 +++--- .../common/input/InputTextAreaFixedShared.tsx | 1 + .../components/common/modal/ModalOverlay.tsx | 16 +++- 7 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 jobdri/src/components/common/credit/CouponRegistrationModal.tsx diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index f9740ed..23367e4 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useEffect, useState, Suspense } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; +import { useSearchParams } 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, confirmPurchase, @@ -12,6 +13,7 @@ import { 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"; function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { const original = basePricePerUnit * plan.creditAmount; @@ -23,8 +25,8 @@ function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { function CreditContent() { const [plans, setPlans] = useState([]); const [isConfirming, setIsConfirming] = useState(false); // 승인 중 로딩 상태 추가 + const [isCouponModalOpen, setIsCouponModalOpen] = useState(false); const searchParams = useSearchParams(); - const router = useRouter(); // 라우터 훅 추가 useEffect(() => { fetchCreditPlans() @@ -67,7 +69,21 @@ function CreditContent() { return ( <> - +
+ +
+ + {isCouponModalOpen && ( + setIsCouponModalOpen(false)} + /> + )} {/* 결제 승인 중일 때 화면을 덮는 로딩 UI */} {isConfirming && ( diff --git a/jobdri/src/components/common/PageHeader.tsx b/jobdri/src/components/common/PageHeader.tsx index cfafb0b..16f7f90 100644 --- a/jobdri/src/components/common/PageHeader.tsx +++ b/jobdri/src/components/common/PageHeader.tsx @@ -18,7 +18,7 @@ export default function PageHeader() { routeTitles[pathname] ?? pathname.split("/").filter(Boolean).pop() ?? "홈"; return ( -
+

{title}

{PageDescriptions[pathname]} diff --git a/jobdri/src/components/common/buttons/ButtonCtaModal.tsx b/jobdri/src/components/common/buttons/ButtonCtaModal.tsx index a6acdc2..730f978 100644 --- a/jobdri/src/components/common/buttons/ButtonCtaModal.tsx +++ b/jobdri/src/components/common/buttons/ButtonCtaModal.tsx @@ -76,7 +76,7 @@ export default function ButtonCtaModal({ className={clsx("h-[46px] w-full", submitClassName)} /> ) : stack === "stack2_horizontal" ? ( -

+
- + {showBottomLine && ( + + )}
void; diff --git a/jobdri/src/components/common/modal/ModalOverlay.tsx b/jobdri/src/components/common/modal/ModalOverlay.tsx index 96890ca..7837ef8 100644 --- a/jobdri/src/components/common/modal/ModalOverlay.tsx +++ b/jobdri/src/components/common/modal/ModalOverlay.tsx @@ -9,15 +9,25 @@ interface ModalOverlayProps { export function ModalOverlay({ children, onClose }: ModalOverlayProps) { useEffect(() => { + const previousOverflow = document.body.style.overflow; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose?.(); + } + }; + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", handleKeyDown); + return () => { - document.body.style.overflow = "unset"; + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", handleKeyDown); }; - }, []); + }, [onClose]); return (
e.stopPropagation()}> From 09f1c11f9ae87616c8582a7376a3358e4cd1cd54 Mon Sep 17 00:00:00 2001 From: minnngo Date: Wed, 29 Jul 2026 17:49:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?Feat:=20=EC=BF=A0=ED=8F=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20api=20=EC=97=B0=EA=B2=B0=20[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 28 +++++++++++++ .../common/buttons/ButtonCtaModal.tsx | 4 ++ .../common/credit/CouponRegistrationModal.tsx | 26 +++++++++++- jobdri/src/lib/api/credit.ts | 40 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index 23367e4..de2dbc1 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -14,6 +14,7 @@ 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"; function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { const original = basePricePerUnit * plan.creditAmount; @@ -26,6 +27,9 @@ function CreditContent() { const [plans, setPlans] = useState([]); const [isConfirming, setIsConfirming] = useState(false); // 승인 중 로딩 상태 추가 const [isCouponModalOpen, setIsCouponModalOpen] = useState(false); + const [couponToastCreditAmount, setCouponToastCreditAmount] = useState< + number | null + >(null); const searchParams = useSearchParams(); useEffect(() => { @@ -64,6 +68,21 @@ function CreditContent() { } }, [searchParams]); + useEffect(() => { + if (couponToastCreditAmount === null) return; + + const toastTimer = window.setTimeout(() => { + setCouponToastCreditAmount(null); + }, 3000); + + return () => window.clearTimeout(toastTimer); + }, [couponToastCreditAmount]); + + const handleCouponRegistrationSuccess = (creditAmount: number) => { + setIsCouponModalOpen(false); + setCouponToastCreditAmount(creditAmount); + }; + const basePricePerUnit = plans.find((p) => p.planCode === "ONE_TIME")?.price ?? 2500; @@ -82,6 +101,15 @@ function CreditContent() { {isCouponModalOpen && ( setIsCouponModalOpen(false)} + onSuccess={handleCouponRegistrationSuccess} + /> + )} + + {couponToastCreditAmount !== null && ( + setCouponToastCreditAmount(null)} /> )} diff --git a/jobdri/src/components/common/buttons/ButtonCtaModal.tsx b/jobdri/src/components/common/buttons/ButtonCtaModal.tsx index 730f978..f341c0d 100644 --- a/jobdri/src/components/common/buttons/ButtonCtaModal.tsx +++ b/jobdri/src/components/common/buttons/ButtonCtaModal.tsx @@ -18,6 +18,7 @@ interface ButtonCtaModalProps { submitClassName?: string; cancelClassName?: string; className?: string; + submitDisabled?: boolean; } interface Stack3Item { @@ -59,6 +60,7 @@ export default function ButtonCtaModal({ submitClassName, cancelClassName, className, + submitDisabled = false, }: ButtonCtaModalProps) { return (
) : stack === "stack2_horizontal" ? ( @@ -89,6 +92,7 @@ export default function ButtonCtaModal({ size="large" styleType={submitStyleType} onClick={onSubmit} + disabled={submitDisabled} className={clsx("h-[46px] flex-1", submitClassName)} />
diff --git a/jobdri/src/components/common/credit/CouponRegistrationModal.tsx b/jobdri/src/components/common/credit/CouponRegistrationModal.tsx index 0ce0378..fdc5971 100644 --- a/jobdri/src/components/common/credit/CouponRegistrationModal.tsx +++ b/jobdri/src/components/common/credit/CouponRegistrationModal.tsx @@ -4,24 +4,44 @@ import { useState } from "react"; import { ButtonCtaModal } from "@/components/common/buttons"; import { InputTextAreaAutoGrowS } from "@/components/common/input"; import { ModalOverlay } from "@/components/common/modal/ModalOverlay"; +import { redeemCoupon } from "@/lib/api/credit"; interface CouponRegistrationModalProps { onClose: () => void; + onSuccess: (creditAmount: number) => void; } export default function CouponRegistrationModal({ onClose, + onSuccess, }: CouponRegistrationModalProps) { const [couponNumber, setCouponNumber] = useState(""); const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); const handleCouponNumberChange = (value: string) => { setCouponNumber(value); setError(""); }; - const handleSubmit = () => { - setError("쿠폰 번호를 확인해주세요."); + const handleSubmit = async () => { + const couponCode = couponNumber.trim(); + + if (!couponCode || isSubmitting) { + setError("쿠폰 번호를 확인해주세요."); + return; + } + + setIsSubmitting(true); + + try { + const result = await redeemCoupon(couponCode); + onSuccess(result.creditAmount); + } catch { + setError("쿠폰 번호를 확인해주세요."); + } finally { + setIsSubmitting(false); + } }; return ( @@ -60,6 +80,7 @@ export default function CouponRegistrationModal({ value={couponNumber} onChange={handleCouponNumberChange} error={error} + disabled={isSubmitting} showAddButton={false} showBottomLine={false} className="gap-1" @@ -75,6 +96,7 @@ export default function CouponRegistrationModal({ submitStyleType="secondary" onCancel={onClose} onSubmit={handleSubmit} + submitDisabled={isSubmitting} className="w-full !pb-0" />
diff --git a/jobdri/src/lib/api/credit.ts b/jobdri/src/lib/api/credit.ts index 091ca0c..a9f6966 100644 --- a/jobdri/src/lib/api/credit.ts +++ b/jobdri/src/lib/api/credit.ts @@ -122,3 +122,43 @@ export async function confirmPurchase( }); checkResponse(response, "결제 승인에 실패했습니다."); } + +export interface RedeemCouponResult { + couponCode: string; + creditAmount: number; + creditBalance: number; + redeemedAt: string; +} + +interface RedeemCouponResponse { + isSuccess: boolean; + code: string; + message: string; + result: RedeemCouponResult | null; + error: string | null; +} + +export async function redeemCoupon( + couponCode: string, +): Promise { + const response = await fetch(`${API_BASE_URL}/api/payments/coupons/redeem`, { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ couponCode }), + }); + + if (response.status === 401) handleUnauthorized(); + + const payload = (await response.json()) as RedeemCouponResponse; + + if (!response.ok || !payload.isSuccess || !payload.result) { + throw new Error( + payload.error || payload.message || "쿠폰 번호를 확인해주세요.", + ); + } + + return payload.result; +} From d185f756de9ea536fed82b1746484b9f7991d45c Mon Sep 17 00:00:00 2001 From: minnngo Date: Wed, 29 Jul 2026 18:26:21 +0900 Subject: [PATCH 3/5] =?UTF-8?q?Feat:=20=EC=BF=A0=ED=8F=B0=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index de2dbc1..6890e23 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -105,14 +105,6 @@ function CreditContent() { /> )} - {couponToastCreditAmount !== null && ( - setCouponToastCreditAmount(null)} - /> - )} - {/* 결제 승인 중일 때 화면을 덮는 로딩 UI */} {isConfirming && (
@@ -132,8 +124,16 @@ function CreditContent() { /> ))} -
+
+ {couponToastCreditAmount !== null && ( + setCouponToastCreditAmount(null)} + className="!absolute !right-7 !bottom-7 !w-[376px] !max-w-none" + /> + )}
); From 121b129de582303f078ece62217ac1fc02ac85e0 Mon Sep 17 00:00:00 2001 From: minnngo Date: Wed, 29 Jul 2026 20:18:19 +0900 Subject: [PATCH 4/5] =?UTF-8?q?Feat:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 21 +++++++++++--------- jobdri/src/components/common/toast/Toast.tsx | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index 6890e23..5101199 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -31,6 +31,9 @@ function CreditContent() { number | null >(null); const searchParams = useSearchParams(); + const isCouponToastPreview = + process.env.NODE_ENV === "development" && + searchParams.get("couponToastPreview") === "1"; useEffect(() => { fetchCreditPlans() @@ -105,6 +108,14 @@ function CreditContent() { /> )} + {(couponToastCreditAmount !== null || isCouponToastPreview) && ( + setCouponToastCreditAmount(null)} + /> + )} + {/* 결제 승인 중일 때 화면을 덮는 로딩 UI */} {isConfirming && (
@@ -124,16 +135,8 @@ function CreditContent() { /> ))} -
+
- {couponToastCreditAmount !== null && ( - setCouponToastCreditAmount(null)} - className="!absolute !right-7 !bottom-7 !w-[376px] !max-w-none" - /> - )}
); diff --git a/jobdri/src/components/common/toast/Toast.tsx b/jobdri/src/components/common/toast/Toast.tsx index 74b18e4..9228709 100644 --- a/jobdri/src/components/common/toast/Toast.tsx +++ b/jobdri/src/components/common/toast/Toast.tsx @@ -34,7 +34,7 @@ export default function Toast({ isTop ? "top-10 left-1/2 w-fit -translate-x-1/2 justify-center gap-3 rounded-card bg-fill-quaternary-default py-3 pr-4 pl-3" - : "right-5 bottom-28 max-w-[400px] justify-between self-stretch rounded-card px-4 py-3 pl-5", + : "right-7 bottom-7 max-w-[400px] justify-between self-stretch rounded-card px-4 py-3 pl-5", isDark ? "bg-fill-tertiary-default" : "bg-fill-quaternary-default", className, From bbf055ae055efac8a62ac814a38554a1226c158a Mon Sep 17 00:00:00 2001 From: minnngo Date: Wed, 29 Jul 2026 20:20:26 +0900 Subject: [PATCH 5/5] =?UTF-8?q?Feat:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A0=9C=EA=B1=B0=20[JDDEV-73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jobdri/src/app/credit/page.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index 5101199..de2dbc1 100644 --- a/jobdri/src/app/credit/page.tsx +++ b/jobdri/src/app/credit/page.tsx @@ -31,9 +31,6 @@ function CreditContent() { number | null >(null); const searchParams = useSearchParams(); - const isCouponToastPreview = - process.env.NODE_ENV === "development" && - searchParams.get("couponToastPreview") === "1"; useEffect(() => { fetchCreditPlans() @@ -108,9 +105,9 @@ function CreditContent() { /> )} - {(couponToastCreditAmount !== null || isCouponToastPreview) && ( + {couponToastCreditAmount !== null && ( setCouponToastCreditAmount(null)} />