npm package: @payxor/react
Description: React hooks and components for PayXor
Author: PayXor
Latest version: 0.1.2

# @payxor/react

React hooks and unstyled UI components for integrating PayXor payments into your application. All components are headless/unstyled—bring your own styling via `className` and slot-level class overrides.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Hooks](#hooks)
  - [usePayXorClient](#usepayxorclient)
  - [useTokenPayment](#usetokenpayment)
  - [usePayment](#usepayment)
  - [useTokenApproval](#usetokenapproval)
  - [useTokenStatuses](#usetokenstatuses)
  - [useFeatureStatus](#usefeaturestatus)
  - [useSessionStatus](#usesessionstatus)
- [Components](#components)
  - [Button](#button)
  - [StatusMessage](#statusmessage)
  - [StatusBadge](#statusbadge)
  - [StablecoinSelector](#stablecoinselector)
  - [ProductCard](#productcard)
  - [DropdownBuyButton](#dropdownbuybutton)
- [Full Integration Example](#full-integration-example)

## Installation

```bash
pnpm add @payxor/react @payxor/sdk @payxor/types
```

```bash
npm install @payxor/react @payxor/sdk @payxor/types
```

```bash
yarn add @payxor/react @payxor/sdk @payxor/types
```

**Peer dependencies:** `react`, `react-dom`, `viem`

## Quick Start

```tsx
import { useTokenPayment, usePayment, DropdownBuyButton } from "@payxor/react";
import { useAccount, useChainId, usePublicClient, useWalletClient } from "wagmi";

function BuyButton({ appId, productId, apiUrl }: Props) {
  const { address } = useAccount();
  const chainId = useChainId();
  const publicClient = usePublicClient();
  const { data: walletClient } = useWalletClient();

  const {
    stablecoins,
    selectedToken,
    setSelectedToken,
    needsApproval,
    approve,
    approving,
  } = useTokenPayment({
    appId,
    productId,
    address,
    chainId,
    publicClient,
    walletClient,
    apiUrl,
  });

  const { executePayment, loading } = usePayment({
    appId,
    productId,
    tokenAddress: selectedToken,
    address,
    chainId,
    publicClient,
    walletClient,
    apiUrl,
  });

  return (
    <DropdownBuyButton
      stablecoins={stablecoins}
      selectedToken={selectedToken}
      onSelect={setSelectedToken}
      needsApproval={needsApproval}
      onApprove={approve}
      onBuy={executePayment}
      loading={loading}
      approving={approving}
    />
  );
}
```

---

## Hooks

### usePayXorClient

Creates a memoized PayXor SDK client instance.

```tsx
import { usePayXorClient } from "@payxor/react";

const client = usePayXorClient({
  apiUrl: "https://api.payxor.xyz",
  walletClient,
});
```

**Options:**

| Option | Type | Description |
|--------|------|-------------|
| `apiUrl` | `string` | PayXor API URL |
| `walletClient` | `WalletClient \| null` | Viem wallet client |

**Returns:** `PayXorClient | null`

---

### useTokenPayment

Manages token selection, balance checking, allowance checking, and approval flow for a product purchase.

```tsx
import { useTokenPayment } from "@payxor/react";

const {
  stablecoins,
  selectedToken,
  setSelectedToken,
  productAmount,
  allowance,
  balance,
  needsApproval,
  hasSufficientBalance,
  loading,
  approving,
  error,
  approve,
} = useTokenPayment({
  appId: "your-app-id",
  productId: "product-id",
  address,
  chainId,
  publicClient,
  walletClient,
  apiUrl: "https://api.payxor.xyz",
  enabled: true,
});
```

**Options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `appId` | `string` | — | Your PayXor app ID |
| `productId` | `string` | — | Product ID to purchase |
| `address` | `Address \| null` | — | Connected wallet address |
| `chainId` | `number \| null` | — | Current chain ID |
| `publicClient` | `PublicClient \| null` | — | Viem public client |
| `walletClient` | `WalletClient \| null` | — | Viem wallet client |
| `apiUrl` | `string` | — | PayXor API URL |
| `enabled` | `boolean` | `true` | Enable/disable the hook |

**Returns:**

| Property | Type | Description |
|----------|------|-------------|
| `stablecoins` | `StablecoinConfig[]` | Available stablecoins for the app |
| `selectedToken` | `Address \| null` | Currently selected token address |
| `setSelectedToken` | `(address: Address) => void` | Token selection handler |
| `productAmount` | `bigint` | Product price in token units |
| `allowance` | `bigint \| null` | Current token allowance |
| `balance` | `bigint \| null` | User's token balance |
| `needsApproval` | `boolean` | Whether approval is required |
| `hasSufficientBalance` | `boolean` | Whether user has enough balance |
| `loading` | `boolean` | Loading state |
| `approving` | `boolean` | Approval transaction in progress |
| `error` | `Error \| null` | Any error that occurred |
| `approve` | `() => Promise<string>` | Trigger token approval |

---

### usePayment

Handles the complete payment flow: quote fetching, transaction execution, and confirmation.

```tsx
import { usePayment } from "@payxor/react";

const {
  executePayment,
  loading,
  status,
  error,
  txHash,
  explorerUrl,
  waitingForConfirmation,
  reset,
} = usePayment({
  appId: "your-app-id",
  productId: "product-id",
  tokenAddress: selectedToken,
  address,
  chainId,
  publicClient,
  walletClient,
  apiUrl: "https://api.payxor.xyz",
  onSuccess: (txHash) => console.log("Payment successful:", txHash),
  onError: (error) => console.error("Payment failed:", error),
  getExplorerUrl: (hash, chainId) => `https://etherscan.io/tx/${hash}`,
});
```

**Options:**

| Option | Type | Description |
|--------|------|-------------|
| `appId` | `string` | Your PayXor app ID |
| `productId` | `string` | Product ID to purchase |
| `tokenAddress` | `Address \| null` | Selected payment token |
| `address` | `Address \| null` | Connected wallet address |
| `chainId` | `number \| null` | Current chain ID |
| `publicClient` | `PublicClient \| null` | Viem public client |
| `walletClient` | `WalletClient \| null` | Viem wallet client |
| `apiUrl` | `string` | PayXor API URL |
| `onSuccess` | `(txHash: string) => void` | Success callback |
| `onError` | `(error: Error) => void` | Error callback |
| `getExplorerUrl` | `(txHash: string, chainId: number) => string \| null` | Block explorer URL builder |

**Returns:**

| Property | Type | Description |
|----------|------|-------------|
| `executePayment` | `() => Promise<void>` | Execute the payment |
| `loading` | `boolean` | Transaction in progress |
| `status` | `string` | Human-readable status message |
| `error` | `Error \| null` | Any error that occurred |
| `txHash` | `string \| null` | Transaction hash |
| `explorerUrl` | `string \| null` | Block explorer URL |
| `waitingForConfirmation` | `boolean` | Waiting for tx confirmation |
| `reset` | `() => void` | Reset hook state |

---

### useTokenApproval

Standalone hook for managing token approval (useful when building custom flows).

```tsx
import { useTokenApproval } from "@payxor/react";

const {
  allowance,
  balance,
  needsApproval,
  hasSufficientBalance,
  loading,
  approving,
  error,
  approve,
} = useTokenApproval({
  tokenAddress: "0x...",
  requiredAmount: 1000000n,
  address,
  chainId,
  publicClient,
  walletClient,
  apiUrl,
  enabled: true,
});
```

**Options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tokenAddress` | `Address \| null` | — | Token to check/approve |
| `requiredAmount` | `bigint` | — | Amount required for the transaction |
| `address` | `Address \| null` | — | Connected wallet address |
| `chainId` | `number \| null` | — | Current chain ID |
| `publicClient` | `PublicClient \| null` | — | Viem public client |
| `walletClient` | `WalletClient \| null` | — | Viem wallet client |
| `apiUrl` | `string` | — | PayXor API URL |
| `enabled` | `boolean` | `true` | Enable/disable the hook |

---

### useTokenStatuses

Computes token status entries for display in selectors.

```tsx
import { useTokenStatuses } from "@payxor/react";

const tokenStatuses = useTokenStatuses({
  stablecoins,
  selectedToken,
  balance,
  allowance,
  requiredAmount: productAmount,
});

// Result: Record<string, TokenStatusEntry>
// {
//   "0x...": { balance: 1000000n, allowance: 500000n, requiredAmount: 100000n }
// }
```

---

### useFeatureStatus

Checks if a user has unlocked a permanent feature/entitlement. Automatically polls until the feature is unlocked.

```tsx
import { useFeatureStatus } from "@payxor/react";

const { isUnlocked, loading, error } = useFeatureStatus({
  appId: "your-app-id",
  entitlementId: "premium-feature",
  address,
  chainId,
  walletClient,
  apiUrl,
  enabled: true,
  pollInterval: 5000, // ms
});

if (isUnlocked) {
  return <PremiumContent />;
}
```

**Options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `appId` | `string` | — | Your PayXor app ID |
| `entitlementId` | `string` | — | Feature/entitlement ID |
| `address` | `Address \| null` | — | Connected wallet address |
| `chainId` | `number \| null` | — | Current chain ID |
| `walletClient` | `WalletClient \| null` | — | Viem wallet client |
| `apiUrl` | `string` | — | PayXor API URL |
| `enabled` | `boolean` | `true` | Enable/disable the hook |
| `pollInterval` | `number` | `5000` | Polling interval in ms |

---

### useSessionStatus

Checks if a user has an active time-based session. Continuously polls to track session expiration.

```tsx
import { useSessionStatus } from "@payxor/react";

const { isActive, loading, error } = useSessionStatus({
  appId: "your-app-id",
  productId: "hourly-access",
  address,
  chainId,
  walletClient,
  apiUrl,
  enabled: true,
  pollInterval: 5000,
});

if (isActive) {
  return <SessionContent />;
}
```

---

## Components

All components are unstyled. Use `className` and `slotClassNames` props to apply your styles.

### Button

Basic button with loading state support.

```tsx
import { Button } from "@payxor/react";

<Button
  onClick={handleClick}
  loading={isLoading}
  disabled={isDisabled}
  loadingIndicator={<Spinner />}
  loadingIndicatorClassName="animate-spin"
  className="px-4 py-2 rounded bg-blue-600 text-white"
>
  Pay Now
</Button>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `loading` | `boolean` | Shows loading state |
| `loadingIndicator` | `ReactNode` | Custom loading indicator |
| `loadingIndicatorClassName` | `string` | Class for loading indicator |
| `className` | `string` | Button class name |
| `children` | `ReactNode` | Button content |
| ...rest | `ButtonHTMLAttributes` | Standard button props |

---

### StatusMessage

Displays status messages with optional icon, link, and dismiss button.

```tsx
import { StatusMessage } from "@payxor/react";

<StatusMessage
  type="success"
  message="Payment completed!"
  link={{ url: explorerUrl, text: "View transaction" }}
  onDismiss={() => setMessage(null)}
  className="flex items-center gap-2 p-4 rounded border"
  icons={{
    success: "✅",
    error: "❌",
    info: "ℹ️",
    loading: "⏳",
  }}
/>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `type` | `"info" \| "success" \| "error" \| "loading"` | Message type |
| `message` | `string` | Message text |
| `icon` | `ReactNode` | Override icon for current type |
| `icons` | `Record<StatusType, ReactNode>` | Icons for each type |
| `link` | `{ url: string, text: string }` | Optional link |
| `onDismiss` | `() => void` | Dismiss handler |
| `className` | `string` | Container class |
| `iconClassName` | `string` | Icon class |
| `messageClassName` | `string` | Message class |
| `linkClassName` | `string` | Link class |
| `dismissButtonClassName` | `string` | Dismiss button class |

---

### StatusBadge

Displays feature unlock or session status.

```tsx
import { StatusBadge } from "@payxor/react";

// Feature status
<StatusBadge
  status={isUnlocked}
  type="feature"
  className="inline-flex items-center gap-2 px-3 py-1 rounded-full"
  icons={{
    unlocked: "🔓",
    locked: "🔒",
    checking: "⏳",
  }}
/>

// Session status with time remaining
<StatusBadge
  status={isActive}
  type="session"
  timeRemaining={3600}
  labels={{
    active: "Session Active",
    inactive: "Session Expired",
    timeRemainingPrefix: "Expires in:",
  }}
/>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `status` | `boolean \| null` | Current status (`null` = checking) |
| `type` | `"feature" \| "session"` | Badge type |
| `timeRemaining` | `number \| null` | Seconds remaining (sessions only) |
| `labels` | `StatusBadgeLabels` | Custom label text |
| `icons` | `StatusBadgeIcons` | Custom icons |
| `className` | `string` | Container class |
| `iconClassName` | `string` | Icon class |
| `textClassName` | `string` | Text class |
| `subtextClassName` | `string` | Subtext class |

---

### StablecoinSelector

Token selection grid/list with balance and approval status indicators.

```tsx
import { StablecoinSelector } from "@payxor/react";

<StablecoinSelector
  stablecoins={stablecoins}
  selectedToken={selectedToken}
  onSelect={setSelectedToken}
  tokenStatuses={tokenStatuses}
  disabled={loading}
  className="space-y-2"
  slotClassNames={{
    list: "grid gap-2",
    listLabel: "text-sm font-medium",
    tokenButton: "p-3 border rounded hover:border-blue-500",
    tokenButtonSelected: "border-blue-500 bg-blue-50",
    tokenButtonDisabled: "opacity-50 cursor-not-allowed",
    tokenSymbol: "font-bold",
    tokenName: "text-sm text-gray-500",
    tokenBalance: "text-xs",
    tokenBalanceSufficient: "text-green-600",
    tokenBalanceInsufficient: "text-red-600",
    tokenApproval: "text-yellow-600",
    tokenSelectedBadge: "text-xs bg-blue-100 px-2 py-0.5 rounded",
  }}
  labels={{
    selectToken: "Choose payment method:",
    balance: "Balance:",
    insufficient: "(Not enough)",
    approvalNeeded: "(Needs approval)",
  }}
/>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `stablecoins` | `StablecoinConfig[]` | Available tokens |
| `selectedToken` | `string \| null` | Selected token address |
| `onSelect` | `(address: string) => void` | Selection handler |
| `tokenStatuses` | `Record<string, TokenStatus>` | Token status data |
| `disabled` | `boolean` | Disable selection |
| `className` | `string` | Container class |
| `label` | `ReactNode` | Custom label |
| `labels` | `StablecoinSelectorLabels` | Custom label text |
| `formatBalance` | `(balance: bigint, decimals: number) => string` | Balance formatter |
| `slotClassNames` | `StablecoinSelectorClassNames` | Slot classes |

---

### ProductCard

Displays a product with price and purchase button.

```tsx
import { ProductCard } from "@payxor/react";

<ProductCard
  name="Premium Access"
  description="Unlock all premium features forever"
  price="9.99"
  purchased={isPurchased}
  loading={isLoading}
  onPurchase={handlePurchase}
  className="p-6 border rounded-lg shadow"
  slotClassNames={{
    header: "flex items-center justify-between",
    title: "text-xl font-bold",
    purchasedBadge: "text-sm bg-green-100 text-green-800 px-2 py-1 rounded",
    description: "mt-2 text-gray-600",
    footer: "mt-4 flex items-center justify-between",
    price: "text-2xl font-bold",
    currency: "text-sm text-gray-500",
    action: "px-4 py-2 bg-blue-600 text-white rounded",
  }}
  labels={{
    purchase: "Buy Now",
    purchased: "Owned",
    processing: "Processing...",
    currency: "USD",
  }}
/>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `name` | `string` | Product name |
| `description` | `string` | Product description |
| `price` | `string` | Display price |
| `purchased` | `boolean` | Already purchased |
| `loading` | `boolean` | Purchase in progress |
| `onPurchase` | `() => void` | Purchase handler |
| `className` | `string` | Container class |
| `labels` | `ProductCardLabels` | Custom label text |
| `slotClassNames` | `ProductCardClassNames` | Slot classes |
| `action` | `ReactNode` | Custom action element |
| `buttonProps` | `ButtonProps` | Props for default button |

---

### DropdownBuyButton

Compact token selector + buy/approve button combo. Automatically switches between "Approve" and "Buy" states based on allowance.

```tsx
import { DropdownBuyButton } from "@payxor/react";

<DropdownBuyButton
  stablecoins={stablecoins}
  selectedToken={selectedToken}
  onSelect={setSelectedToken}
  needsApproval={needsApproval}
  onApprove={approve}
  onBuy={executePayment}
  loading={loading}
  approving={approving}
  disabled={!address}
  label="Pay with"
  slotClassNames={{
    container: "space-y-2",
    label: "text-sm font-medium",
    row: "flex",
    selectWrapper: "relative",
    select: "border rounded-l px-3 py-2 pr-8 appearance-none",
    selectIcon: "absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none",
    button: "px-4 py-2 bg-blue-600 text-white rounded-r",
  }}
  selectIcon={<ChevronDownIcon className="w-4 h-4" />}
  labels={{
    selectToken: "Select token",
    buyWith: (token) => `Pay ${token?.symbol}`,
    approveWith: (token) => `Approve ${token?.symbol}`,
    optionLabel: (token) => `${token.symbol} - ${token.name}`,
  }}
/>
```

**Props:**

| Prop | Type | Description |
|------|------|-------------|
| `stablecoins` | `StablecoinConfig[]` | Available tokens |
| `selectedToken` | `string \| null` | Selected token address |
| `onSelect` | `(address: string) => void` | Selection handler |
| `onBuy` | `() => void` | Buy handler |
| `onApprove` | `() => void` | Approve handler |
| `needsApproval` | `boolean` | Force approval state |
| `tokenStatuses` | `Record<string, TokenStatus>` | Token status (for auto-detection) |
| `requiredAmount` | `bigint` | Required amount (for auto-detection) |
| `disabled` | `boolean` | Disable interactions |
| `loading` | `boolean` | Buy loading state |
| `approving` | `boolean` | Approve loading state |
| `className` | `string` | Container class |
| `label` | `ReactNode \| null` | Label (set `null` to hide) |
| `labels` | `DropdownBuyButtonLabels` | Custom label text |
| `slotClassNames` | `DropdownBuyButtonClassNames` | Slot classes |
| `selectIcon` | `ReactNode` | Custom dropdown icon |
| `buttonProps` | `ButtonProps` | Props for the button |
| `selectProps` | `SelectHTMLAttributes` | Props for the select |

**Slot Classes:**

- `container` - Outer wrapper
- `label` - Label element
- `row` - Row containing select and button
- `selectWrapper` - Wrapper around select (for icon positioning)
- `select` - The select element
- `selectIcon` - Icon inside select wrapper
- `button` - The action button
- `empty` - Message when no tokens available

---

## Full Integration Example

Here's a complete example integrating all pieces with wagmi:

```tsx
import { useAccount, useChainId, usePublicClient, useWalletClient, useConfig } from "wagmi";
import {
  useTokenPayment,
  usePayment,
  useTokenStatuses,
  useFeatureStatus,
  DropdownBuyButton,
  StatusMessage,
  StatusBadge,
} from "@payxor/react";

const API_URL = "https://api.payxor.xyz";

function PremiumFeature({ appId, productId, entitlementId }: Props) {
  const { address } = useAccount();
  const chainId = useChainId();
  const publicClient = usePublicClient();
  const { data: walletClient } = useWalletClient();
  const config = useConfig();

  // Check if already unlocked
  const { isUnlocked, loading: checkingStatus } = useFeatureStatus({
    appId,
    entitlementId,
    address,
    chainId,
    walletClient,
    apiUrl: API_URL,
  });

  // Token and approval management
  const {
    stablecoins,
    selectedToken,
    setSelectedToken,
    productAmount,
    allowance,
    balance,
    needsApproval,
    approving,
    approve,
  } = useTokenPayment({
    appId,
    productId,
    address,
    chainId,
    publicClient,
    walletClient,
    apiUrl: API_URL,
    enabled: !isUnlocked, // Only fetch if not already unlocked
  });

  // Token statuses for display
  const tokenStatuses = useTokenStatuses({
    stablecoins,
    selectedToken,
    balance,
    allowance,
    requiredAmount: productAmount,
  });

  // Payment execution
  const { executePayment, loading, status, error, explorerUrl, reset } = usePayment({
    appId,
    productId,
    tokenAddress: selectedToken,
    address,
    chainId,
    publicClient,
    walletClient,
    apiUrl: API_URL,
    getExplorerUrl: (hash, targetChainId) => {
      const chain = config.chains.find((c) => c.id === targetChainId);
      return chain?.blockExplorers?.default?.url
        ? `${chain.blockExplorers.default.url}/tx/${hash}`
        : null;
    },
  });

  // Already unlocked - show content
  if (isUnlocked) {
    return (
      <div>
        <StatusBadge status={true} type="feature" className="mb-4" />
        <PremiumContent />
      </div>
    );
  }

  return (
    <div className="space-y-4">
      <StatusBadge status={isUnlocked} type="feature" />

      {status && (
        <StatusMessage
          type={error ? "error" : "info"}
          message={status}
          link={explorerUrl ? { url: explorerUrl, text: "View transaction" } : undefined}
          onDismiss={reset}
        />
      )}

      <DropdownBuyButton
        stablecoins={stablecoins}
        selectedToken={selectedToken}
        onSelect={setSelectedToken}
        tokenStatuses={tokenStatuses}
        needsApproval={needsApproval}
        onApprove={approve}
        onBuy={executePayment}
        loading={loading}
        approving={approving}
        disabled={!address || checkingStatus}
      />
    </div>
  );
}
```

---

## Notes

- All hooks require `apiUrl` to be provided for API communication.
- Hooks are wallet-agnostic—pass `address`, `chainId`, `publicClient`, and `walletClient` from your wallet setup (wagmi, RainbowKit, etc.).
- Components are completely unstyled. Use Tailwind, CSS modules, or any styling solution.
- This package does not include analytics utilities (see `@payxor/sdk` for analytics).