"use client"; import { useEffect, useId, useRef, useState, type ChangeEvent } from "react"; import { FoxFormLabel } from "../fox-form-label"; import { cx } from "../../utils"; import { FoxButton } from "../fox-button"; import { FoxPhoneNumber } from "../fox-phone-number"; export interface FoxCertifiedProps { /** 전화번호. 숫자만 담긴 문자열이다 — `FoxPhoneNumber`와 같다. */ phone?: string; defaultPhone?: string; onPhoneChange?: (phone: string) => void; /** 전화번호 앞자리 목록. 밖에서 받은 값을 칸으로 나눌 때도 쓴다. */ prefixOptions?: string[]; /** * 인증번호 받기. resolve하면 성공으로 보고 인증번호 칸과 확인 버튼을 연다. * throw하거나 reject하면 실패로 보고 `onSendFail`을 부른다. */ onSend: (phone: string) => Promise; /** 인증번호 확인. resolve하면 성공, throw·reject하면 실패다. */ onVerify: (phone: string, code: string) => Promise; onSendSuccess?: () => void; onSendFail?: (error: unknown) => void; onVerifySuccess?: () => void; onVerifyFail?: (error: unknown) => void; /** 남은 시간이 0이 됐을 때 한 번 부른다. */ onExpire?: () => void; /** * 제한 시간(초). 주면 인증번호를 보낸 시점부터 컴포넌트가 직접 센다. * `remainingSeconds`를 함께 주면 그쪽이 이긴다. */ expiresIn?: number; /** 남은 시간(초)을 직접 셀 때. 주면 컴포넌트는 세지 않고 이 값만 그린다. */ remainingSeconds?: number; label?: string; sendLabel?: string; verifyLabel?: string; codePlaceholder?: string; codeMaxLength?: number; /** 전체 잠금. 인증에 성공하면 이 값과 무관하게 잠긴다. */ disabled?: boolean; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. */ className?: string; } /** 시안의 `24:16` 표기. 분이 두 자리를 넘어가면 그대로 늘어난다. */ function formatTimer(seconds: number): string { const safe = Math.max(0, Math.floor(seconds)); const minutes = Math.floor(safe / 60); const rest = safe % 60; return `${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; } /** 시안의 ico/ClockCountdown 자리. @fox에 아이콘 세트가 없어 같은 모양을 직접 그린다. */ function TimerIcon() { return ( ); } /** * @fox 휴대폰 인증. 전화번호를 받고, 인증번호를 보내고, 확인한다. * * 보내기·확인은 호출부가 `Promise`로 넘긴다 — resolve면 성공, throw·reject면 실패다. * 성공·실패는 모두 이벤트로 올라오고, 화면은 최소한만 반응한다(보내기 성공이면 인증번호 * 칸과 확인 버튼이 열리고, 인증 성공이면 전부 잠긴다). 실패 문구는 호출부가 보여준다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxCertified({ phone, defaultPhone, onPhoneChange, prefixOptions = [], onSend, onVerify, onSendSuccess, onSendFail, onVerifySuccess, onVerifyFail, onExpire, expiresIn, remainingSeconds, label, sendLabel = "인증번호 받기", verifyLabel = "인증번호 확인", codePlaceholder = "인증번호 입력", codeMaxLength, disabled = false, hidden = false, className, }: FoxCertifiedProps) { const autoId = useId(); const [innerPhone, setInnerPhone] = useState(defaultPhone ?? ""); const [code, setCode] = useState(""); const [sent, setSent] = useState(false); const [verified, setVerified] = useState(false); const [sending, setSending] = useState(false); const [verifying, setVerifying] = useState(false); const [ticking, setTicking] = useState(null); const expiredRef = useRef(false); const selfTimed = remainingSeconds === undefined && expiresIn !== undefined; const left = remainingSeconds ?? ticking; // 스스로 셀 때만 1초마다 줄인다. 호출부가 값을 주면 그리기만 한다. useEffect(() => { if (!selfTimed || !sent) { return; } const id = setInterval(() => { setTicking((prev) => (prev === null || prev <= 0 ? prev : prev - 1)); }, 1000); return () => clearInterval(id); }, [selfTimed, sent]); // 0에 닿는 순간 한 번만 알린다. 다시 보내 시간이 살아나면 잠금을 푼다. useEffect(() => { if (left === null || left === undefined) { return; } if (left <= 0 && !expiredRef.current) { expiredRef.current = true; onExpire?.(); } if (left > 0) { expiredRef.current = false; } }, [left, onExpire]); if (hidden) { return null; } const phoneValue = phone ?? innerPhone; const locked = disabled || verified; const codeId = `${autoId}-code`; const handlePhoneChange = (next: string) => { if (phone === undefined) { setInnerPhone(next); } onPhoneChange?.(next); }; const handleSend = async () => { if (sending) { return; } setSending(true); try { await onSend(phoneValue); setSent(true); expiredRef.current = false; if (selfTimed) { setTicking(expiresIn ?? null); } onSendSuccess?.(); } catch (error) { onSendFail?.(error); } finally { setSending(false); } }; const handleVerify = async () => { if (verifying) { return; } setVerifying(true); try { await onVerify(phoneValue, code); setVerified(true); onVerifySuccess?.(); } catch (error) { onVerifyFail?.(error); } finally { setVerifying(false); } }; return (
{label && ( {label} )}
{/* 보내기가 성공해야 열린다. */} {sent && ( <>
) => setCode(event.target.value)} /> {left !== null && left !== undefined && ( {formatTimer(left)} )}
)}
); }