Skip to content
Open
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
50 changes: 47 additions & 3 deletions jobdri/src/app/credit/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -23,8 +26,11 @@ function calcDiscountRate(plan: CreditPlan, basePricePerUnit: number): string {
function CreditContent() {
const [plans, setPlans] = useState<CreditPlan[]>([]);
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()
Expand Down Expand Up @@ -62,12 +68,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 (
<>
<PageHeader />
<div className="flex self-stretch items-end justify-between">
<PageHeader />
<Button
label="쿠폰 등록하기"
styleType="tertiary"
size="large"
onClick={() => setIsCouponModalOpen(true)}
/>
</div>

{isCouponModalOpen && (
<CouponRegistrationModal
onClose={() => setIsCouponModalOpen(false)}
onSuccess={handleCouponRegistrationSuccess}
/>
)}

{couponToastCreditAmount !== null && (
<Toast
message={`${couponToastCreditAmount}크레딧이 충전되었습니다!`}
variant="check"
onClose={() => setCouponToastCreditAmount(null)}
/>
)}
Comment on lines +108 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Toast credit amount isn't locale-formatted.

couponToastCreditAmount is interpolated raw, unlike plan.price.toLocaleString() used elsewhere in this file (line 129). Larger credited amounts will render without thousands separators.

🐛 Proposed fix
-          message={`${couponToastCreditAmount}크레딧이 충전되었습니다!`}
+          message={`${couponToastCreditAmount.toLocaleString()}크레딧이 충전되었습니다!`}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{couponToastCreditAmount !== null && (
<Toast
message={`${couponToastCreditAmount}크레딧이 충전되었습니다!`}
variant="check"
onClose={() => setCouponToastCreditAmount(null)}
/>
)}
{couponToastCreditAmount !== null && (
<Toast
message={`${couponToastCreditAmount.toLocaleString()}크레딧이 충전되었습니다!`}
variant="check"
onClose={() => setCouponToastCreditAmount(null)}
/>
)}
🤖 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 108 - 114, Update the Toast
message in the couponToastCreditAmount rendering block to format the amount with
toLocaleString(), matching the existing plan.price formatting in the credit
page. Keep the current Korean message and null-check behavior unchanged.


{/* 결제 승인 중일 때 화면을 덮는 로딩 UI */}
{isConfirming && (
Expand Down
2 changes: 1 addition & 1 deletion jobdri/src/components/common/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function PageHeader() {
routeTitles[pathname] ?? pathname.split("/").filter(Boolean).pop() ?? "홈";

return (
<div className="gap-y-1">
<div className="flex flex-col items-start gap-2">
<h1 className="text-h24-bold text-text-neutral-title">{title}</h1>
<p className="text-b16-med text-text-neutral-description">
{PageDescriptions[pathname]}
Expand Down
6 changes: 5 additions & 1 deletion jobdri/src/components/common/buttons/ButtonCtaModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface ButtonCtaModalProps {
submitClassName?: string;
cancelClassName?: string;
className?: string;
submitDisabled?: boolean;
}

interface Stack3Item {
Expand Down Expand Up @@ -59,6 +60,7 @@ export default function ButtonCtaModal({
submitClassName,
cancelClassName,
className,
submitDisabled = false,
}: ButtonCtaModalProps) {
return (
<div
Expand All @@ -73,10 +75,11 @@ export default function ButtonCtaModal({
size="large"
styleType={submitStyleType}
onClick={onSubmit}
disabled={submitDisabled}
className={clsx("h-[46px] w-full", submitClassName)}
/>
) : stack === "stack2_horizontal" ? (
<div className="flex w-full items-start gap-3 self-stretch">
<div className="flex w-full items-start gap-2 self-stretch">
<Button
label={cancelLabel}
size="large"
Expand All @@ -89,6 +92,7 @@ export default function ButtonCtaModal({
size="large"
styleType={submitStyleType}
onClick={onSubmit}
disabled={submitDisabled}
className={clsx("h-[46px] flex-1", submitClassName)}
/>
</div>
Expand Down
106 changes: 106 additions & 0 deletions jobdri/src/components/common/credit/CouponRegistrationModal.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +37 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Specific coupon-redemption error is discarded.

redeemCoupon (credit.ts) deliberately throws payload.error || payload.message || fallback to surface specific reasons (invalid code, already redeemed, expired, server error), but this bare catch {} always shows the same generic "쿠폰 번호를 확인해주세요." Users lose meaningful feedback about why redemption actually failed.

🐛 Proposed fix to surface the actual error message
-    } catch {
-      setError("쿠폰 번호를 확인해주세요.");
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "쿠폰 번호를 확인해주세요.");
     } finally {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const result = await redeemCoupon(couponCode);
onSuccess(result.creditAmount);
} catch {
setError("쿠폰 번호를 확인해주세요.");
} finally {
setIsSubmitting(false);
}
try {
const result = await redeemCoupon(couponCode);
onSuccess(result.creditAmount);
} catch (err) {
setError(err instanceof Error ? err.message : "쿠폰 번호를 확인해주세요.");
} finally {
setIsSubmitting(false);
}
🤖 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/credit/CouponRegistrationModal.tsx` around lines
37 - 44, Update the coupon submission handler around redeemCoupon to capture the
thrown error and display its specific message via setError instead of always
using the generic invalid-code text. Preserve the existing fallback message for
non-Error or message-less failures, and keep setIsSubmitting(false) in the
finally path.

};

return (
<ModalOverlay onClose={onClose}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="coupon-registration-title"
aria-describedby="coupon-registration-description"
className="flex w-[400px] shrink-0 flex-col items-center justify-center gap-0 rounded-card-l bg-bg-contents-default shadow-modal"
>
<div className="flex flex-col items-start gap-4 self-stretch px-7 pt-7">
<div className="flex flex-col items-start gap-3 self-stretch">
<h2
id="coupon-registration-title"
className="self-stretch text-t20-semibold text-text-neutral-title [font-feature-settings:'liga'_off,'clig'_off]"
>
쿠폰 등록하기
</h2>
<p
id="coupon-registration-description"
className="self-stretch text-sub14-med text-text-neutral-description [font-feature-settings:'liga'_off,'clig'_off]"
>
&ldquo;-&rdquo;와 띄어쓰기 없이 쿠폰번호를 입력해주세요.
</p>
</div>
</div>

<div className="flex flex-col items-start gap-2.5 self-stretch px-7 pt-6">
<InputTextAreaAutoGrowS
aria-label="쿠폰 일련 번호"
autoFocus
label=""
required={false}
placeholder="일련 번호를 입력해주세요."
value={couponNumber}
onChange={handleCouponNumberChange}
error={error}
disabled={isSubmitting}
showAddButton={false}
showBottomLine={false}
className="gap-1"
/>
</div>

<div className="flex items-end justify-end gap-2 self-stretch px-5 pt-8 pb-5">
<ButtonCtaModal
stack="stack2_horizontal"
cancelLabel="닫기"
label="등록하기"
cancelStyleType="quaternary"
submitStyleType="secondary"
onCancel={onClose}
onSubmit={handleSubmit}
submitDisabled={isSubmitting}
className="w-full !pb-0"
/>
</div>
</div>
</ModalOverlay>
);
}
27 changes: 15 additions & 12 deletions jobdri/src/components/common/input/InputTextAreaAutoGrowS.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function InputTextAreaAutoGrowS({
leadingContent,
trailingContent,
showAddButton = true,
showBottomLine = true,
addButtonLabel = "추가하기",
addButtonDisabled,
onAdd,
Expand Down Expand Up @@ -171,18 +172,20 @@ export function InputTextAreaAutoGrowS({
</div>
</div>

<InputTextAreaFixedBottom
addButtonDisabled={addButtonDisabled}
addButtonLabel={addButtonLabel}
count={count}
disabled={disabled}
hasValue={hasValue}
leadingContent={leadingContent}
maxLength={maxLength}
onAdd={onAdd}
showAddButton={showAddButton}
trailingContent={trailingContent}
/>
{showBottomLine && (
<InputTextAreaFixedBottom
addButtonDisabled={addButtonDisabled}
addButtonLabel={addButtonLabel}
count={count}
disabled={disabled}
hasValue={hasValue}
leadingContent={leadingContent}
maxLength={maxLength}
onAdd={onAdd}
showAddButton={showAddButton}
trailingContent={trailingContent}
/>
)}
</div>

<InputTextAreaFixedMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface InputTextAreaFixedSharedProps extends Omit<
leadingContent?: ReactNode;
trailingContent?: ReactNode;
showAddButton?: boolean;
showBottomLine?: boolean;
addButtonLabel?: string;
addButtonDisabled?: boolean;
onAdd?: () => void;
Expand Down
16 changes: 13 additions & 3 deletions jobdri/src/components/common/modal/ModalOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity"
className="fixed inset-0 z-[100] flex items-center justify-center bg-bg-lightbox-default transition-opacity"
onClick={onClose}
>
<div className="relative z-[101]" onClick={(e) => e.stopPropagation()}>
Expand Down
2 changes: 1 addition & 1 deletion jobdri/src/components/common/toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions jobdri/src/lib/api/credit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RedeemCouponResult> {
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;
}