{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "send-bsv21",
  "title": "Send BSV21",
  "author": "Satchmo <https://bigblocks.dev>",
  "description": "Token send form with selector dropdown, decimal-aware amount input, recipient address, and confirmation flow for transferring BSV21 fungible tokens via @1sat/actions sendBsv21",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "button",
    "card",
    "input",
    "label",
    "separator"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/send-bsv21/index.tsx",
      "content": "\"use client\"\n\nimport { useSendBsv21 } from \"./use-send-bsv21\"\nimport { SendBsv21Ui } from \"./send-bsv21-ui\"\nimport type { SendBsv21Params, SendBsv21Result, TokenBalance } from \"./use-send-bsv21\"\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport { useSendBsv21 } from \"./use-send-bsv21\"\nexport { SendBsv21Ui } from \"./send-bsv21-ui\"\nexport type {\n  TokenBalance,\n  SendBsv21Params,\n  SendBsv21Result,\n  UseSendBsv21Options,\n  UseSendBsv21Return,\n} from \"./use-send-bsv21\"\nexport type { SendBsv21UiProps } from \"./send-bsv21-ui\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Props for the composed SendBsv21 block */\nexport interface SendBsv21Props {\n  /** Available token balances for the selector */\n  balances?: TokenBalance[]\n  /** Callback to execute the token transfer */\n  onSend?: (params: SendBsv21Params) => Promise<SendBsv21Result>\n  /** Called on successful send */\n  onSuccess?: (result: SendBsv21Result) => void\n  /** Called on error */\n  onError?: (error: Error) => void\n  /** Optional CSS class */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\n/**\n * Full Send BSV21 Tokens block: a card form with token selector,\n * amount input with decimal formatting, recipient address, and send button.\n *\n * The `onSend` callback receives `{ tokenId, amount, address }` where\n * `amount` is a raw integer string (accounting for decimals) and should\n * call `@1sat/actions` `sendBsv21.execute(ctx, { tokenId, amount, address })`.\n *\n * @example\n * ```tsx\n * import { SendBsv21 } from \"@/components/blocks/send-bsv21\"\n *\n * <SendBsv21\n *   balances={tokenBalances}\n *   onSend={async ({ tokenId, amount, address }) => {\n *     const result = await sendBsv21.execute(ctx, { tokenId, amount, address })\n *     return result\n *   }}\n * />\n * ```\n */\nexport function SendBsv21({\n  balances = [],\n  onSend,\n  onSuccess,\n  onError,\n  className,\n}: SendBsv21Props) {\n  const { isLoading, error, result, execute, reset } = useSendBsv21({\n    onSend,\n    onSuccess,\n    onError,\n  })\n\n  return (\n    <SendBsv21Ui\n      balances={balances}\n      isLoading={isLoading}\n      error={error}\n      result={result}\n      onSubmit={execute}\n      onReset={reset}\n      className={className}\n    />\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/send-bsv21/index.tsx"
    },
    {
      "path": "registry/new-york/blocks/send-bsv21/send-bsv21-ui.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\"\nimport {\n  AlertCircle,\n  CheckCircle2,\n  ChevronDown,\n  Coins,\n  Loader2,\n  Send,\n} from \"lucide-react\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { cn } from \"@/lib/utils\"\nimport type { SendBsv21Params, SendBsv21Result, TokenBalance } from \"./use-send-bsv21\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SendBsv21UiProps {\n  /** Available token balances for the selector */\n  balances: TokenBalance[]\n  /** Whether a send is in progress */\n  isLoading: boolean\n  /** Current error state */\n  error: Error | null\n  /** Result of the last send */\n  result: SendBsv21Result | null\n  /** Called when the user submits the send form */\n  onSubmit: (params: SendBsv21Params) => Promise<SendBsv21Result>\n  /** Reset hook state */\n  onReset: () => void\n  /** Optional CSS class */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst BSV_ADDRESS_RE = /^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction isValidBsvAddress(address: string): boolean {\n  return BSV_ADDRESS_RE.test(address.trim())\n}\n\n/**\n * Format a raw token amount string using the token's decimal places.\n * e.g. \"1500\" with decimals=2 becomes \"15\".\n */\nfunction formatTokenAmount(raw: string, decimals: number): string {\n  if (decimals === 0) return raw\n  const padded = raw.padStart(decimals + 1, \"0\")\n  const intPart = padded.slice(0, padded.length - decimals)\n  const decPart = padded.slice(padded.length - decimals)\n  const trimmedDec = decPart.replace(/0+$/, \"\")\n  return trimmedDec ? `${intPart}.${trimmedDec}` : intPart\n}\n\n/**\n * Parse a human-readable amount string (e.g. \"10.5\") into a raw integer string\n * given the token's decimal places.\n */\nfunction parseAmountToRaw(input: string, decimals: number): string | null {\n  if (!input || input === \".\") return null\n\n  const parts = input.split(\".\")\n  if (parts.length > 2) return null\n\n  const intPart = parts[0] || \"0\"\n  const decPart = (parts[1] || \"\").padEnd(decimals, \"0\").slice(0, decimals)\n  const raw = intPart + decPart\n\n  // Strip leading zeros but keep at least \"0\"\n  const stripped = raw.replace(/^0+/, \"\") || \"0\"\n  return stripped\n}\n\n/**\n * Compare two numeric strings as BigInt values.\n * Returns negative if a < b, zero if equal, positive if a > b.\n */\nfunction compareBigIntStrings(a: string, b: string): number {\n  const bigA = BigInt(a)\n  const bigB = BigInt(b)\n  if (bigA < bigB) return -1\n  if (bigA > bigB) return 1\n  return 0\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport function SendBsv21Ui({\n  balances,\n  isLoading,\n  error,\n  result,\n  onSubmit,\n  onReset,\n  className,\n}: SendBsv21UiProps) {\n  const [selectedTokenId, setSelectedTokenId] = useState(\"\")\n  const [amountInput, setAmountInput] = useState(\"\")\n  const [address, setAddress] = useState(\"\")\n  const [dropdownOpen, setDropdownOpen] = useState(false)\n\n  // Auto-select first token if none selected\n  useEffect(() => {\n    if (!selectedTokenId && balances.length > 0) {\n      setSelectedTokenId(balances[0].tokenId)\n    }\n  }, [balances, selectedTokenId])\n\n  const selectedToken = useMemo(\n    () => balances.find((b) => b.tokenId === selectedTokenId),\n    [balances, selectedTokenId],\n  )\n\n  const formattedBalance = useMemo(() => {\n    if (!selectedToken) return \"0\"\n    return formatTokenAmount(selectedToken.balance, selectedToken.decimals)\n  }, [selectedToken])\n\n  // Validation\n  const validationError = useMemo(() => {\n    if (!amountInput && !address) return null\n\n    if (address && address.trim().length > 0 && !isValidBsvAddress(address)) {\n      return \"Invalid BSV address\"\n    }\n\n    if (amountInput && selectedToken) {\n      const raw = parseAmountToRaw(amountInput, selectedToken.decimals)\n      if (raw === null || raw === \"0\") {\n        return \"Amount must be greater than zero\"\n      }\n      if (compareBigIntStrings(raw, selectedToken.balance) > 0) {\n        return `Insufficient balance (${formattedBalance} ${selectedToken.symbol} available)`\n      }\n    }\n\n    return null\n  }, [amountInput, address, selectedToken, formattedBalance])\n\n  const canSend = useMemo(() => {\n    if (!selectedToken || !amountInput || !address) return false\n    if (!isValidBsvAddress(address)) return false\n    const raw = parseAmountToRaw(amountInput, selectedToken.decimals)\n    if (raw === null || raw === \"0\") return false\n    if (compareBigIntStrings(raw, selectedToken.balance) > 0) return false\n    return true\n  }, [selectedToken, amountInput, address])\n\n  const handleAmountChange = useCallback(\n    (value: string) => {\n      const decimals = selectedToken?.decimals ?? 0\n      if (decimals === 0) {\n        // Integer only\n        setAmountInput(value.replace(/[^0-9]/g, \"\"))\n      } else {\n        // Allow digits and single decimal point, limit decimal places\n        const cleaned = value\n          .replace(/[^0-9.]/g, \"\")\n          .replace(/(\\..*)\\./g, \"$1\")\n\n        const parts = cleaned.split(\".\")\n        if (parts.length === 2 && parts[1].length > decimals) {\n          setAmountInput(`${parts[0]}.${parts[1].slice(0, decimals)}`)\n        } else {\n          setAmountInput(cleaned)\n        }\n      }\n    },\n    [selectedToken],\n  )\n\n  const handleMaxClick = useCallback(() => {\n    if (selectedToken) {\n      setAmountInput(formattedBalance)\n    }\n  }, [selectedToken, formattedBalance])\n\n  const handleSelectToken = useCallback((tokenId: string) => {\n    setSelectedTokenId(tokenId)\n    setAmountInput(\"\")\n    setDropdownOpen(false)\n  }, [])\n\n  const handleSend = useCallback(async () => {\n    if (!canSend || !selectedToken) return\n\n    const raw = parseAmountToRaw(amountInput, selectedToken.decimals)\n    if (!raw) return\n\n    await onSubmit({\n      tokenId: selectedToken.tokenId,\n      amount: raw,\n      address: address.trim(),\n    })\n  }, [canSend, selectedToken, amountInput, address, onSubmit])\n\n  const handleNewTransfer = useCallback(() => {\n    setAmountInput(\"\")\n    setAddress(\"\")\n    onReset()\n  }, [onReset])\n\n  return (\n    <Card className={cn(\"w-full max-w-md\", className)}>\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          <Coins className=\"size-5\" aria-hidden=\"true\" />\n          Send Tokens\n        </CardTitle>\n        <CardDescription>\n          Transfer BSV21 tokens to any address\n        </CardDescription>\n      </CardHeader>\n\n      <CardContent className=\"flex flex-col gap-4\">\n        {/* Token selector */}\n        <div className=\"flex flex-col gap-2\">\n          <Label htmlFor=\"send-bsv21-token\">Token</Label>\n          <div className=\"relative\">\n            <button\n              id=\"send-bsv21-token\"\n              type=\"button\"\n              className={cn(\n                \"flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n                \"disabled:cursor-not-allowed disabled:opacity-50\",\n                \"transition-colors duration-150\",\n              )}\n              onClick={() => setDropdownOpen((prev) => !prev)}\n              disabled={isLoading || !!result?.txid || balances.length === 0}\n              aria-expanded={dropdownOpen}\n              aria-haspopup=\"listbox\"\n            >\n              {selectedToken ? (\n                <span className=\"flex items-center gap-2 truncate\">\n                  {selectedToken.iconUrl ? (\n                    <img\n                      src={selectedToken.iconUrl}\n                      alt=\"\"\n                      className=\"size-5 rounded-full object-cover\"\n                    />\n                  ) : (\n                    <span className=\"flex size-5 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground\">\n                      {selectedToken.symbol.charAt(0).toUpperCase()}\n                    </span>\n                  )}\n                  <span className=\"font-medium\">{selectedToken.symbol}</span>\n                  <span className=\"text-muted-foreground\">\n                    {formattedBalance}\n                  </span>\n                </span>\n              ) : (\n                <span className=\"text-muted-foreground\">Select a token</span>\n              )}\n              <ChevronDown\n                className={cn(\n                  \"size-4 text-muted-foreground transition-transform duration-150\",\n                  dropdownOpen && \"rotate-180\",\n                )}\n                aria-hidden=\"true\"\n              />\n            </button>\n\n            {dropdownOpen && balances.length > 0 && (\n              <div\n                className=\"absolute z-50 mt-1 w-full rounded-md border bg-popover p-1 shadow-md\"\n                role=\"listbox\"\n                aria-label=\"Token list\"\n              >\n                {balances.map((token) => (\n                  <button\n                    key={token.tokenId}\n                    type=\"button\"\n                    role=\"option\"\n                    aria-selected={token.tokenId === selectedTokenId}\n                    className={cn(\n                      \"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm\",\n                      \"hover:bg-accent hover:text-accent-foreground\",\n                      \"transition-colors duration-100\",\n                      token.tokenId === selectedTokenId && \"bg-accent/50\",\n                    )}\n                    onClick={() => handleSelectToken(token.tokenId)}\n                  >\n                    {token.iconUrl ? (\n                      <img\n                        src={token.iconUrl}\n                        alt=\"\"\n                        className=\"size-5 rounded-full object-cover\"\n                      />\n                    ) : (\n                      <span className=\"flex size-5 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground\">\n                        {token.symbol.charAt(0).toUpperCase()}\n                      </span>\n                    )}\n                    <span className=\"font-medium\">{token.symbol}</span>\n                    <span className=\"ml-auto text-muted-foreground\">\n                      {formatTokenAmount(token.balance, token.decimals)}\n                    </span>\n                  </button>\n                ))}\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Amount input */}\n        <div className=\"flex flex-col gap-2\">\n          <Label htmlFor=\"send-bsv21-amount\">Amount</Label>\n          <div className=\"flex items-center gap-2\">\n            <div className=\"relative flex-1\">\n              <Input\n                id=\"send-bsv21-amount\"\n                type=\"text\"\n                inputMode=\"decimal\"\n                placeholder=\"0.00\"\n                value={amountInput}\n                onChange={(e) => handleAmountChange(e.target.value)}\n                disabled={isLoading || !!result?.txid || !selectedToken}\n                className=\"pr-16\"\n                aria-describedby=\"send-bsv21-amount-hint\"\n                autoComplete=\"off\"\n                spellCheck={false}\n              />\n              {selectedToken && (\n                <span className=\"pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground\">\n                  {selectedToken.symbol}\n                </span>\n              )}\n            </div>\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              className=\"flex-shrink-0 text-xs\"\n              onClick={handleMaxClick}\n              disabled={isLoading || !!result?.txid || !selectedToken}\n            >\n              Max\n            </Button>\n          </div>\n          {selectedToken && amountInput && (\n            <p\n              id=\"send-bsv21-amount-hint\"\n              className=\"text-xs text-muted-foreground\"\n            >\n              Balance: {formattedBalance} {selectedToken.symbol}\n            </p>\n          )}\n        </div>\n\n        {/* Recipient address */}\n        <div className=\"flex flex-col gap-2\">\n          <Label htmlFor=\"send-bsv21-address\">Recipient Address</Label>\n          <Input\n            id=\"send-bsv21-address\"\n            type=\"text\"\n            placeholder=\"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\"\n            value={address}\n            onChange={(e) => setAddress(e.target.value)}\n            disabled={isLoading || !!result?.txid}\n            className=\"font-mono text-sm\"\n            autoComplete=\"off\"\n            spellCheck={false}\n          />\n        </div>\n\n        {/* Validation error */}\n        {validationError && (\n          <p className=\"text-sm text-destructive\" role=\"alert\">\n            {validationError}\n          </p>\n        )}\n\n        {/* Summary */}\n        {canSend && !result?.txid && (\n          <>\n            <Separator />\n            <div className=\"flex items-center justify-between rounded-md border bg-muted/50 px-4 py-3\">\n              <div className=\"flex flex-col gap-0.5\">\n                <p className=\"text-sm text-muted-foreground\">Sending</p>\n                <p className=\"text-sm font-medium\">\n                  {amountInput} {selectedToken?.symbol}\n                </p>\n              </div>\n              <Badge variant=\"secondary\" className=\"font-mono text-xs\">\n                {address.slice(0, 6)}...{address.slice(-4)}\n              </Badge>\n            </div>\n          </>\n        )}\n\n        {/* Success */}\n        {result?.txid && (\n          <div className=\"flex items-start gap-3 rounded-md border border-primary/20 bg-primary/5 p-3\">\n            <CheckCircle2 className=\"mt-0.5 size-4 flex-shrink-0 text-primary\" />\n            <div className=\"min-w-0 flex flex-col gap-1\">\n              <p className=\"text-sm font-medium\">Tokens sent successfully</p>\n              <Badge\n                variant=\"outline\"\n                className=\"max-w-full truncate font-mono text-xs\"\n              >\n                {result.txid}\n              </Badge>\n            </div>\n          </div>\n        )}\n\n        {/* Error */}\n        {error && (\n          <div className=\"flex items-start gap-3 rounded-md border border-destructive/20 bg-destructive/5 p-3\">\n            <AlertCircle className=\"mt-0.5 size-4 flex-shrink-0 text-destructive\" />\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-medium\">Send failed</p>\n              <p className=\"text-xs text-muted-foreground\">{error.message}</p>\n            </div>\n          </div>\n        )}\n      </CardContent>\n\n      <CardFooter>\n        {result?.txid ? (\n          <Button className=\"w-full\" variant=\"outline\" onClick={handleNewTransfer}>\n            Send Another\n          </Button>\n        ) : (\n          <Button\n            className=\"w-full\"\n            onClick={handleSend}\n            disabled={!canSend || isLoading}\n            aria-busy={isLoading}\n          >\n            {isLoading ? (\n              <>\n                <Loader2\n                  className=\"animate-spin\"\n                  data-icon=\"inline-start\"\n                  aria-hidden=\"true\"\n                />\n                Sending...\n              </>\n            ) : (\n              <>\n                <Send data-icon=\"inline-start\" aria-hidden=\"true\" />\n                Send Tokens\n              </>\n            )}\n          </Button>\n        )}\n      </CardFooter>\n    </Card>\n  )\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/send-bsv21/send-bsv21-ui.tsx"
    },
    {
      "path": "registry/new-york/blocks/send-bsv21/use-send-bsv21.ts",
      "content": "import { useCallback, useState } from \"react\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** A token balance entry for display in the selector */\nexport interface TokenBalance {\n  /** Token ID (txid_vout format) */\n  tokenId: string\n  /** Token ticker symbol */\n  symbol: string\n  /** Available balance as a decimal string */\n  balance: string\n  /** Number of decimal places for this token */\n  decimals: number\n  /** Optional URL for the token icon */\n  iconUrl?: string\n}\n\n/** Parameters passed to the onSend callback */\nexport interface SendBsv21Params {\n  /** Token ID being sent */\n  tokenId: string\n  /** Amount as a decimal string (human-readable, e.g. \"10.5\") */\n  amount: string\n  /** Recipient BSV address */\n  address: string\n}\n\n/** Result returned from the onSend callback */\nexport interface SendBsv21Result {\n  /** Transaction ID on success */\n  txid?: string\n  /** Error message on failure */\n  error?: string\n}\n\n/** Options for the useSendBsv21 hook */\nexport interface UseSendBsv21Options {\n  /** Callback to execute the actual token transfer */\n  onSend?: (params: SendBsv21Params) => Promise<SendBsv21Result>\n  /** Called on successful send */\n  onSuccess?: (result: SendBsv21Result) => void\n  /** Called on error */\n  onError?: (error: Error) => void\n}\n\n/** Return type of useSendBsv21 hook */\nexport interface UseSendBsv21Return {\n  /** Whether a send operation is in progress */\n  isLoading: boolean\n  /** Current error, if any */\n  error: Error | null\n  /** Result of the last successful send */\n  result: SendBsv21Result | null\n  /** Execute a token send */\n  execute: (params: SendBsv21Params) => Promise<SendBsv21Result>\n  /** Reset error and result state */\n  reset: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useSendBsv21({\n  onSend,\n  onSuccess,\n  onError,\n}: UseSendBsv21Options = {}): UseSendBsv21Return {\n  const [isLoading, setIsLoading] = useState(false)\n  const [error, setError] = useState<Error | null>(null)\n  const [result, setResult] = useState<SendBsv21Result | null>(null)\n\n  const execute = useCallback(\n    async (params: SendBsv21Params): Promise<SendBsv21Result> => {\n      if (!onSend) {\n        const err = new Error(\"onSend callback is required\")\n        setError(err)\n        onError?.(err)\n        return { error: err.message }\n      }\n\n      setIsLoading(true)\n      setError(null)\n      setResult(null)\n\n      try {\n        const sendResult = await onSend(params)\n\n        if (sendResult.error) {\n          const err = new Error(sendResult.error)\n          setError(err)\n          onError?.(err)\n          return sendResult\n        }\n\n        setResult(sendResult)\n        onSuccess?.(sendResult)\n        return sendResult\n      } catch (err) {\n        const e = err instanceof Error ? err : new Error(String(err))\n        setError(e)\n        onError?.(e)\n        return { error: e.message }\n      } finally {\n        setIsLoading(false)\n      }\n    },\n    [onSend, onSuccess, onError],\n  )\n\n  const reset = useCallback(() => {\n    setError(null)\n    setResult(null)\n  }, [])\n\n  return { isLoading, error, result, execute, reset }\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/send-bsv21/use-send-bsv21.ts"
    }
  ],
  "type": "registry:block"
}