From a77ac0fb79b3ce37489d9afac8f45f157c027e25 Mon Sep 17 00:00:00 2001
From: Park Chan Hyeok <56295771+WithM2@users.noreply.github.com>
Date: Fri, 31 Oct 2025 00:35:50 +0900
Subject: [PATCH] feat: open toss payments checkout from mentoring subscribe
---
src/app/mentoring/apply/page.tsx | 2 +-
.../apply/subscribe/SubscribePageClient.tsx | 607 ++++++++++++++++++
.../mentoring/apply/subscribe/fail/page.tsx | 39 ++
src/app/mentoring/apply/subscribe/page.tsx | 62 ++
.../apply/subscribe/success/page.tsx | 39 ++
5 files changed, 748 insertions(+), 1 deletion(-)
create mode 100644 src/app/mentoring/apply/subscribe/SubscribePageClient.tsx
create mode 100644 src/app/mentoring/apply/subscribe/fail/page.tsx
create mode 100644 src/app/mentoring/apply/subscribe/page.tsx
create mode 100644 src/app/mentoring/apply/subscribe/success/page.tsx
diff --git a/src/app/mentoring/apply/page.tsx b/src/app/mentoring/apply/page.tsx
index 5ef7ee0..6abbfd6 100644
--- a/src/app/mentoring/apply/page.tsx
+++ b/src/app/mentoring/apply/page.tsx
@@ -138,7 +138,7 @@ export default function MentoringApplyPage() {
TossPaymentsInstance;
+
+type TossPaymentsInstance = {
+ requestPayment: (method: "CARD" | "VIRTUAL_ACCOUNT", options: TossPaymentOptions) => Promise
;
+};
+
+type TossPaymentOptions = {
+ amount: number;
+ orderId: string;
+ orderName: string;
+ customerName?: string;
+ customerEmail?: string;
+ customerMobilePhone?: string;
+ successUrl: string;
+ failUrl: string;
+};
+
+declare global {
+ interface Window {
+ TossPayments?: TossPaymentsInitializer;
+ }
+}
+
+type SubscribePageClientProps = {
+ planId: MentoringPlanId;
+ selectedPlan: MentoringPlanDetails;
+ finalAmount: number;
+};
+
+type TouchedState = {
+ ordererName: boolean;
+ contact: boolean;
+ email: boolean;
+};
+
+const formatCurrency = (value: number) => `${value.toLocaleString("ko-KR")}원`;
+
+const getNameError = (value: string) => {
+ if (!value.trim()) {
+ return "이름을 입력해주세요.";
+ }
+
+ return "";
+};
+
+const getContactError = (value: string) => {
+ if (!value.trim()) {
+ return "연락처를 입력해주세요.";
+ }
+
+ if (!/^\d{10,11}$/.test(value)) {
+ return "연락처는 숫자만 포함한 10~11자리로 입력해주세요.";
+ }
+
+ return "";
+};
+
+const getEmailError = (value: string) => {
+ if (!value.trim()) {
+ return "이메일을 입력해주세요.";
+ }
+
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailPattern.test(value)) {
+ return "올바른 이메일 주소를 입력해주세요.";
+ }
+
+ return "";
+};
+
+export default function SubscribePageClient({
+ planId,
+ selectedPlan,
+ finalAmount,
+}: SubscribePageClientProps) {
+ const [ordererName, setOrdererName] = useState("");
+ const [contact, setContact] = useState("");
+ const [email, setEmail] = useState("");
+ const [studentName, setStudentName] = useState("");
+ const [studentSchool, setStudentSchool] = useState("");
+ const [studentGrade, setStudentGrade] = useState("");
+ const [studentInterest, setStudentInterest] = useState("");
+ const [agreeRequired, setAgreeRequired] = useState(false);
+ const [agreeMarketing, setAgreeMarketing] = useState(false);
+
+ const [touched, setTouched] = useState({
+ ordererName: false,
+ contact: false,
+ email: false,
+ });
+ const [isSubmitted, setIsSubmitted] = useState(false);
+
+ const [isCodeSent, setIsCodeSent] = useState(false);
+ const [verificationCode, setVerificationCode] = useState("");
+ const [enteredCode, setEnteredCode] = useState("");
+ const [contactVerificationError, setContactVerificationError] = useState("");
+ const [contactVerificationMessage, setContactVerificationMessage] = useState("");
+ const [isContactVerified, setIsContactVerified] = useState(false);
+ const [isPaymentProcessing, setIsPaymentProcessing] = useState(false);
+ const [paymentError, setPaymentError] = useState("");
+
+ const nameError = (touched.ordererName || isSubmitted) ? getNameError(ordererName) : "";
+ const contactError = (touched.contact || isSubmitted) ? getContactError(contact) : "";
+ const emailError = (touched.email || isSubmitted) ? getEmailError(email) : "";
+ const requiredAgreementError = isSubmitted && !agreeRequired ? "필수 약관에 동의해주세요." : "";
+
+ const isPaymentEnabled = useMemo(() => {
+ return (
+ !getNameError(ordererName) &&
+ !getContactError(contact) &&
+ !getEmailError(email) &&
+ agreeRequired &&
+ isContactVerified
+ );
+ }, [ordererName, contact, email, agreeRequired, isContactVerified]);
+
+ const tossClientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY ?? "";
+
+ const handleBlur = (field: keyof TouchedState) => {
+ setTouched((prev) => ({ ...prev, [field]: true }));
+ };
+
+ const handleContactChange = (event: ChangeEvent) => {
+ const digitsOnly = event.target.value.replace(/[^\d]/g, "");
+ setContact(digitsOnly);
+ setTouched((prev) => ({ ...prev, contact: true }));
+ if (isContactVerified) {
+ setIsContactVerified(false);
+ }
+ setIsCodeSent(false);
+ setVerificationCode("");
+ setEnteredCode("");
+ setContactVerificationError("");
+ setContactVerificationMessage("");
+ setPaymentError("");
+ };
+
+ const requestVerificationCode = () => {
+ const error = getContactError(contact);
+ setTouched((prev) => ({ ...prev, contact: true }));
+
+ if (error) {
+ setContactVerificationError("");
+ setContactVerificationMessage("");
+ setIsCodeSent(false);
+ setVerificationCode("");
+ return;
+ }
+
+ const generatedCode = Math.floor(100000 + Math.random() * 900000).toString();
+ setVerificationCode(generatedCode);
+ setIsCodeSent(true);
+ setIsContactVerified(false);
+ setContactVerificationError("");
+ setContactVerificationMessage(`인증번호가 발송되었습니다. (테스트용 코드: ${generatedCode})`);
+ setEnteredCode("");
+ setPaymentError("");
+ };
+
+ const verifyCode = () => {
+ if (!enteredCode.trim()) {
+ setContactVerificationError("인증번호를 입력해주세요.");
+ setContactVerificationMessage("");
+ return;
+ }
+
+ if (enteredCode !== verificationCode) {
+ setContactVerificationError("인증번호가 일치하지 않습니다.");
+ setContactVerificationMessage("");
+ return;
+ }
+
+ setIsContactVerified(true);
+ setContactVerificationError("");
+ setContactVerificationMessage("연락처 인증이 완료되었습니다.");
+ setPaymentError("");
+ };
+
+ const validateBeforePayment = () => {
+ setIsSubmitted(true);
+ setTouched({ ordererName: true, contact: true, email: true });
+
+ const contactValidation = getContactError(contact);
+
+ if (contactValidation) {
+ setContactVerificationError("");
+ setContactVerificationMessage("");
+ return false;
+ }
+
+ if (!isContactVerified) {
+ if (isCodeSent) {
+ setContactVerificationError("연락처 인증을 완료해주세요.");
+ } else {
+ setContactVerificationMessage("연락처 인증을 완료해주세요.");
+ }
+ return false;
+ }
+
+ return (
+ !getNameError(ordererName) &&
+ !getEmailError(email) &&
+ agreeRequired &&
+ isContactVerified
+ );
+ };
+
+ const initializeTossPayments = async () => {
+ if (!tossClientKey) {
+ throw new Error("토스페이먼츠 클라이언트 키가 설정되어 있지 않습니다.");
+ }
+
+ await loadTossPaymentsScript();
+
+ if (typeof window.TossPayments !== "function") {
+ throw new Error("토스페이먼츠 결제 모듈을 불러오지 못했습니다.");
+ }
+
+ return window.TossPayments(tossClientKey);
+ };
+
+ const attemptPayment = async () => {
+ const isValid = validateBeforePayment();
+
+ if (!isValid) {
+ return;
+ }
+
+ try {
+ setPaymentError("");
+ setIsPaymentProcessing(true);
+
+ const tossPayments = await initializeTossPayments();
+ const orderId = `MENTORING-${Date.now()}`;
+ const baseUrl = window.location.origin;
+
+ await tossPayments.requestPayment("CARD", {
+ amount: finalAmount,
+ orderId,
+ orderName: selectedPlan.displayName,
+ customerName: ordererName,
+ customerEmail: email,
+ customerMobilePhone: contact,
+ successUrl: `${baseUrl}/mentoring/apply/subscribe/success`,
+ failUrl: `${baseUrl}/mentoring/apply/subscribe/fail`,
+ });
+ } catch (error) {
+ if (error instanceof Error) {
+ setPaymentError(error.message);
+ } else {
+ setPaymentError("결제 창을 여는 중 오류가 발생했습니다. 다시 시도해주세요.");
+ }
+ } finally {
+ setIsPaymentProcessing(false);
+ }
+ };
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ void attemptPayment();
+ };
+
+ const canAttemptPayment = isPaymentEnabled && !isPaymentProcessing;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+type FieldProps = {
+ label: string;
+ error?: string;
+ trailing?: ReactNode;
+} & Omit, "className" | "children">;
+
+function Field({ label, error, trailing, ...inputProps }: FieldProps) {
+ return (
+
+ );
+}
+
+type SummaryRowProps = {
+ label: string;
+ value: string;
+};
+
+function SummaryRow({ label, value }: SummaryRowProps) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+const TOSS_SCRIPT_SRC = "https://js.tosspayments.com/v1/payment";
+
+const loadTossPaymentsScript = () => {
+ if (typeof window === "undefined") {
+ return Promise.reject(new Error("브라우저 환경에서만 결제를 진행할 수 있습니다."));
+ }
+
+ const existingScript = document.querySelector(`script[src="${TOSS_SCRIPT_SRC}"]`);
+
+ if (existingScript) {
+ if (typeof window.TossPayments === "function") {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ existingScript.addEventListener("load", () => resolve());
+ existingScript.addEventListener("error", () => reject(new Error("토스페이먼츠 스크립트를 불러오지 못했습니다.")));
+ });
+ }
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = TOSS_SCRIPT_SRC;
+ script.async = true;
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error("토스페이먼츠 스크립트를 불러오지 못했습니다."));
+ document.head.appendChild(script);
+ });
+};
diff --git a/src/app/mentoring/apply/subscribe/fail/page.tsx b/src/app/mentoring/apply/subscribe/fail/page.tsx
new file mode 100644
index 0000000..8d6f7a0
--- /dev/null
+++ b/src/app/mentoring/apply/subscribe/fail/page.tsx
@@ -0,0 +1,39 @@
+import Footer from "@/components/common/Footer";
+import Header from "@/components/common/Header";
+import Link from "next/link";
+
+export default function MentoringSubscribeFailPage() {
+ return (
+ <>
+
+
+
+
+ 결제 실패
+
+
+ 결제를 완료하지 못했습니다.
+
+
+ 다시 시도하거나 다른 결제 수단을 선택해주세요. 문제가 계속된다면 고객센터로 문의 부탁드립니다.
+
+
+
+ 결제 다시 시도하기
+
+
+ 홈으로 돌아가기
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/mentoring/apply/subscribe/page.tsx b/src/app/mentoring/apply/subscribe/page.tsx
new file mode 100644
index 0000000..a636f4c
--- /dev/null
+++ b/src/app/mentoring/apply/subscribe/page.tsx
@@ -0,0 +1,62 @@
+import Footer from "@/components/common/Footer";
+import Header from "@/components/common/Header";
+import SubscribePageClient from "./SubscribePageClient";
+
+export type MentoringPlanId = "standard" | "plus";
+
+export type MentoringPlanDetails = {
+ displayName: string;
+ subtitle: string;
+ benefits: readonly string[];
+ baseAmount: number;
+ discountAmount: number;
+};
+
+const mentoringPlanDetails: Record = {
+ standard: {
+ displayName: "Dream Maker Standard",
+ subtitle: "주 1회 60분 수업 · 1:3 멘토링",
+ benefits: ["6개월 과정", "정규 교육과 병행 가능한 커리큘럼"],
+ baseAmount: 80000,
+ discountAmount: 0,
+ },
+ plus: {
+ displayName: "Dream Maker Plus",
+ subtitle: "주 2회 60분 수업 · 1:3 멘토링",
+ benefits: ["3개월 집중 과정", "빠르게 레벨을 완료하는 커리큘럼"],
+ baseAmount: 160000,
+ discountAmount: 20000,
+ },
+};
+
+type MentoringSubscribePageProps = {
+ searchParams?: Promise<{
+ plan?: string;
+ }>;
+};
+
+export default async function MentoringSubscribePage({
+ searchParams,
+}: MentoringSubscribePageProps) {
+ const resolvedSearchParams = searchParams ? await searchParams : undefined;
+ const requestedPlan = resolvedSearchParams?.plan;
+ const isValidPlan = requestedPlan === "standard" || requestedPlan === "plus";
+ const planId: MentoringPlanId = isValidPlan ? requestedPlan : "plus";
+ const selectedPlan = mentoringPlanDetails[planId];
+
+ const finalAmount = selectedPlan.baseAmount - selectedPlan.discountAmount;
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export { mentoringPlanDetails };
diff --git a/src/app/mentoring/apply/subscribe/success/page.tsx b/src/app/mentoring/apply/subscribe/success/page.tsx
new file mode 100644
index 0000000..1223ba7
--- /dev/null
+++ b/src/app/mentoring/apply/subscribe/success/page.tsx
@@ -0,0 +1,39 @@
+import Footer from "@/components/common/Footer";
+import Header from "@/components/common/Header";
+import Link from "next/link";
+
+export default function MentoringSubscribeSuccessPage() {
+ return (
+ <>
+
+
+
+
+ 결제 완료
+
+
+ 결제가 정상적으로 완료되었습니다.
+
+
+ 담당 매니저가 곧 안내 전화를 드릴 예정입니다. 궁금한 점이 있다면 언제든지 문의해주세요.
+
+
+
+ 멘토링 프로그램 살펴보기
+
+
+ 홈으로 돌아가기
+
+
+
+
+
+ >
+ );
+}