{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "connection-card",
  "type": "registry:component",
  "title": "Connection Card",
  "description": "Everything one connected service needs on a single card, its key, its site and its time zone, with a save bar that only turns up once you've changed something.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/input-group.json",
    "https://coss.com/ui/r/kbd.json",
    "https://coss.com/ui/r/select.json",
    "https://coss.com/ui/r/spinner.json",
    "https://coss.com/ui/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/devl/connection-card.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  CheckIcon,\n  CircleAlertIcon,\n  CircleCheckIcon,\n  CopyIcon,\n  EyeIcon,\n  EyeOffIcon,\n  KeyRoundIcon,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from \"@/components/ui/input-group\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ConnectionCardValues {\n  enabled: boolean;\n  apiKey: string;\n  siteUrl: string;\n  timeZone: string;\n}\n\nexport interface ConnectionCardService {\n  name: string;\n  description: string;\n  icon: LucideIcon;\n  iconClassName?: string;\n}\n\nexport interface ConnectionCardProps {\n  service: ConnectionCardService;\n  defaultValues: ConnectionCardValues;\n  timeZones?: { value: string; label: string }[];\n  lastSaved?: { by: string; at: string };\n  onSave?: (values: ConnectionCardValues) => Promise<void> | void;\n  className?: string;\n}\n\nconst defaultZones = [\n  { value: \"America/Los_Angeles\", label: \"Pacific Time — Los Angeles\" },\n  { value: \"America/New_York\", label: \"Eastern Time — New York\" },\n  { value: \"Europe/London\", label: \"London\" },\n  { value: \"Europe/Berlin\", label: \"Central Europe — Berlin\" },\n  { value: \"Asia/Singapore\", label: \"Singapore\" },\n  { value: \"Australia/Sydney\", label: \"Sydney\" },\n];\n\nconst iconHit = \"after:absolute after:-inset-1 transition-[scale,background-color] duration-150 ease-out active:scale-[0.94]\";\nconst press = \"transition-[scale,box-shadow,background-color] duration-150 ease-out active:scale-[0.97]\";\n\nconst domain = /^(?=.{4,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,24}(:\\d{2,5})?(\\/[^\\s]*)?$/i;\n\nfunction validateUrl(raw: string): string | null {\n  const v = raw.trim().replace(/^https?:\\/\\//i, \"\");\n  if (!v) return \"Add the site this service sends from.\";\n  if (/\\s/.test(v)) return \"Remove the spaces.\";\n  if (!v.includes(\".\")) return \"Use a full domain, like shop.example.com.\";\n  if (!domain.test(v)) return \"That doesn't look like a valid domain.\";\n  return null;\n}\n\nfunction useClock(timeZone: string) {\n  const [now, setNow] = useState<Date | null>(null);\n  useEffect(() => {\n    setNow(new Date());\n    const id = setInterval(() => setNow(new Date()), 15000);\n    return () => clearInterval(id);\n  }, []);\n  if (!now) return null;\n  try {\n    return new Intl.DateTimeFormat(\"en-US\", { timeZone, hour: \"numeric\", minute: \"2-digit\" }).format(now);\n  } catch {\n    return null;\n  }\n}\n\nfunction Changed({ on }: { on: boolean }) {\n  return (\n    <AnimatePresence>\n      {on && (\n        <motion.span\n          animate={{ scale: 1, opacity: 1 }}\n          aria-label=\"Changed\"\n          className=\"inline-block size-1.5 rounded-full bg-warning align-middle\"\n          exit={{ scale: 0.5, opacity: 0 }}\n          initial={{ scale: 0.5, opacity: 0 }}\n          role=\"img\"\n          transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n        />\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction same(a: ConnectionCardValues, b: ConnectionCardValues) {\n  return a.enabled === b.enabled && a.apiKey === b.apiKey && a.siteUrl === b.siteUrl && a.timeZone === b.timeZone;\n}\n\nexport function ConnectionCard({\n  service,\n  defaultValues,\n  timeZones = defaultZones,\n  lastSaved,\n  onSave,\n  className,\n}: ConnectionCardProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const [saved, setSaved] = useState(defaultValues);\n  const [values, setValues] = useState(defaultValues);\n  const [footer, setFooter] = useState(lastSaved);\n  const [revealed, setRevealed] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const [checking, setChecking] = useState(false);\n  const [urlTouched, setUrlTouched] = useState(false);\n  const [phase, setPhase] = useState<\"idle\" | \"saving\" | \"saved\" | \"failed\">(\"idle\");\n  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const checkTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const savedTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(\n    () => () => {\n      clearTimeout(copyTimer.current);\n      clearTimeout(checkTimer.current);\n      clearTimeout(savedTimer.current);\n    },\n    [],\n  );\n\n  const dirty = !same(values, saved);\n  const urlError = validateUrl(values.siteUrl);\n  const showUrlError = urlTouched && !checking && urlError;\n  const clock = useClock(values.timeZone);\n  const Icon = service.icon;\n  const showBar = dirty || phase !== \"idle\";\n\n  const set = <K extends keyof ConnectionCardValues>(key: K, v: ConnectionCardValues[K]) => {\n    setValues((prev) => ({ ...prev, [key]: v }));\n    setPhase((p) => (p === \"failed\" ? \"idle\" : p));\n  };\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard?.writeText(values.apiKey);\n    } catch {}\n    setCopied(true);\n    clearTimeout(copyTimer.current);\n    copyTimer.current = setTimeout(() => setCopied(false), 1600);\n  };\n\n  const onUrl = (v: string) => {\n    set(\"siteUrl\", v);\n    setUrlTouched(true);\n    setChecking(true);\n    clearTimeout(checkTimer.current);\n    checkTimer.current = setTimeout(() => setChecking(false), 450);\n  };\n\n  const save = async () => {\n    if (!dirty || urlError || phase === \"saving\") return;\n    setPhase(\"saving\");\n    try {\n      await (onSave?.(values) ?? new Promise((r) => setTimeout(r, 900)));\n      setSaved(values);\n      setFooter({ by: \"you\", at: \"just now\" });\n      setPhase(\"saved\");\n      savedTimer.current = setTimeout(() => setPhase(\"idle\"), 1100);\n    } catch {\n      setPhase(\"failed\");\n    }\n  };\n\n  const discard = () => {\n    setValues(saved);\n    setPhase(\"idle\");\n    setUrlTouched(false);\n    setChecking(false);\n  };\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"s\") {\n      e.preventDefault();\n      save();\n    }\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 34 };\n  const swap = {\n    initial: reduce ? false : { opacity: 0, scale: 0.8, filter: \"blur(2px)\" },\n    animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\" },\n    transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] },\n  } as const;\n\n  return (\n    <section\n      aria-labelledby={`${id}-title`}\n      className={cn(\n        \"relative w-full max-w-[440px] overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\",\n        className,\n      )}\n      onKeyDown={onKeyDown}\n    >\n      <header className=\"flex items-center gap-3 p-4\">\n        <span\n          className={cn(\n            \"flex size-10 shrink-0 items-center justify-center rounded-xl shadow-xs/5 transition-[filter,opacity] duration-300\",\n            service.iconClassName ?? \"bg-info text-white\",\n            !values.enabled && \"opacity-70 grayscale\",\n          )}\n        >\n          <Icon className=\"size-5\" strokeWidth={2.25} />\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <h3 className=\"truncate font-medium text-sm\" id={`${id}-title`}>\n            {service.name}\n          </h3>\n          <p className=\"flex items-center gap-1.5 text-muted-foreground text-xs\">\n            <span\n              className={cn(\n                \"size-1.5 shrink-0 rounded-full transition-colors\",\n                values.enabled ? \"bg-success\" : \"bg-muted-foreground/40\",\n              )}\n            />\n            <span className=\"truncate\">{values.enabled ? service.description : \"Paused — nothing is sent\"}</span>\n          </p>\n        </div>\n        <Switch\n          aria-label={`Enable ${service.name}`}\n          checked={values.enabled}\n          onCheckedChange={(v) => set(\"enabled\", v)}\n        />\n      </header>\n\n      <motion.div\n        animate={{ opacity: values.enabled ? 1 : 0.5 }}\n        className=\"flex flex-col gap-5 border-t p-4\"\n        inert={!values.enabled}\n        transition={{ duration: 0.2 }}\n      >\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-baseline justify-between gap-2\">\n            <label className=\"flex items-center gap-1.5 font-medium text-sm\" htmlFor={`${id}-key`}>\n              API key\n              <Changed on={values.apiKey !== saved.apiKey} />\n            </label>\n            <span className=\"text-muted-foreground text-xs\">Server-side only</span>\n          </div>\n          <InputGroup>\n            <InputGroupAddon>\n              <KeyRoundIcon />\n            </InputGroupAddon>\n            <InputGroupInput\n              autoComplete=\"off\"\n              className=\"font-mono text-[13px]\"\n              id={`${id}-key`}\n              onChange={(e) => set(\"apiKey\", e.target.value)}\n              spellCheck={false}\n              type={revealed ? \"text\" : \"password\"}\n              value={values.apiKey}\n            />\n            <InputGroupAddon align=\"inline-end\" className=\"gap-0.5\">\n              <Button\n                aria-label={revealed ? \"Hide API key\" : \"Show API key\"} className={iconHit}\n                aria-pressed={revealed}\n                onClick={() => setRevealed((r) => !r)}\n                size=\"icon-xs\"\n                variant=\"ghost\"\n              >\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span key={revealed ? \"hide\" : \"show\"} {...swap}>\n                    {revealed ? <EyeOffIcon /> : <EyeIcon />}\n                  </motion.span>\n                </AnimatePresence>\n              </Button>\n              <Button aria-label=\"Copy API key\" className={iconHit} onClick={copy} size=\"icon-xs\" variant=\"ghost\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span key={copied ? \"ok\" : \"copy\"} {...swap}>\n                    {copied ? <CheckIcon className=\"text-success\" /> : <CopyIcon />}\n                  </motion.span>\n                </AnimatePresence>\n              </Button>\n            </InputGroupAddon>\n          </InputGroup>\n          <span aria-live=\"polite\" className=\"sr-only\">\n            {copied ? \"API key copied\" : \"\"}\n          </span>\n        </div>\n\n        <div className=\"flex flex-col gap-2\">\n          <label className=\"flex items-center gap-1.5 font-medium text-sm\" htmlFor={`${id}-url`}>\n            Site URL\n            <Changed on={values.siteUrl !== saved.siteUrl} />\n          </label>\n          <InputGroup>\n            <InputGroupAddon>\n              <InputGroupText className=\"font-mono text-[13px]\">https://</InputGroupText>\n            </InputGroupAddon>\n            <InputGroupInput\n              aria-describedby={`${id}-url-msg`}\n              aria-invalid={showUrlError ? true : undefined}\n              autoComplete=\"off\"\n              className=\"ps-0! font-mono text-[13px]\"\n              id={`${id}-url`}\n              inputMode=\"url\"\n              onBlur={() => setUrlTouched(true)}\n              onChange={(e) => onUrl(e.target.value)}\n              placeholder=\"shop.example.com\"\n              spellCheck={false}\n              value={values.siteUrl}\n            />\n            <InputGroupAddon align=\"inline-end\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {checking ? (\n                  <motion.span key=\"checking\" {...swap}>\n                    <Spinner className=\"size-4 text-muted-foreground\" />\n                  </motion.span>\n                ) : urlError ? (\n                  urlTouched && (\n                    <motion.span key=\"bad\" {...swap}>\n                      <CircleAlertIcon className=\"size-4 text-destructive-foreground opacity-100!\" />\n                    </motion.span>\n                  )\n                ) : (\n                  <motion.span key=\"ok\" {...swap}>\n                    <CircleCheckIcon className=\"size-4 text-success opacity-100!\" />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </InputGroupAddon>\n          </InputGroup>\n          <AnimatePresence initial={false} mode=\"wait\">\n            <motion.p\n              animate={{ opacity: 1, y: 0 }}\n              className={cn(\"text-xs\", showUrlError ? \"text-destructive-foreground\" : \"text-muted-foreground\")}\n              exit={{ opacity: 0, y: reduce ? 0 : -2 }}\n              id={`${id}-url-msg`}\n              initial={reduce ? false : { opacity: 0, y: -2 }}\n              key={showUrlError ? `e-${urlError}` : \"hint\"}\n              transition={{ duration: 0.14 }}\n            >\n              {showUrlError ? urlError : \"Links in every email point here.\"}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex items-baseline justify-between gap-2\">\n            <span className=\"flex items-center gap-1.5 font-medium text-sm\" id={`${id}-tz`}>\n              Time zone\n              <Changed on={values.timeZone !== saved.timeZone} />\n            </span>\n            {clock && (\n              <span className=\"text-muted-foreground text-xs tabular-nums\">\n                It's <span className=\"font-medium text-foreground\">{clock}</span> there\n              </span>\n            )}\n          </div>\n          <Select items={timeZones} onValueChange={(v) => v && set(\"timeZone\", String(v))} value={values.timeZone}>\n            <SelectTrigger aria-labelledby={`${id}-tz`}>\n              <SelectValue />\n            </SelectTrigger>\n            <SelectPopup>\n              {timeZones.map((z) => (\n                <SelectItem key={z.value} value={z.value}>\n                  {z.label}\n                </SelectItem>\n              ))}\n            </SelectPopup>\n          </Select>\n          <p className=\"text-muted-foreground text-xs\">Digests go out at 9:00 in this time zone.</p>\n        </div>\n      </motion.div>\n\n      <footer className=\"relative flex h-14 items-center border-t bg-muted/40 px-4\">\n        <p className=\"truncate text-muted-foreground text-xs\">\n          {footer ? (\n            <>\n              Last saved by <span className=\"font-medium text-foreground\">{footer.by}</span> · {footer.at}\n            </>\n          ) : (\n            \"No changes yet\"\n          )}\n        </p>\n        <AnimatePresence>\n          {showBar && (\n            <motion.div\n              animate={{ y: 0, opacity: 1 }}\n              className=\"absolute inset-x-1.5 bottom-1.5 flex h-11 items-center gap-2 rounded-lg border bg-popover ps-3 pe-1.5 shadow-lg/5\"\n              exit={{ y: reduce ? 0 : 56, opacity: reduce ? 0 : 1 }}\n              initial={{ y: reduce ? 0 : 56, opacity: reduce ? 0 : 1 }}\n              role=\"status\"\n              transition={spring}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {phase === \"saved\" ? (\n                  <motion.span\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"flex min-w-0 flex-1 items-center gap-2 font-medium text-sm\"\n                    initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                    key=\"saved\"\n                    transition={spring}\n                  >\n                    <span className=\"flex size-4.5 items-center justify-center rounded-full bg-success text-white\">\n                      <CheckIcon className=\"size-3\" strokeWidth={3} />\n                    </span>\n                    Saved\n                  </motion.span>\n                ) : (\n                  <motion.span\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"flex min-w-0 flex-1 items-center gap-2 text-sm\"\n                    exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n                    key=\"dirty\"\n                    transition={spring}\n                  >\n                    <span\n                      className={cn(\n                        \"size-1.5 shrink-0 rounded-full\",\n                        phase === \"failed\" ? \"bg-destructive\" : \"bg-warning\",\n                      )}\n                    />\n                    <span className=\"truncate\">\n                      {urlError\n                        ? \"Fix the URL to save\"\n                        : phase === \"failed\"\n                          ? \"Couldn’t save. Try again.\"\n                          : \"Unsaved changes\"}\n                    </span>\n                  </motion.span>\n                )}\n              </AnimatePresence>\n              {phase !== \"saved\" && (\n                <>\n                  <Button className={press} disabled={phase === \"saving\"} onClick={discard} size=\"sm\" variant=\"ghost\">\n                    Discard\n                  </Button>\n                  <Button className={press} disabled={!!urlError} loading={phase === \"saving\"} onClick={save} size=\"sm\">\n                    Save\n                    <Kbd className=\"-me-1 hidden bg-primary-foreground/16 text-primary-foreground/80 sm:inline-flex\">\n                      ⌘S\n                    </Kbd>\n                  </Button>\n                </>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </footer>\n    </section>\n  );\n}\n"
    }
  ]
}