임동욱 임동욱 08-13
feat: FoxCertified 추가
Figma form-certified(190:2017)의 세 줄을 옮긴다. 전화번호는 FoxPhoneNumber
(combine), 두 버튼은 FoxButton(secondary·primary 전체폭)을 그대로 쓴다 —
시안도 그 인스턴스다. 인증번호 상자만 자체로 그리는데, 오른쪽 남은 시간이
FoxInput의 아이콘 슬롯(정사각 20px)보다 넓기 때문이다.

보내기·확인은 호출부가 Promise로 넘긴다. resolve면 성공, throw·reject면
실패로 보고 이벤트를 올린다 — onSendSuccess/onSendFail/onVerifySuccess/
onVerifyFail/onExpire.

화면은 최소한만 반응한다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고,
인증에 성공하면 전화번호·인증번호·두 버튼이 전부 잠긴다. 실패 문구는 호출부가
보여준다.

남은 시간은 remainingSeconds를 주면 그 값을 그리고, 없으면 expiresIn부터
컴포넌트가 센다. 0에 닿는 순간 onExpire를 한 번만 올린다.

Co-Authored-By: Claude Opus 5 
@4ab0b1b085c6d4afe44b74fb2dc8320d39b81275
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -149,6 +149,10 @@
   비활성·읽기전용은 네이티브 속성, 오류는 `aria-invalid="true"`. 모양을 못박으려면 루트에
   `data-state`를 줍니다 (`default` `focused` `completed` `error` `disabled` `view`).
 - 여러 줄 입력에는 크기 축이 없습니다. 높이는 `rows`가 정하고 시안은 5줄입니다.
+- 휴대폰 인증은 보내기·확인을 호출부가 `Promise`로 넘깁니다 — resolve면 성공, reject면
+  실패이고 결과는 이벤트로 올라옵니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고,
+  인증에 성공하면 전부 잠깁니다. 남은 시간은 `remainingSeconds`를 주면 그 값을, 없으면
+  `expiresIn`부터 컴포넌트가 셉니다.
 - 전화번호는 화면만 세 칸이고 값은 숫자만 담긴 문자열 하나입니다 — 하이픈은 값에 들어가지
   않습니다. `unit`은 앞자리가 드롭다운이라 조각을 select·input으로 조립하고, `combine`은
   상자 하나에 세 칸을 둡니다.
 
@fox/core/components/fox-certified/fox-certified.tsx (added)
+++ @fox/core/components/fox-certified/fox-certified.tsx
@@ -0,0 +1,261 @@
+"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>
+  );
+}
 
@fox/core/components/fox-certified/index.ts (added)
+++ @fox/core/components/fox-certified/index.ts
@@ -0,0 +1,1 @@
+export { FoxCertified, type FoxCertifiedProps } from "./fox-certified";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -1,6 +1,7 @@
 export * from "./fox-button";
 export * from "./fox-button-group";
 export * from "./fox-button-panel";
+export * from "./fox-certified";
 export * from "./fox-icon-button";
 export * from "./fox-input";
 export * from "./fox-link-button";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -7,6 +7,7 @@
 import { FoxButtonPanel } from "../core/components/fox-button-panel";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
+import { FoxCertified } from "../core/components/fox-certified";
 import { FoxPhoneNumber, type FoxPhoneNumberState } from "../core/components/fox-phone-number";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
@@ -156,6 +157,40 @@
           message="앞자리 목록에 02가 있어 지역번호도 제자리에 갈립니다"
         />
       </InputStage>
+    </>
+  );
+}
+
+// 이벤트가 실제로 도는 걸 보려면 예제가 결과를 적어 둬야 한다. 서버 대신 지연만 흉내낸다.
+function CertifiedLiveDemo({ failSend = false, failVerify = false }) {
+  const [log, setLog] = useState<string[]>([]);
+  const write = (line: string) => setLog((prev) => [line, ...prev].slice(0, 5));
+  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+  return (
+    <>
+      <InputStage>
+        <FoxCertified
+          prefixOptions={PHONE_PREFIXES}
+          defaultPhone="01012345678"
+          expiresIn={180}
+          onSend={async (phone) => {
+            await wait(400);
+            if (failSend) throw new Error("발송 한도를 넘었습니다");
+            write(`보내기 요청 → ${phone}`);
+          }}
+          onVerify={async (_phone, code) => {
+            await wait(400);
+            if (failVerify || code !== "123456") throw new Error("번호가 맞지 않습니다");
+          }}
+          onSendSuccess={() => write("onSendSuccess")}
+          onSendFail={(error) => write(`onSendFail — ${(error as Error).message}`)}
+          onVerifySuccess={() => write("onVerifySuccess — 이제 전부 잠깁니다")}
+          onVerifyFail={(error) => write(`onVerifyFail — ${(error as Error).message}`)}
+          onExpire={() => write("onExpire")}
+        />
+      </InputStage>
+      <pre className={styles.code}>{log.length ? log.join("\n") : "이벤트가 여기에 쌓입니다 (인증번호는 123456)"}</pre>
     </>
   );
 }
@@ -1046,4 +1081,48 @@
       },
     ],
   },
+  {
+    id: "fox-certified",
+    name: "FoxCertified",
+    description:
+      "Figma 시안(form-certified 190:2017)을 옮겼습니다. 전화번호는 FoxPhoneNumber(combine), 두 버튼은 FoxButton(secondary·primary)을 그대로 씁니다. 보내기·확인은 호출부가 Promise로 넘기고, resolve면 성공 reject면 실패입니다. 보내기가 성공해야 인증번호 칸과 확인 버튼이 열리고, 인증에 성공하면 전부 잠깁니다.",
+    variants: [
+      {
+        label: "직접 조작 — 인증번호 123456이면 성공, 아니면 실패",
+        node: <CertifiedLiveDemo />,
+      },
+      {
+        label: "보내기가 실패하는 경우 — 칸이 열리지 않고 이벤트만 옵니다",
+        node: <CertifiedLiveDemo failSend />,
+      },
+      {
+        label: "타이머를 호출부가 넘길 때 (remainingSeconds)",
+        node: (
+          <InputStage>
+            <FoxCertified
+              prefixOptions={PHONE_PREFIXES}
+              defaultPhone="01012345678"
+              remainingSeconds={1456}
+              onSend={async () => undefined}
+              onVerify={async () => undefined}
+            />
+          </InputStage>
+        ),
+      },
+      {
+        label: "전체 잠금 (disabled)",
+        node: (
+          <InputStage>
+            <FoxCertified
+              prefixOptions={PHONE_PREFIXES}
+              defaultPhone="01012345678"
+              disabled
+              onSend={async () => undefined}
+              onVerify={async () => undefined}
+            />
+          </InputStage>
+        ),
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-certified.scss (added)
+++ @fox/styles/_fox-certified.scss
@@ -0,0 +1,147 @@
+// FoxCertified — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F)
+//                       Figma form-certified (190:2017 · 섹션 392:5850)
+//
+// 세 줄이다. [전화번호 + 인증번호 받기] / [인증번호 입력 + 남은 시간] / [인증번호 확인].
+// 전화번호는 combine 모양 그대로이고 두 버튼도 시안에서 btn 인스턴스라, 이 파일은 줄 배치와
+// 인증번호 상자만 갖는다. 인증번호 상자를 FoxInput으로 쓰지 못한 이유는 오른쪽 남은 시간이
+// 아이콘 슬롯(정사각 20px)보다 넓기 때문이다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-certified">
+//     <div class="fox-certified__row">
+//       <div class="fox-certified__phone">…fox-phone-number…</div>
+//       <button class="fox-button fox-button--secondary fox-button--md">인증번호 받기</button>
+//     </div>
+//     <div class="fox-certified__box">
+//       <input class="fox-certified__field" placeholder="인증번호 입력">
+//       <span class="fox-certified__timer">
+//         <span class="fox-certified__timer-icon">…svg…</span>
+//         <span class="fox-certified__timer-text">24:16</span>
+//       </span>
+//     </div>
+//     <button class="fox-button fox-button--primary fox-button--md fox-button--full-width">
+//       인증번호 확인
+//     </button>
+//   </div>
+//
+// 인증번호 상자의 상태는 FoxInput과 같은 방식으로 브라우저가 판단한다 — 포커스는
+// `:focus-within`, 비활성은 네이티브 속성. 시안에 상태 변형이 없어 고정 표기는 두지 않는다.
+//
+// 토큰이 아닌 값(FoxInput과 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로
+//     export하지 않는다.
+//   - box-sizing / flex: 구조 규칙.
+
+@use "abstracts" as fox;
+
+.fox-certified {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+  inline-size: 100%;
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+    line-height: 1;
+    letter-spacing: -0.025em;
+  }
+
+  // 전화번호는 남는 폭을 먹고 버튼은 글자만큼만 차지한다.
+  &__row {
+    display: flex;
+    align-items: flex-start;
+    gap: fox.gap(3);
+    inline-size: 100%;
+  }
+
+  &__phone {
+    flex: 1 0 0;
+    min-inline-size: 0;
+  }
+
+  &__box {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    block-size: fox.form(height-md);
+    padding-inline: fox.form(padding-md);
+    border: fox.border(1) solid fox.color(input-border);
+    border-radius: fox.form(radius-md);
+    background: fox.color(input-surface);
+  }
+
+  &__field {
+    flex: 1 0 0;
+    min-inline-size: 0;
+    margin: 0;
+    padding: 0;
+    border: none;
+    background: none;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(regular);
+    line-height: 1;
+    letter-spacing: -0.025em;
+
+    &::placeholder {
+      color: fox.color(font-neutral-subtler);
+    }
+
+    // 포커스는 상자 테두리가 표시하므로 기본 외곽선을 지운다.
+    &:focus {
+      outline: none;
+    }
+
+    &:disabled {
+      color: fox.color(font-neutral-disabled-strong);
+      cursor: default;
+    }
+  }
+
+  &__timer {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    gap: fox.gap(2);
+    color: fox.color(icon-system-warning-strong);
+  }
+
+  &__timer-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+
+    > * {
+      inline-size: fox.icon(3);
+      block-size: fox.icon(3);
+    }
+  }
+
+  &__timer-text {
+    color: fox.color(font-system-warning);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-sm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1;
+    letter-spacing: -0.025em;
+    text-align: end;
+  }
+}
+
+// ── 인증번호 상자 상태 ──────────────────────────────────────────────────────
+// 뒤에 오는 규칙이 이긴다: 포커스 < 비활성.
+.fox-certified__box:focus-within {
+  border-width: fox.border(2);
+  border-color: fox.color(input-border-active);
+}
+
+.fox-certified__box:has(.fox-certified__field:disabled) {
+  border-width: fox.border(1);
+  border-color: fox.color(input-border-disabled);
+  background: fox.color(input-surface-disabled);
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -7,6 +7,7 @@
 @use "fox-icon-button";
 @use "fox-button-group";
 @use "fox-button-panel";
+@use "fox-certified";
 @use "fox-input";
 @use "fox-phone-number";
 @use "fox-select";
Add a comment
List