recur-tw 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,6 +211,7 @@ function ProductsPage() {
211
211
 
212
212
  - `useProducts(options?)` - 取得商品列表
213
213
  - `useRecur()` - 取得 checkout 函式與狀態
214
+ - `useCustomer()` - 檢查客戶權限與訂閱狀態
214
215
 
215
216
  ```tsx
216
217
  // 篩選商品類型
@@ -218,6 +219,57 @@ const { data: subscriptions } = useProducts({ type: 'SUBSCRIPTION' });
218
219
  const { data: oneTimeProducts } = useProducts({ type: 'ONE_TIME' });
219
220
  ```
220
221
 
222
+ #### 權限檢查(useCustomer)
223
+
224
+ 檢查客戶是否有特定產品的訂閱權限,無需自建資料庫或處理 Webhook:
225
+
226
+ ```tsx
227
+ import { RecurProvider, useCustomer } from 'recur-tw';
228
+
229
+ // 1. Provider 需傳入 customer 識別資訊
230
+ function App() {
231
+ const user = useAuth(); // 你的認證系統
232
+ return (
233
+ <RecurProvider
234
+ config={{ publishableKey: 'pk_live_xxx' }}
235
+ customer={{ email: user?.email }}
236
+ >
237
+ <PremiumFeature />
238
+ </RecurProvider>
239
+ );
240
+ }
241
+
242
+ // 2. 使用 check() 檢查權限
243
+ function PremiumFeature() {
244
+ const { check, isLoading } = useCustomer();
245
+
246
+ if (isLoading) return <Spinner />;
247
+
248
+ const { allowed } = check({ product: 'pro-plan' });
249
+
250
+ if (!allowed) {
251
+ return <UpgradePrompt />;
252
+ }
253
+
254
+ return <PremiumContent />;
255
+ }
256
+
257
+ // 3. 顯示訂閱狀態
258
+ function AccountPage() {
259
+ const { customer, subscription, entitlements } = useCustomer();
260
+
261
+ return (
262
+ <div>
263
+ <p>方案:{subscription?.product.name}</p>
264
+ <p>到期日:{subscription?.currentPeriodEnd}</p>
265
+ <p>擁有權限:{entitlements.map(e => e.product).join(', ')}</p>
266
+ </div>
267
+ );
268
+ }
269
+ ```
270
+
271
+ > **安全提醒:** `check()` 從本地快取讀取,可被繞過。僅用於 UI 控制,敏感操作請在後端驗證。
272
+
221
273
  ---
222
274
 
223
275
  ### 方式五:`<recur-checkout>` Web Component
@@ -244,15 +296,13 @@ const { data: oneTimeProducts } = useProducts({ type: 'ONE_TIME' });
244
296
  後端驗證與管理:
245
297
 
246
298
  ```typescript
247
- import { RecurServer } from 'recur-tw/server';
299
+ import { Recur } from 'recur-tw/server';
248
300
 
249
- const recur = new RecurServer({
250
- secretKey: 'sk_live_xxx'
251
- });
301
+ const recur = new Recur(process.env.RECUR_SECRET_KEY!);
252
302
 
253
303
  // 建立 Portal Session(讓客戶管理訂閱)
254
- const session = await recur.portalSessions.create({
255
- customerId: 'cus_xxx',
304
+ const session = await recur.portal.sessions.create({
305
+ customer: 'cus_xxx',
256
306
  returnUrl: 'https://yoursite.com/account'
257
307
  });
258
308
 
@@ -260,6 +310,60 @@ const session = await recur.portalSessions.create({
260
310
  res.redirect(session.url);
261
311
  ```
262
312
 
313
+ ### 權限檢查(Server-side)
314
+
315
+ 在 API 路由中驗證客戶權限,確保安全:
316
+
317
+ ```typescript
318
+ import { Recur } from 'recur-tw/server';
319
+
320
+ const recur = new Recur(process.env.RECUR_SECRET_KEY!);
321
+
322
+ // API 路由範例
323
+ export async function GET(request: Request) {
324
+ const user = await getUser(request);
325
+
326
+ // 檢查單一產品權限
327
+ const { allowed } = await recur.entitlements.check({
328
+ product: 'pro-plan',
329
+ customer: { email: user.email },
330
+ });
331
+
332
+ if (!allowed) {
333
+ return Response.json(
334
+ { error: '請升級到 Pro 方案' },
335
+ { status: 403 }
336
+ );
337
+ }
338
+
339
+ return Response.json(protectedData);
340
+ }
341
+
342
+ // 列出所有權限
343
+ const { entitlements } = await recur.entitlements.list({
344
+ customer: { email: 'user@example.com' },
345
+ });
346
+
347
+ console.log(entitlements);
348
+ // [
349
+ // { product: 'pro-plan', status: 'active', subscriptionId: 'sub_xxx' },
350
+ // { product: 'addon-ai', status: 'active', subscriptionId: 'sub_yyy' },
351
+ // ]
352
+ ```
353
+
354
+ **客戶識別方式(擇一):**
355
+
356
+ ```typescript
357
+ // 方式一:Email(推薦)
358
+ customer: { email: 'user@example.com' }
359
+
360
+ // 方式二:External ID(你的系統 User ID)
361
+ customer: { externalId: 'usr_12345' }
362
+
363
+ // 方式三:Recur Customer ID
364
+ customer: { id: 'cus_xxx' }
365
+ ```
366
+
263
367
  ---
264
368
 
265
369
  ## API Key
package/dist/index.cjs CHANGED
@@ -247,7 +247,7 @@ var init_error_display = __esm({
247
247
  "src/components/base/error-display.ts"() {
248
248
  RecurErrorDisplay = class extends HTMLElement {
249
249
  static get observedAttributes() {
250
- return ["error", "dismissible"];
250
+ return ["error", "error-title", "dismissible"];
251
251
  }
252
252
  constructor() {
253
253
  super();
@@ -264,6 +264,9 @@ var init_error_display = __esm({
264
264
  get error() {
265
265
  return this.getAttribute("error") || "";
266
266
  }
267
+ get errorTitle() {
268
+ return this.getAttribute("error-title") || "";
269
+ }
267
270
  get isDismissible() {
268
271
  return this.getAttribute("dismissible") === "true";
269
272
  }
@@ -308,6 +311,14 @@ var init_error_display = __esm({
308
311
  flex: 1;
309
312
  }
310
313
 
314
+ .recur-sdk__error-title {
315
+ margin: 0 0 4px 0;
316
+ font-size: 14px;
317
+ font-weight: 600;
318
+ color: var(--recur-error-text, #991b1b);
319
+ line-height: 1.4;
320
+ }
321
+
311
322
  .recur-sdk__error-message {
312
323
  margin: 0;
313
324
  font-size: 14px;
@@ -359,6 +370,7 @@ var init_error_display = __esm({
359
370
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
360
371
  </svg>
361
372
  <div class="recur-sdk__error-content">
373
+ ${this.errorTitle ? `<p class="recur-sdk__error-title">${this.errorTitle}</p>` : ""}
362
374
  <p class="recur-sdk__error-message">${this.error}</p>
363
375
  </div>
364
376
  ${this.isDismissible ? `
@@ -1968,7 +1980,13 @@ var init_payment_form = __esm({
1968
1980
  this.setButtonLoading(false);
1969
1981
  }
1970
1982
  }
1971
- showError(message) {
1983
+ /**
1984
+ * Show error message in the form
1985
+ * @param options - Either a string message or an object with title and message
1986
+ */
1987
+ showError(options) {
1988
+ const message = typeof options === "string" ? options : options.message;
1989
+ const title = typeof options === "object" ? options.title : void 0;
1972
1990
  let errorContainer = this.querySelector(".recur-sdk__error-container");
1973
1991
  if (!errorContainer) {
1974
1992
  errorContainer = document.createElement("div");
@@ -1978,6 +1996,9 @@ var init_payment_form = __esm({
1978
1996
  }
1979
1997
  const errorDisplay = document.createElement("recur-error-display");
1980
1998
  errorDisplay.setAttribute("error", message);
1999
+ if (title) {
2000
+ errorDisplay.setAttribute("error-title", title);
2001
+ }
1981
2002
  errorDisplay.setAttribute("dismissible", "true");
1982
2003
  errorContainer.innerHTML = "";
1983
2004
  errorContainer.appendChild(errorDisplay);
@@ -2653,16 +2674,27 @@ function toCamelCase(obj) {
2653
2674
 
2654
2675
  // package.json
2655
2676
  var package_default = {
2656
- version: "0.10.6"};
2677
+ version: "0.11.0"};
2657
2678
  var SDK_VERSION = package_default.version;
2658
2679
  var SDK_TYPE = "react";
2659
2680
  var RecurContext = React.createContext(null);
2660
- function RecurProvider({ children, config: initialConfig = {} }) {
2681
+ var CustomerContext = React.createContext(null);
2682
+ function RecurProvider({ children, config: initialConfig = {}, customer: customerIdentifier }) {
2661
2683
  const [config, setConfig] = React.useState({
2662
2684
  checkoutMode: "embedded",
2663
2685
  ...initialConfig
2664
2686
  });
2665
2687
  const [isCheckingOut, setIsCheckingOut] = React.useState(false);
2688
+ const [customerData, setCustomerData] = React.useState({
2689
+ customer: null,
2690
+ subscription: null,
2691
+ entitlements: []
2692
+ });
2693
+ const [customerIsLoading, setCustomerIsLoading] = React.useState(false);
2694
+ const [customerError, setCustomerError] = React.useState(null);
2695
+ const lastSuccessfulCache = React__default.default.useRef(null);
2696
+ const isRefetching = React__default.default.useRef(false);
2697
+ const refetchDebounceTimer = React__default.default.useRef(null);
2666
2698
  React__default.default.useEffect(() => {
2667
2699
  setConfig({
2668
2700
  checkoutMode: "embedded",
@@ -2672,6 +2704,89 @@ function RecurProvider({ children, config: initialConfig = {} }) {
2672
2704
  React__default.default.useEffect(() => {
2673
2705
  setIsCheckingOut(false);
2674
2706
  }, [config.checkoutMode]);
2707
+ const fetchEntitlements = React.useCallback(async () => {
2708
+ if (!config.publishableKey) {
2709
+ console.warn("[Recur SDK] Cannot fetch entitlements: publishableKey is required");
2710
+ return;
2711
+ }
2712
+ if (!customerIdentifier?.email && !customerIdentifier?.externalId && !customerIdentifier?.id) {
2713
+ setCustomerData({ customer: null, subscription: null, entitlements: [] });
2714
+ return;
2715
+ }
2716
+ setCustomerIsLoading(true);
2717
+ setCustomerError(null);
2718
+ try {
2719
+ const baseUrl = config.baseUrl || "https://api.recur.tw";
2720
+ const params = new URLSearchParams();
2721
+ if (customerIdentifier.email) params.set("email", customerIdentifier.email);
2722
+ if (customerIdentifier.externalId) params.set("external_id", customerIdentifier.externalId);
2723
+ if (customerIdentifier.id) params.set("customer_id", customerIdentifier.id);
2724
+ const response = await fetch(`${baseUrl}/v1/customers/entitlements?${params.toString()}`, {
2725
+ method: "GET",
2726
+ headers: {
2727
+ "X-Recur-Publishable-Key": config.publishableKey,
2728
+ "X-Recur-SDK-Type": SDK_TYPE,
2729
+ "X-Recur-SDK-Version": SDK_VERSION
2730
+ }
2731
+ });
2732
+ if (!response.ok) {
2733
+ const errorData = await response.json().catch(() => ({}));
2734
+ throw new Error(errorData.error?.message || "Failed to fetch entitlements");
2735
+ }
2736
+ const rawResult = await response.json();
2737
+ const result = toCamelCase(rawResult);
2738
+ const newData = {
2739
+ customer: result.customer || null,
2740
+ subscription: result.subscription || null,
2741
+ entitlements: result.entitlements || []
2742
+ };
2743
+ setCustomerData(newData);
2744
+ lastSuccessfulCache.current = newData;
2745
+ } catch (error) {
2746
+ console.error("[Recur SDK] Failed to fetch entitlements:", error);
2747
+ setCustomerError(error instanceof Error ? error : new Error("Failed to fetch entitlements"));
2748
+ if (lastSuccessfulCache.current) {
2749
+ setCustomerData(lastSuccessfulCache.current);
2750
+ }
2751
+ } finally {
2752
+ setCustomerIsLoading(false);
2753
+ }
2754
+ }, [config.publishableKey, config.baseUrl, customerIdentifier?.email, customerIdentifier?.externalId, customerIdentifier?.id]);
2755
+ React__default.default.useEffect(() => {
2756
+ fetchEntitlements();
2757
+ }, [fetchEntitlements]);
2758
+ const refetch = React.useCallback(async () => {
2759
+ if (isRefetching.current) {
2760
+ console.log("[Recur SDK] Refetch skipped - already in progress");
2761
+ return;
2762
+ }
2763
+ if (refetchDebounceTimer.current) {
2764
+ clearTimeout(refetchDebounceTimer.current);
2765
+ }
2766
+ isRefetching.current = true;
2767
+ try {
2768
+ await fetchEntitlements();
2769
+ } finally {
2770
+ refetchDebounceTimer.current = setTimeout(() => {
2771
+ isRefetching.current = false;
2772
+ }, 300);
2773
+ }
2774
+ }, [fetchEntitlements]);
2775
+ React__default.default.useEffect(() => {
2776
+ return () => {
2777
+ if (refetchDebounceTimer.current) {
2778
+ clearTimeout(refetchDebounceTimer.current);
2779
+ }
2780
+ };
2781
+ }, []);
2782
+ const customerContextValue = React.useMemo(() => ({
2783
+ customer: customerData.customer,
2784
+ subscription: customerData.subscription,
2785
+ entitlements: customerData.entitlements,
2786
+ isLoading: customerIsLoading,
2787
+ error: customerError,
2788
+ refetch
2789
+ }), [customerData, customerIsLoading, customerError, refetch]);
2675
2790
  const updateConfig = React.useCallback((newConfig) => {
2676
2791
  setConfig((prev) => ({ ...prev, ...newConfig }));
2677
2792
  }, []);
@@ -3051,6 +3166,46 @@ function RecurProvider({ children, config: initialConfig = {} }) {
3051
3166
  const rawPaymentResult = await paymentResponse.json();
3052
3167
  const paymentResult = toCamelCase(rawPaymentResult);
3053
3168
  console.log("[Recur SDK] Payment executed:", paymentResult);
3169
+ if (paymentResult.success === false && paymentResult.failure) {
3170
+ console.log("[Recur SDK] Payment failed:", paymentResult.failure);
3171
+ const failureError = {
3172
+ code: "PAYMENT_FAILED",
3173
+ message: paymentResult.failure.message || "\u4ED8\u6B3E\u5931\u6557",
3174
+ details: {
3175
+ failure_code: paymentResult.failure.code,
3176
+ failure_message: paymentResult.failure.message,
3177
+ can_retry: paymentResult.failure.canRetry
3178
+ }
3179
+ };
3180
+ let action;
3181
+ if (options.onPaymentFailed) {
3182
+ action = options.onPaymentFailed(failureError);
3183
+ }
3184
+ if (!action) {
3185
+ action = paymentResult.failure.canRetry ? { action: "retry" } : { action: "retry" };
3186
+ }
3187
+ if (action.action === "close") {
3188
+ options.onError?.(failureError);
3189
+ paymentForm.resetButton?.();
3190
+ closeDialog();
3191
+ setIsCheckingOut(false);
3192
+ return;
3193
+ } else if (action.action === "custom") {
3194
+ paymentForm.showError?.({
3195
+ title: action.customTitle || "\u4ED8\u6B3E\u5931\u6557",
3196
+ message: action.customMessage || failureError.message
3197
+ });
3198
+ paymentForm.resetButton?.();
3199
+ return;
3200
+ } else {
3201
+ paymentForm.showError?.({
3202
+ title: "\u4ED8\u6B3E\u5931\u6557",
3203
+ message: failureError.details?.failure_message || failureError.message
3204
+ });
3205
+ paymentForm.resetButton?.();
3206
+ return;
3207
+ }
3208
+ }
3054
3209
  if (paymentResult.requires3D && paymentResult.redirectUrl) {
3055
3210
  console.log("[Recur SDK] 3D verification required");
3056
3211
  const isMobileOrWebView = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || // Detect common WebView user agents
@@ -3359,7 +3514,7 @@ function RecurProvider({ children, config: initialConfig = {} }) {
3359
3514
  }),
3360
3515
  [config, checkout, fetchProducts, fetchPlans, isCheckingOut, updateConfig, getCheckoutStatus]
3361
3516
  );
3362
- return /* @__PURE__ */ jsxRuntime.jsx(RecurContext.Provider, { value, children });
3517
+ return /* @__PURE__ */ jsxRuntime.jsx(RecurContext.Provider, { value, children: /* @__PURE__ */ jsxRuntime.jsx(CustomerContext.Provider, { value: customerContextValue, children }) });
3363
3518
  }
3364
3519
  function useRecur() {
3365
3520
  const context = React.useContext(RecurContext);
@@ -3401,7 +3556,7 @@ function useProducts(options = {}) {
3401
3556
  };
3402
3557
  }
3403
3558
  function useSubscribe(options = {}) {
3404
- const { onSuccess, onError, onPaymentComplete, onPaymentCancel } = options;
3559
+ const { onSuccess, onError, onPaymentComplete, onPaymentCancel, onPaymentFailed } = options;
3405
3560
  const { checkout, isCheckingOut } = useRecur();
3406
3561
  const [error, setError] = React.useState(null);
3407
3562
  const mutate = React.useCallback(
@@ -3420,6 +3575,9 @@ function useSubscribe(options = {}) {
3420
3575
  setError(err);
3421
3576
  onError?.(err);
3422
3577
  },
3578
+ onPaymentFailed: (err) => {
3579
+ return onPaymentFailed?.(err);
3580
+ },
3423
3581
  onPaymentCancel: () => {
3424
3582
  onPaymentCancel?.();
3425
3583
  }
@@ -3433,7 +3591,7 @@ function useSubscribe(options = {}) {
3433
3591
  onError?.(checkoutError);
3434
3592
  }
3435
3593
  },
3436
- [checkout, onSuccess, onError, onPaymentComplete, onPaymentCancel]
3594
+ [checkout, onSuccess, onError, onPaymentComplete, onPaymentCancel, onPaymentFailed]
3437
3595
  );
3438
3596
  const reset = React.useCallback(() => {
3439
3597
  setError(null);
@@ -3446,8 +3604,47 @@ function useSubscribe(options = {}) {
3446
3604
  reset
3447
3605
  };
3448
3606
  }
3607
+ function useCustomer() {
3608
+ const context = React.useContext(CustomerContext);
3609
+ if (!context) {
3610
+ throw new Error("useCustomer must be used within a RecurProvider");
3611
+ }
3612
+ const { customer, subscription, entitlements, isLoading: isLoading2, error, refetch } = context;
3613
+ const check = React.useCallback(
3614
+ (options) => {
3615
+ if (!customer) {
3616
+ return { allowed: false, reason: "no_customer" };
3617
+ }
3618
+ if (entitlements.length === 0) {
3619
+ return { allowed: false, reason: "no_subscription" };
3620
+ }
3621
+ const entitlement = entitlements.find(
3622
+ (e) => e.product === options.product || e.productId === options.product
3623
+ );
3624
+ if (!entitlement) {
3625
+ return { allowed: false, reason: "wrong_product" };
3626
+ }
3627
+ let matchingSubscription;
3628
+ if (subscription && (subscription.product.slug === options.product || subscription.product.id === options.product)) {
3629
+ matchingSubscription = subscription;
3630
+ }
3631
+ return { allowed: true, subscription: matchingSubscription };
3632
+ },
3633
+ [customer, entitlements, subscription]
3634
+ );
3635
+ return {
3636
+ customer,
3637
+ subscription,
3638
+ entitlements,
3639
+ check,
3640
+ refetch,
3641
+ isLoading: isLoading2,
3642
+ error
3643
+ };
3644
+ }
3449
3645
 
3450
3646
  exports.RecurProvider = RecurProvider;
3647
+ exports.useCustomer = useCustomer;
3451
3648
  exports.usePlans = useProducts;
3452
3649
  exports.useProducts = useProducts;
3453
3650
  exports.useRecur = useRecur;