{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transaction-list",
  "type": "registry:component",
  "title": "Transaction List",
  "description": "Chronological credit/debit history with locale-aware currency formatting and a pinned subscription row.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/transaction-list/transaction-list.tsx",
      "content": "import {\n  type ComponentPropsWithoutRef,\n  forwardRef,\n  type ReactNode,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\n\nconst CENTS_PER_UNIT = 100;\nconst DEFAULT_LOCALE = \"en-US\";\nconst DEFAULT_CURRENCY = \"USD\";\n\nconst CURRENCY_FORMATTER_CACHE = new Map<string, Intl.NumberFormat>();\nfunction getCurrencyFormatter(\n  locale: string,\n  currency: string,\n): Intl.NumberFormat {\n  const key = `${locale}|${currency}`;\n  let formatter = CURRENCY_FORMATTER_CACHE.get(key);\n  if (!formatter) {\n    formatter = new Intl.NumberFormat(locale, {\n      currency,\n      style: \"currency\",\n    });\n    CURRENCY_FORMATTER_CACHE.set(key, formatter);\n  }\n  return formatter;\n}\n\nconst DATE_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();\nfunction getTransactionDateFormatter(locale: string): Intl.DateTimeFormat {\n  let formatter = DATE_FORMATTER_CACHE.get(locale);\n  if (!formatter) {\n    formatter = new Intl.DateTimeFormat(locale, {\n      day: \"numeric\",\n      month: \"short\",\n      year: \"numeric\",\n    });\n    DATE_FORMATTER_CACHE.set(locale, formatter);\n  }\n  return formatter;\n}\n\n/**\n * Transaction type for {@link TransactionListItem}.\n *\n * @public\n */\nexport type TransactionType = \"credit\" | \"debit\" | \"initial\" | \"refund\";\n\n/**\n * Renewal interval for {@link TransactionListSubscriptionRow}.\n *\n * @public\n */\nexport type SubscriptionInterval = \"day\" | \"month\" | \"week\" | \"year\";\n\n/**\n * Subscription status for {@link TransactionListSubscriptionRow}.\n *\n * @public\n */\nexport type SubscriptionStatus =\n  | \"active\"\n  | \"canceled\"\n  | \"past_due\"\n  | \"trialing\";\n\n/**\n * Localizable strings.\n *\n * @public\n */\nexport type TransactionListLabels = {\n  /** Caption for the active subscription badge. Defaults to `\"Active\"`. */\n  active?: string;\n  /** Caption for the canceled subscription badge. Defaults to `\"Canceled\"`. */\n  canceled?: string;\n  /** Caption for the past-due subscription badge. Defaults to `\"Past due\"`. */\n  pastDue?: string;\n  /** Renewal label prefix. Defaults to `\"Renews\"`. */\n  renews?: string;\n  /** Caption for the trial subscription badge. Defaults to `\"Trial\"`. */\n  trialing?: string;\n};\n\nconst DEFAULT_LABELS = {\n  active: \"Active\",\n  canceled: \"Canceled\",\n  pastDue: \"Past due\",\n  renews: \"Renews\",\n  trialing: \"Trial\",\n} as const satisfies Required<TransactionListLabels>;\n\n/**\n * One transaction entry.\n *\n * @public\n */\nexport type Transaction = {\n  /** Amount in minor units (cents). Always positive — `type` decides the sign. */\n  amountCents: number;\n  /** Unix timestamp (ms) for the transaction. */\n  createdAt: number;\n  /** Free-form description. */\n  description: ReactNode;\n  /** Stable identifier. */\n  id: string;\n  /** Optional secondary line (e.g. `\"VAT incl.\"`). */\n  meta?: ReactNode;\n  /** Transaction type. */\n  type: TransactionType;\n};\n\nconst SIGN_BY_TYPE: Record<TransactionType, \"negative\" | \"positive\"> = {\n  credit: \"positive\",\n  debit: \"negative\",\n  initial: \"positive\",\n  refund: \"positive\",\n};\n\nconst AMOUNT_CLASS: Record<\"negative\" | \"positive\", string> = {\n  negative: \"text-destructive\",\n  positive: \"text-emerald-600 dark:text-emerald-400\",\n};\n\nconst STATUS_VARIANT: Record<\n  SubscriptionStatus,\n  \"default\" | \"destructive\" | \"outline\" | \"secondary\"\n> = {\n  active: \"default\",\n  canceled: \"secondary\",\n  past_due: \"destructive\",\n  trialing: \"outline\",\n};\n\nconst STATUS_LABEL_KEY: Record<\n  SubscriptionStatus,\n  keyof Required<TransactionListLabels>\n> = {\n  active: \"active\",\n  canceled: \"canceled\",\n  past_due: \"pastDue\",\n  trialing: \"trialing\",\n};\n\nconst INTERVAL_LABEL: Record<SubscriptionInterval, string> = {\n  day: \"day\",\n  month: \"mo\",\n  week: \"wk\",\n  year: \"yr\",\n};\n\n/**\n * Format an amount in minor units as a localized currency string. Use the\n * locale + currency from {@link TransactionListProps} to drive the output.\n *\n * @public\n */\nexport function formatTransactionAmount(\n  amountCents: number,\n  options: {\n    currency?: string;\n    locale?: string;\n  } = {},\n): string {\n  const { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = options;\n  return getCurrencyFormatter(locale, currency).format(\n    amountCents / CENTS_PER_UNIT,\n  );\n}\n\n/**\n * Format a Unix timestamp (ms) as a short locale-aware date.\n *\n * @public\n */\nexport function formatTransactionDate(\n  timestamp: number,\n  locale: string = DEFAULT_LOCALE,\n): string {\n  return getTransactionDateFormatter(locale).format(new Date(timestamp));\n}\n\n/**\n * Props for {@link TransactionList}.\n *\n * @public\n */\nexport type TransactionListProps = {\n  /** Currency code (ISO 4217). Defaults to `\"USD\"`. */\n  currency?: string;\n  /** Caption shown when the list is empty and no pinned children exist. */\n  emptyMessage?: ReactNode;\n  /** Localizable strings. */\n  labels?: TransactionListLabels;\n  /** BCP-47 locale tag. Defaults to `\"en-US\"`. */\n  locale?: string;\n  /** Transaction array (rendered after pinned children). */\n  transactions: Transaction[];\n} & ComponentPropsWithoutRef<\"div\">;\n\ntype TransactionListComponent = ReturnType<\n  typeof forwardRef<HTMLDivElement, TransactionListProps>\n> & {\n  Pinned: typeof TransactionListPinned;\n  SubscriptionRow: typeof TransactionListSubscriptionRow;\n};\n\nconst TransactionListBase = forwardRef<HTMLDivElement, TransactionListProps>(\n  (props, ref) => {\n    const {\n      children,\n      className,\n      currency,\n      emptyMessage,\n      labels,\n      locale,\n      transactions,\n      ...rest\n    } = props;\n    const isEmpty = transactions.length === 0;\n    const hasPinned = Boolean(children);\n    return (\n      <div\n        className={cn(\n          \"flex w-full flex-col gap-2 rounded-2xl border bg-background p-3\",\n          className,\n        )}\n        ref={ref}\n        {...rest}\n      >\n        {children}\n        {isEmpty && !hasPinned && emptyMessage ? (\n          <p className=\"py-6 text-center text-sm text-muted-foreground\">\n            {emptyMessage}\n          </p>\n        ) : null}\n        {isEmpty ? null : (\n          <ul className=\"flex flex-col gap-1.5\">\n            {transactions.map((transaction) => (\n              <TransactionListItem\n                currency={currency}\n                key={transaction.id}\n                labels={labels}\n                locale={locale}\n                transaction={transaction}\n              />\n            ))}\n          </ul>\n        )}\n      </div>\n    );\n  },\n);\nTransactionListBase.displayName = \"TransactionList\";\n\n/**\n * Props for {@link TransactionListPinned}.\n *\n * @public\n */\nexport type TransactionListPinnedProps = ComponentPropsWithoutRef<\"div\">;\n\n/**\n * Wrapper that renders pinned content (typically the active subscription\n * row) above the main transaction list.\n *\n * @public\n */\nexport const TransactionListPinned = forwardRef<\n  HTMLDivElement,\n  TransactionListPinnedProps\n>(({ children, className, ...rest }, ref) => (\n  <div className={cn(\"flex flex-col gap-1.5\", className)} ref={ref} {...rest}>\n    {children}\n  </div>\n));\nTransactionListPinned.displayName = \"TransactionList.Pinned\";\n\ntype TransactionListItemProps = {\n  currency?: string;\n  labels?: TransactionListLabels;\n  locale?: string;\n  transaction: Transaction;\n};\n\nfunction TransactionListItem({\n  currency,\n  locale,\n  transaction,\n}: TransactionListItemProps): ReactNode {\n  const sign = SIGN_BY_TYPE[transaction.type];\n  const formatted = formatTransactionAmount(transaction.amountCents, {\n    currency,\n    locale,\n  });\n  const display = sign === \"negative\" ? `-${formatted}` : `+${formatted}`;\n  const ariaLabel =\n    typeof transaction.description === \"string\"\n      ? `${sign === \"negative\" ? \"Debit\" : \"Credit\"} ${display} for ${transaction.description}`\n      : undefined;\n  return (\n    <li\n      className=\"flex items-start justify-between gap-3 rounded-lg border border-border bg-muted/20 px-3 py-2\"\n      data-transaction-type={transaction.type}\n    >\n      <div className=\"flex min-w-0 flex-col gap-0.5\">\n        <p className=\"truncate text-sm font-medium text-foreground\">\n          {transaction.description}\n        </p>\n        <p className=\"text-xs text-muted-foreground\">\n          {formatTransactionDate(transaction.createdAt, locale)}\n          {transaction.meta ? <span> · {transaction.meta}</span> : null}\n        </p>\n      </div>\n      <span\n        aria-label={ariaLabel}\n        className={cn(\n          \"shrink-0 font-mono text-sm font-semibold\",\n          AMOUNT_CLASS[sign],\n        )}\n      >\n        {display}\n      </span>\n    </li>\n  );\n}\n\n/**\n * Props for {@link TransactionListSubscriptionRow}.\n *\n * @public\n */\nexport type TransactionListSubscriptionRowProps = {\n  /** Subscription amount per interval, in minor units. */\n  amountCents: number;\n  /** Currency code (ISO 4217). Defaults to `\"USD\"`. */\n  currency?: string;\n  /** Renewal interval. */\n  interval: SubscriptionInterval;\n  /** Localizable strings. */\n  labels?: TransactionListLabels;\n  /** BCP-47 locale tag. Defaults to `\"en-US\"`. */\n  locale?: string;\n  /** Optional secondary metadata (e.g. `\"VAT incl.\"`). */\n  meta?: ReactNode;\n  /** Plan display name. */\n  plan: ReactNode;\n  /** Optional renewal timestamp (ms). */\n  renewsAt?: number;\n  /** Subscription status. */\n  status: SubscriptionStatus;\n} & ComponentPropsWithoutRef<\"div\">;\n\ntype SubscriptionMetaProps = {\n  locale?: string;\n  meta?: ReactNode;\n  renewsAt?: number;\n  renewsLabel: string;\n};\n\nfunction SubscriptionMeta({\n  locale,\n  meta,\n  renewsAt,\n  renewsLabel,\n}: SubscriptionMetaProps): ReactNode {\n  if (renewsAt === undefined && !meta) return null;\n  return (\n    <p className=\"text-xs text-muted-foreground\">\n      {renewsAt === undefined\n        ? null\n        : `${renewsLabel} ${formatTransactionDate(renewsAt, locale)}`}\n      {renewsAt !== undefined && meta ? <span> · {meta}</span> : null}\n      {renewsAt === undefined && meta ? meta : null}\n    </p>\n  );\n}\n\n/**\n * Active-subscription row for the pinned section. Renders a green-border\n * card with plan name, status badge, amount/interval, and optional\n * renewal date.\n *\n * @public\n */\nexport const TransactionListSubscriptionRow = forwardRef<\n  HTMLDivElement,\n  TransactionListSubscriptionRowProps\n>((props, ref) => {\n  const {\n    amountCents,\n    className,\n    currency,\n    interval,\n    labels,\n    locale,\n    meta,\n    plan,\n    renewsAt,\n    status,\n    ...rest\n  } = props;\n  const resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n  const formatted = formatTransactionAmount(amountCents, { currency, locale });\n  const intervalSuffix = INTERVAL_LABEL[interval];\n  const isActive = status === \"active\";\n\n  return (\n    <div\n      className={cn(\n        \"flex items-start justify-between gap-3 rounded-lg border px-3 py-2\",\n        isActive\n          ? \"border-emerald-500/40 bg-emerald-500/5\"\n          : \"border-border bg-muted/20\",\n        className,\n      )}\n      data-status={status}\n      ref={ref}\n      {...rest}\n    >\n      <div className=\"flex min-w-0 flex-col gap-1\">\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <p className=\"text-sm font-semibold text-foreground\">{plan}</p>\n          <Badge variant={STATUS_VARIANT[status]}>\n            {resolvedLabels[STATUS_LABEL_KEY[status]]}\n          </Badge>\n        </div>\n        <SubscriptionMeta\n          locale={locale}\n          meta={meta}\n          renewsAt={renewsAt}\n          renewsLabel={resolvedLabels.renews}\n        />\n      </div>\n      <span className=\"shrink-0 font-mono text-sm font-semibold text-foreground\">\n        {formatted}/{intervalSuffix}\n      </span>\n    </div>\n  );\n});\nTransactionListSubscriptionRow.displayName = \"TransactionList.SubscriptionRow\";\n\n/**\n * Chronological list of financial transactions with credit/debit color\n * coding, locale-aware currency / date formatting, and an optional pinned\n * section for the active subscription.\n *\n * @example\n * ```tsx\n * <TransactionList\n *   transactions={transactions}\n *   currency=\"EUR\"\n *   locale=\"en-IE\"\n *   emptyMessage=\"No transactions yet\"\n * >\n *   <TransactionList.Pinned>\n *     <TransactionList.SubscriptionRow\n *       plan=\"AI OS Pro\"\n *       status=\"active\"\n *       amountCents={1200}\n *       renewsAt={1713139200000}\n *       interval=\"month\"\n *     />\n *   </TransactionList.Pinned>\n * </TransactionList>\n * ```\n *\n * @public\n */\nexport const TransactionList = TransactionListBase as TransactionListComponent;\nTransactionList.Pinned = TransactionListPinned;\nTransactionList.SubscriptionRow = TransactionListSubscriptionRow;\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
