Skip to content

Wallet Prompt

Install this component.

Installation

npx shadcn@latest add https://ui.monark.io/r/wallet-prompt.json

Source

"use client"

import * as React from "react"
import { Loader2Icon, TriangleAlertIcon } from "lucide-react"
import { cn } from "cn"

import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import {
  NetworkBadge,
  type NetworkStatus,
} from "@/components/ui/network-badge"
import { WalletAddress, WalletAvatar } from "@/components/ui/wallet"

type WalletPromptStatus = "idle" | "signing"

type WalletPromptRow = {
  label: React.ReactNode
  value: React.ReactNode
}

type WalletPromptAccount = {
  name?: React.ReactNode
  address: string
}

/**
 * A simulated wallet's "confirm this transaction" sheet: what is being signed
 * (summary rows), on which network, for what fee, then Confirm or Reject.
 * Dismissing it (close button, Escape, outside click) counts as a rejection.
 * While `status="signing"` the actions are disabled and it can't be dismissed.
 */
function WalletPrompt({
  open,
  onOpenChange,
  title,
  description = "Confirm in your wallet",
  site,
  account,
  accountLabel,
  rows = [],
  network,
  networkStatus = "live",
  fee,
  noFee = false,
  disclaimer = "Simulated signature. Nothing leaves your browser.",
  warning = false,
  status = "idle",
  onConfirm,
  onReject,
  confirmLabel = "Confirm",
  rejectLabel = "Reject",
  signingLabel = "Confirming…",
  networkLabel = "Network",
  feeLabel = "Estimated network fee",
  noFeeLabel = "No fee",
  closeLabel = "Close",
  className,
  ...props
}: Omit<React.ComponentProps<typeof DialogContent>, "title" | "children"> & {
  open: boolean
  onOpenChange?: (open: boolean) => void
  /** The action being signed, e.g. "Fund bounty". Falls back to `description`. */
  title?: React.ReactNode
  /** Sub-heading under the title. */
  description?: React.ReactNode
  /** Small eyebrow above the title: the requesting site, a logo + domain. */
  site?: React.ReactNode
  /** The signing account, shown as an avatar card. */
  account?: WalletPromptAccount
  /** Caption above the account name, e.g. "Signing as". */
  accountLabel?: React.ReactNode
  rows?: WalletPromptRow[]
  /** Network name. The row is hidden when omitted. */
  network?: React.ReactNode
  networkStatus?: NetworkStatus
  /** Pre-formatted fee, e.g. "0.00042 tETH". */
  fee?: React.ReactNode
  /** The action costs no fee: shows `noFeeLabel` instead of `fee`. */
  noFee?: boolean
  /** One-line note under the summary. Pass null to hide it. */
  disclaimer?: React.ReactNode
  /** Shows the disclaimer with a warning icon (use when the action moves value). */
  warning?: boolean
  status?: WalletPromptStatus
  onConfirm?: () => void
  /** Called by the Reject button and when the prompt is dismissed. */
  onReject?: () => void
  confirmLabel?: React.ReactNode
  rejectLabel?: React.ReactNode
  signingLabel?: React.ReactNode
  networkLabel?: React.ReactNode
  feeLabel?: React.ReactNode
  noFeeLabel?: React.ReactNode
  closeLabel?: string
}) {
  const signing = status === "signing"
  const showFee = noFee || (fee !== undefined && fee !== null && fee !== "")

  const reject = () => {
    onReject?.()
    onOpenChange?.(false)
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        if (next) onOpenChange?.(true)
        else if (!signing) reject()
      }}
    >
      <DialogContent
        data-slot="wallet-prompt"
        data-status={status}
        aria-busy={signing || undefined}
        showCloseButton={!signing}
        closeLabel={closeLabel}
        className={cn("gap-5 sm:max-w-md", className)}
        {...props}
      >
        <DialogHeader data-slot="wallet-prompt-header" className="gap-1 text-left">
          {site ? (
            <div
              data-slot="wallet-prompt-site"
              className="flex items-center gap-2 text-xs font-semibold text-muted-foreground [&_img]:size-5 [&_svg]:size-5"
            >
              {site}
            </div>
          ) : null}
          <DialogTitle className={cn(site && "pt-2")}>
            {title ?? description}
          </DialogTitle>
          <DialogDescription className={cn(!title && "sr-only")}>
            {description}
          </DialogDescription>
        </DialogHeader>

        {account ? (
          <div
            data-slot="wallet-prompt-account"
            className="flex items-center gap-3 rounded-2xl border bg-muted/50 p-3"
          >
            <WalletAvatar address={account.address} size={32} />
            <div className="min-w-0 leading-tight">
              {accountLabel ? (
                <p className="text-xs text-muted-foreground">{accountLabel}</p>
              ) : null}
              {account.name ? (
                <p className="truncate text-sm font-bold">{account.name}</p>
              ) : null}
              <WalletAddress
                address={account.address}
                className="text-xs text-muted-foreground"
              />
            </div>
          </div>
        ) : null}

        {rows.length > 0 || network || showFee ? (
          <dl
            data-slot="wallet-prompt-summary"
            className="divide-y rounded-2xl border text-sm"
          >
            {rows.map((row, i) => (
              <div
                key={i}
                data-slot="wallet-prompt-row"
                className="flex items-start justify-between gap-4 px-4 py-2.5"
              >
                <dt className="text-muted-foreground">{row.label}</dt>
                <dd className="text-right font-semibold">{row.value}</dd>
              </div>
            ))}
            {network ? (
              <div
                data-slot="wallet-prompt-network"
                className="flex items-center justify-between gap-4 px-4 py-2.5"
              >
                <dt className="text-muted-foreground">{networkLabel}</dt>
                <dd>
                  <NetworkBadge
                    name={network}
                    status={networkStatus}
                    variant="outline"
                  />
                </dd>
              </div>
            ) : null}
            {showFee ? (
              <div
                data-slot="wallet-prompt-fee"
                className="flex items-center justify-between gap-4 px-4 py-2.5"
              >
                <dt className="text-muted-foreground">{feeLabel}</dt>
                <dd className={cn(noFee ? "font-semibold" : "font-mono text-xs")}>
                  {noFee ? noFeeLabel : fee}
                </dd>
              </div>
            ) : null}
          </dl>
        ) : null}

        {disclaimer ? (
          <p
            data-slot="wallet-prompt-disclaimer"
            className="flex items-start gap-1.5 text-xs text-muted-foreground"
          >
            {warning ? (
              <TriangleAlertIcon
                aria-hidden="true"
                className="mt-px size-3.5 shrink-0 text-warning"
              />
            ) : null}
            <span>{disclaimer}</span>
          </p>
        ) : null}

        <DialogFooter data-slot="wallet-prompt-actions" className="pt-0">
          <Button
            variant="outline"
            size="lg"
            disabled={signing}
            onClick={reject}
          >
            {rejectLabel}
          </Button>
          <Button size="lg" disabled={signing} onClick={onConfirm} autoFocus>
            {signing ? (
              <>
                <Loader2Icon aria-hidden="true" className="animate-spin" />
                {signingLabel}
              </>
            ) : (
              confirmLabel
            )}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

export { WalletPrompt }
export type { WalletPromptAccount, WalletPromptRow, WalletPromptStatus }