# 前端整合 (/guides/portal/client-integration)





前端整合 [#前端整合]

Recur SDK 提供 `<recur-portal>` Web Component，讓您可以快速在前端加入 Customer Portal 按鈕。

安裝 SDK [#安裝-sdk]

<Tabs items="['npm', 'CDN']">
  <Tab value="npm">
    ```bash
    npm install recur-tw
    ```

    ```typescript
    // 在應用程式入口點引入
    import 'recur-tw';
    ```
  </Tab>

  <Tab value="CDN">
    ```html
    <script src="https://cdn.recur.tw/sdk/recur.umd.js"></script>
    ```
  </Tab>
</Tabs>

`<recur-portal>` 組件 [#recur-portal-組件]

使用方式一：直接提供 Portal URL [#使用方式一直接提供-portal-url]

適合 Server-Side Rendering (SSR) 的應用，在後端取得 Portal URL 後直接傳入：

```html
<recur-portal portal-url="https://portal.recur.tw/s/ps_xxxxx">
  管理訂閱
</recur-portal>
```

```tsx
// Next.js Server Component 範例
import { Recur } from 'recur-tw/server';

const recur = new Recur(process.env.RECUR_SECRET_KEY!);

export default async function AccountPage() {
  const session = await recur.portal.sessions.create({
    customer: 'cus_xxxxx',
    returnUrl: 'https://your-site.com/account',
  });

  return (
    <recur-portal portal-url={session.url}>
      管理訂閱
    </recur-portal>
  );
}
```

使用方式二：透過 API 動態建立 [#使用方式二透過-api-動態建立]

適合 Client-Side Rendering (CSR) 的應用，點擊時呼叫您的後端 API 建立 Session：

```html
<recur-portal
  api-endpoint="/api/portal/create"
  customer-id="cus_xxxxx">
  管理訂閱
</recur-portal>
```

<Callout type="info">
  使用 `api-endpoint` 模式時，組件會在點擊後 POST 到指定端點，期望回應 `{ url: "..." }` 格式。
</Callout>

屬性說明 [#屬性說明]

| 屬性             | 必填  | 說明                                         |
| -------------- | --- | ------------------------------------------ |
| `portal-url`   | ❌\* | 直接提供 Portal URL                            |
| `api-endpoint` | ❌\* | 後端 API 端點（用於動態建立 Session）                  |
| `customer-id`  | ❌   | 客戶 ID（傳送給 API）                             |
| `return-url`   | ❌   | 返回 URL（傳送給 API）                            |
| `button-text`  | ❌   | 按鈕文字（預設：插槽內容或「管理訂閱」）                       |
| `button-style` | ❌   | 按鈕樣式：`primary`、`outline`、`gradient`、`link` |
| `target`       | ❌   | 設為 `_blank` 在新視窗開啟                         |
| `disabled`     | ❌   | 禁用按鈕                                       |

<Callout type="warning">
  `portal-url` 和 `api-endpoint` 至少需要提供一個。
</Callout>

按鈕樣式 [#按鈕樣式]

```html
<!-- Primary（預設） -->
<recur-portal portal-url="..." button-style="primary">
  管理訂閱
</recur-portal>

<!-- Outline -->
<recur-portal portal-url="..." button-style="outline">
  管理訂閱
</recur-portal>

<!-- Gradient -->
<recur-portal portal-url="..." button-style="gradient">
  管理訂閱
</recur-portal>

<!-- Link -->
<recur-portal portal-url="..." button-style="link">
  管理訂閱
</recur-portal>
```

事件處理 [#事件處理]

`portal-redirect` [#portal-redirect]

在導向 Portal 前觸發：

```javascript
document.querySelector('recur-portal').addEventListener('portal-redirect', (event) => {
  console.log('Redirecting to:', event.detail.url);
  // 可用於追蹤分析
  analytics.track('portal_opened');
});
```

`portal-error` [#portal-error]

發生錯誤時觸發：

```javascript
document.querySelector('recur-portal').addEventListener('portal-error', (event) => {
  console.error('Portal error:', event.detail.message);
  // 顯示錯誤訊息給用戶
  alert('無法開啟訂閱管理頁面，請稍後再試');
});
```

React 整合 [#react-整合]

使用 Web Component [#使用-web-component]

```tsx
// components/PortalButton.tsx
'use client';

import { useEffect, useRef } from 'react';
import 'recur-tw';

interface PortalButtonProps {
  portalUrl?: string;
  apiEndpoint?: string;
  customerId?: string;
}

export function PortalButton({ portalUrl, apiEndpoint, customerId }: PortalButtonProps) {
  const ref = useRef<HTMLElement>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleError = (e: Event) => {
      const { message } = (e as CustomEvent).detail;
      console.error('Portal error:', message);
    };

    element.addEventListener('portal-error', handleError);
    return () => element.removeEventListener('portal-error', handleError);
  }, []);

  return (
    <recur-portal
      ref={ref}
      portal-url={portalUrl}
      api-endpoint={apiEndpoint}
      customer-id={customerId}
    >
      管理訂閱
    </recur-portal>
  );
}
```

使用自訂按鈕 [#使用自訂按鈕]

如果您想使用自己的 UI 元件，可以直接呼叫 SDK 方法：

```tsx
'use client';

import { useState } from 'react';
import { RecurCheckout } from 'recur-tw/vanilla';

export function CustomPortalButton({ customerId }: { customerId: string }) {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);

    try {
      const recur = RecurCheckout.init({ publishableKey: 'pk_xxx' });

      const session = await recur.createPortalSession('/api/portal/create', {
        customerId,
      });

      window.location.href = session.url;
    } catch (error) {
      console.error('Failed to open portal:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className="btn btn-primary"
    >
      {loading ? '載入中...' : '管理訂閱'}
    </button>
  );
}
```

Vue 整合 [#vue-整合]

```vue
<template>
  <recur-portal
    :portal-url="portalUrl"
    @portal-redirect="onRedirect"
    @portal-error="onError"
  >
    管理訂閱
  </recur-portal>
</template>

<script setup lang="ts">
import 'recur-tw';

defineProps<{
  portalUrl: string;
}>();

const onRedirect = (event: CustomEvent) => {
  console.log('Redirecting to:', event.detail.url);
};

const onError = (event: CustomEvent) => {
  console.error('Portal error:', event.detail.message);
};
</script>
```

後端 API 範例 [#後端-api-範例]

當使用 `api-endpoint` 模式時，您需要建立對應的後端 API：

```typescript
// app/api/portal/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Recur } from 'recur-tw/server';
import { auth } from '@/lib/auth';

const recur = new Recur(process.env.RECUR_SECRET_KEY!);

export async function POST(request: NextRequest) {
  // 驗證用戶
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: { message: 'Unauthorized' } }, { status: 401 });
  }

  // 從請求中取得參數（可選）
  const body = await request.json().catch(() => ({}));

  // 建立 Portal Session
  const portalSession = await recur.portal.sessions.create({
    customer: session.user.recurCustomerId,
    returnUrl: body.returnUrl || `${process.env.NEXT_PUBLIC_URL}/account`,
  });

  // 返回 URL（組件期望的格式）
  return NextResponse.json({
    url: portalSession.url,
  });
}
```

TypeScript 支援 [#typescript-支援]

```typescript
// 宣告 Web Component 類型
declare global {
  namespace JSX {
    interface IntrinsicElements {
      'recur-portal': React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement> & {
          'portal-url'?: string;
          'api-endpoint'?: string;
          'customer-id'?: string;
          'return-url'?: string;
          'button-text'?: string;
          'button-style'?: 'primary' | 'outline' | 'gradient' | 'link';
          'target'?: string;
          'disabled'?: boolean;
        },
        HTMLElement
      >;
    }
  }
}
```

下一步 [#下一步]

* [後端整合](/guides/portal/server-integration) - Server SDK 詳細說明
* [Portal API 參考](/api/portal) - 完整 API 文件
* [Webhook 整合](/guides/webhooks) - 接收訂閱變更通知
