{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transaction-history",
  "title": "Transaction History",
  "author": "Satchmo <https://bigblocks.dev>",
  "description": "Transaction list with status indicators, amounts, relative dates, and pagination. Supports default and compact variants with inbound/outbound display.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "card",
    "separator",
    "skeleton",
    "button"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/transaction-history/index.tsx",
      "content": "\"use client\"\n\nimport { TransactionHistoryUI } from \"./transaction-history-ui\"\nimport {\n  useTransactionHistory,\n  type HistoryEntry,\n  type UseTransactionHistoryOptions,\n} from \"./use-transaction-history\"\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport {\n  TransactionHistoryUI,\n  type TransactionHistoryUIProps,\n} from \"./transaction-history-ui\"\nexport {\n  useTransactionHistory,\n  type UseTransactionHistoryOptions,\n  type UseTransactionHistoryReturn,\n  type HistoryEntry,\n  type TransactionStatus,\n} from \"./use-transaction-history\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Props for the composed TransactionHistory block */\nexport interface TransactionHistoryProps {\n  /** BSV payment address to fetch transaction history for */\n  address?: string | null\n  /** Pre-loaded transaction entries (bypasses API fetch) */\n  entries?: HistoryEntry[]\n  /** Custom API base URL (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** Number of entries per page (default: 20) */\n  pageSize?: number\n  /** Whether to auto-fetch on mount (default: true) */\n  autoFetch?: boolean\n  /** Callback when a row is clicked */\n  onRowClick?: (txid: string) => void\n  /** Callback for external link button */\n  onExternalLink?: (url: string) => void\n  /** Callback fired on successful fetch */\n  onSuccess?: (entries: HistoryEntry[]) => void\n  /** Callback fired on fetch error */\n  onError?: (error: Error) => void\n  /** Visual variant: \"default\" shows full details, \"compact\" shows description + amount only */\n  variant?: \"default\" | \"compact\"\n  /** Number of skeleton rows to show while loading (default: 5) */\n  skeletonCount?: number\n  /** Optional CSS class name */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Composed component\n// ---------------------------------------------------------------------------\n\n/**\n * Transaction history list with status indicators, amounts, and relative\n * dates. Displays inbound/outbound transactions with pagination support.\n *\n * Composes the `useTransactionHistory` hook with the `TransactionHistoryUI`\n * presentation component. Pass `entries` to use pre-loaded data, or\n * `address` to fetch from the 1sat-stack API.\n *\n * @example\n * ```tsx\n * import { TransactionHistory } from \"@/components/blocks/transaction-history\"\n *\n * function WalletActivity() {\n *   return (\n *     <TransactionHistory\n *       address=\"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\"\n *       onRowClick={(txid) => console.log(\"Clicked:\", txid)}\n *     />\n *   )\n * }\n * ```\n */\nexport function TransactionHistory({\n  address,\n  entries: externalEntries,\n  apiUrl,\n  pageSize,\n  autoFetch,\n  onRowClick,\n  onExternalLink,\n  onSuccess,\n  onError,\n  variant,\n  skeletonCount,\n  className,\n}: TransactionHistoryProps) {\n  const hookOptions: UseTransactionHistoryOptions = {\n    address,\n    entries: externalEntries,\n    apiUrl,\n    pageSize,\n    autoFetch,\n    onSuccess,\n    onError,\n  }\n\n  const { entries, isLoading, error, hasMore, loadMore } =\n    useTransactionHistory(hookOptions)\n\n  return (\n    <TransactionHistoryUI\n      entries={entries}\n      isLoading={isLoading}\n      error={error}\n      hasMore={hasMore}\n      onLoadMore={loadMore}\n      onRowClick={onRowClick}\n      onExternalLink={onExternalLink}\n      variant={variant}\n      skeletonCount={skeletonCount}\n      className={className}\n    />\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/transaction-history/index.tsx"
    },
    {
      "path": "registry/new-york/blocks/transaction-history/transaction-history-ui.tsx",
      "content": "\"use client\"\n\nimport { useCallback } from \"react\"\nimport { ArrowDownLeft, ArrowUpRight, ExternalLink, History, Loader2 } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { Button } from \"@/components/ui/button\"\nimport type { HistoryEntry, TransactionStatus } from \"./use-transaction-history\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Props for the TransactionHistoryUI presentation component */\nexport interface TransactionHistoryUIProps {\n  /** Transaction entries to display */\n  entries: HistoryEntry[]\n  /** Whether the list is loading */\n  isLoading: boolean\n  /** Error from the last fetch attempt */\n  error: Error | null\n  /** Whether more entries can be loaded */\n  hasMore?: boolean\n  /** Load the next page of entries */\n  onLoadMore?: () => void\n  /** Callback when a row is clicked */\n  onRowClick?: (txid: string) => void\n  /** Callback for external link button (e.g. open in block explorer) */\n  onExternalLink?: (url: string) => void\n  /** Visual variant: \"default\" shows full details, \"compact\" shows description + amount only */\n  variant?: \"default\" | \"compact\"\n  /** Number of skeleton rows to show while loading (default: 5) */\n  skeletonCount?: number\n  /** Optional CSS class name */\n  className?: string\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Status dot color mapping using semantic tokens */\nfunction statusDotClass(status: TransactionStatus): string {\n  switch (status) {\n    case \"completed\":\n      return \"bg-chart-2\"\n    case \"sending\":\n    case \"unproven\":\n      return \"bg-chart-4\"\n    case \"failed\":\n      return \"bg-destructive\"\n  }\n}\n\n/** Status label for screen readers */\nfunction statusLabel(status: TransactionStatus): string {\n  switch (status) {\n    case \"completed\":\n      return \"Completed\"\n    case \"sending\":\n      return \"Sending\"\n    case \"unproven\":\n      return \"Unproven\"\n    case \"failed\":\n      return \"Failed\"\n  }\n}\n\n/** Format satoshi amount for display */\nfunction formatSatoshis(satoshis: number): string {\n  const abs = Math.abs(satoshis)\n  if (abs >= 100_000_000) {\n    return `${(satoshis / 100_000_000).toFixed(8)} BSV`\n  }\n  return `${satoshis.toLocaleString()} sat`\n}\n\n/** Truncate a txid for display */\nfunction truncateTxid(txid: string): string {\n  if (txid.length <= 16) return txid\n  return `${txid.slice(0, 8)}...${txid.slice(-6)}`\n}\n\n/** Relative time from an ISO date string */\nfunction relativeTime(isoDate: string): string {\n  const now = Date.now()\n  const then = new Date(isoDate).getTime()\n  const diffMs = now - then\n\n  const seconds = Math.floor(diffMs / 1000)\n  if (seconds < 60) return \"just now\"\n\n  const minutes = Math.floor(seconds / 60)\n  if (minutes < 60) return `${minutes}m ago`\n\n  const hours = Math.floor(minutes / 60)\n  if (hours < 24) return `${hours}h ago`\n\n  const days = Math.floor(hours / 24)\n  if (days < 30) return `${days}d ago`\n\n  const months = Math.floor(days / 30)\n  if (months < 12) return `${months}mo ago`\n\n  const years = Math.floor(months / 12)\n  return `${years}y ago`\n}\n\n// ---------------------------------------------------------------------------\n// Sub-components\n// ---------------------------------------------------------------------------\n\ninterface TransactionRowProps {\n  entry: HistoryEntry\n  variant: \"default\" | \"compact\"\n  onRowClick?: (txid: string) => void\n  onExternalLink?: (url: string) => void\n  isLast: boolean\n}\n\nfunction TransactionRow({\n  entry,\n  variant,\n  onRowClick,\n  onExternalLink,\n  isLast,\n}: TransactionRowProps) {\n  const isInbound = entry.satoshis >= 0\n  const isInteractive = !!onRowClick\n\n  const handleClick = useCallback(() => {\n    onRowClick?.(entry.txid)\n  }, [onRowClick, entry.txid])\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault()\n        onRowClick?.(entry.txid)\n      }\n    },\n    [onRowClick, entry.txid]\n  )\n\n  const handleExternalClick = useCallback(\n    (e: React.MouseEvent) => {\n      e.stopPropagation()\n      onExternalLink?.(entry.txid)\n    },\n    [onExternalLink, entry.txid]\n  )\n\n  return (\n    <>\n      <div\n        role={isInteractive ? \"button\" : undefined}\n        tabIndex={isInteractive ? 0 : undefined}\n        className={cn(\n          \"flex items-center gap-3 px-4 py-3\",\n          isInteractive &&\n            \"cursor-pointer transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md\"\n        )}\n        onClick={isInteractive ? handleClick : undefined}\n        onKeyDown={isInteractive ? handleKeyDown : undefined}\n        aria-label={\n          isInteractive\n            ? `${entry.description}, ${formatSatoshis(entry.satoshis)}, ${statusLabel(entry.status)}`\n            : undefined\n        }\n      >\n        {/* Direction icon + status dot */}\n        <div className=\"relative flex-shrink-0\">\n          <div\n            className={cn(\n              \"flex items-center justify-center size-9 rounded-full\",\n              isInbound\n                ? \"bg-chart-2/10 text-chart-2\"\n                : \"bg-muted text-muted-foreground\"\n            )}\n          >\n            {isInbound ? (\n              <ArrowDownLeft className=\"size-4\" data-icon aria-hidden=\"true\" />\n            ) : (\n              <ArrowUpRight className=\"size-4\" data-icon aria-hidden=\"true\" />\n            )}\n          </div>\n          <span\n            className={cn(\n              \"absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background\",\n              statusDotClass(entry.status)\n            )}\n            aria-label={statusLabel(entry.status)}\n          />\n        </div>\n\n        {/* Description + txid */}\n        <div className=\"flex-1 min-w-0\">\n          <p className=\"text-sm font-medium text-foreground truncate\">\n            {entry.description}\n          </p>\n          {variant === \"default\" && (\n            <div className=\"flex items-center gap-1.5\">\n              <span className=\"text-xs text-muted-foreground font-mono truncate\">\n                {truncateTxid(entry.txid)}\n              </span>\n              {onExternalLink && (\n                <button\n                  type=\"button\"\n                  onClick={handleExternalClick}\n                  className=\"text-muted-foreground hover:text-foreground transition-colors flex-shrink-0\"\n                  aria-label={`Open transaction ${truncateTxid(entry.txid)} in explorer`}\n                >\n                  <ExternalLink className=\"size-3\" data-icon aria-hidden=\"true\" />\n                </button>\n              )}\n            </div>\n          )}\n        </div>\n\n        {/* Amount + date */}\n        <div className=\"flex flex-col items-end gap-0.5 flex-shrink-0\">\n          <span\n            className={cn(\n              \"text-sm font-semibold tabular-nums\",\n              isInbound ? \"text-chart-2\" : \"text-foreground\"\n            )}\n          >\n            {isInbound ? \"+\" : \"\"}\n            {formatSatoshis(entry.satoshis)}\n          </span>\n          {variant === \"default\" && (\n            <span className=\"text-xs text-muted-foreground\">\n              {relativeTime(entry.dateCreated)}\n            </span>\n          )}\n        </div>\n      </div>\n      {!isLast && <Separator />}\n    </>\n  )\n}\n\nfunction SkeletonRow({\n  variant,\n  isLast,\n}: {\n  variant: \"default\" | \"compact\"\n  isLast: boolean\n}) {\n  return (\n    <>\n      <div className=\"flex items-center gap-3 px-4 py-3\">\n        <Skeleton className=\"size-9 rounded-full flex-shrink-0\" />\n        <div className=\"flex-1 flex flex-col gap-1.5\">\n          <Skeleton className=\"h-4 w-32\" />\n          {variant === \"default\" && <Skeleton className=\"h-3 w-24\" />}\n        </div>\n        <div className=\"flex flex-col items-end gap-1.5\">\n          <Skeleton className=\"h-4 w-20\" />\n          {variant === \"default\" && <Skeleton className=\"h-3 w-12\" />}\n        </div>\n      </div>\n      {!isLast && <Separator />}\n    </>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Main UI\n// ---------------------------------------------------------------------------\n\n/**\n * Pure presentation component for a list of transaction history entries.\n *\n * Renders loading skeletons, an empty state, an error state, or the\n * transaction rows with optional \"Load more\" pagination. Supports\n * \"default\" (full detail) and \"compact\" (description + amount) variants.\n */\nexport function TransactionHistoryUI({\n  entries,\n  isLoading,\n  error,\n  hasMore = false,\n  onLoadMore,\n  onRowClick,\n  onExternalLink,\n  variant = \"default\",\n  skeletonCount = 5,\n  className,\n}: TransactionHistoryUIProps) {\n  // Loading state (initial)\n  if (isLoading && entries.length === 0) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"p-0\">\n          {Array.from({ length: skeletonCount }, (_, i) => (\n            <SkeletonRow\n              key={`skeleton-${i}`}\n              variant={variant}\n              isLast={i === skeletonCount - 1}\n            />\n          ))}\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Error state\n  if (error) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"flex flex-col items-center gap-2 py-10\">\n          <History\n            className=\"size-10 text-destructive/60\"\n            aria-hidden=\"true\"\n          />\n          <p className=\"text-sm font-medium text-destructive\">\n            Failed to load transactions\n          </p>\n          <p className=\"text-xs text-muted-foreground\">{error.message}</p>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Empty state\n  if (entries.length === 0) {\n    return (\n      <Card className={cn(\"w-full\", className)}>\n        <CardContent className=\"flex flex-col items-center gap-2 py-10\">\n          <History\n            className=\"size-10 text-muted-foreground/50\"\n            aria-hidden=\"true\"\n          />\n          <p className=\"text-sm text-muted-foreground\">No transactions yet</p>\n        </CardContent>\n      </Card>\n    )\n  }\n\n  // Transaction list\n  return (\n    <Card className={cn(\"w-full overflow-hidden\", className)}>\n      <CardContent className=\"p-0\">\n        {entries.map((entry, index) => (\n          <TransactionRow\n            key={entry.txid}\n            entry={entry}\n            variant={variant}\n            onRowClick={onRowClick}\n            onExternalLink={onExternalLink}\n            isLast={index === entries.length - 1 && !hasMore}\n          />\n        ))}\n        {hasMore && (\n          <>\n            <Separator />\n            <div className=\"flex justify-center py-3\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={onLoadMore}\n                disabled={isLoading}\n              >\n                {isLoading ? (\n                  <Loader2 className=\"size-4 animate-spin\" data-icon aria-hidden=\"true\" />\n                ) : null}\n                {isLoading ? \"Loading...\" : \"Load more\"}\n              </Button>\n            </div>\n          </>\n        )}\n      </CardContent>\n    </Card>\n  )\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/transaction-history/transaction-history-ui.tsx"
    },
    {
      "path": "registry/new-york/blocks/transaction-history/use-transaction-history.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Transaction status */\nexport type TransactionStatus = \"completed\" | \"unproven\" | \"sending\" | \"failed\"\n\n/** A single transaction history entry */\nexport interface HistoryEntry {\n  /** Transaction ID */\n  txid: string\n  /** Human-readable description */\n  description: string\n  /** Amount in satoshis (positive = inbound, negative = outbound) */\n  satoshis: number\n  /** Current transaction status */\n  status: TransactionStatus\n  /** ISO-8601 date string */\n  dateCreated: string\n}\n\n/** Shape returned from the 1sat-stack transaction endpoint */\ninterface TxHistoryApiEntry {\n  txid: string\n  description: string\n  satoshis: number\n  status: string\n  dateCreated: string\n}\n\n/** Shape returned from the 1sat-stack history list endpoint */\ninterface TxHistoryApiResponse {\n  entries: TxHistoryApiEntry[]\n  hasMore: boolean\n}\n\n/** Options for the useTransactionHistory hook */\nexport interface UseTransactionHistoryOptions {\n  /** BSV payment address to fetch transaction history for */\n  address?: string | null\n  /** Pre-loaded entries (bypasses API fetch when provided) */\n  entries?: HistoryEntry[]\n  /** Custom API base URL (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** Number of entries per page (default: 20) */\n  pageSize?: number\n  /** Whether to auto-fetch on mount (default: true) */\n  autoFetch?: boolean\n  /** Callback fired on successful fetch */\n  onSuccess?: (entries: HistoryEntry[]) => void\n  /** Callback fired on fetch error */\n  onError?: (error: Error) => void\n}\n\n/** Return value of the useTransactionHistory hook */\nexport interface UseTransactionHistoryReturn {\n  /** List of transaction history entries */\n  entries: HistoryEntry[]\n  /** Whether the initial fetch is in progress */\n  isLoading: boolean\n  /** Error from the last fetch attempt */\n  error: Error | null\n  /** Whether more entries can be loaded */\n  hasMore: boolean\n  /** Load the next page of entries */\n  loadMore: () => void\n  /** Manually trigger a refetch from the beginning */\n  refetch: () => void\n}\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_API_URL = \"https://api.1sat.app\"\nconst DEFAULT_PAGE_SIZE = 20\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction parseStatus(raw: string): TransactionStatus {\n  switch (raw) {\n    case \"completed\":\n    case \"unproven\":\n    case \"sending\":\n    case \"failed\":\n      return raw\n    default:\n      return \"unproven\"\n  }\n}\n\nfunction mapApiEntry(entry: TxHistoryApiEntry): HistoryEntry {\n  return {\n    txid: entry.txid,\n    description: entry.description,\n    satoshis: entry.satoshis,\n    status: parseStatus(entry.status),\n    dateCreated: entry.dateCreated,\n  }\n}\n\nasync function fetchJson<T>(url: string, signal?: AbortSignal): Promise<T> {\n  const res = await fetch(url, { signal })\n  if (!res.ok) {\n    throw new Error(`HTTP ${res.status}: ${url}`)\n  }\n  return res.json() as Promise<T>\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\n/**\n * Fetches transaction history for a BSV address from the 1sat-stack API,\n * or accepts pre-loaded entries via the `entries` prop.\n *\n * Supports pagination with `loadMore` and manual `refetch`.\n *\n * @example\n * ```ts\n * const { entries, isLoading, error, hasMore, loadMore } = useTransactionHistory({\n *   address: \"1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa\",\n * })\n * ```\n */\nexport function useTransactionHistory(\n  options: UseTransactionHistoryOptions = {}\n): UseTransactionHistoryReturn {\n  const {\n    address,\n    entries: externalEntries,\n    apiUrl = DEFAULT_API_URL,\n    pageSize = DEFAULT_PAGE_SIZE,\n    autoFetch = true,\n    onSuccess,\n    onError,\n  } = options\n\n  const [entries, setEntries] = useState<HistoryEntry[]>(externalEntries ?? [])\n  const [isLoading, setIsLoading] = useState(false)\n  const [error, setError] = useState<Error | null>(null)\n  const [hasMore, setHasMore] = useState(false)\n  const [offset, setOffset] = useState(0)\n  const abortRef = useRef<AbortController | null>(null)\n\n  // Sync external entries when they change\n  useEffect(() => {\n    if (externalEntries) {\n      setEntries(externalEntries)\n      setHasMore(false)\n      setError(null)\n    }\n  }, [externalEntries])\n\n  const fetchPage = useCallback(\n    async (pageOffset: number, append: boolean) => {\n      if (!address) return\n\n      abortRef.current?.abort()\n      const controller = new AbortController()\n      abortRef.current = controller\n\n      setIsLoading(true)\n      setError(null)\n\n      try {\n        const data = await fetchJson<TxHistoryApiResponse>(\n          `${apiUrl}/1sat/owner/${address}/tx-history?limit=${pageSize}&offset=${pageOffset}`,\n          controller.signal\n        )\n\n        const mapped = data.entries.map(mapApiEntry)\n        setEntries((prev) => (append ? [...prev, ...mapped] : mapped))\n        setHasMore(data.hasMore)\n        setOffset(pageOffset + mapped.length)\n        onSuccess?.(mapped)\n      } catch (err) {\n        if (err instanceof DOMException && err.name === \"AbortError\") return\n        const fetchError =\n          err instanceof Error\n            ? err\n            : new Error(\"Failed to fetch transaction history\")\n        setError(fetchError)\n        onError?.(fetchError)\n      } finally {\n        setIsLoading(false)\n      }\n    },\n    [address, apiUrl, pageSize, onSuccess, onError]\n  )\n\n  const refetch = useCallback(() => {\n    setOffset(0)\n    setEntries([])\n    void fetchPage(0, false)\n  }, [fetchPage])\n\n  const loadMore = useCallback(() => {\n    if (!isLoading && hasMore) {\n      void fetchPage(offset, true)\n    }\n  }, [isLoading, hasMore, offset, fetchPage])\n\n  // Auto-fetch on mount / address change (only when no external entries)\n  useEffect(() => {\n    if (autoFetch && address && !externalEntries) {\n      setOffset(0)\n      void fetchPage(0, false)\n    }\n\n    return () => {\n      abortRef.current?.abort()\n    }\n  }, [autoFetch, address, externalEntries, fetchPage])\n\n  return {\n    entries,\n    isLoading,\n    error,\n    hasMore,\n    loadMore,\n    refetch,\n  }\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/transaction-history/use-transaction-history.ts"
    }
  ],
  "categories": [
    "wallet"
  ],
  "type": "registry:block"
}