File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
"use client";
import { useEffect, useId, useRef, useState, type ChangeEvent } from "react";
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<unknown>;
/** 인증번호 확인. resolve하면 성공, throw·reject하면 실패다. */
onVerify: (phone: string, code: string) => Promise<unknown>;
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 (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<circle cx="8" cy="8" r="6.3" />
<path d="M8 4.4V8l2.4 1.6" strokeLinecap="round" />
</svg>
);
}
/**
* @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<number | null>(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 (
<div className={cx("fox-certified", className)}>
{label && (
<span className="fox-certified__label" id={`${autoId}-label`}>
{label}
</span>
)}
<div className="fox-certified__row">
<div className="fox-certified__phone">
<FoxPhoneNumber
type="combine"
prefixOptions={prefixOptions}
value={phoneValue}
onChange={handlePhoneChange}
disabled={locked}
/>
</div>
<FoxButton
size="md"
type="secondary"
label={sendLabel}
loading={sending}
disabled={locked}
onAction={handleSend}
/>
</div>
{/* 보내기가 성공해야 열린다. */}
{sent && (
<>
<div className="fox-certified__box">
<input
id={codeId}
className="fox-certified__field"
inputMode="numeric"
autoComplete="one-time-code"
aria-label={codePlaceholder}
placeholder={codePlaceholder}
value={code}
maxLength={codeMaxLength}
disabled={locked}
onChange={(event: ChangeEvent<HTMLInputElement>) => setCode(event.target.value)}
/>
{left !== null && left !== undefined && (
<span className="fox-certified__timer">
<span className="fox-certified__timer-icon" aria-hidden="true">
<TimerIcon />
</span>
<span className="fox-certified__timer-text">{formatTimer(left)}</span>
</span>
)}
</div>
<FoxButton
size="md"
type="primary"
label={verifyLabel}
fullWidth
loading={verifying}
disabled={locked}
onAction={handleVerify}
/>
</>
)}
</div>
);
}