File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
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>
);
}