{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"login-card","type":"registry:block","title":"Login Card","description":"Chinese product sign-in modal: a blue gradient header with floating bokeh, a WeChat QR-scan column beside phone quick-login (country-code dropdown, code field with a 60s resend countdown), an agreement checkbox that shakes on invalid submit, and an ICP footer.","author":"UI Lab","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/login-card.tsx","type":"registry:component","target":"@components/motion/login-card.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/login-card\n\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  type AnimationControls,\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LoginCardProps {\n  className?: string;\n  /** 头部两行标题，默认 [\"登录后\", \"有问题，免费聊\"] */\n  titleLines?: string[];\n  /** 区号，默认 \"+86\" */\n  countryCode?: string;\n  /** 区号可选项，默认 [\"+86\",\"+852\",\"+886\",\"+1\",\"+44\",\"+81\"] */\n  countryOptions?: string[];\n  /** 覆盖二维码槽位；不传则渲染内置占位二维码 */\n  qr?: React.ReactNode;\n  /** 内置占位二维码的种子，默认 20260719 */\n  qrSeed?: number;\n  /** 卡片下方的备案小字；传 null 隐藏；不传用默认 ICP 文案 */\n  footer?: React.ReactNode;\n  /** 点「登录」回调，可 async；期间按钮显示加载态 */\n  onSubmit?: (data: {\n    phone: string;\n    code: string;\n    countryCode: string;\n  }) => void | Promise<void>;\n  /** 点「发送」回调，可 async */\n  onSendCode?: (phone: string) => void | Promise<void>;\n}\n\nconst DEFAULT_TITLE_LINES = [\"登录后\", \"有问题，免费聊\"];\nconst DEFAULT_COUNTRY_OPTIONS = [\"+86\", \"+852\", \"+886\", \"+1\", \"+44\", \"+81\"];\n\nfunction shake(controls: AnimationControls, reduce: boolean) {\n  if (reduce) return;\n  controls.start({\n    x: [0, -6, 6, -4, 4, 0],\n    transition: { duration: 0.4 },\n  });\n}\n\nfunction PlaceholderQr({\n  seed = 20260719,\n  size = 25,\n}: {\n  seed?: number;\n  size?: number;\n}) {\n  const matrix = useMemo(() => {\n    let a = seed >>> 0;\n    const rand = () => {\n      a = (a + 0x6d2b79f5) | 0;\n      let t = Math.imul(a ^ (a >>> 15), 1 | a);\n      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n    };\n    const m: boolean[][] = Array.from({ length: size }, () =>\n      Array.from({ length: size }, () => rand() > 0.52),\n    );\n    const finder = (r0: number, c0: number) => {\n      // 先清出 9x9 的静默区\n      for (let r = r0 - 1; r <= r0 + 7; r++)\n        for (let c = c0 - 1; c <= c0 + 7; c++)\n          if (r >= 0 && c >= 0 && r < size && c < size) m[r][c] = false;\n      // 画 7x7 回字定位块\n      for (let r = 0; r < 7; r++)\n        for (let c = 0; c < 7; c++) {\n          const ring = r === 0 || r === 6 || c === 0 || c === 6;\n          const core = r >= 2 && r <= 4 && c >= 2 && c <= 4;\n          m[r0 + r][c0 + c] = ring || core;\n        }\n    };\n    finder(0, 0);\n    finder(0, size - 7);\n    finder(size - 7, 0);\n    return m;\n  }, [seed, size]);\n\n  return (\n    <svg\n      viewBox={`0 0 ${size} ${size}`}\n      className=\"h-[168px] w-[168px]\"\n      shapeRendering=\"crispEdges\"\n      role=\"img\"\n      aria-label=\"登录二维码占位图\"\n    >\n      <rect width={size} height={size} fill=\"#ffffff\" />\n      {matrix.map((row, r) =>\n        row.map((on, c) =>\n          on ? (\n            <rect\n              // biome-ignore lint/suspicious/noArrayIndexKey: fixed-size deterministic grid, cells never reorder\n              key={`${r}-${c}`}\n              x={c}\n              y={r}\n              width={1}\n              height={1}\n              fill=\"#18181b\"\n            />\n          ) : null,\n        ),\n      )}\n    </svg>\n  );\n}\n\nexport function LoginCard({\n  className,\n  titleLines = DEFAULT_TITLE_LINES,\n  countryCode = \"+86\",\n  countryOptions = DEFAULT_COUNTRY_OPTIONS,\n  qr,\n  qrSeed = 20260719,\n  footer,\n  onSubmit,\n  onSendCode,\n}: LoginCardProps) {\n  const reduce = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(\n    null,\n  );\n\n  const [phone, setPhone] = useState(\"\");\n  const [code, setCode] = useState(\"\");\n  const [agreed, setAgreed] = useState(false);\n  const [countdown, setCountdown] = useState(0);\n  const [submitting, setSubmitting] = useState(false);\n  const [cc, setCc] = useState(countryCode);\n  const [ccOpen, setCcOpen] = useState(false);\n\n  const phoneShake = useAnimationControls();\n  const agreeShake = useAnimationControls();\n\n  useEffect(\n    () => () => {\n      if (countdownTimerRef.current !== null) {\n        clearInterval(countdownTimerRef.current);\n      }\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (!ccOpen) return;\n    const onPointerDown = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {\n        setCcOpen(false);\n      }\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [ccOpen]);\n\n  const handleSend = async () => {\n    if (countdown > 0) return;\n    if (phone.trim().length === 0) {\n      shake(phoneShake, !!reduce);\n      return;\n    }\n    await onSendCode?.(phone);\n    setCountdown(60);\n    countdownTimerRef.current = setInterval(() => {\n      setCountdown((c) => {\n        if (c <= 1) {\n          if (countdownTimerRef.current !== null) {\n            clearInterval(countdownTimerRef.current);\n            countdownTimerRef.current = null;\n          }\n          return 0;\n        }\n        return c - 1;\n      });\n    }, 1000);\n  };\n\n  const handleSubmit = async () => {\n    if (!agreed) {\n      shake(agreeShake, !!reduce);\n      return;\n    }\n    setSubmitting(true);\n    try {\n      await onSubmit?.({ phone, code, countryCode: cc });\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  const dropdownInitial = reduce\n    ? { opacity: 0 }\n    : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n  const dropdownAnimate = reduce\n    ? { opacity: 1 }\n    : { opacity: 1, y: 0, filter: \"blur(0px)\" };\n  const dropdownExit = dropdownInitial;\n\n  return (\n    <div ref={rootRef} className={cn(\"w-full max-w-[680px]\", className)}>\n      <div className=\"overflow-hidden rounded-3xl bg-white shadow-[0_20px_60px_-15px_rgba(0,0,0,0.28)]\">\n        {/* (a) 头部渐变条 */}\n        <div className=\"relative overflow-hidden px-9 pt-10 pb-12 bg-[linear-gradient(120deg,#5a9bff_0%,#3f82ff_52%,#2f6cff_100%)]\">\n          <div\n            className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n            aria-hidden\n          >\n            {/* 右上角大面积柔光，营造高光区 */}\n            <div\n              className=\"absolute -right-16 -top-24 h-72 w-72 rounded-full blur-2xl\"\n              style={{\n                background:\n                  \"radial-gradient(circle, rgba(255,255,255,0.4), rgba(255,255,255,0) 70%)\",\n              }}\n            />\n            {/* 柔和玻璃光球：左上更亮、向外渐淡，缓慢漂浮 */}\n            <motion.div\n              className=\"absolute right-[11%] top-[12%] h-32 w-32 rounded-full\"\n              style={{\n                background:\n                  \"radial-gradient(circle at 32% 28%, rgba(255,255,255,0.62), rgba(255,255,255,0.05) 68%)\",\n              }}\n              animate={reduce ? undefined : { y: [0, -8, 0] }}\n              transition={{ duration: 6, repeat: Infinity, ease: \"easeInOut\" }}\n            />\n            <motion.div\n              className=\"absolute right-[3%] top-[46%] h-24 w-24 rounded-full\"\n              style={{\n                background:\n                  \"radial-gradient(circle at 35% 30%, rgba(255,255,255,0.5), rgba(255,255,255,0.04) 70%)\",\n              }}\n              animate={reduce ? undefined : { y: [0, -6, 0] }}\n              transition={{\n                duration: 7,\n                repeat: Infinity,\n                ease: \"easeInOut\",\n                delay: 1.2,\n              }}\n            />\n            <div\n              className=\"absolute right-[30%] top-[8%] h-14 w-14 rounded-full blur-[1px]\"\n              style={{\n                background:\n                  \"radial-gradient(circle at 35% 30%, rgba(255,255,255,0.4), rgba(255,255,255,0) 72%)\",\n              }}\n            />\n            {/* 极淡弧线 */}\n            <svg\n              className=\"absolute inset-0 h-full w-full\"\n              viewBox=\"0 0 680 200\"\n              fill=\"none\"\n              preserveAspectRatio=\"none\"\n              aria-hidden=\"true\"\n            >\n              <path\n                d=\"M400 -20 C 520 40, 560 120, 700 150\"\n                stroke=\"rgba(255,255,255,0.12)\"\n                strokeWidth=\"1\"\n              />\n              <path\n                d=\"M440 -40 C 600 30, 620 150, 760 180\"\n                stroke=\"rgba(255,255,255,0.08)\"\n                strokeWidth=\"1\"\n              />\n            </svg>\n            {/* 点缀亮点 */}\n            <div className=\"absolute right-[16%] top-[38%] h-1 w-1 rounded-full bg-white/80\" />\n            <div className=\"absolute right-[38%] top-[50%] h-1 w-1 rounded-full bg-white/70\" />\n            <div className=\"absolute right-[9%] top-[74%] h-1 w-1 rounded-full bg-white/70\" />\n          </div>\n          <h2 className=\"relative text-[27px] font-bold leading-[1.32] text-white\">\n            {titleLines.map((line) => (\n              <span key={line} className=\"block\">\n                {line}\n              </span>\n            ))}\n          </h2>\n        </div>\n\n        {/* (b) 主体两栏 */}\n        <div className=\"grid grid-cols-1 gap-8 px-9 py-8 md:grid-cols-2 md:gap-0\">\n          <div className=\"flex flex-col items-center md:pr-9\">\n            <div className=\"mb-6 text-base font-medium text-[#374151]\">\n              微信扫码登录\n            </div>\n            <div className=\"rounded-2xl border border-slate-200 bg-white p-3 shadow-sm\">\n              {qr ?? <PlaceholderQr seed={qrSeed} />}\n            </div>\n          </div>\n\n          <div className=\"flex flex-col md:border-l md:border-[#eef0f3] md:pl-9\">\n            <div className=\"mb-6 text-center text-base font-medium text-[#374151]\">\n              手机号快捷登录\n            </div>\n\n            <motion.div animate={phoneShake}>\n              <div className=\"flex h-14 items-center rounded-xl border border-transparent bg-[#f5f6f8] px-4 transition-colors focus-within:border-[#2f6bff]\">\n                <div className=\"relative\">\n                  <button\n                    type=\"button\"\n                    onClick={() => setCcOpen((v) => !v)}\n                    className=\"flex shrink-0 items-center gap-1 text-[15px] text-[#374151]\"\n                  >\n                    {cc}\n                    <ChevronDown className=\"h-4 w-4 text-[#9ca3af]\" />\n                  </button>\n                  <AnimatePresence>\n                    {ccOpen ? (\n                      <motion.ul\n                        initial={dropdownInitial}\n                        animate={dropdownAnimate}\n                        exit={dropdownExit}\n                        transition={{ duration: 0.16, ease: EASE_OUT }}\n                        className=\"absolute left-0 top-full z-20 mt-2 w-28 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg\"\n                      >\n                        {countryOptions.map((o) => (\n                          <li key={o}>\n                            <button\n                              type=\"button\"\n                              className=\"block w-full px-3 py-1.5 text-left text-sm text-[#374151] hover:bg-slate-50\"\n                              onClick={() => {\n                                setCc(o);\n                                setCcOpen(false);\n                              }}\n                            >\n                              {o}\n                            </button>\n                          </li>\n                        ))}\n                      </motion.ul>\n                    ) : null}\n                  </AnimatePresence>\n                </div>\n                <span className=\"mx-3 h-5 w-px bg-slate-200\" aria-hidden />\n                <input\n                  value={phone}\n                  onChange={(e) => setPhone(e.target.value)}\n                  inputMode=\"tel\"\n                  placeholder=\"手机号\"\n                  className=\"min-w-0 flex-1 bg-transparent text-[15px] text-[#374151] outline-none placeholder:text-[#9ca3af]\"\n                />\n              </div>\n            </motion.div>\n\n            <div className=\"mt-4 flex h-14 items-center rounded-xl border border-transparent bg-[#f5f6f8] px-4 focus-within:border-[#2f6bff]\">\n              <input\n                value={code}\n                onChange={(e) => setCode(e.target.value)}\n                inputMode=\"numeric\"\n                placeholder=\"验证码\"\n                className=\"min-w-0 flex-1 bg-transparent text-[15px] text-[#374151] outline-none placeholder:text-[#9ca3af]\"\n              />\n              <span className=\"mx-3 h-5 w-px bg-slate-200\" aria-hidden />\n              <button\n                type=\"button\"\n                disabled={countdown > 0}\n                onClick={handleSend}\n                className=\"shrink-0 text-[15px] font-medium text-[#2f6bff] disabled:text-[#9ca3af]\"\n              >\n                {countdown > 0 ? `${countdown}s` : \"发送\"}\n              </button>\n            </div>\n\n            <motion.button\n              type=\"button\"\n              whileTap={reduce ? undefined : { scale: 0.98 }}\n              transition={SPRING_PRESS}\n              onClick={handleSubmit}\n              disabled={submitting}\n              className=\"mt-6 h-14 w-full rounded-xl bg-[#2f6bff] text-[16px] font-medium text-white transition-colors hover:bg-[#2560e8] disabled:opacity-70\"\n            >\n              {submitting ? \"登录中…\" : \"登录\"}\n            </motion.button>\n          </div>\n        </div>\n\n        {/* (c) 协议 + 反馈行 */}\n        <div className=\"px-9 pb-7\">\n          <motion.div animate={agreeShake}>\n            <div className=\"flex items-center justify-center gap-2 text-sm text-[#6b7280]\">\n              <button\n                type=\"button\"\n                aria-pressed={agreed}\n                aria-label=\"同意协议\"\n                onClick={() => setAgreed((v) => !v)}\n                className={cn(\n                  \"grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full border transition-colors\",\n                  agreed\n                    ? \"border-[#2f6bff] bg-[#2f6bff]\"\n                    : \"border-slate-300 bg-white\",\n                )}\n              >\n                <AnimatePresence>\n                  {agreed ? (\n                    <motion.svg\n                      viewBox=\"0 0 24 24\"\n                      className=\"h-3 w-3 text-white\"\n                    >\n                      <motion.path\n                        d=\"M5 12.5l4.5 4.5L19 7.5\"\n                        stroke=\"currentColor\"\n                        strokeWidth={3}\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        initial={\n                          reduce ? { pathLength: 1 } : { pathLength: 0 }\n                        }\n                        animate={{ pathLength: 1 }}\n                        transition={{ duration: 0.25, ease: \"easeOut\" }}\n                      />\n                    </motion.svg>\n                  ) : null}\n                </AnimatePresence>\n              </button>\n              <span>\n                已阅读同意{\" \"}\n                {/* biome-ignore lint/a11y/useValidAnchor: placeholder legal links, real product wires the actual URLs */}\n                <a href=\"#\" className=\"text-[#2f6bff] hover:underline\">\n                  《模型服务协议》\n                </a>{\" \"}\n                和{\" \"}\n                {/* biome-ignore lint/a11y/useValidAnchor: placeholder legal links, real product wires the actual URLs */}\n                <a href=\"#\" className=\"text-[#2f6bff] hover:underline\">\n                  《用户隐私协议》\n                </a>\n              </span>\n            </div>\n          </motion.div>\n          <div className=\"mt-2.5 text-center text-sm text-[#9ca3af]\">\n            遇到问题？{\" \"}\n            <a\n              // biome-ignore lint/a11y/useValidAnchor: placeholder feedback link, real product wires the actual URL\n              href=\"#\"\n              className=\"text-[#6b7280] hover:text-[#374151] hover:underline\"\n            >\n              去反馈\n            </a>\n          </div>\n        </div>\n      </div>\n\n      {/* (d) footer（卡片外） */}\n      {footer !== null ? (\n        <div className=\"mt-6\">\n          {footer ?? (\n            <div className=\"flex flex-wrap items-center justify-center gap-x-5 gap-y-1 text-center text-xs text-[#9ca3af]\">\n              <span>© 2026 你的公司名称</span>\n              <span>ICP 备案号 000000</span>\n              <span>公安备案号 000000</span>\n            </div>\n          )}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}