---
title: usePromoCode
description: 驗證優惠碼、取得折扣資訊的 React Hook
---

# usePromoCode

`usePromoCode` 讓你在前端即時驗證優惠碼，取得折扣資訊和適用產品。

  這是唯一可以**不需要 RecurProvider** 獨立使用的 Hook。傳入 `publishableKey` 即可。

## 基本用法

```tsx
import { usePromoCode } from 'recur-tw';

function PromoInput({ userId }) {
  // publishableKey 從 Provider context 取得
  const promo = usePromoCode({ customerId: userId });

  return (
    <div>
      <input
        placeholder="輸入優惠碼"
        onBlur={(e) => promo.apply(e.target.value)}
      />
      {promo.isLoading && <span>驗證中...</span>}
      {promo.isValid && <span className="text-green-600">{promo.discount?.label}</span>}
      {promo.error && <span className="text-red-500">{promo.error}</span>}
    </div>
  );
}
```

```tsx
import { usePromoCode } from 'recur-tw';

function PromoInput({ userId }) {
  // 直接傳入 publishableKey，不需要 RecurProvider
  const promo = usePromoCode({
    publishableKey: 'pk_test_xxx',
    customerId: userId,
  });

  return (
    <div>
      <input
        placeholder="輸入優惠碼"
        onBlur={(e) => promo.apply(e.target.value)}
      />
      {promo.isValid && <span className="text-green-600">{promo.discount?.label}</span>}
      {promo.error && <span className="text-red-500">{promo.error}</span>}
    </div>
  );
}
```

## Options

| 參數 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `customerId` | string | 條件必填 | 您系統中的客戶 ID（external ID） |
| `recurCustomerId` | string | 條件必填 | Recur 內部客戶 ID（二擇一） |
| `publishableKey` | string | 選填 | 不使用 RecurProvider 時必填 |
| `baseUrl` | string | 選填 | 自訂 API URL（預設 `https://api.recur.tw`） |

  `customerId` 或 `recurCustomerId` 至少需要一個。這是 API 的安全要求，用於防止匿名暴力列舉優惠碼。

## Return 值

| 屬性 | 類型 | 說明 |
|------|------|------|
| `apply` | `(code: string) => Promise<void>` | 驗證優惠碼 |
| `clear` | `() => void` | 清除狀態 |
| `status` | `PromoCodeStatus` | 當前狀態 |
| `isLoading` | boolean | 驗證進行中 |
| `isValid` | boolean | 優惠碼有效 |
| `code` | `string \| null` | 驗證通過的優惠碼，直接傳給 `subscribe()` |
| `discount` | `PromoCodeDiscount \| null` | 折扣資訊 |
| `appliesToAllProducts` | boolean | 是否適用所有產品 |
| `applicableProducts` | `ApplicableProduct[]` | 適用產品（含折後價） |
| `error` | `string \| null` | 錯誤訊息 |

### PromoCodeStatus

```typescript
type PromoCodeStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'already_used';
```

### PromoCodeDiscount

```typescript
interface PromoCodeDiscount {
  type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FIRST_PERIOD_PRICE';
  amount: number;
  discountedAmount: number;
  label: string;           // "20% off", "NT$100 off"
  bonusTrialDays?: number;
  bonusMonths?: number;
}
```

### ApplicableProduct

```typescript
interface ApplicableProduct {
  id: string;
  name: string;
  price: number;
  priceAfterDiscount: number;
  interval?: string;
  intervalCount?: number;
}
```

## 顯示折後價

當優惠碼限定特定產品時，`applicableProducts` 會包含每個產品的折後價：

```tsx
function PricingWithPromo({ userId }) {
  const promo = usePromoCode({ customerId: userId });

  return (
    <div>
      <input onBlur={(e) => promo.apply(e.target.value)} />

      {promo.isValid && !promo.appliesToAllProducts && (
        <div>
          <p>此優惠碼適用於以下產品：</p>
          {promo.applicableProducts.map((product) => (
            <div key={product.id}>
              <span>{product.name}</span>
              <span className="line-through">NT${product.price}</span>
              <span className="text-green-600 font-bold">
                NT${product.priceAfterDiscount}
              </span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

## 搭配 useSubscribe 結帳

`promo.code` 在優惠碼有效時是驗證過的字串，無效或未輸入時是 `null`，可以直接傳給 `subscribe()`：

```tsx
import { useSubscribe, usePromoCode } from 'recur-tw';

function CheckoutPage({ userId, userEmail }) {
  const promo = usePromoCode({ customerId: userId });
  const { subscribe, isLoading } = useSubscribe({
    onPaymentComplete: () => window.location.href = '/welcome',
  });

  return (
    <div>
      <input
        placeholder="優惠碼（選填）"
        onBlur={(e) => promo.apply(e.target.value)}
      />
      {promo.isValid && <p className="text-green-600">{promo.discount?.label}</p>}

      <button
        disabled={isLoading}
        onClick={() => subscribe({
          productId: 'prod_xxx',
          customerEmail: userEmail,
          promoCode: promo.code,  // null if no valid promo
        })}
      >
        {isLoading ? '處理中...' : '訂閱'}
      </button>
    </div>
  );
}
```

## PromoCodeInput 元件

不想自己建 UI？使用 `<PromoCodeInput />` 即可獲得完整的優惠碼輸入體驗：

```tsx
import { usePromoCode, PromoCodeInput } from 'recur-tw';

function Checkout({ userId }) {
  const promo = usePromoCode({ customerId: userId });

  return <PromoCodeInput promo={promo} />;
}
```

包含：input 欄位 + 套用/移除按鈕 + loading 狀態 + 成功/錯誤訊息。

### 自訂樣式

```tsx
// 開箱即用，有基本外觀
<PromoCodeInput promo={promo} />
```

```tsx
// 用 className 覆蓋樣式
<PromoCodeInput
  promo={promo}
  unstyled
  className="flex flex-col gap-1"
  inputClassName="border rounded-lg px-3 py-2 uppercase tracking-wider"
  buttonClassName="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700"
  messageClassName="text-sm mt-1"
/>
```

```tsx
// 用 inline style 微調
<PromoCodeInput
  promo={promo}
  inputStyle={{ border: '2px solid #6366f1', borderRadius: 8 }}
  buttonStyle={{ backgroundColor: '#6366f1', color: 'white' }}
  style={{ maxWidth: 400 }}
/>
```

```tsx
// 不用元件，只用 hook 自己建 UI
const promo = usePromoCode({ customerId: userId });

<input onBlur={(e) => promo.apply(e.target.value)} />
<button onClick={() => promo.clear()}>清除</button>
{promo.isValid && <span>{promo.discount?.label}</span>}
{promo.error && <span>{promo.error}</span>}
```

### PromoCodeInput Props

| Prop | 類型 | 預設 | 說明 |
|------|------|------|------|
| `promo` | `UsePromoCodeReturn` | **必填** | `usePromoCode()` 的回傳值 |
| `placeholder` | string | `"優惠碼"` | Input placeholder |
| `applyText` | string | `"套用"` | 套用按鈕文字 |
| `clearText` | string | `"移除"` | 移除按鈕文字 |
| `disabled` | boolean | `false` | 停用 |
| `unstyled` | boolean | `false` | 關閉預設樣式 |
| `className` | string | - | 容器 CSS class |
| `style` | CSSProperties | - | 容器 inline style |
| `inputClassName` | string | - | Input CSS class |
| `inputStyle` | CSSProperties | - | Input inline style |
| `buttonClassName` | string | - | 按鈕 CSS class |
| `buttonStyle` | CSSProperties | - | 按鈕 inline style |
| `messageClassName` | string | - | 訊息 CSS class |
| `messageStyle` | CSSProperties | - | 訊息 inline style |

## 連續輸入處理

`apply()` 內建自動取消機制 — 如果用戶快速連續輸入，只有最後一次請求會生效，不會產生 race condition。

## API 參考

底層使用 `POST /v1/promotion-codes/validate`，完整 API 文件請參考 [Promotion Codes API](/api-reference/promotion-codes)。
