diff --git a/jobdri/src/app/credit/page.tsx b/jobdri/src/app/credit/page.tsx index e2e96ac..b6ce236 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, checkPaymentStatus, @@ -12,6 +13,8 @@ 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"; +import { Toast } from "@/components/common/toast"; function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { const original = basePricePerUnit * plan.creditAmount; @@ -23,8 +26,11 @@ function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string { 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(); - const router = useRouter(); useEffect(() => { fetchCreditPlans() @@ -87,12 +93,50 @@ 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; return ( <> - +
+ +
+ + {isCouponModalOpen && ( + setIsCouponModalOpen(false)} + onSuccess={handleCouponRegistrationSuccess} + /> + )} + + {couponToastCreditAmount !== null && ( + setCouponToastCreditAmount(null)} + /> + )} {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..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" ? ( -
+
diff --git a/jobdri/src/components/common/credit/CouponRegistrationModal.tsx b/jobdri/src/components/common/credit/CouponRegistrationModal.tsx new file mode 100644 index 0000000..fdc5971 --- /dev/null +++ b/jobdri/src/components/common/credit/CouponRegistrationModal.tsx @@ -0,0 +1,106 @@ +"use client"; + +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 = 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 ( + +
+
+
+

+ 쿠폰 등록하기 +

+

+ “-”와 띄어쓰기 없이 쿠폰번호를 입력해주세요. +

+
+
+ +
+ +
+ +
+ +
+
+
+ ); +} diff --git a/jobdri/src/components/common/input/InputTextAreaAutoGrowS.tsx b/jobdri/src/components/common/input/InputTextAreaAutoGrowS.tsx index ea36f56..54d0f32 100644 --- a/jobdri/src/components/common/input/InputTextAreaAutoGrowS.tsx +++ b/jobdri/src/components/common/input/InputTextAreaAutoGrowS.tsx @@ -37,6 +37,7 @@ export function InputTextAreaAutoGrowS({ leadingContent, trailingContent, showAddButton = true, + showBottomLine = true, addButtonLabel = "추가하기", addButtonDisabled, onAdd, @@ -171,18 +172,20 @@ export function InputTextAreaAutoGrowS({
- + {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()}> 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, diff --git a/jobdri/src/lib/api/credit.ts b/jobdri/src/lib/api/credit.ts index 69278fd..054aa36 100644 --- a/jobdri/src/lib/api/credit.ts +++ b/jobdri/src/lib/api/credit.ts @@ -128,3 +128,43 @@ export async function checkPaymentStatus(orderId: string) { if (!response.ok) throw new Error("Failed to fetch payment status"); return response.json(); } + +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; +}