# Clink Elements SDK

[English](./README.md)

将安全的、预构建的支付 UI 组件嵌入任意网站。Clink Elements SDK 是一个轻量、框架无关的 JavaScript 库，集成多种支付方式。

## 前置条件

- 拥有 Clink 商户账号及 **publish key**（`pk_...`）
- 通过 [Clink 服务端 API](https://docs.clinkbill.com) 创建的 **checkout session ID**

## 安装

### npm / yarn / pnpm

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

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

### CDN（Script 标签）

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

通过 `<script>` 标签加载时，SDK 挂载在全局变量 `ClinkElements` 下：

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

## 快速开始

### 使用打包工具（ESM）

```html
<!-- 可选: 货币切换组件 -->
<div id="currency-select"></div>
<!-- 必须: 支付表单组件 -->
<div id="payment-method"></div>
<!-- 您自定义的 submit 按钮 -->
<button id="pay-button" disabled>支付</button>
```

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

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

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('支付成功！');
});

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

### 使用 Script 标签（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>支付</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('支付成功！');
      });

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

## API 参考

### `loadClinkElements(options)`

异步工厂函数，验证商户凭证后返回 `ClinkElements` 实例。

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

若 publish key 或 session ID 无效，抛出 `ClinkApiError`。

### `LoadClinkElementsOptions`

| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `publishKey` | `string` | 是 | 商户 publish key（以 `pk_` 开头） |
| `environment` | `'sandbox' \| 'production'` | 是 | 目标环境 |
| `sessionId` | `string` | 是 | 服务端 API 创建的 checkout session ID |
| `presetOptions` | [`PresetOptions`](#presetoptions) | 否 | UI 自定义配置 |

### `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;
  };
}
```

| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `locale` | `LocaleKey` | `'en-US'` | 显示语言，支持：`'de-DE'`、`'en-US'`、`'es-ES'`、`'fr-FR'`、`'ja-JP'`、`'ko-KR'`、`'pt-PT'`、`'zh-CN'` |
| `theme` | `'light' \| 'dark'` | `'light'` | 颜色主题 |
| `primaryColor` | `string` | — | 主色调（任意 CSS 颜色值，如 `'#1677FF'`） |
| `radius.components` | `number` | `6` | 输入框和按钮的圆角半径（px） |
| `radius.card` | `number` | `6` | 卡片容器的圆角半径（px） |
| `currencySelect` | `object` | — | 货币选择器行为（[详情](#货币选择器行为)） |
| `section` | `object` | — | 控制 UI 状态的可见性（[详情](#区块可见性)） |

> **注意：** `currencySelect.hideIfOneCurrency` 与 `currencySelect.oneCurrencyStyle` **互斥** — 只能设置其中一个。

### `ClinkElements`

`loadClinkElements()` 返回的主实例。

#### `createElement(type)`

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

创建支付元素，返回可挂载到 DOM 的 [`ClinkElement`](#clinkelement) 实例。

- `'paymentMethod'` — 主支付表单（银行卡输入、钱包按钮等）
- `'currencySelect'` — 货币选择下拉框

**约束：**

- `'paymentMethod'` **必须**在 `'currencySelect'` 之前创建
- 每种类型在同一实例中只能创建一次

#### `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
```

订阅或取消订阅[事件](#事件)。在 TypeScript 中回调参数会自动推断类型。

#### `submit()`

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

触发支付提交。SDK 内部自动处理所有支付流程（3DS 认证、二维码支付、第三方跳转）。

监听 [`session-success`](#事件) 或 [`session-pending`](#事件) 获取支付结果。

#### `setLocale(locale)`

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

运行时切换显示语言，作用于所有已挂载的元素。

#### `setTheme(theme)`

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

运行时切换颜色主题，作用于所有已挂载的元素。

#### `promoCodeChange(data)`

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

应用或清除优惠码。完整流程参见[优惠码](#优惠码)章节。

#### `destroy()`

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

卸载所有元素、移除事件监听、清理资源。可安全多次调用（幂等）。页面卸载时务必调用此方法。

### `ClinkElement`

单个 UI 元素。

#### `mount(target)`

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

将元素挂载到 DOM。接受 CSS 选择器字符串（如 `'#payment-method'`）或 `HTMLElement` 引用。

若目标元素不存在或元素已挂载，将抛出异常。

#### `unmount()`

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

从 DOM 中移除元素并恢复容器的原始样式。未挂载时调用无副作用。

## 事件

使用 `clink.on(event, callback)` 监听事件：

| 事件 | 回调数据 | 说明 |
|---|---|---|
| `submit-enabled` | `boolean` | 支付按钮是否应启用 |
| `submit-visible` | `boolean` | 支付按钮是否应显示（部分钱包使用内置按钮） |
| `session-init-success` | `undefined` | 会话初始化完成，元素已就绪 |
| `session-success` | `undefined` | 支付成功 |
| `session-pending` | `undefined` | 支付待确认（异步支付方式） |
| `amount-change` | `{ amount: DueTodayAmountInfo }` | 订单金额或价格明细发生变化 |
| `promo-code-error` | `{ message: string }` | 优惠码验证失败 |
| `error` | `{ error: Error }` | 发生错误（参见[错误处理](#错误处理)） |

### 示例

```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('支付错误:', error);
});
```

### `DueTodayAmountInfo`

`amount-change` 事件提供详细的价格信息：

```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;
  /** 多语言产品名称，key 为 locale（如 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;
}
```

## 自定义配置

### 语言

通过 `presetOptions.locale` 设置初始语言，或运行时切换：

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

### 主题

通过 `presetOptions.theme` 设置初始主题，或运行时切换：

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

### 主色调

覆盖按钮和交互元素的主色调：

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

### 圆角半径

自定义 UI 组件和卡片容器的圆角半径：

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

### 货币选择器行为

控制仅有一种货币时的货币选择器行为：

```js
// 方式 A：直接隐藏
presetOptions: {
  currencySelect: { hideIfOneCurrency: true },
}

// 方式 B：自定义样式
presetOptions: {
  currencySelect: {
    oneCurrencyStyle: { opacity: 0.5, pointerEvents: 'none' },
  },
}
```

> 两个选项互斥，不可同时设置。

### 区块可见性

控制支付后各 UI 状态的显示：

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

| 属性 | 说明 |
|---|---|
| `hideExpire` | 隐藏会话过期状态 |
| `hideError` | 隐藏错误状态 |
| `hideSkeleton` | 隐藏加载骨架屏 |
| `hideSuccess` | 隐藏支付成功状态 |
| `hidePending` | 隐藏支付待确认状态 |

## 优惠码

在结账流程中使用优惠码的完整步骤：

**1. 检查是否启用优惠码**

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

**2. 应用优惠码**

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

**3. 处理错误**

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

**4. 读取折扣信息**

下一次 `amount-change` 事件的数据中将包含 `promotionCodeInfo`，携带折扣详情。

**5. 清除优惠码**

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

## 错误处理

### 初始化错误

使用 try-catch 包裹 `loadClinkElements()` 处理初始化失败：

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

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

### 运行时错误

监听 `error` 事件处理初始化之后发生的错误：

```js
clink.on('error', ({ error }) => {
  if (error instanceof SessionExpiredError) {
    showMessage('会话已过期，请刷新页面。');
  } else if (error instanceof SessionCompleteError) {
    showMessage('该笔支付已完成。');
  } else {
    showMessage('出了点问题，请重试。');
  }
});
```

### 异常类

| 类名 | 说明 |
|---|---|
| `ClinkApiError` | API 请求失败（publish key 无效、网络错误等） |
| `SessionExpiredError` | checkout 会话已过期 |
| `SessionCompleteError` | 该笔支付已完成 |
| `SessionLoadError` | 加载会话数据失败 |
| `SessionNotSupportedError` | 会话的 UI 模式与 Elements 不兼容 |
| `PromoCodeError` | 优惠码操作失败 |

所有异常类均继承自原生 `Error` 类。

## TypeScript 支持

SDK 附带完整的 TypeScript 类型声明，所有导出均有完整类型：

```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';
```

事件回调的参数类型会根据事件名自动推断：

```ts
clink.on('submit-enabled', (enabled) => {
  // `enabled` 被推断为 `boolean`
});

clink.on('amount-change', ({ amount }) => {
  // `amount` 被推断为 `DueTodayAmountInfo`
});
```

## 注意事项

- **仅限浏览器** — SDK 依赖 `window` 和 `document`，不兼容服务端渲染。请在客户端加载（如 `onMounted`、`useEffect` 或 `DOMContentLoaded` 中）。
- **元素创建顺序** — `'paymentMethod'` 必须在 `'currencySelect'` 之前创建，否则会抛出异常。
- **单次创建** — 每种元素类型在同一 `ClinkElements` 实例中只能创建一次。
- **资源清理** — 页面卸载时务必调用 `destroy()` 以避免内存泄漏。
- **挂载目标** — 调用 `mount()` 时目标 DOM 元素必须已存在。
- **沙箱测试** — 开发和测试阶段使用 `environment: 'sandbox'` 配合测试 publish key，上线时切换为 `'production'`。
