# 문의 섹션

문의 양식, 연락 채널, 지점 안내 중에서 고를 수 있는 연락처 섹션이에요.

- Type: block
- Theme: base
- Tags: arc, block, contact, form, support, offices, section, illustration
- English title: Contact section
- URL: https://dsui.vercel.app/items/contact-section

## Used items

- [아바타](https://dsui.vercel.app/items/avatar): 사진이나 이름 첫 글자로 사람을 나타내고 상태 점을 달 수 있어요.
- [버튼](https://dsui.vercel.app/items/button): 눌렀을 때 살짝 눌리고 소리가 나는 기본 버튼이에요.
- [복사 버튼](https://dsui.vercel.app/items/copy-button): 눌러서 값을 클립보드에 복사하고 복사했다고 알려 줘요.
- [입력 필드](https://dsui.vercel.app/items/input): 라벨과 설명, 오류 문구를 함께 보여 주는 기본 텍스트 입력 필드예요.
- [세그먼트 컨트롤](https://dsui.vercel.app/items/segmented-control): 한 번에 하나만 고르는 짧은 선택지를 나란히 보여 주는 컨트롤이에요.
- [텍스트 영역](https://dsui.vercel.app/items/textarea): 여러 줄을 입력하고 글자 수와 안내 문구를 함께 보여 주는 입력창이에요.
- [일러스트](https://dsui.vercel.app/items/illustration): 히어로, 섹션 헤더, 카드 커버에 쓰는 도형 일러스트예요. 테마가 직접 그린 그림이 없으면 베이스 그림을 테마 색으로 그려요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| variant | select | 레이아웃 | form |
| illustration | boolean | 일러스트 | true |
| title | text | 제목 |  |
| description | text | 설명 |  |

## Source

### contact-section.module.css

```css
.contact { container: contact / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.tabular { font-variant-numeric: tabular-nums; }

.inner { display: grid; max-width: 1120px; gap: var(--space-10); margin: 0 auto; padding: var(--space-20) var(--space-8); }
.contact[data-variant="form"] .inner { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: start; gap: var(--space-12); }
.intro { display: grid; align-content: start; gap: var(--space-3); max-width: 520px; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(1.75rem, 1rem + 3cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-base); line-height: 1.5; text-wrap: pretty; }
.facts { display: grid; gap: var(--space-2); margin: var(--space-4) 0 0; padding: 0; list-style: none; }
.facts li { display: flex; min-height: 32px; align-items: center; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-sm); }
.facts svg { flex: none; }

/* Morphing surface: height springs between faces; the leaving face floats out of flow. */
.morph { position: relative; overflow: hidden; }
.face { width: 100%; }

.formCard { border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
.formCard > .face { padding: var(--space-6); }
.formBody { display: grid; gap: var(--space-5); }
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.topicField { display: grid; }
.fieldLabel { margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.topics { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.topic { position: relative; isolation: isolate; height: var(--control-height-sm); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 0 var(--space-4); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.topic[aria-checked="true"] { border-color: transparent; color: var(--control-glyph); }
@media (hover: hover) and (pointer: fine) { .topic:not([aria-checked="true"]):hover { border-color: var(--border-strong); color: var(--foreground); } }
.topicHighlight { position: absolute; z-index: -1; inset: -1px; border-radius: inherit; background: var(--control-on); }
.failure { overflow: hidden; margin: 0; color: var(--danger); font-size: var(--text-sm); }
.submitRow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-4); }
.fine { margin: 0; color: var(--text-muted); font-size: var(--text-sm); }

.success { display: grid; justify-items: center; gap: var(--space-3); padding: var(--space-10) var(--space-2); text-align: center; }
.successMark { display: grid; width: 52px; height: 52px; place-items: center; margin-bottom: var(--space-2); border-radius: 50%; background: color-mix(in oklab, var(--success) 13%, transparent); color: var(--success); }
.successMark svg { width: 26px; height: 26px; }
.successTitle { margin: 0; font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); }
.successText { max-width: 360px; margin: 0 0 var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.55; }

/* Channels: a tab list beside one detail surface that morphs to each channel. */
.channels { display: grid; grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); align-items: start; gap: var(--space-6); }
.channelList { display: grid; gap: 2px; }
.channel { position: relative; isolation: isolate; display: flex; align-items: center; gap: var(--space-3); border: 0; border-radius: 16px; padding: var(--space-3) var(--space-4); background: none; color: var(--foreground); font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.channelHighlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--surface-muted); }
.channelIcon { display: flex; flex: none; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
.channel[aria-selected="true"] .channelIcon { color: var(--accent); }
.channelText { display: grid; gap: 1px; min-width: 0; }
.channelText > span:first-child { font-size: var(--text-sm); font-weight: 500; }
.channelText > span:last-child { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
@media (hover: hover) and (pointer: fine) { .channel:hover .channelIcon { color: var(--foreground); } }
.channelPanel { border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); }
.channelPanel > .face { padding: var(--space-6); }
.channelDetail { display: grid; justify-items: start; gap: var(--space-3); }
.channelDetail h3 { margin: 0; font-size: var(--text-lg); font-weight: 500; line-height: var(--leading-body); }
.channelDetail p { max-width: 480px; margin: 0 0 var(--space-2); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
.copyRow { display: flex; width: min(100%, 360px); align-items: center; justify-content: space-between; gap: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 4px 4px 4px var(--space-4); font-size: var(--text-sm); }
.copyRow > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.confirm { display: grid; justify-items: start; gap: var(--space-2); }
.confirmNote { overflow: hidden; color: var(--text-secondary); font-size: var(--text-sm); }
.agent { display: flex; align-items: center; gap: var(--space-2); padding-top: var(--space-1); }
.agent strong { color: var(--foreground); font-weight: 500; }

/* Offices */
.offices { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-4); margin: 0; padding: 0; list-style: none; }
.office { display: grid; align-content: start; gap: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-panel); padding: var(--space-6); background: var(--surface); }
.officeHead { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.officeHead h3 { margin: 0; font-size: var(--text-lg); font-weight: 500; line-height: var(--leading-body); }
/* Baseline alignment lets the time text, not the clock icon, sit on the city's baseline. */
.officeTime { display: inline-flex; flex: none; align-items: baseline; gap: 6px; color: var(--text-secondary); font-size: var(--text-sm); }
.officeTime svg { align-self: center; }
.officeStatus { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-sm); }
.statusDot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); transition: background-color var(--duration-standard) var(--ease-standard); }
.officeStatus[data-open] .statusDot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in oklab, var(--success) 18%, transparent); }
.address { display: grid; margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-sm); font-style: normal; line-height: 1.55; }
.officeActions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); margin-top: var(--space-2); border-top: 1px solid var(--border-subtle); padding-top: var(--space-3); }
.officeLink { color: var(--foreground); font-size: var(--text-sm); text-decoration: none; }
@media (hover: hover) and (pointer: fine) { .officeLink:hover { text-decoration: underline; text-underline-offset: 3px; } }

@container contact (max-width: 820px) {
  .contact[data-variant="form"] .inner { grid-template-columns: minmax(0, 1fr); gap: var(--space-8); }
  .channels { grid-template-columns: minmax(0, 1fr); }
  .channelList { display: flex; overflow-x: auto; scrollbar-width: none; }
  .channelList::-webkit-scrollbar { display: none; }
  .channel { flex: none; }
  .channelText > span:last-child { display: none; }
}
@container contact (max-width: 520px) {
  .inner { padding: var(--space-12) var(--space-4); }
  .row { grid-template-columns: minmax(0, 1fr); }
  .formCard > .face, .channelPanel > .face { padding: var(--space-5); }
  .submitRow > button { width: 100%; }
}

.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
.previewNote { min-height: 20px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); }

@media (prefers-reduced-motion: reduce) {
  .topic, .channelIcon, .statusDot { transition: none; }
}
/* Press feedback: the same answer the hover gives, so touch and mouse both respond. */
.officeLink:active { color: var(--accent-strong); text-decoration: underline; text-underline-offset: 3px; }

/* The page-top illustration sits above the title, at most as wide as the intro column. */
.intro .art { width: 100%; max-width: 420px; margin-bottom: var(--space-3); border-radius: var(--radius-panel); }

```

### contact-section.tsx

```tsx
"use client";

import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, motion, useAnimate, useIsPresent } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CallIcon, Message01Icon, UserGroupIcon } from "@hugeicons/core-free-icons";
import { Avatar } from "../../avatar/avatar";
import { Button } from "../../button/button";
import { CopyButton } from "../../copy-button/copy-button";
import { Input } from "../../input/input";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Textarea } from "../../textarea/textarea";
import { motionTokens } from "../../lib/motion-tokens";
import { person } from "../../lib/media";
import { Illustration } from "../../illustration/illustration";
import styles from "./contact-section.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export type ContactSectionVariant = "form" | "channels" | "offices";

export interface ContactMessage {
  name: string;
  email: string;
  topic: string;
  message: string;
}

export interface ContactChannel {
  value: string;
  label: string;
  /** One line under the label in the list, such as a response time. */
  meta: string;
  icon?: ReactNode;
  /** The detail shown when the channel is selected. */
  detail: ReactNode;
}

export interface ContactOffice {
  city: string;
  /** IANA time zone, used for the live local time and open status. */
  timeZone: string;
  address: string[];
  email?: string;
  /** 현지 24시간제 영업 시간이에요. 기본은 9시부터 18시까지예요. */
  hours?: [number, number];
}

export interface ContactSectionProps {
  /** `form` validates and morphs into a confirmation, `channels` lists ways to reach you, `offices` shows live local times. */
  variant?: ContactSectionVariant;
  title?: string;
  description?: string;
  /** Topics offered in the form. */
  topics?: string[];
  /** Called with a valid message. Reject to keep the form and show an error; resolve to show the confirmation. */
  onSubmit?: (message: ContactMessage) => void | Promise<void>;
  channels?: ContactChannel[];
  /** Selected channel (controlled). */
  channel?: string;
  defaultChannel?: string;
  onChannelChange?: (value: string) => void;
  offices?: ContactOffice[];
  /** Draws the contact page-top illustration (an envelope and chat bubbles) above the title. The theme's own art is used when it has one. */
  illustration?: boolean;
  className?: string;
}

type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const MAX_MESSAGE = 500;

/** Faces slide along one axis in the direction of travel and settle out of a soft blur. */
const faceVariants: Variants = {
  hidden: ({ direction, axis }: { direction: number; axis: "x" | "y" }) => ({ opacity: 0, x: axis === "x" ? direction * 18 : 0, y: axis === "y" ? direction * 18 : 0, filter: `blur(${motionTokens.blur.soft}px)` }),
  shown: { opacity: 1, x: 0, y: 0, filter: "blur(0px)", transition: { x: motionTokens.spring.smooth, y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.delay.micro } } },
  gone: ({ direction, axis }: { direction: number; axis: "x" | "y" }) => ({ opacity: 0, x: axis === "x" ? direction * -12 : 0, y: axis === "y" ? direction * -12 : 0, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } }),
};
const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };

function Face({ children, custom, reduced, className }: { children: ReactNode; custom: { direction: number; axis: "x" | "y" }; reduced: boolean; className?: string }) {
  const present = useIsPresent();
  return <motion.div className={className} data-face="" data-leaving={present ? undefined : ""} inert={!present} custom={custom} variants={reduced ? fadeVariants : faceVariants} initial="hidden" animate="shown" exit="gone">{children}</motion.div>;
}

/**
 * One surface that springs from the height of its old content to the new one when `faceKey` changes, while the faces
 * crossfade along an axis. Between switches the height is automatic, so fields that grow inside it never get clipped.
 */
function MorphPanel({ faceKey, direction = 1, axis = "y", reduced, className, children, ...rest }: { faceKey: string; direction?: number; axis?: "x" | "y"; reduced: boolean; className?: string; children: ReactNode; id?: string; role?: string; "aria-labelledby"?: string }) {
  const [scope, animate] = useAnimate<HTMLDivElement>();
  const lastKey = useRef(faceKey);
  const lastHeight = useRef(0);
  useEffect(() => {
    const node = scope.current;
    if (!node || typeof ResizeObserver === "undefined") return;
    lastHeight.current = node.offsetHeight;
    const observer = new ResizeObserver(() => { lastHeight.current = node.offsetHeight; });
    observer.observe(node);
    return () => observer.disconnect();
  }, [scope]);
  useLayoutEffect(() => {
    if (lastKey.current === faceKey) return;
    lastKey.current = faceKey;
    const node = scope.current;
    const face = node?.querySelector<HTMLElement>(":scope > [data-face]:not([data-leaving])");
    if (!node || !face || reduced) return;
    const from = lastHeight.current || node.offsetHeight;
    const to = face.offsetHeight;
    if (Math.abs(from - to) < 1) return;
    node.style.setProperty("height", `${from}px`);
    const controls = animate(node, { height: [from, to] }, motionTokens.spring.smooth);
    controls.then(() => node.style.removeProperty("height"));
    return () => { controls.stop(); node.style.removeProperty("height"); };
  }, [faceKey, animate, scope, reduced]);
  return <div ref={scope} className={[styles.morph, className].filter(Boolean).join(" ")} {...rest}>
    <AnimatePresence initial={false} mode="popLayout" custom={{ direction, axis }}>
      <Face key={faceKey} custom={{ direction, axis }} reduced={reduced} className={styles.face}>{children}</Face>
    </AnimatePresence>
  </div>;
}

/** Topic chips as a radio group: a highlight glides to the choice and arrow keys move it. */
function TopicPicker({ topics, value, onChange: change, reduced }: { topics: string[]; value: string; onChange: (topic: string) => void; reduced: boolean }) {
  const onChange = (topic: string) => { if (topic !== value) playUiSound("select"); change(topic); };
  const id = useId();
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  function onKeyDown(event: KeyboardEvent) {
    const delta = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
    if (!delta) return;
    event.preventDefault();
    const next = (topics.indexOf(value) + delta + topics.length) % topics.length;
    onChange(topics[next]!);
    refs.current[next]?.focus();
  }
  return <div className={styles.topicField}>
    <span id={`${id}-label`} className={styles.fieldLabel}>주제</span>
    <LayoutGroup id={id}>
      <div className={styles.topics} role="radiogroup" aria-labelledby={`${id}-label`} onKeyDown={onKeyDown}>
        {topics.map((topic, index) => <button key={topic} ref={node => { refs.current[index] = node; }} type="button" role="radio" aria-checked={topic === value} tabIndex={topic === value ? 0 : -1} className={styles.topic} onClick={() => onChange(topic)}>
          {topic === value && <motion.span layoutId="topic" className={styles.topicHighlight} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
          <span>{topic}</span>
        </button>)}
      </div>
    </LayoutGroup>
  </div>;
}

type Errors = Partial<Record<"name" | "email" | "message", string>>;
function validate(values: { name: string; email: string; message: string }): Errors {
  const errors: Errors = {};
  if (!values.name.trim()) errors.name = "이름을 입력해 주세요";
  if (!values.email.trim()) errors.email = "이메일 주소를 입력해 주세요";
  else if (!EMAIL.test(values.email.trim())) errors.email = "name@example.com 같은 형식으로 입력해 주세요";
  if (values.message.trim().length < 20) errors.message = values.message.trim() ? "20자 이상으로 조금 더 자세히 적어 주세요" : "어떤 도움이 필요한지 알려 주세요";
  return errors;
}

function ContactForm({ topics, onSubmit, reduced }: { topics: string[]; onSubmit?: ContactSectionProps["onSubmit"]; reduced: boolean }) {
  const [values, setValues] = useState({ name: "", email: "", message: "" });
  const [topic, setTopic] = useState(topics[0] ?? "");
  const [errors, setErrors] = useState<Errors>({});
  const [submitted, setSubmitted] = useState(false);
  const [phase, setPhase] = useState<"editing" | "sending" | "sent">("editing");
  const [failure, setFailure] = useState<string | null>(null);
  const [sentTo, setSentTo] = useState({ name: "", email: "" });
  const nameRef = useRef<HTMLInputElement>(null), emailRef = useRef<HTMLInputElement>(null), messageRef = useRef<HTMLTextAreaElement>(null);
  const restoreFocus = useRef(false);
  const successRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    if (phase === "sent") successRef.current?.focus();
    if (phase === "editing" && restoreFocus.current) { restoreFocus.current = false; nameRef.current?.focus(); }
  }, [phase]);

  const update = (field: keyof typeof values) => (event: { target: { value: string } }) => {
    const next = { ...values, [field]: event.target.value };
    setValues(next);
    if (submitted) setErrors(validate(next));
  };

  async function submit(event: FormEvent) {
    event.preventDefault();
    if (phase !== "editing") return;
    setSubmitted(true);
    setFailure(null);
    const found = validate(values);
    setErrors(found);
    if (found.name) { nameRef.current?.focus(); return; }
    if (found.email) { emailRef.current?.focus(); return; }
    if (found.message) { messageRef.current?.focus(); return; }
    setPhase("sending");
    const payload = { name: values.name.trim(), email: values.email.trim(), topic, message: values.message.trim() };
    try {
      await (onSubmit ? onSubmit(payload) : new Promise(resolve => setTimeout(resolve, 1100)));
      setSentTo({ name: payload.name.split(/\s+/)[0]!, email: payload.email });
      setPhase("sent");
      playUiSound("success");
    } catch {
      setPhase("editing");
      playUiSound("error");
      setFailure("메시지를 보내지 못했어요. 네트워크 연결을 확인하고 다시 해 볼까요?");
    }
  }

  function reset() {
    setValues({ name: "", email: "", message: "" });
    setErrors({});
    setSubmitted(false);
    setFailure(null);
    restoreFocus.current = true;
    setPhase("editing");
  }

  const left = MAX_MESSAGE - values.message.length;
  return <MorphPanel faceKey={phase === "sent" ? "sent" : "form"} direction={phase === "sent" ? 1 : -1} reduced={reduced} className={styles.formCard}>
    {phase === "sent"
      ? <div className={styles.success} aria-live="polite">
          <span className={styles.successMark} aria-hidden="true">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduced ? 0 : motionTokens.duration.considered, ease: enter, delay: motionTokens.duration.fast }} />
            </svg>
          </span>
          <h3 ref={successRef} tabIndex={-1} className={styles.successTitle}>{sentTo.name}님, 고마워요</h3>
          <p className={styles.successText}>문의하신 내용을 {topic} 팀에 전달했어요. 영업일 기준 1일 이내에 {sentTo.email}(으)로 답해 드릴게요.</p>
          <Button variant="secondary" onClick={reset}>다른 메시지 보내기</Button>
        </div>
      : <form className={styles.formBody} onSubmit={submit} noValidate aria-label="문의 양식">
          <div className={styles.row}>
            <Input ref={nameRef} label="이름" name="name" autoComplete="name" placeholder="홍길동" value={values.name} onChange={update("name")} error={errors.name} readOnly={phase === "sending"} />
            <Input ref={emailRef} label="업무용 이메일" name="email" type="email" inputMode="email" autoComplete="email" placeholder="seoyeon@hanbit.example" value={values.email} onChange={update("email")} error={errors.email} readOnly={phase === "sending"} />
          </div>
          <TopicPicker topics={topics} value={topic} onChange={setTopic} reduced={reduced} />
          <Textarea ref={messageRef} label="메시지" name="message" rows={4} maxLength={MAX_MESSAGE} placeholder="어떤 제품을 만들고 계신가요?" value={values.message} onChange={update("message")} error={errors.message} description={`${left}자 남았어요`} readOnly={phase === "sending"} />
          <AnimatePresence initial={false}>
            {failure && <motion.p key="failure" role="alert" className={styles.failure} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: standard } }}>{failure}</motion.p>}
          </AnimatePresence>
          <div className={styles.submitRow}>
            <p className={styles.fine}>영업일 기준 1일 이내에 답해 드려요.</p>
            <Button type="submit" variant="primary" loading={phase === "sending"}>메시지 보내기</Button>
          </div>
        </form>}
  </MorphPanel>;
}

const hannah = person("hannah-walsh");
const ICON = { size: 18, strokeWidth: 1.75, "aria-hidden": true } as const;

/** A demo action that confirms in place: the label morphs, and the result stays visible below it. */
function ConfirmAction({ idle, busy, done, note }: { idle: string; busy?: boolean; done: string; note?: ReactNode }) {
  const reduced = !!useReducedMotion();
  const [state, setState] = useState<"idle" | "busy" | "done">("idle");
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);
  return <div className={styles.confirm}>
    <Button variant={state === "done" ? "secondary" : "primary"} loading={state === "busy"} onClick={() => {
      if (state !== "idle") { setState("idle"); return; }
      if (busy) { setState("busy"); timer.current = window.setTimeout(() => setState("done"), 800); } else setState("done");
    }}>
      {state === "done" ? <><UiIcon iconRole="check" size={15} strokeWidth={2.25} aria-hidden="true" />{done}</> : idle}
    </Button>
    <div aria-live="polite">
      <AnimatePresence initial={false}>
        {state === "done" && note && <motion.div key="note" className={styles.confirmNote} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: standard } }}>{note}</motion.div>}
      </AnimatePresence>
    </div>
  </div>;
}

export const contactExampleChannels: ContactChannel[] = [
  {
    value: "chat", label: "지원팀과 채팅하기", meta: "평균 4분 안에 답해요", icon: <HugeiconsIcon icon={Message01Icon} {...ICON} />,
    detail: <>
      <h3>지원팀과 채팅하기</h3>
      <p>김하나 님과 박준호 님이 지금 온라인이에요. 설치, 테마 설정, 컴포넌트 오작동처럼 빠르게 풀 수 있는 질문은 채팅이 가장 좋아요.</p>
      <ConfirmAction idle="채팅 시작하기" busy done="채팅을 시작했어요" note={<span className={styles.agent}><Avatar name="김하나" src={hannah.src} size="sm" status="online" /><span><strong>김하나</strong>님이 채팅에 참여했어요. 인사해 볼까요?</span></span>} />
    </>,
  },
  {
    value: "email", label: "이메일로 문의하기", meta: "영업일 기준 1일 이내에 답해요", icon: <UiIcon iconRole="mail" {...ICON} />,
    detail: <>
      <h3>이메일로 문의하기</h3>
      <p>스크린샷, 링크, 재현 방법을 보내 주세요. 모든 메시지에 영업일 기준 1일 이내에 담당자가 직접 답해요.</p>
      <div className={styles.copyRow}><span>support@example.com</span><CopyButton value="support@example.com" label="이메일 복사하기" /></div>
    </>,
  },
  {
    value: "sales", label: "영업팀과 상담하기", meta: "평일 오전 9시~오후 6시", icon: <HugeiconsIcon icon={CallIcon} {...ICON} />,
    detail: <>
      <h3>영업팀과 상담하기</h3>
      <p>대규모 팀 라이선스, 청구서 발행, 보안 검토가 필요하면 이태윤 님이 20분 통화로 안내해 드려요.</p>
      <div className={styles.copyRow}><span className={styles.tabular}>02-555-0132</span><CopyButton value="025550132" label="번호 복사하기" /></div>
      <ConfirmAction idle="전화 요청하기" busy done="요청했어요" note="이태윤 님이 이번 주 가능한 시간을 이메일로 보내 드려요." />
    </>,
  },
  {
    value: "community", label: "커뮤니티에 물어보기", meta: "멤버 4,200명", icon: <HugeiconsIcon icon={UserGroupIcon} {...ICON} />,
    detail: <>
      <h3>커뮤니티에 물어보기</h3>
      <p>devs7udio로 제품을 만드는 디자이너와 엔지니어가 컴포넌트, 테마, 모션에 관한 질문에 보통 한 시간 안에 답해 줘요.</p>
      <ConfirmAction idle="포럼 열기" done="포럼을 열었어요" note="실제 사이트에서는 포럼이 새 탭에서 열려요." />
    </>,
  },
];

export const contactExampleOffices: ContactOffice[] = [
  { city: "서울", timeZone: "Asia/Seoul", address: ["서울특별시 강남구 예시로 100, 4층", "06000"], email: "seoul@example.com" },
  { city: "싱가포르", timeZone: "Asia/Singapore", address: ["200 Sample Avenue, #12-01", "싱가포르 000000"], email: "singapore@example.com" },
  { city: "샌프란시스코", timeZone: "America/Los_Angeles", address: ["100 Example Street, Floor 4", "샌프란시스코, CA 94000"], email: "sf@example.com" },
];

function useNow(intervalMs: number) {
  const [now, setNow] = useState<Date | null>(null);
  useEffect(() => {
    const tick = () => setNow(new Date());
    tick();
    const timer = window.setInterval(tick, intervalMs);
    return () => window.clearInterval(timer);
  }, [intervalMs]);
  return now;
}

function officeState(office: ContactOffice, now: Date | null) {
  if (!now) return { time: "--:--", open: null as boolean | null };
  const parts = new Intl.DateTimeFormat("en-US", { timeZone: office.timeZone, hour: "numeric", minute: "2-digit", weekday: "short", hourCycle: "h23" }).formatToParts(now);
  const get = (type: string) => parts.find(part => part.type === type)?.value ?? "";
  const hour = Number(get("hour")), weekday = get("weekday");
  const [start, end] = office.hours ?? [9, 18];
  const time = new Intl.DateTimeFormat("ko-KR", { timeZone: office.timeZone, hour: "numeric", minute: "2-digit" }).format(now);
  return { time, open: weekday !== "Sat" && weekday !== "Sun" && hour >= start && hour < end };
}

const hourLabel = (hour: number) => `${hour < 12 || hour === 24 ? "오전" : "오후"} ${hour % 12 || 12}시`;

function Offices({ offices }: { offices: ContactOffice[] }) {
  const now = useNow(15000);
  return <ul className={styles.offices}>
    {offices.map(office => {
      const { time, open } = officeState(office, now);
      return <li key={office.city} className={styles.office}>
        <div className={styles.officeHead}>
          <h3>{office.city}</h3>
          <span className={styles.officeTime}><UiIcon iconRole="clock" size={14} strokeWidth={1.75} aria-hidden="true" /><span className={styles.tabular}>{time}</span></span>
        </div>
        <span className={styles.officeStatus} data-open={open === null ? undefined : open ? "" : undefined} data-closed={open === false ? "" : undefined}>
          <span className={styles.statusDot} aria-hidden="true" />{open === null ? "영업 시간을 확인하는 중이에요" : open ? `${hourLabel((office.hours ?? [9, 18])[1])}까지 영업해요` : "지금은 영업하지 않아요"}
        </span>
        <address className={styles.address}>{office.address.map(line => <span key={line}>{line}</span>)}</address>
        <div className={styles.officeActions}>
          {office.email && <a className={styles.officeLink} href={`mailto:${office.email}`} onClick={() => playUiSound("tick")}>{office.email}</a>}
          <CopyButton value={office.address.join(", ")} label="주소 복사하기" variant="plain" />
        </div>
      </li>;
    })}
  </ul>;
}

function Channels({ channels, value, onChange, reduced }: { channels: ContactChannel[]; value: string; onChange: (value: string) => void; reduced: boolean }) {
  const id = useId();
  const [direction, setDirection] = useState(1);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const active = channels.find(channel => channel.value === value) ?? channels[0]!;
  const choose = (next: string) => {
    if (next === active.value) return;
    playUiSound("tick");
    setDirection(Math.sign(channels.findIndex(channel => channel.value === next) - channels.indexOf(active)) || 1);
    onChange(next);
  };
  function onKeyDown(event: KeyboardEvent) {
    const delta = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : event.key === "ArrowUp" || event.key === "ArrowLeft" ? -1 : 0;
    const edge = event.key === "Home" ? 0 : event.key === "End" ? channels.length - 1 : -1;
    if (!delta && edge < 0) return;
    event.preventDefault();
    const next = edge >= 0 ? edge : (channels.indexOf(active) + delta + channels.length) % channels.length;
    choose(channels[next]!.value);
    refs.current[next]?.focus();
  }
  return <div className={styles.channels}>
    <LayoutGroup id={id}>
      <div className={styles.channelList} role="tablist" aria-orientation="vertical" aria-label="문의 방법" onKeyDown={onKeyDown}>
        {channels.map((channel, index) => {
          const selected = channel.value === active.value;
          return <button key={channel.value} ref={node => { refs.current[index] = node; }} type="button" role="tab" id={`${id}-tab-${channel.value}`} aria-selected={selected} aria-controls={`${id}-panel`} tabIndex={selected ? 0 : -1} className={styles.channel} onClick={() => choose(channel.value)}>
            {selected && <motion.span layoutId="channel" className={styles.channelHighlight} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
            <span className={styles.channelIcon}>{channel.icon}</span>
            <span className={styles.channelText}><span>{channel.label}</span><span>{channel.meta}</span></span>
          </button>;
        })}
      </div>
    </LayoutGroup>
    <MorphPanel faceKey={active.value} direction={direction} axis="y" reduced={reduced} className={styles.channelPanel} id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${active.value}`}>
      <div className={styles.channelDetail}>{active.detail}</div>
    </MorphPanel>
  </div>;
}

/**
 * A contact section in three layouts: a validated form whose card springs into a confirmation, a list of support
 * channels whose detail panel morphs to each channel, and office cards with live local times and open status.
 */
export const ContactSection = forwardRef<HTMLElement, ContactSectionProps>(function ContactSection({
  variant = "form",
  title,
  description,
  topics = ["영업", "지원", "제휴", "보도"],
  onSubmit,
  channels = contactExampleChannels,
  channel: channelProp,
  defaultChannel,
  onChannelChange,
  offices = contactExampleOffices,
  illustration = false,
  className,
}, ref) {
  const id = useId();
  const reduced = !!useReducedMotion();
  const [innerChannel, setInnerChannel] = useState(defaultChannel ?? channels[0]?.value ?? "");
  const activeChannel = channelProp ?? innerChannel;
  const setChannel = (next: string) => { if (channelProp === undefined) setInnerChannel(next); onChannelChange?.(next); };
  const copy = {
    form: { title: "팀과 이야기해 보세요", description: "라이선스 문의, 원인을 찾기 어려운 버그, 있었으면 하는 컴포넌트까지 무엇이든 보내 주세요. 모든 메시지를 사람이 직접 읽어요." },
    channels: { title: "편한 방법으로 도움받으세요", description: "질문에 맞는 채널을 골라 보세요. 어느 채널이든 같은 팀이 답해요." },
    offices: { title: "찾아오세요", description: "세 개의 시간대에서 일하고 있어서 대체로 누군가는 깨어 있어요. 커피 한잔하러 오실 때는 미리 알려 주세요." },
  }[variant];

  return <section ref={ref} className={[styles.contact, className].filter(Boolean).join(" ")} data-variant={variant} aria-labelledby={`${id}-title`}>
    <div className={styles.inner}>
      <div className={styles.intro}>
        {illustration && <Illustration name="page-contact" animated className={styles.art} />}
        <h2 id={`${id}-title`} className={styles.title}>{title ?? copy.title}</h2>
        <p className={styles.description}>{description ?? copy.description}</p>
        {variant === "form" && <ul className={styles.facts}>
          <li><UiIcon iconRole="clock" size={16} strokeWidth={1.75} aria-hidden="true" />영업일 기준 1일 이내에 답해 드려요</li>
          <li><UiIcon iconRole="mail" size={16} strokeWidth={1.75} aria-hidden="true" /><span>hello@example.com</span><CopyButton value="hello@example.com" label="이메일 복사하기" iconOnly variant="plain" /></li>
        </ul>}
      </div>
      {variant === "form" && <ContactForm topics={topics} onSubmit={onSubmit} reduced={reduced} />}
      {variant === "channels" && <Channels channels={channels} value={activeChannel} onChange={setChannel} reduced={reduced} />}
      {variant === "offices" && <Offices offices={offices} />}
    </div>
  </section>;
});

ContactSection.displayName = "ContactSection";

const variantOptions = [{ value: "form", label: "양식" }, { value: "channels", label: "채널" }, { value: "offices", label: "오피스" }];

/** Preview: the contact section with a switch between its three layouts. Messages are simulated and never sent. */
export function ContactSectionBlock({ variant: initial = "form" }: { variant?: ContactSectionVariant }) {
  const [variant, setVariant] = useState<ContactSectionVariant>(initial);
  return <div className={styles.preview}>
    <SegmentedControl label="문의 레이아웃" options={variantOptions} value={variant} onValueChange={next => setVariant(next as ContactSectionVariant)} />
    <div className={styles.frame}>
      <ContactSection key={variant} variant={variant} illustration />
    </div>
  </div>;
}

export default ContactSectionBlock;

```
