# Clink Elements SDK

[中文文档](./README.zh-CN.md)

Embed secure, pre-built payment UI components into any website. The Clink Elements SDK is a lightweight, framework-agnostic JavaScript library that integrates multiple payment methods.

## Prerequisites

- A Clink merchant account with a **publish key** (`pk_...`)
- A **checkout session ID** created via the [Clink Server API](https://docs.clinkbill.com)

## Installation

### npm / yarn / pnpm

```bash
npm install @clink-ai/clink-elements
# or
yarn add @clink-ai/clink-elements
# or
pnpm add @clink-ai/clink-elements
```

```js
import { loadClinkElements } from '@clink-ai/clink-elements';
```

### CDN (Script Tag)

```html
<script src="https://unpkg.com/@clink-ai/clink-elements/dist/index.iife.js"></script>
```

When loaded via `<script>` tag, the SDK is available under the global `ClinkElements` namespace:

```js
const { loadClinkElements } = ClinkElements;
```

## Quick Start

### Using a Bundler (ESM)

```html
<!-- Optional: currency selector -->
<div id="currency-select"></div>
<!-- Required: payment form -->
<div id="payment-method"></div>
<!-- Your custom submit button -->
<button id="pay-button" disabled>Pay</button>
```

```js
import { loadClinkElements } from '@clink-ai/clink-elements';

const clink = await loadClinkElements({
  publishKey: 'pk_live_xxxxxxxx',
  environment: 'production',
  sessionId: 'cs_xxxxxxxx',
});

const paymentMethod = clink.createElement('paymentMethod');
const currencySelect = clink.createElement('currencySelect');

paymentMethod.mount('#payment-method');
currencySelect.mount('#currency-select');

clink.on('submit-enabled', (enabled) => {
  document.getElementById('pay-button').disabled = !enabled;
});

clink.on('session-success', () => {
  alert('Payment successful!');
});

document.getElementById('pay-button').addEventListener('click', () => {
  clink.submit();
});
```

### Using a Script Tag (IIFE)

```html
<!DOCTYPE html>
<html>
<head>
  <script src="https://unpkg.com/@clink-ai/clink-elements/dist/index.iife.js"></script>
</head>
<body>
  <div id="currency-select"></div>
  <div id="payment-method"></div>
  <button id="pay-button" disabled>Pay</button>

  <script>
    (async function () {
      var clink = await ClinkElements.loadClinkElements({
        publishKey: 'pk_live_xxxxxxxx',
        environment: 'production',
        sessionId: 'cs_xxxxxxxx',
      });

      var paymentMethod = clink.createElement('paymentMethod');
      var currencySelect = clink.createElement('currencySelect');

      paymentMethod.mount('#payment-method');
      currencySelect.mount('#currency-select');

      clink.on('submit-enabled', function (enabled) {
        document.getElementById('pay-button').disabled = !enabled;
      });

      clink.on('session-success', function () {
        alert('Payment successful!');
      });

      document.getElementById('pay-button').addEventListener('click', function () {
        clink.submit();
      });
    })();
  </script>
</body>
</html>
```

## API Reference

### `loadClinkElements(options)`

Async factory function that validates your merchant credentials and returns a `ClinkElements` instance.

```ts
async function loadClinkElements(
  options: LoadClinkElementsOptions
): Promise<ClinkElements>;
```

Throws `ClinkApiError` if the publish key or session ID is invalid.

### `LoadClinkElementsOptions`

| Property | Type | Required | Description |
|---|---|---|---|
| `publishKey` | `string` | Yes | Your merchant publish key (starts with `pk_`) |
| `environment` | `'sandbox' \| 'production'` | Yes | Target environment |
| `sessionId` | `string` | Yes | Checkout session ID from the server API |
| `presetOptions` | [`PresetOptions`](#presetoptions) | No | UI customization options |

### `PresetOptions`

```ts
interface PresetOptions {
  locale?: 'de-DE' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'ko-KR' | 'pt-PT' | 'zh-CN';
  theme?: 'light' | 'dark';
  primaryColor?: string;
  radius?: {
    components?: number;
    card?: number;
  };
  currencySelect?:
    | { hideIfOneCurrency?: boolean }
    | { oneCurrencyStyle?: Record<string, string | number> };
  section?: {
    hideExpire?: boolean;
    hideError?: boolean;
    hideSkeleton?: boolean;
    hideSuccess?: boolean;
    hidePending?: boolean;
  };
}
```

| Property | Type | Default | Description |
|---|---|---|---|
| `locale` | `LocaleKey` | `'en-US'` | Display language. Supported: `'de-DE'`, `'en-US'`, `'es-ES'`, `'fr-FR'`, `'ja-JP'`, `'ko-KR'`, `'pt-PT'`, `'zh-CN'` |
| `theme` | `'light' \| 'dark'` | `'light'` | Color theme |
| `primaryColor` | `string` | — | Primary accent color (any CSS color, e.g. `'#1677FF'`) |
| `radius.components` | `number` | `6` | Border radius for inputs and buttons (px) |
| `radius.card` | `number` | `6` | Border radius for the card container (px) |
| `currencySelect` | `object` | — | Currency selector behavior ([details](#currency-select-behavior)) |
| `section` | `object` | — | Control visibility of UI states ([details](#section-visibility)) |

> **Note:** `currencySelect.hideIfOneCurrency` and `currencySelect.oneCurrencyStyle` are **mutually exclusive** — you can set one or the other, not both.

### `ClinkElements`

The main SDK instance returned by `loadClinkElements()`.

#### `createElement(type)`

```ts
createElement(type: 'paymentMethod' | 'currencySelect'): ClinkElement
```

Creates a payment element. Returns a [`ClinkElement`](#clinkelement) that you can mount to the DOM.

- `'paymentMethod'` — Main payment form (card inputs, wallet buttons, etc.)
- `'currencySelect'` — Currency selection dropdown

**Constraints:**

- `'paymentMethod'` **must** be created before `'currencySelect'`
- Each type can only be created once per instance

#### `on(event, callback)` / `off(event, callback)`

```ts
on<K extends EventType>(event: K, callback: EventCallback<K>): void
off<K extends EventType>(event: K, callback: EventCallback<K>): void
```

Subscribe to or unsubscribe from [events](#events). Callbacks are fully typed in TypeScript.

#### `submit()`

```ts
submit(): void
```

Triggers payment submission. The SDK handles all payment flows internally (3DS authentication, QR code payments, third-party redirects).

Listen to [`session-success`](#events) or [`session-pending`](#events) to know when the payment completes.

#### `setLocale(locale)`

```ts
setLocale(locale: 'de-DE' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'ko-KR' | 'pt-PT' | 'zh-CN'): void
```

Switches the display language at runtime. Applies to all mounted elements.

#### `setTheme(theme)`

```ts
setTheme(theme: 'light' | 'dark'): void
```

Switches the color theme at runtime. Applies to all mounted elements.

#### `promoCodeChange(data)`

```ts
promoCodeChange(
  data: { type: 'apply'; code: string } | { type: 'clear' }
): void
```

Applies or clears a promotion code. See [Promo Codes](#promo-codes) for the full workflow.

#### `destroy()`

```ts
destroy(): void
```

Unmounts all elements, removes event listeners, and cleans up resources. Safe to call multiple times (idempotent). Always call this when the checkout page is unmounted.

### `ClinkElement`

Represents a single UI element.

#### `mount(target)`

```ts
mount(target: HTMLElement | string): void
```

Attaches the element to the DOM. Accepts a CSS selector string (e.g. `'#payment-method'`) or an `HTMLElement` reference.

Throws if the target element is not found or the element is already mounted.

#### `unmount()`

```ts
unmount(): void
```

Removes the element from the DOM and restores the container's original styles. Safe to call when not mounted (no-op).

## Events

Use `clink.on(event, callback)` to listen for events:

| Event | Callback Data | Description |
|---|---|---|
| `submit-enabled` | `boolean` | Whether the pay button should be enabled |
| `submit-visible` | `boolean` | Whether the pay button should be visible (some wallets use built-in buttons) |
| `session-init-success` | `undefined` | Session initialized, elements are ready |
| `session-success` | `undefined` | Payment completed successfully |
| `session-pending` | `undefined` | Payment is pending confirmation (async payment methods) |
| `amount-change` | `{ amount: DueTodayAmountInfo }` | Order amount or pricing breakdown changed |
| `promo-code-error` | `{ message: string }` | Promotion code validation failed |
| `error` | `{ error: Error }` | An error occurred (see [Error Handling](#error-handling)) |

### Example

```js
clink.on('submit-enabled', (enabled) => {
  payButton.disabled = !enabled;
});

clink.on('submit-visible', (visible) => {
  payButton.style.display = visible ? 'block' : 'none';
});

clink.on('amount-change', ({ amount }) => {
  priceDisplay.textContent = `${amount.currency} ${amount.dueTodayAmount}`;
});

clink.on('session-success', () => {
  window.location.href = '/thank-you';
});

clink.on('error', ({ error }) => {
  console.error('Payment error:', error);
});
```

### `DueTodayAmountInfo`

The `amount-change` event provides detailed pricing information:

```ts
interface DueTodayAmountInfo {
  currency: string;
  subtotalAmount: number;
  dueTodayAmount: number;
  product: Product;
  multiProducts?: MultiProduct[];
  subscription: Subscription;
  enablePromotionCode?: boolean;
  promotionCodeInfo?: PromotionCodeInfo;
  requiresTaxCalculation?: boolean;
  taxInfo?: TaxInfo;
}

interface Product {
  name?: string;
  /** Localized product names, keyed by locale (e.g. en-US, zh-CN) */
  localizedNames: Record<string, string> | null;
  type: 'ONETIME' | 'SUBSCRIPTION';
  imageUrl?: string;
}

interface Subscription {
  units?: number;
  recurring?: 'DAY' | 'WEEK' | 'MONTH' | 'QUARTER' | 'HALF_YEAR' | 'YEAR' | 'CUSTOM';
  customDays?: number;
  isFreeTrial?: boolean;
  freeTrialDays?: number;
  freeTrialEndDate?: string;
}

interface MultiProduct {
  name: string;
  quantity: number;
  unitAmount: number;
  currency: string;
  imageUrl?: string;
}

interface PromotionCodeInfo {
  name: string;
  terms: string;
  discountAmount: number;
  currency: string;
  durationType: 'ONCE' | 'REPEATING' | 'FOREVER' | null;
  durationPeriods?: number;
}

interface TaxInfo {
  name?: string;
  rate?: string;
  amount: number | null;
  currency: string;
}
```

## Customization

### Locale

Set the initial language via `presetOptions.locale`, or switch at runtime:

```js
clink.setLocale('zh-CN');
```

### Theme

Set the initial theme via `presetOptions.theme`, or switch at runtime:

```js
clink.setTheme('dark');
```

### Primary Color

Override the accent color used for buttons and interactive elements:

```js
const clink = await loadClinkElements({
  // ...
  presetOptions: {
    primaryColor: '#7C3AED',
  },
});
```

### Border Radius

Customize the border radius for UI components and the card container:

```js
presetOptions: {
  radius: {
    components: 12,
    card: 16,
  },
}
```

### Currency Select Behavior

Control the currency selector when only one currency is available:

```js
// Option A: Hide entirely
presetOptions: {
  currencySelect: { hideIfOneCurrency: true },
}

// Option B: Custom styles
presetOptions: {
  currencySelect: {
    oneCurrencyStyle: { opacity: 0.5, pointerEvents: 'none' },
  },
}
```

> These two options are mutually exclusive.

### Section Visibility

Control which post-payment UI states are displayed:

```js
presetOptions: {
  section: {
    hideExpire: false,
    hideError: false,
    hideSkeleton: false,
    hideSuccess: true,
    hidePending: true,
  },
}
```

| Property | Description |
|---|---|
| `hideExpire` | Hide session expired state |
| `hideError` | Hide error state |
| `hideSkeleton` | Hide loading skeleton |
| `hideSuccess` | Hide payment success state |
| `hidePending` | Hide payment pending state |

## Promo Codes

To support promotion codes in your checkout flow:

**1. Check if promo codes are enabled**

```js
clink.on('amount-change', ({ amount }) => {
  if (amount.enablePromotionCode) {
    showPromoCodeInput();
  }
});
```

**2. Apply a promo code**

```js
clink.promoCodeChange({ type: 'apply', code: 'SAVE20' });
```

**3. Handle errors**

```js
clink.on('promo-code-error', ({ message }) => {
  showError(message);
});
```

**4. Read applied discount**

The next `amount-change` event will include `promotionCodeInfo` with discount details.

**5. Clear a promo code**

```js
clink.promoCodeChange({ type: 'clear' });
```

## Error Handling

### Initialization Errors

Wrap `loadClinkElements()` in a try-catch to handle initialization failures:

```js
import {
  loadClinkElements,
  ClinkApiError,
  SessionExpiredError,
  SessionCompleteError,
} from '@clink-ai/clink-elements';

try {
  const clink = await loadClinkElements({ /* ... */ });
} catch (error) {
  if (error instanceof ClinkApiError) {
    console.error('Invalid credentials:', error.message);
  }
}
```

### Runtime Errors

Listen to the `error` event for errors that occur after initialization:

```js
clink.on('error', ({ error }) => {
  if (error instanceof SessionExpiredError) {
    showMessage('Your session has expired. Please refresh the page.');
  } else if (error instanceof SessionCompleteError) {
    showMessage('This payment has already been completed.');
  } else {
    showMessage('Something went wrong. Please try again.');
  }
});
```

### Error Classes

| Class | Description |
|---|---|
| `ClinkApiError` | API request failed (invalid publish key, network error, etc.) |
| `SessionExpiredError` | The checkout session has expired |
| `SessionCompleteError` | The payment has already been completed |
| `SessionLoadError` | Failed to load session data |
| `SessionNotSupportedError` | The session's UI mode is not compatible with Elements |
| `PromoCodeError` | Promotion code operation failed |

All error classes extend the native `Error` class.

## TypeScript Support

The SDK ships with full TypeScript declarations. All exports are fully typed:

```ts
import { loadClinkElements } from '@clink-ai/clink-elements';
import type {
  ClinkElements,
  ClinkElement,
  LoadClinkElementsOptions,
  PresetOptions,
  EventType,
  EventCallback,
  EventDataMap,
  Environment,
  ElementType,
  DueTodayAmountInfo,
  Product,
  Subscription,
  MultiProduct,
  PromotionCodeInfo,
  TaxInfo,
} from '@clink-ai/clink-elements';
```

Event callbacks are automatically typed based on the event name:

```ts
clink.on('submit-enabled', (enabled) => {
  // `enabled` is inferred as `boolean`
});

clink.on('amount-change', ({ amount }) => {
  // `amount` is inferred as `DueTodayAmountInfo`
});
```

## Important Notes

- **Browser only** — The SDK requires `window` and `document`. It is not compatible with server-side rendering. Load it client-side only (e.g. in `onMounted`, `useEffect`, or `DOMContentLoaded`).
- **Element order** — `'paymentMethod'` must be created before `'currencySelect'`. Creating `currencySelect` first will throw an error.
- **Single instance** — Each element type can only be created once per `ClinkElements` instance.
- **Cleanup** — Always call `destroy()` when the checkout page is unmounted to prevent memory leaks.
- **Mount target** — The DOM element passed to `mount()` must exist at the time of the call.
- **Sandbox testing** — Use `environment: 'sandbox'` with your test publish key for development and testing. Switch to `'production'` for live payments.
