{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wallet-card",
  "title": "Wallet card",
  "description": "A payment card that slides into a stitched leather pocket and peeks out above it, with the balance counting up in the pocket behind an eye toggle. Tap the card to pull it up.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/wallet-card/wallet-card.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useReducedMotionConfig } from \"motion/react\";\nimport { Eye, EyeOff } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface WalletCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The balance shown in the pocket. Counts up from zero when it appears. */\n  balance: number;\n  /** Prefix for the default formatter. Defaults to \"₦\". */\n  currency?: string;\n  /** Locale for the default formatter's digit grouping. Defaults to \"en-NG\". */\n  locale?: string;\n  /** Replaces the default `${currency}${balance.toLocaleString(locale)}`. */\n  formatBalance?: (value: number) => string;\n  /** Label above the balance. Defaults to \"Available to Withdraw\". */\n  balanceLabel?: React.ReactNode;\n  /** Whether the balance starts visible. Defaults to true. */\n  defaultBalanceVisible?: boolean;\n  /** Called when the eye toggle shows or hides the balance. */\n  onBalanceVisibleChange?: (visible: boolean) => void;\n  /** Shows a placeholder bar instead of the balance. */\n  loading?: boolean;\n  /** Name on the card, as printed. Defaults to \"MEMBER\". */\n  holderName?: string;\n  /** The characters after the masked dots. Defaults to \"8888\". */\n  cardNumberSuffix?: string;\n  /** The mark in the card's top-right corner. Defaults to \"Steeze\". */\n  brand?: React.ReactNode;\n  /** Artwork printed on the card, shown under a veil. Optional. */\n  cardImage?: string;\n  /** Texture for the wallet's leather, shown under a veil. Optional. */\n  leatherImage?: string;\n  /** Called when the card is tapped, with whether it is now pulled up. */\n  onCardPress?: (pulledUp: boolean) => void;\n  /**\n   * `dark` (default) keeps the wallet a dark leather object in light mode too,\n   * as in the app, by scoping the theme's dark tokens to it. `auto` follows the page.\n   */\n  tone?: \"dark\" | \"auto\";\n}\n\n// Pocket geometry, in pixels, as drawn in the app.\nconst H = 200; // pocket height\nconst R = 24; // corner radius\nconst D = 20; // depth of the scoop's start below the top\nconst SCOOP_START = 130; // where the scoop leaves each edge\nconst INSET = 10; // stitched inner panel\nconst DEFAULT_WIDTH = 342; // a 390pt phone less the screen's 24px gutters\nconst CARD_REVEAL = 96; // Clear both holder lines above the pocket, with 8px to spare.\nconst CARD_TOP_PADDING = 68; // Leave 4px above the card at its fully raised position.\n\nconst EASE_OUT_CUBIC = [0.33, 1, 0.68, 1] as const;\nconst EASE_IN_OUT_CUBIC = [0.65, 0, 0.35, 1] as const;\nconst EASE_IN_OUT_QUAD = [0.45, 0, 0.55, 1] as const;\nconst EASE_OUT = [0, 0, 0.58, 1] as const;\n\n/**\n * The app's greys are charcoal with a little light mixed in, so they are\n * written as ink mixed into ground: exact in Steeze's dark mode, and they\n * follow any theme's ground and ink elsewhere.\n */\nconst lift = (percent: number) => `color-mix(in srgb, var(--ink) ${percent}%, var(--ground))`;\nconst SHADE = {\n  back: lift(13), // the wallet's back panel veil\n  pocketTop: lift(13),\n  pocketBottom: lift(4),\n  inner: lift(8), // the stitched panel, darker inside the stitching\n  leather: lift(5.3), // veil over the leather texture\n  rivetCap: lift(4),\n  stitch: \"color-mix(in srgb, var(--ink) 10%, transparent)\",\n  // The card's gold chip: the theme's yellow, softened toward ink.\n  chip: \"color-mix(in srgb, color-mix(in srgb, var(--milestone-yellow) 40%, var(--ink)) 90%, var(--ground))\",\n  chipEdge: \"color-mix(in srgb, color-mix(in srgb, var(--milestone-yellow) 65%, var(--ink)) 76%, var(--ground))\",\n};\n\nfunction outerPocket(w: number) {\n  return [\n    `M 0 ${R + D}`,\n    `Q 0 ${D}, ${R} ${D}`,\n    `H ${SCOOP_START}`,\n    `Q ${w * 0.5} ${D + 40}, ${w - SCOOP_START} ${D}`,\n    `H ${w - R}`,\n    `Q ${w} ${D}, ${w} ${R + D}`,\n    `V ${H - R}`,\n    `Q ${w} ${H}, ${w - R} ${H}`,\n    `H ${R}`,\n    `Q 0 ${H}, 0 ${H - R}`,\n    \"Z\",\n  ].join(\" \");\n}\n\nfunction innerPocket(w: number) {\n  const top = D + INSET + 5;\n  return [\n    `M ${INSET} ${R + D + 5}`,\n    `Q ${INSET} ${top}, ${R + 2} ${top}`,\n    `H ${SCOOP_START - 8}`,\n    `Q ${w * 0.5} ${D + 48}, ${w - SCOOP_START + 8} ${top}`,\n    `H ${w - R - 2}`,\n    `Q ${w - INSET} ${top}, ${w - INSET} ${R + D + 5}`,\n    `V ${H - R - INSET}`,\n    `Q ${w - INSET} ${H - INSET}, ${w - R - 2} ${H - INSET}`,\n    `H ${R + 2}`,\n    `Q ${INSET} ${H - INSET}, ${INSET} ${H - R - INSET}`,\n    \"Z\",\n  ].join(\" \");\n}\n\n/** The balance counts up from zero over 900ms each time it is shown. */\nfunction CountUp({ value, format, still }: { value: number; format: (value: number) => string; still: boolean }) {\n  const [shown, setShown] = React.useState(0);\n  React.useEffect(() => {\n    if (still) return;\n    const controls = animate(0, value, {\n      duration: 0.9,\n      ease: EASE_OUT,\n      onUpdate: (latest) => setShown(Math.floor(latest)),\n      onComplete: () => setShown(value),\n    });\n    return () => controls.stop();\n  }, [value, still]);\n  return <>{format(still ? value : shown)}</>;\n}\n\n/**\n * Steeze's earnings wallet: a payment card that slides down into a stitched\n * leather pocket and peeks out above it. Tap the card to pull it up and show\n * the rest; tap again to tuck it back. The pocket holds the balance, which\n * counts up when it appears and hides behind an eye toggle.\n *\n * Every colour is a theme token. The app's charcoal greys are ink mixed into\n * ground, so in Steeze's dark mode it matches the app, and in a light theme\n * it becomes a light wallet with dark type. The card and leather textures are\n * optional images; without them the card and pocket are flat shades.\n */\nexport function WalletCard({\n  balance,\n  currency = \"₦\",\n  locale = \"en-NG\",\n  formatBalance,\n  balanceLabel = \"Available to Withdraw\",\n  defaultBalanceVisible = true,\n  onBalanceVisibleChange,\n  loading = false,\n  holderName = \"MEMBER\",\n  cardNumberSuffix = \"8888\",\n  brand = \"Steeze\",\n  cardImage,\n  leatherImage,\n  onCardPress,\n  tone = \"dark\",\n  className,\n  style,\n  ...props\n}: WalletCardProps) {\n  const reduceMotion = useReducedMotionConfig() ?? false;\n  const [visible, setVisible] = React.useState(defaultBalanceVisible);\n  const [pulledUp, setPulledUp] = React.useState(false);\n  const [touched, setTouched] = React.useState(false);\n  const [width, setWidth] = React.useState(DEFAULT_WIDTH);\n  const pocketRef = React.useRef<HTMLDivElement>(null);\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const ids = {\n    pocketGrad: `${uid}-pocket-grad`,\n    innerGrad: `${uid}-inner-grad`,\n    pocketClip: `${uid}-pocket-clip`,\n  };\n\n  // The pocket's curves are drawn in pixels, so redraw them at the real width.\n  React.useEffect(() => {\n    const node = pocketRef.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const format = formatBalance ?? ((value: number) => `${currency}${value.toLocaleString(locale)}`);\n  const outer = outerPocket(width);\n  const inner = innerPocket(width);\n  const rivets = [SCOOP_START - 67, width - SCOOP_START + 67];\n\n  // The card slides in from above after 300ms; later taps move it in 300ms.\n  const transition = reduceMotion\n    ? { duration: 0 }\n    : touched\n      ? { y: { duration: 0.3, ease: EASE_IN_OUT_CUBIC }, opacity: { duration: 0 } }\n      : {\n          y: { delay: 0.3, duration: 0.8, ease: EASE_OUT_CUBIC },\n          opacity: { delay: 0.3, duration: 0.4, ease: EASE_IN_OUT_QUAD },\n        };\n\n  function pressCard() {\n    const next = !pulledUp;\n    setTouched(true);\n    setPulledUp(next);\n    onCardPress?.(next);\n  }\n\n  function toggleBalance() {\n    const next = !visible;\n    setVisible(next);\n    onBalanceVisibleChange?.(next);\n  }\n\n  return (\n    <div className={cn(\"relative w-full font-body\", tone === \"dark\" && \"dark\", className)} style={{ paddingTop: CARD_TOP_PADDING, ...style }} {...props}>\n      {/* The wallet's back panel, behind the card. */}\n      <div\n        aria-hidden=\"true\"\n        className={cn(\"absolute bottom-0 left-0 w-[98.5%] overflow-hidden rounded-[24px] bg-cover bg-center\", !leatherImage && \"bg-ground\")}\n        style={{ top: CARD_TOP_PADDING - 2, backgroundImage: leatherImage ? `url(${leatherImage})` : undefined }}\n      >\n        <div className=\"size-full opacity-70\" style={{ background: SHADE.back }} />\n      </div>\n\n      {/* The card, peeking out of the pocket. */}\n      <motion.div\n        className=\"relative mx-4 mt-8 overflow-hidden rounded-panel\"\n        initial={reduceMotion ? false : { y: -100, opacity: 0 }}\n        animate={{ y: pulledUp ? -CARD_REVEAL : 0, opacity: 1 }}\n        transition={transition}\n      >\n        <motion.button\n          type=\"button\"\n          onClick={pressCard}\n          aria-pressed={pulledUp}\n          aria-label={`Card ending ${cardNumberSuffix}, ${pulledUp ? \"tuck the card back\" : \"pull the card up\"}`}\n          whileTap={{ opacity: 0.95 }}\n          className={cn(\n            \"relative flex h-[180px] w-full cursor-pointer flex-col bg-cover bg-center text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring\",\n            !cardImage && \"bg-paper-sunk\",\n          )}\n          style={{ backgroundImage: cardImage ? `url(${cardImage})` : undefined }}\n        >\n          <span aria-hidden=\"true\" className=\"absolute inset-0 bg-ground/50\" />\n          <span aria-hidden=\"true\" className=\"relative flex w-full shrink-0 flex-row items-start justify-between px-8 py-4\">\n            <span\n              className=\"relative h-8 w-10 overflow-hidden rounded-[4px] border\"\n              style={{ background: SHADE.chip, borderColor: SHADE.chipEdge }}\n            >\n              {[\n                \"top-0 left-1/2 h-full w-px\",\n                \"top-0 left-1/4 h-full w-px\",\n                \"top-1/2 left-0 h-px w-full\",\n                \"top-1/4 left-0 h-px w-full\",\n                \"top-[18.5px] left-0 h-px w-full\",\n              ].map((line) => (\n                <span\n                  key={line}\n                  className={cn(\"absolute\", line)}\n                  style={{ background: `color-mix(in srgb, ${SHADE.chipEdge} 50%, transparent)` }}\n                />\n              ))}\n              <span\n                className=\"absolute top-1/2 left-1/2 size-4 -translate-x-2 -translate-y-2 rounded-[2px] border\"\n                style={{ borderColor: `color-mix(in srgb, ${SHADE.chipEdge} 50%, transparent)` }}\n              />\n            </span>\n            <span className=\"font-heading text-2xl tracking-tighter text-ink\">{brand}</span>\n          </span>\n          <span aria-hidden=\"true\" className=\"relative mt-4 mb-4 flex w-full shrink-0 flex-row items-center justify-center\">\n            <span className=\"text-xl font-bold tracking-[4px] whitespace-pre text-ink\">{\"••••  \"}</span>\n            <span className=\"font-mono text-xl tracking-[4px] text-ink\">{cardNumberSuffix}</span>\n          </span>\n          <span aria-hidden=\"true\" className=\"relative mt-2 flex w-full shrink-0 flex-col px-8\">\n            <span className=\"text-xs font-medium tracking-tight text-ink/50\">CARD HOLDER</span>\n            <span className=\"text-sm font-medium text-ink/90\">{holderName}</span>\n          </span>\n        </motion.button>\n      </motion.div>\n\n      {/* The pocket, in front of the card. */}\n      <div ref={pocketRef} className=\"relative w-full\" style={{ height: H, marginTop: -120 }}>\n        <svg aria-hidden=\"true\" width={width} height={H} viewBox={`0 0 ${width} ${H}`} className=\"absolute top-0 left-0\">\n          <defs>\n            <linearGradient id={ids.pocketGrad} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0\" stopColor={SHADE.pocketTop} />\n              <stop offset=\"1\" stopColor={SHADE.pocketBottom} />\n            </linearGradient>\n            <linearGradient id={ids.innerGrad} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0\" stopColor={SHADE.inner} stopOpacity=\"0.5\" />\n              <stop offset=\"1\" stopColor={SHADE.inner} stopOpacity=\"0.8\" />\n            </linearGradient>\n            <clipPath id={ids.pocketClip}>\n              <path d={outer} />\n            </clipPath>\n          </defs>\n\n          {leatherImage ? (\n            // In the app the texture covers the pocket and its stitched panel alike.\n            <g clipPath={`url(#${ids.pocketClip})`}>\n              <image href={leatherImage} x=\"0\" y=\"0\" width={width} height={H} preserveAspectRatio=\"xMidYMid slice\" />\n              <rect x=\"0\" y=\"0\" width={width} height={H} fill={SHADE.leather} fillOpacity=\"0.85\" />\n            </g>\n          ) : (\n            <>\n              <path d={outer} fill={`url(#${ids.pocketGrad})`} />\n              <path d={inner} fill={`url(#${ids.innerGrad})`} />\n            </>\n          )}\n\n          {rivets.map((cx) => (\n            <g key={cx}>\n              <circle cx={cx} cy={D + 35} r=\"8\" fill=\"var(--ground)\" />\n              <circle cx={cx} cy={D + 32} r=\"4\" fill={SHADE.rivetCap} />\n            </g>\n          ))}\n          <path d={inner} fill=\"none\" stroke={SHADE.stitch} strokeWidth=\"1.5\" strokeDasharray=\"5, 4\" />\n        </svg>\n\n        {/* The balance, at the foot of the pocket. */}\n        <div className=\"absolute inset-0 flex flex-col justify-between px-6 pt-24 pb-6\">\n          <div />\n          <div className=\"flex flex-row items-center justify-between\">\n            <div>\n              <div className=\"mb-1 flex flex-row items-center gap-4\">\n                <span className=\"text-base font-medium tracking-tight text-ink/50\">{balanceLabel}</span>\n                <button\n                  type=\"button\"\n                  onClick={toggleBalance}\n                  aria-pressed={!visible}\n                  aria-label={visible ? \"Hide balance\" : \"Show balance\"}\n                  className=\"cursor-pointer rounded-[4px] text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n                >\n                  {visible ? <EyeOff aria-hidden=\"true\" className=\"size-5\" /> : <Eye aria-hidden=\"true\" className=\"size-5\" />}\n                </button>\n              </div>\n              {loading ? (\n                <div className=\"h-10 w-40 rounded-[4px] bg-ink/10\" />\n              ) : (\n                <div className=\"text-3xl font-bold text-ink\">\n                  {/* Screen readers get the settled figure, not every step of the count. */}\n                  <span className=\"sr-only\">{visible ? format(balance) : \"Balance hidden\"}</span>\n                  <span aria-hidden=\"true\">\n                    {visible ? <CountUp value={balance} format={format} still={reduceMotion} /> : \"********\"}\n                  </span>\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default WalletCard;\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use as the hero of an earnings or wallet screen, where one balance matters and the card is the user's identity in the product. Not for lists of accounts or several balances; it is one object, and it needs about 340px of width and 330px of height.",
    "origin": {
      "project": "Steeze",
      "path": "app/(root)/earnings.tsx (wallet / card holder section)"
    },
    "credits": [
      {
        "name": "\"The New Home and Colonial Offices, Westminster, 1875\", Wellcome Collection: the engraved card artwork, changes made; gallery preview only, not shipped",
        "license": "CC-BY-4.0"
      },
      {
        "name": "Leather texture image by Freepik (free licence: credit required); gallery preview only, not shipped",
        "url": "https://www.freepik.com",
        "license": "LicenseRef-Freepik"
      }
    ]
  },
  "type": "registry:component"
}
