{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contact-section",
  "type": "registry:block",
  "title": "Contact section",
  "description": "A validated contact form that morphs into a confirmation, support channels, and office cards with local times.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/avatar.json",
    "https://irisui.dev/r/button.json",
    "https://irisui.dev/r/copy-button.json",
    "https://irisui.dev/r/input.json",
    "https://irisui.dev/r/segmented-control.json",
    "https://irisui.dev/r/textarea.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/contact-section/contact-section.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport type { FormEvent, KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion, useAnimate, useIsPresent, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { Check, Clock, Mail, MessageCircle, Phone, Users } from \"lucide-react\";\nimport { Avatar } from \"../../avatar/avatar\";\nimport { Button } from \"../../button/button\";\nimport { CopyButton } from \"../../copy-button/copy-button\";\nimport { Input } from \"../../input/input\";\nimport SegmentedControl from \"../../segmented-control/segmented-control\";\nimport { Textarea } from \"../../textarea/textarea\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\n\nexport type ContactSectionVariant = \"form\" | \"channels\" | \"offices\";\n\nexport interface ContactMessage {\n  name: string;\n  email: string;\n  topic: string;\n  message: string;\n}\n\nexport interface ContactChannel {\n  value: string;\n  label: string;\n  /** One line under the label in the list, such as a response time. */\n  meta: string;\n  icon?: ReactNode;\n  /** The detail shown when the channel is selected. */\n  detail: ReactNode;\n}\n\nexport interface ContactOffice {\n  city: string;\n  /** IANA time zone, used for the live local time and open status. */\n  timeZone: string;\n  address: string[];\n  email?: string;\n  /** Opening hours in local 24 hour time. Defaults to 9 to 18. */\n  hours?: [number, number];\n}\n\nexport interface ContactSectionProps {\n  /** `form` validates and morphs into a confirmation, `channels` lists ways to reach you, `offices` shows live local times. */\n  variant?: ContactSectionVariant;\n  title?: string;\n  description?: string;\n  /** Topics offered in the form. */\n  topics?: string[];\n  /** Called with a valid message. Reject to keep the form and show an error; resolve to show the confirmation. */\n  onSubmit?: (message: ContactMessage) => void | Promise<void>;\n  channels?: ContactChannel[];\n  /** Selected channel (controlled). */\n  channel?: string;\n  defaultChannel?: string;\n  onChannelChange?: (value: string) => void;\n  offices?: ContactOffice[];\n  className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\nconst MAX_MESSAGE = 500;\n\n/** Faces slide along one axis in the direction of travel and settle out of a soft blur. */\nconst faceVariants: Variants = {\n  hidden: ({ direction, axis }: { direction: number; axis: \"x\" | \"y\" }) => ({\n    opacity: 0,\n    x: axis === \"x\" ? direction * 18 : 0,\n    y: axis === \"y\" ? direction * 18 : 0,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  shown: {\n    opacity: 1,\n    x: 0,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: {\n      x: motionTokens.spring.smooth,\n      y: motionTokens.spring.smooth,\n      opacity: { duration: motionTokens.duration.standard, ease: enter, delay: 0.04 },\n      filter: { duration: motionTokens.duration.standard, ease: enter, delay: 0.04 },\n    },\n  },\n  gone: ({ direction, axis }: { direction: number; axis: \"x\" | \"y\" }) => ({\n    opacity: 0,\n    x: axis === \"x\" ? direction * -12 : 0,\n    y: axis === \"y\" ? direction * -12 : 0,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\nconst fadeVariants: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } },\n  gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\n\nfunction Face({\n  children,\n  custom,\n  reduced,\n  className,\n}: {\n  children: ReactNode;\n  custom: { direction: number; axis: \"x\" | \"y\" };\n  reduced: boolean;\n  className?: string;\n}) {\n  const present = useIsPresent();\n  return (\n    <motion.div\n      className={className}\n      data-face=\"\"\n      data-leaving={present ? undefined : \"\"}\n      inert={!present}\n      custom={custom}\n      variants={reduced ? fadeVariants : faceVariants}\n      initial=\"hidden\"\n      animate=\"shown\"\n      exit=\"gone\"\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n/**\n * One surface that springs from the height of its old content to the new one when `faceKey` changes, while the faces\n * crossfade along an axis. Between switches the height is automatic, so fields that grow inside it never get clipped.\n */\nfunction MorphPanel({\n  faceKey,\n  direction = 1,\n  axis = \"y\",\n  reduced,\n  className,\n  children,\n  ...rest\n}: {\n  faceKey: string;\n  direction?: number;\n  axis?: \"x\" | \"y\";\n  reduced: boolean;\n  className?: string;\n  children: ReactNode;\n  id?: string;\n  role?: string;\n  \"aria-labelledby\"?: string;\n}) {\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const lastKey = useRef(faceKey);\n  const lastHeight = useRef(0);\n  useEffect(() => {\n    const node = scope.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    lastHeight.current = node.offsetHeight;\n    const observer = new ResizeObserver(() => {\n      lastHeight.current = node.offsetHeight;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [scope]);\n  useLayoutEffect(() => {\n    if (lastKey.current === faceKey) return;\n    lastKey.current = faceKey;\n    const node = scope.current;\n    const face = node?.querySelector<HTMLElement>(\":scope > [data-face]:not([data-leaving])\");\n    if (!node || !face || reduced) return;\n    const from = lastHeight.current || node.offsetHeight;\n    const to = face.offsetHeight;\n    if (Math.abs(from - to) < 1) return;\n    node.style.setProperty(\"height\", `${from}px`);\n    const controls = animate(node, { height: [from, to] }, motionTokens.spring.smooth);\n    void controls.then(() => node.style.removeProperty(\"height\"));\n    return () => {\n      controls.stop();\n      node.style.removeProperty(\"height\");\n    };\n  }, [faceKey, animate, scope, reduced]);\n  return (\n    <div ref={scope} className={cn(\"relative overflow-hidden\", className)} {...rest}>\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={{ direction, axis }}>\n        <Face key={faceKey} custom={{ direction, axis }} reduced={reduced} className=\"w-full\">\n          {children}\n        </Face>\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** Topic chips as a radio group: a highlight glides to the choice and arrow keys move it. */\nfunction TopicPicker({\n  topics,\n  value,\n  onChange,\n  reduced,\n}: {\n  topics: string[];\n  value: string;\n  onChange: (topic: string) => void;\n  reduced: boolean;\n}) {\n  const id = useId();\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  function onKeyDown(event: KeyboardEvent) {\n    const delta =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? 1\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? -1\n          : 0;\n    if (!delta) return;\n    event.preventDefault();\n    const next = (topics.indexOf(value) + delta + topics.length) % topics.length;\n    onChange(topics[next]!);\n    refs.current[next]?.focus();\n  }\n  return (\n    <div className=\"grid\">\n      <span id={`${id}-label`} className=\"mb-2 text-sm leading-body font-medium text-ink\">\n        Topic\n      </span>\n      <LayoutGroup id={id}>\n        {/* oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- roving tabindex: the checked radio takes focus, and arrow keys bubble up to this group */}\n        <div className=\"flex flex-wrap gap-1\" role=\"radiogroup\" aria-labelledby={`${id}-label`} onKeyDown={onKeyDown}>\n          {topics.map((topic, index) => (\n            <button\n              key={topic}\n              ref={(node) => {\n                refs.current[index] = node;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={topic === value}\n              tabIndex={topic === value ? 0 : -1}\n              className=\"relative isolate h-control-sm cursor-pointer rounded-button border border-line bg-transparent px-4 text-sm/[inherit] font-medium text-ink-2 [-webkit-tap-highlight-color:transparent] transition-[color,border-color] duration-fast ease-standard not-aria-checked:hover:border-line-strong not-aria-checked:hover:text-ink aria-checked:border-transparent aria-checked:text-control-glyph motion-reduce:transition-none\"\n              onClick={() => onChange(topic)}\n            >\n              {topic === value && (\n                <motion.span\n                  layoutId=\"topic\"\n                  className=\"absolute -inset-px -z-1 rounded-[inherit] bg-control-on\"\n                  transition={reduced ? { duration: 0 } : motionTokens.spring.morph}\n                  aria-hidden=\"true\"\n                />\n              )}\n              <span>{topic}</span>\n            </button>\n          ))}\n        </div>\n      </LayoutGroup>\n    </div>\n  );\n}\n\ntype Errors = Partial<Record<\"name\" | \"email\" | \"message\", string>>;\nfunction validate(values: { name: string; email: string; message: string }): Errors {\n  const errors: Errors = {};\n  if (!values.name.trim()) errors.name = \"Enter your name\";\n  if (!values.email.trim()) errors.email = \"Enter your email address\";\n  else if (!EMAIL.test(values.email.trim())) errors.email = \"Enter an email like name@example.com\";\n  if (values.message.trim().length < 20)\n    errors.message = values.message.trim()\n      ? \"Add a little more detail, at least 20 characters\"\n      : \"Tell us how we can help\";\n  return errors;\n}\n\nfunction ContactForm({\n  topics,\n  onSubmit,\n  reduced,\n}: {\n  topics: string[];\n  onSubmit?: ContactSectionProps[\"onSubmit\"];\n  reduced: boolean;\n}) {\n  const [values, setValues] = useState({ name: \"\", email: \"\", message: \"\" });\n  const [topic, setTopic] = useState(topics[0] ?? \"\");\n  const [errors, setErrors] = useState<Errors>({});\n  const [submitted, setSubmitted] = useState(false);\n  const [phase, setPhase] = useState<\"editing\" | \"sending\" | \"sent\">(\"editing\");\n  const [failure, setFailure] = useState<string | null>(null);\n  const [sentTo, setSentTo] = useState({ name: \"\", email: \"\" });\n  const nameRef = useRef<HTMLInputElement>(null),\n    emailRef = useRef<HTMLInputElement>(null),\n    messageRef = useRef<HTMLTextAreaElement>(null);\n  const restoreFocus = useRef(false);\n  const successRef = useRef<HTMLHeadingElement>(null);\n\n  useEffect(() => {\n    if (phase === \"sent\") successRef.current?.focus();\n    if (phase === \"editing\" && restoreFocus.current) {\n      restoreFocus.current = false;\n      nameRef.current?.focus();\n    }\n  }, [phase]);\n\n  const update = (field: keyof typeof values) => (event: { target: { value: string } }) => {\n    const next = { ...values, [field]: event.target.value };\n    setValues(next);\n    if (submitted) setErrors(validate(next));\n  };\n\n  async function submit(event: FormEvent) {\n    event.preventDefault();\n    if (phase !== \"editing\") return;\n    setSubmitted(true);\n    setFailure(null);\n    const found = validate(values);\n    setErrors(found);\n    if (found.name) {\n      nameRef.current?.focus();\n      return;\n    }\n    if (found.email) {\n      emailRef.current?.focus();\n      return;\n    }\n    if (found.message) {\n      messageRef.current?.focus();\n      return;\n    }\n    setPhase(\"sending\");\n    const payload = { name: values.name.trim(), email: values.email.trim(), topic, message: values.message.trim() };\n    try {\n      await (onSubmit ? onSubmit(payload) : new Promise((resolve) => setTimeout(resolve, 1100)));\n      setSentTo({ name: payload.name.split(/\\s+/)[0]!, email: payload.email });\n      setPhase(\"sent\");\n    } catch {\n      setPhase(\"editing\");\n      setFailure(\"We couldn't send your message. Check your connection and try again.\");\n    }\n  }\n\n  function reset() {\n    setValues({ name: \"\", email: \"\", message: \"\" });\n    setErrors({});\n    setSubmitted(false);\n    setFailure(null);\n    restoreFocus.current = true;\n    setPhase(\"editing\");\n  }\n\n  const left = MAX_MESSAGE - values.message.length;\n  return (\n    <MorphPanel\n      faceKey={phase === \"sent\" ? \"sent\" : \"form\"}\n      direction={phase === \"sent\" ? 1 : -1}\n      reduced={reduced}\n      className=\"rounded-panel border border-line bg-surface *:p-6 @max-[520px]/contact:*:p-5\"\n    >\n      {phase === \"sent\" ? (\n        <div className=\"grid justify-items-center gap-3 px-2 py-10 text-center\" aria-live=\"polite\">\n          <span\n            className=\"mb-2 grid size-[52px] place-items-center rounded-full bg-[color-mix(in_oklab,var(--green)_13%,transparent)] text-green [&_svg]:size-[26px]\"\n            aria-hidden=\"true\"\n          >\n            <svg\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <motion.path\n                d=\"M5 12.5l4.5 4.5L19 7.5\"\n                initial={reduced ? false : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: reduced ? 0 : 0.42, ease: enter, delay: 0.16 }}\n              />\n            </svg>\n          </span>\n          <h3\n            ref={successRef}\n            tabIndex={-1}\n            className=\"m-0 font-display text-2xl leading-display font-medium tracking-display\"\n          >\n            Thanks, {sentTo.name}\n          </h3>\n          <p className=\"mx-0 mt-0 mb-3 max-w-[360px] text-sm/[1.55] text-ink-2\">\n            Your message is with our {topic.toLowerCase()} team. We&rsquo;ll reply to {sentTo.email} within one business\n            day.\n          </p>\n          <Button variant=\"secondary\" onClick={reset}>\n            Send another message\n          </Button>\n        </div>\n      ) : (\n        <form className=\"grid gap-5\" onSubmit={submit} noValidate aria-label=\"Contact form\">\n          <div className=\"grid grid-cols-2 gap-4 @max-[520px]/contact:grid-cols-1\">\n            <Input\n              ref={nameRef}\n              label=\"Name\"\n              name=\"name\"\n              autoComplete=\"name\"\n              placeholder=\"Thanos Galanis\"\n              value={values.name}\n              onChange={update(\"name\")}\n              error={errors.name}\n              readOnly={phase === \"sending\"}\n            />\n            <Input\n              ref={emailRef}\n              label=\"Work email\"\n              name=\"email\"\n              type=\"email\"\n              inputMode=\"email\"\n              autoComplete=\"email\"\n              placeholder=\"thanos@rentals.example\"\n              value={values.email}\n              onChange={update(\"email\")}\n              error={errors.email}\n              readOnly={phase === \"sending\"}\n            />\n          </div>\n          <TopicPicker topics={topics} value={topic} onChange={setTopic} reduced={reduced} />\n          <Textarea\n            ref={messageRef}\n            label=\"Message\"\n            name=\"message\"\n            rows={4}\n            maxLength={MAX_MESSAGE}\n            placeholder=\"What are you verifying, and in which countries?\"\n            value={values.message}\n            onChange={update(\"message\")}\n            error={errors.message}\n            description={`${left} characters left`}\n            readOnly={phase === \"sending\"}\n          />\n          <AnimatePresence initial={false}>\n            {failure && (\n              <motion.p\n                key=\"failure\"\n                role=\"alert\"\n                className=\"m-0 overflow-hidden text-sm/[inherit] text-red\"\n                initial={{ opacity: 0, height: 0 }}\n                animate={{ opacity: 1, height: \"auto\" }}\n                exit={{ opacity: 0, height: 0 }}\n                transition={\n                  reduced\n                    ? { duration: 0 }\n                    : {\n                        height: motionTokens.spring.smooth,\n                        opacity: { duration: motionTokens.duration.fast, ease: standard },\n                      }\n                }\n              >\n                {failure}\n              </motion.p>\n            )}\n          </AnimatePresence>\n          <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-3 @max-[520px]/contact:[&>button]:w-full\">\n            <p className=\"m-0 text-sm/[inherit] text-ink-3\">We reply within one business day.</p>\n            <Button type=\"submit\" variant=\"primary\" loading={phase === \"sending\"}>\n              Send message\n            </Button>\n          </div>\n        </form>\n      )}\n    </MorphPanel>\n  );\n}\n\nconst supportAgent = \"Stelios Antoniou\";\nconst agentClass = \"flex items-center gap-2 pt-1 [&_strong]:font-medium [&_strong]:text-ink\";\nconst copyRowClass =\n  \"flex w-[min(100%,360px)] items-center justify-between gap-3 rounded-control border border-line py-1 pr-1 pl-4 text-sm/[inherit] [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap\";\nconst ICON = { size: 18, strokeWidth: 1.75, \"aria-hidden\": true } as const;\n\n/** A demo action that confirms in place: the label morphs, and the result stays visible below it. */\nfunction ConfirmAction({ idle, busy, done, note }: { idle: string; busy?: boolean; done: string; note?: ReactNode }) {\n  const reduced = !!useReducedMotion();\n  const [state, setState] = useState<\"idle\" | \"busy\" | \"done\">(\"idle\");\n  const timer = useRef<number | undefined>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n  return (\n    <div className=\"grid justify-items-start gap-2\">\n      <Button\n        variant={state === \"done\" ? \"secondary\" : \"primary\"}\n        loading={state === \"busy\"}\n        onClick={() => {\n          if (state !== \"idle\") {\n            setState(\"idle\");\n            return;\n          }\n          if (busy) {\n            setState(\"busy\");\n            timer.current = window.setTimeout(() => setState(\"done\"), 800);\n          } else setState(\"done\");\n        }}\n      >\n        {state === \"done\" ? (\n          <>\n            <Check size={15} strokeWidth={2.25} aria-hidden=\"true\" />\n            {done}\n          </>\n        ) : (\n          idle\n        )}\n      </Button>\n      <div aria-live=\"polite\">\n        <AnimatePresence initial={false}>\n          {state === \"done\" && note && (\n            <motion.div\n              key=\"note\"\n              className=\"overflow-hidden text-sm/[inherit] text-ink-2\"\n              initial={{ opacity: 0, height: 0 }}\n              animate={{ opacity: 1, height: \"auto\" }}\n              exit={{ opacity: 0, height: 0 }}\n              transition={\n                reduced\n                  ? { duration: 0 }\n                  : {\n                      height: motionTokens.spring.smooth,\n                      opacity: { duration: motionTokens.duration.fast, ease: standard },\n                    }\n              }\n            >\n              {note}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nexport const contactExampleChannels: ContactChannel[] = [\n  {\n    value: \"chat\",\n    label: \"Chat with support\",\n    meta: \"Replies in about 4 minutes\",\n    icon: <MessageCircle {...ICON} />,\n    detail: (\n      <>\n        <h3>Chat with support</h3>\n        <p>\n          Stelios and Ioanna are online now. Chat is best for quick questions about the hosted flow, a webhook delivery\n          or a session waiting for review.\n        </p>\n        <ConfirmAction\n          idle=\"Start a chat\"\n          busy\n          done=\"Chat started\"\n          note={\n            <span className={agentClass}>\n              <Avatar name={supportAgent} size=\"sm\" status=\"online\" />\n              <span>\n                <strong>{supportAgent}</strong> joined the chat. Say hello.\n              </span>\n            </span>\n          }\n        />\n      </>\n    ),\n  },\n  {\n    value: \"email\",\n    label: \"Email support\",\n    meta: \"Replies within a business day\",\n    icon: <Mail {...ICON} />,\n    detail: (\n      <>\n        <h3>Email support</h3>\n        <p>\n          Send the session ID, its purpose and the country. Every message gets a reply from a person within one business\n          day.\n        </p>\n        <div className={copyRowClass}>\n          <span>support@opsisid.example</span>\n          <CopyButton value=\"support@opsisid.example\" label=\"Copy email\" />\n        </div>\n      </>\n    ),\n  },\n  {\n    value: \"sales\",\n    label: \"Talk to sales\",\n    meta: \"Weekdays, 09:00 to 18:00 Athens time\",\n    icon: <Phone {...ICON} />,\n    detail: (\n      <>\n        <h3>Talk to sales</h3>\n        <p>\n          Pricing is by quote. Bring your purposes, countries and monthly sessions, and Katerina will walk you through\n          the policy table on a 20 minute call.\n        </p>\n        <div className={copyRowClass}>\n          <span className=\"tabular-nums\">+30 210 000 0132</span>\n          <CopyButton value=\"+302100000132\" label=\"Copy number\" />\n        </div>\n        <ConfirmAction\n          idle=\"Request a call\"\n          busy\n          done=\"Call requested\"\n          note=\"Katerina will email you a few times that work this week.\"\n        />\n      </>\n    ),\n  },\n  {\n    value: \"decision\",\n    label: \"Ask about a decision\",\n    meta: \"For people who were verified\",\n    icon: <Users {...ICON} />,\n    detail: (\n      <>\n        <h3>Ask about a decision</h3>\n        <p>If a check in your session failed, a reviewer looks at the case again. A model never decides a case.</p>\n        <ConfirmAction\n          idle=\"Request a review\"\n          done=\"Review requested\"\n          note=\"A reviewer will reply to the address on your session.\"\n        />\n      </>\n    ),\n  },\n];\n\nexport const contactExampleOffices: ContactOffice[] = [\n  {\n    city: \"Athens\",\n    timeZone: \"Europe/Athens\",\n    address: [\"10 Sample Street, 4th floor\", \"106 00 Athens, Greece\"],\n    email: \"athens@opsisid.example\",\n  },\n  {\n    city: \"Amsterdam\",\n    timeZone: \"Europe/Amsterdam\",\n    address: [\"Voorbeeldstraat 20\", \"1000 AA Amsterdam, Netherlands\"],\n    email: \"amsterdam@opsisid.example\",\n  },\n  {\n    city: \"Lisbon\",\n    timeZone: \"Europe/Lisbon\",\n    address: [\"Rua do Exemplo 10, 3º\", \"1000-000 Lisboa, Portugal\"],\n    email: \"lisbon@opsisid.example\",\n  },\n];\n\nfunction useNow(intervalMs: number) {\n  const [now, setNow] = useState<Date | null>(null);\n  useEffect(() => {\n    const tick = () => setNow(new Date());\n    tick();\n    const timer = window.setInterval(tick, intervalMs);\n    return () => window.clearInterval(timer);\n  }, [intervalMs]);\n  return now;\n}\n\nfunction officeState(office: ContactOffice, now: Date | null) {\n  if (!now) return { time: \"--:--\", open: null as boolean | null };\n  const parts = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone: office.timeZone,\n    hour: \"numeric\",\n    minute: \"2-digit\",\n    weekday: \"short\",\n    hourCycle: \"h23\",\n  }).formatToParts(now);\n  const get = (type: string) => parts.find((part) => part.type === type)?.value ?? \"\";\n  const hour = Number(get(\"hour\")),\n    weekday = get(\"weekday\");\n  const [start, end] = office.hours ?? [9, 18];\n  const time = new Intl.DateTimeFormat(\"en-GB\", {\n    timeZone: office.timeZone,\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n    hourCycle: \"h23\",\n  }).format(now);\n  return { time, open: weekday !== \"Sat\" && weekday !== \"Sun\" && hour >= start && hour < end };\n}\n\nconst hourLabel = (hour: number) => `${String(hour).padStart(2, \"0\")}:00`;\n\nfunction Offices({ offices }: { offices: ContactOffice[] }) {\n  const now = useNow(15000);\n  return (\n    <ul className=\"m-0 grid list-none grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4 p-0\">\n      {offices.map((office) => {\n        const { time, open } = officeState(office, now);\n        return (\n          <li key={office.city} className=\"grid content-start gap-3 rounded-panel border border-line bg-surface p-6\">\n            <div className=\"flex items-baseline justify-between gap-3\">\n              <h3 className=\"m-0 text-lg leading-body font-medium\">{office.city}</h3>\n              <span className=\"inline-flex flex-none items-baseline gap-1.5 text-sm/[inherit] text-ink-2 [&_svg]:self-center\">\n                <Clock size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                <span className=\"tabular-nums\">{time}</span>\n              </span>\n            </div>\n            <span\n              className=\"inline-flex items-center gap-2 text-sm/[inherit] text-ink-2\"\n              data-open={open === null ? undefined : open ? \"\" : undefined}\n              data-closed={open === false ? \"\" : undefined}\n            >\n              <span\n                className={cn(\n                  \"size-[7px] rounded-full bg-line-strong transition-[background-color] duration-standard ease-standard motion-reduce:transition-none\",\n                  open && \"bg-green shadow-[0_0_0_3px_color-mix(in_oklab,var(--green)_18%,transparent)]\",\n                )}\n                aria-hidden=\"true\"\n              />\n              {open === null\n                ? \"Checking hours\"\n                : open\n                  ? `Open until ${hourLabel((office.hours ?? [9, 18])[1])}`\n                  : \"Closed now\"}\n            </span>\n            <address className=\"mt-1 grid text-sm/[1.55] text-ink-2 not-italic\">\n              {office.address.map((line) => (\n                <span key={line}>{line}</span>\n              ))}\n            </address>\n            <div className=\"mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-line-soft pt-3\">\n              {office.email && (\n                <a\n                  className=\"text-sm/[inherit] text-ink no-underline hover:underline hover:underline-offset-3\"\n                  href={`mailto:${office.email}`}\n                >\n                  {office.email}\n                </a>\n              )}\n              <CopyButton value={office.address.join(\", \")} label=\"Copy address\" variant=\"plain\" />\n            </div>\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n\nfunction Channels({\n  channels,\n  value,\n  onChange,\n  reduced,\n}: {\n  channels: ContactChannel[];\n  value: string;\n  onChange: (value: string) => void;\n  reduced: boolean;\n}) {\n  const id = useId();\n  const [direction, setDirection] = useState(1);\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  const active = channels.find((channel) => channel.value === value) ?? channels[0]!;\n  const choose = (next: string) => {\n    if (next === active.value) return;\n    setDirection(Math.sign(channels.findIndex((channel) => channel.value === next) - channels.indexOf(active)) || 1);\n    onChange(next);\n  };\n  function onKeyDown(event: KeyboardEvent) {\n    const delta =\n      event.key === \"ArrowDown\" || event.key === \"ArrowRight\"\n        ? 1\n        : event.key === \"ArrowUp\" || event.key === \"ArrowLeft\"\n          ? -1\n          : 0;\n    const edge = event.key === \"Home\" ? 0 : event.key === \"End\" ? channels.length - 1 : -1;\n    if (!delta && edge < 0) return;\n    event.preventDefault();\n    const next = edge >= 0 ? edge : (channels.indexOf(active) + delta + channels.length) % channels.length;\n    choose(channels[next]!.value);\n    refs.current[next]?.focus();\n  }\n  return (\n    <div className=\"grid grid-cols-[minmax(220px,300px)_minmax(0,1fr)] items-start gap-6 @max-[820px]/contact:grid-cols-1\">\n      <LayoutGroup id={id}>\n        {/* oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- roving tabindex: the selected tab takes focus, and arrow keys bubble up to this list */}\n        <div\n          className=\"grid gap-0.5 @max-[820px]/contact:flex @max-[820px]/contact:overflow-x-auto @max-[820px]/contact:[scrollbar-width:none] @max-[820px]/contact:[&::-webkit-scrollbar]:hidden\"\n          role=\"tablist\"\n          aria-orientation=\"vertical\"\n          aria-label=\"Ways to reach us\"\n          onKeyDown={onKeyDown}\n        >\n          {channels.map((channel, index) => {\n            const selected = channel.value === active.value;\n            return (\n              <button\n                key={channel.value}\n                ref={(node) => {\n                  refs.current[index] = node;\n                }}\n                type=\"button\"\n                role=\"tab\"\n                id={`${id}-tab-${channel.value}`}\n                aria-selected={selected}\n                aria-controls={`${id}-panel`}\n                tabIndex={selected ? 0 : -1}\n                className=\"group/channel relative isolate flex cursor-pointer items-center gap-3 rounded-button px-[18px] py-3 text-left text-ink [-webkit-tap-highlight-color:transparent] @max-[820px]/contact:flex-none\"\n                onClick={() => choose(channel.value)}\n              >\n                {selected && (\n                  <motion.span\n                    layoutId=\"channel\"\n                    className=\"absolute inset-0 -z-1 rounded-[inherit] bg-hover\"\n                    transition={reduced ? { duration: 0 } : motionTokens.spring.morph}\n                    aria-hidden=\"true\"\n                  />\n                )}\n                <span\n                  className={cn(\n                    \"flex flex-none text-ink-3 transition-[color] duration-fast ease-standard group-hover/channel:text-ink motion-reduce:transition-none\",\n                    selected && \"text-accent\",\n                  )}\n                >\n                  {channel.icon}\n                </span>\n                <span className=\"grid min-w-0 gap-px\">\n                  <span className=\"text-sm/[inherit] font-medium\">{channel.label}</span>\n                  <span className=\"overflow-hidden text-xs/[inherit] text-ellipsis whitespace-nowrap text-ink-3 @max-[820px]/contact:hidden\">\n                    {channel.meta}\n                  </span>\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      </LayoutGroup>\n      <MorphPanel\n        faceKey={active.value}\n        direction={direction}\n        axis=\"y\"\n        reduced={reduced}\n        className=\"rounded-panel border border-line bg-surface *:p-6 @max-[520px]/contact:*:p-5\"\n        id={`${id}-panel`}\n        role=\"tabpanel\"\n        aria-labelledby={`${id}-tab-${active.value}`}\n      >\n        <div className=\"grid justify-items-start gap-3 [&_h3]:m-0 [&_h3]:text-lg [&_h3]:leading-body [&_h3]:font-medium [&_p]:mx-0 [&_p]:mt-0 [&_p]:mb-2 [&_p]:max-w-[480px] [&_p]:text-sm/[1.6] [&_p]:text-ink-2\">\n          {active.detail}\n        </div>\n      </MorphPanel>\n    </div>\n  );\n}\n\n/**\n * A contact section in three layouts: a validated form whose card springs into a confirmation, a list of support\n * channels whose detail panel morphs to each channel, and office cards with live local times and open status.\n */\nexport const ContactSection = forwardRef<HTMLElement, ContactSectionProps>(function ContactSection(\n  {\n    variant = \"form\",\n    title,\n    description,\n    topics = [\"Sales\", \"Support\", \"Compliance\", \"Press\"],\n    onSubmit,\n    channels = contactExampleChannels,\n    channel: channelProp,\n    defaultChannel,\n    onChannelChange,\n    offices = contactExampleOffices,\n    className,\n  },\n  ref,\n) {\n  const id = useId();\n  const reduced = !!useReducedMotion();\n  const [innerChannel, setInnerChannel] = useState(defaultChannel ?? channels[0]?.value ?? \"\");\n  const activeChannel = channelProp ?? innerChannel;\n  const setChannel = (next: string) => {\n    if (channelProp === undefined) setInnerChannel(next);\n    onChannelChange?.(next);\n  };\n  const copy = {\n    form: {\n      title: \"Talk to our team\",\n      description:\n        \"Questions about pricing, a session stuck in review, or a country you need covered. A person reads every message.\",\n    },\n    channels: {\n      title: \"Get help your way\",\n      description:\n        \"Pick whatever suits the question. Every channel reaches the same small team of reviewers and engineers.\",\n    },\n    offices: {\n      title: \"Visit us\",\n      description: \"We work from three cities in three time zones. Drop by, just let us know first.\",\n    },\n  }[variant];\n\n  return (\n    <section\n      ref={ref}\n      className={cn(\"@container/contact bg-page font-body tracking-body text-ink\", className)}\n      data-variant={variant}\n      aria-labelledby={`${id}-title`}\n    >\n      <div\n        className={cn(\n          \"mx-auto grid max-w-[1120px] px-8 py-20 @max-[520px]/contact:px-4 @max-[520px]/contact:py-12\",\n          variant === \"form\"\n            ? \"grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)] items-start gap-12 @max-[820px]/contact:grid-cols-1 @max-[820px]/contact:gap-8\"\n            : \"gap-10\",\n        )}\n      >\n        <div className=\"grid max-w-[520px] content-start gap-3\">\n          <h2\n            id={`${id}-title`}\n            className=\"m-0 font-display text-[length:clamp(1.75rem,1rem_+_3cqi,var(--text-4xl))] leading-display font-medium tracking-display text-balance\"\n          >\n            {title ?? copy.title}\n          </h2>\n          <p className=\"m-0 text-base/[1.5] text-pretty text-ink-2\">{description ?? copy.description}</p>\n          {variant === \"form\" && (\n            <ul className=\"mx-0 mt-4 mb-0 grid list-none gap-2 p-0 [&_li]:flex [&_li]:min-h-8 [&_li]:items-center [&_li]:gap-2 [&_li]:text-sm/[inherit] [&_li]:text-ink-2 [&_svg]:flex-none\">\n              <li>\n                <Clock size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                Replies within one business day\n              </li>\n              <li>\n                <Mail size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                <span>hello@opsisid.example</span>\n                <CopyButton value=\"hello@opsisid.example\" label=\"Copy email\" iconOnly variant=\"plain\" />\n              </li>\n            </ul>\n          )}\n        </div>\n        {variant === \"form\" && <ContactForm topics={topics} onSubmit={onSubmit} reduced={reduced} />}\n        {variant === \"channels\" && (\n          <Channels channels={channels} value={activeChannel} onChange={setChannel} reduced={reduced} />\n        )}\n        {variant === \"offices\" && <Offices offices={offices} />}\n      </div>\n    </section>\n  );\n});\n\nContactSection.displayName = \"ContactSection\";\n\nconst variantOptions = [\n  { value: \"form\", label: \"Form\" },\n  { value: \"channels\", label: \"Channels\" },\n  { value: \"offices\", label: \"Offices\" },\n];\n\n/** Preview: the contact section with a switch between its three layouts. Messages are simulated and never sent. */\nexport function ContactSectionBlock({ variant: initial = \"form\" }: { variant?: ContactSectionVariant }) {\n  const [variant, setVariant] = useState<ContactSectionVariant>(initial);\n  return (\n    <div className=\"grid w-full justify-items-center gap-4\">\n      <SegmentedControl\n        label=\"Contact layout\"\n        options={variantOptions}\n        value={variant}\n        onValueChange={(next) => setVariant(next as ContactSectionVariant)}\n      />\n      <div className=\"w-full overflow-hidden rounded-[20px] border border-line bg-page\">\n        <ContactSection key={variant} variant={variant} />\n      </div>\n    </div>\n  );\n}\n\nexport default ContactSectionBlock;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/contact-section/contact-section.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/contact-section",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "ContactSection",
    "docs": "https://irisui.dev/components/contact-section"
  }
}
