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 ( +
+
+
+
+
+

결제 정보 입력

+

결제하기

+

+ 구독 정보를 확인하고 결제자, 학생 정보를 입력해주세요. +

+
+
+ +
+
+
+
+

결제 상품 정보

+

+ 구독할 멘토링 상품을 확인해주세요. +

+
+ +
+
+ {planId === "plus" ? "추천" : "정규"} +

{selectedPlan.displayName}

+
+

{selectedPlan.subtitle}

+ +
    + {selectedPlan.benefits.map((benefit) => ( +
  • {benefit}
  • + ))} +
+
+
+ +
+
+

주문자 정보 입력

+

+ 결제 진행을 위해 주문자 정보를 입력해주세요. +

+
+ +
+ setOrdererName(event.target.value)} + onBlur={() => handleBlur("ordererName")} + error={nameError} + required + /> + handleBlur("contact")} + error={contactError} + required + disabled={isContactVerified} + trailing={ + + } + /> + {isCodeSent || isContactVerified ? ( + { + setEnteredCode(event.target.value.replace(/[^\d]/g, "")); + if (contactVerificationError) { + setContactVerificationError(""); + } + }} + disabled={isContactVerified} + error={contactVerificationError} + trailing={ + !isContactVerified ? ( + + ) : undefined + } + /> + ) : null} + {contactVerificationMessage ? ( +

{contactVerificationMessage}

+ ) : null} + setEmail(event.target.value)} + onBlur={() => handleBlur("email")} + error={emailError} + required + /> +
+
+ +
+
+

학생 정보 입력

+

+ 수업을 듣는 학생의 기본 정보를 입력해주세요. +

+
+ +
+ setStudentName(event.target.value)} + /> + setStudentSchool(event.target.value)} + /> + setStudentGrade(event.target.value)} + /> + setStudentInterest(event.target.value)} + /> +
+
+ +
+
+

약관 동의

+

+ 결제 진행을 위해 약관에 동의해주세요. +

+
+ +
+ + + {requiredAgreementError ? ( +

{requiredAgreementError}

+ ) : null} +
+ + + {paymentError ? ( +

{paymentError}

+ ) : null} +
+
+ + +
+
+
+
+ ); +} + +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 ( + <> +
+
+
+

+ 결제 실패 +

+

+ 결제를 완료하지 못했습니다. +

+

+ 다시 시도하거나 다른 결제 수단을 선택해주세요. 문제가 계속된다면 고객센터로 문의 부탁드립니다. +

+
+ + 결제 다시 시도하기 + + + 홈으로 돌아가기 + +
+
+
+