# usePromoCode (/guides/react-hooks/use-promo-code)





usePromoCode [#usepromocode]

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

<Callout type="info">
  這是唯一可以**不需要 RecurProvider** 獨立使用的 Hook。傳入 `publishableKey` 即可。
</Callout>

基本用法 [#基本用法]

<Tabs items="['有 RecurProvider', '獨立使用']">
  <Tab value="有 RecurProvider">
    ```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>
      );
    }
    ```
  </Tab>

  <Tab value="獨立使用">
    ```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>
      );
    }
    ```
  </Tab>
</Tabs>

Options [#options]

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

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

Return 值 [#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 [#promocodestatus]

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

PromoCodeDiscount [#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 [#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 結帳 [#搭配-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 元件 [#promocodeinput-元件]

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

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

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

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

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

自訂樣式 [#自訂樣式]

<Tabs items="['預設樣式', 'className（Tailwind）', 'style props', '完全自訂（unstyled）']">
  <Tab value="預設樣式">
    ```tsx
    // 開箱即用，有基本外觀
    <PromoCodeInput promo={promo} />
    ```
  </Tab>

  <Tab value="className（Tailwind）">
    ```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"
    />
    ```
  </Tab>

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

  <Tab value="完全自訂（unstyled）">
    ```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>}
    ```
  </Tab>
</Tabs>

PromoCodeInput Props [#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 參考 [#api-參考]

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