recur-tw 0.10.8 → 0.12.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
@@ -1048,7 +1048,7 @@ var init_payment_form_shadow = __esm({
1048
1048
  var payment_form_light_default;
1049
1049
  var init_payment_form_light = __esm({
1050
1050
  "src/components/styles/payment-form-light.css"() {
1051
- payment_form_light_default = "/**\n * Payment Form - Light DOM Styles\n * These styles are applied to slotted content in the Light DOM\n * Using BEM naming convention with 'recur-' prefix for isolation\n */\n\n/* ============================================\n * Order Summary Styles\n * ============================================ */\n.recur-order-summary {\n background: #f7fafc;\n border: 1px solid #e2e8f0;\n border-radius: 8px;\n padding: 16px;\n}\n\n.recur-order-summary__row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 8px;\n}\n\n.recur-order-summary__row--total {\n margin-bottom: 0;\n padding-top: 12px;\n border-top: 1px solid #e2e8f0;\n}\n\n.recur-order-summary__label {\n font-size: 14px;\n color: #4a5568;\n}\n\n.recur-order-summary__value {\n font-size: 14px;\n line-height: 20px;\n font-weight: 500;\n color: #2d3748;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n.recur-order-summary__total {\n font-size: 18px;\n line-height: 26px;\n font-weight: 700;\n color: #1a202c;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ============================================\n * Coupon Section Styles\n * ============================================ */\n.recur-coupon {\n padding: 12px 0;\n border-top: 1px solid #e2e8f0;\n margin-top: 12px;\n min-height: 36px;\n display: flex;\n align-items: center;\n}\n\n.recur-coupon__trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n height: 36px;\n background: none;\n border: none;\n padding: 0;\n font-size: 14px;\n line-height: 1;\n color: #718096;\n cursor: pointer;\n transition: color 0.2s;\n}\n\n.recur-coupon__trigger:hover {\n color: #4a5568;\n}\n\n.recur-coupon__trigger-icon {\n width: 16px;\n height: 16px;\n}\n\n.recur-coupon__input-row {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n}\n\n.recur-coupon__input-wrapper {\n position: relative;\n flex: 1;\n min-width: 0;\n}\n\n.recur-coupon__input {\n width: 100%;\n height: 36px;\n padding: 0 32px 0 12px;\n font-size: 14px;\n text-transform: uppercase;\n background-color: #ffffff;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n outline: none;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n.recur-coupon__input::placeholder {\n text-transform: none;\n}\n\n.recur-coupon__input:hover:not(:focus) {\n border-color: #9ca3af;\n}\n\n.recur-coupon__input:focus {\n border-color: var(--ring, hsl(215 16% 47%));\n outline: 0;\n box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring, hsl(215 16% 47%)) 50%, transparent);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n.recur-coupon__clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n padding: 4px;\n cursor: pointer;\n color: #9ca3af;\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: color 0.2s;\n}\n\n.recur-coupon__clear:hover {\n color: #6b7280;\n}\n\n.recur-coupon__clear-icon {\n width: 16px;\n height: 16px;\n}\n\n.recur-coupon__btn {\n height: 36px;\n padding: 0 12px;\n font-size: 14px;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: background-color 0.2s, opacity 0.2s;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.recur-coupon__btn--apply {\n background: #18181b;\n color: white;\n border: none;\n min-width: 52px;\n}\n\n.recur-coupon__btn--apply .recur-coupon__spinner {\n width: 14px;\n height: 14px;\n}\n\n.recur-coupon__btn--apply:hover:not(:disabled) {\n background: #27272a;\n}\n\n.recur-coupon__btn--apply:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.recur-coupon__error {\n width: 100%;\n margin-top: 8px;\n font-size: 13px;\n color: #dc2626;\n}\n\n.recur-coupon__applied-wrapper {\n width: 100%;\n}\n\n.recur-coupon__description {\n font-size: 12px;\n color: #6b7280;\n margin: 4px 0 0 22px;\n}\n\n.recur-coupon__bonus {\n display: flex;\n align-items: center;\n gap: 6px;\n background: #dcfce7;\n border-radius: 6px;\n padding: 8px 12px;\n margin-top: 8px;\n font-size: 12px;\n color: #15803d;\n}\n\n.recur-coupon__applied {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n}\n\n.recur-coupon__applied-info {\n display: flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n flex: 1;\n}\n\n.recur-coupon__applied-icon {\n width: 16px;\n height: 16px;\n color: #dc2626;\n flex-shrink: 0;\n}\n\n.recur-coupon__applied-name {\n font-size: 14px;\n font-weight: 500;\n color: #dc2626;\n white-space: nowrap;\n}\n\n.recur-coupon__applied-code {\n font-size: 14px;\n color: #9ca3af;\n white-space: nowrap;\n margin-right: 2px;\n}\n\n.recur-coupon__applied-amount {\n font-size: 14px;\n font-weight: 500;\n color: #dc2626;\n white-space: nowrap;\n font-variant-numeric: tabular-nums;\n flex-shrink: 0;\n margin-left: auto;\n}\n\n.recur-coupon__remove {\n background: none;\n border: none;\n padding: 4px;\n cursor: pointer;\n color: #9ca3af;\n border-radius: 4px;\n transition: color 0.2s, background-color 0.2s;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.recur-coupon__remove:hover {\n color: #6b7280;\n background: rgba(0, 0, 0, 0.05);\n}\n\n.recur-coupon__remove-icon {\n width: 14px;\n height: 14px;\n}\n\n.recur-coupon__spinner {\n width: 14px;\n height: 14px;\n border: 2px solid currentColor;\n border-top-color: transparent;\n border-radius: 50%;\n animation: recur-spin 0.6s linear infinite;\n}\n\n/* Discount Row */\n.recur-order-summary__row--discount {\n color: #16a34a;\n}\n\n.recur-order-summary__row--discount .recur-order-summary__value {\n color: #16a34a;\n font-weight: 500;\n}\n\n/* ============================================\n * Customer Info Styles\n * ============================================ */\n.recur-info-display {\n background: #f7fafc;\n border: 1px solid #e2e8f0;\n border-radius: 6px;\n padding: 12px;\n margin-bottom: 8px;\n}\n\n.recur-info-row {\n margin-bottom: 12px;\n}\n\n.recur-info-row:last-child {\n margin-bottom: 0;\n}\n\n.recur-info-label {\n display: block;\n font-size: 12px;\n line-height: 16px;\n color: #718096;\n margin-bottom: 2px;\n}\n\n.recur-info-value {\n display: block;\n font-size: 14px;\n line-height: 20px;\n min-height: 20px;\n color: #2d3748;\n font-weight: 500;\n word-break: break-all;\n}\n\n.recur-form-group {\n margin-bottom: 16px;\n}\n\n.recur-form-label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #2d3748;\n margin-bottom: 6px;\n}\n\n.recur-form-input {\n width: 100%;\n height: 36px;\n padding: 0 12px;\n font-size: 14px;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n transition: border-color 0.2s;\n box-sizing: border-box;\n}\n\n.recur-form-input:focus {\n border-color: var(--ring, hsl(215 16% 47%));\n outline: 0;\n box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring, hsl(215 16% 47%)) 50%, transparent);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n.recur-form-input:hover:not(:focus) {\n border-color: #9ca3af;\n}\n\n/* ============================================\n * Card Fields Styles\n * ============================================ */\n.recur-card-field {\n margin-bottom: 16px;\n}\n\n.recur-card-field-label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #2d3748;\n margin-bottom: 6px;\n}\n\n.recur-payuni-iframe {\n height: 36px;\n width: 100%;\n max-width: 100%;\n box-sizing: border-box;\n touch-action: manipulation;\n}\n\n.recur-card-row {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n column-gap: 16px;\n max-width: 100%;\n box-sizing: border-box;\n}\n\n.recur-card-field-container {\n position: relative;\n touch-action: manipulation;\n}\n\n.recur-card-skeleton {\n position: absolute;\n top: 0;\n left: 0;\n height: 36px;\n width: 100%;\n background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 50%, #f0f0f0 100%);\n background-size: 200% 100%;\n animation: recur-skeleton-loading 1.5s ease-in-out infinite;\n border-radius: 4px;\n z-index: 1;\n pointer-events: none;\n}\n\n.recur-card-skeleton.hidden {\n display: none;\n}\n\n@keyframes recur-skeleton-loading {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n\n/* ============================================\n * Submit Button Styles\n * ============================================ */\n.recur-submit-button {\n width: 100%;\n padding: 14px 20px;\n font-size: 16px;\n font-weight: 500;\n color: #ffffff;\n background: #18181b;\n border: none;\n border-radius: 6px;\n cursor: pointer;\n transition: background-color 0.2s;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n box-sizing: border-box;\n}\n\n.recur-submit-button:hover:not(:disabled) {\n background: #27272a;\n}\n\n.recur-submit-button:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.recur-submit-button.loading {\n cursor: wait;\n}\n\n.recur-loading-spinner {\n width: 16px;\n height: 16px;\n border: 2px solid currentColor;\n border-top-color: transparent;\n border-radius: 50%;\n animation: recur-spin 0.6s linear infinite;\n}\n\n@keyframes recur-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ============================================\n * Skeleton Loading Styles\n * ============================================ */\n.recur-skeleton {\n display: inline-block;\n background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 50%, #f0f0f0 100%);\n background-size: 200% 100%;\n animation: recur-skeleton-loading 1.5s ease-in-out infinite;\n border-radius: 4px;\n}\n";
1051
+ payment_form_light_default = "/**\n * Payment Form - Light DOM Styles\n * These styles are applied to slotted content in the Light DOM\n * Using BEM naming convention with 'recur-' prefix for isolation\n */\n\n/* ============================================\n * Order Summary Styles\n * ============================================ */\n.recur-order-summary {\n background: #f7fafc;\n border: 1px solid #e2e8f0;\n border-radius: 8px;\n padding: 16px;\n}\n\n.recur-order-summary__row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 8px;\n}\n\n.recur-order-summary__row--total {\n margin-bottom: 0;\n padding-top: 12px;\n border-top: 1px solid #e2e8f0;\n}\n\n.recur-order-summary__label {\n font-size: 14px;\n color: #4a5568;\n}\n\n.recur-order-summary__value {\n font-size: 14px;\n line-height: 20px;\n font-weight: 500;\n color: #2d3748;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n.recur-order-summary__total {\n font-size: 18px;\n line-height: 26px;\n font-weight: 700;\n color: #1a202c;\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n\n/* ============================================\n * Coupon Section Styles\n * ============================================ */\n.recur-coupon {\n padding: 12px 0;\n border-top: 1px solid #e2e8f0;\n margin-top: 12px;\n min-height: 36px;\n display: flex;\n align-items: center;\n}\n\n.recur-coupon__trigger {\n display: flex;\n align-items: center;\n gap: 6px;\n width: 100%;\n height: 36px;\n background: none;\n border: none;\n padding: 0;\n font-size: 14px;\n line-height: 1;\n color: #718096;\n cursor: pointer;\n transition: color 0.2s;\n}\n\n.recur-coupon__trigger:hover {\n color: #4a5568;\n}\n\n.recur-coupon__trigger-icon {\n width: 16px;\n height: 16px;\n}\n\n.recur-coupon__input-row {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n}\n\n.recur-coupon__input-wrapper {\n position: relative;\n flex: 1;\n min-width: 0;\n}\n\n.recur-coupon__input {\n width: 100%;\n height: 36px;\n padding: 0 32px 0 12px;\n font-size: 14px;\n text-transform: uppercase;\n background-color: #ffffff;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n outline: none;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n.recur-coupon__input::placeholder {\n text-transform: none;\n}\n\n.recur-coupon__input:hover:not(:focus) {\n border-color: #9ca3af;\n}\n\n.recur-coupon__input:focus {\n border-color: var(--ring, hsl(215 16% 47%));\n outline: 0;\n box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring, hsl(215 16% 47%)) 50%, transparent);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n.recur-coupon__clear {\n position: absolute;\n right: 8px;\n top: 50%;\n transform: translateY(-50%);\n background: none;\n border: none;\n padding: 4px;\n cursor: pointer;\n color: #9ca3af;\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: color 0.2s;\n}\n\n.recur-coupon__clear:hover {\n color: #6b7280;\n}\n\n.recur-coupon__clear-icon {\n width: 16px;\n height: 16px;\n}\n\n.recur-coupon__btn {\n height: 36px;\n padding: 0 12px;\n font-size: 14px;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: background-color 0.2s, opacity 0.2s;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.recur-coupon__btn--apply {\n background: #18181b;\n color: white;\n border: none;\n min-width: 52px;\n}\n\n.recur-coupon__btn--apply .recur-coupon__spinner {\n width: 14px;\n height: 14px;\n}\n\n.recur-coupon__btn--apply:hover:not(:disabled) {\n background: #27272a;\n}\n\n.recur-coupon__btn--apply:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.recur-coupon__error {\n width: 100%;\n margin-top: 8px;\n font-size: 13px;\n color: #dc2626;\n}\n\n.recur-coupon__applied-wrapper {\n width: 100%;\n}\n\n.recur-coupon__description {\n font-size: 12px;\n color: #6b7280;\n margin: 4px 0 0 22px;\n}\n\n.recur-coupon__bonus {\n display: flex;\n align-items: center;\n gap: 6px;\n background: #dcfce7;\n border-radius: 6px;\n padding: 8px 12px;\n margin-top: 8px;\n font-size: 12px;\n color: #15803d;\n}\n\n.recur-coupon__applied {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: 36px;\n}\n\n.recur-coupon__applied-info {\n display: flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n flex: 1;\n}\n\n.recur-coupon__applied-icon {\n width: 16px;\n height: 16px;\n color: #dc2626;\n flex-shrink: 0;\n}\n\n.recur-coupon__applied-name {\n font-size: 14px;\n font-weight: 500;\n color: #dc2626;\n white-space: nowrap;\n}\n\n.recur-coupon__applied-code {\n font-size: 14px;\n color: #9ca3af;\n white-space: nowrap;\n margin-right: 2px;\n}\n\n.recur-coupon__applied-amount {\n font-size: 14px;\n font-weight: 500;\n color: #dc2626;\n white-space: nowrap;\n font-variant-numeric: tabular-nums;\n flex-shrink: 0;\n margin-left: auto;\n}\n\n.recur-coupon__remove {\n background: none;\n border: none;\n padding: 4px;\n cursor: pointer;\n color: #9ca3af;\n border-radius: 4px;\n transition: color 0.2s, background-color 0.2s;\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.recur-coupon__remove:hover {\n color: #6b7280;\n background: rgba(0, 0, 0, 0.05);\n}\n\n.recur-coupon__remove-icon {\n width: 14px;\n height: 14px;\n}\n\n.recur-coupon__spinner {\n width: 14px;\n height: 14px;\n border: 2px solid currentColor;\n border-top-color: transparent;\n border-radius: 50%;\n animation: recur-spin 0.6s linear infinite;\n}\n\n/* Discount Row */\n.recur-order-summary__row--discount {\n color: #16a34a;\n}\n\n.recur-order-summary__row--discount .recur-order-summary__value {\n color: #16a34a;\n font-weight: 500;\n}\n\n/* ============================================\n * Customer Info Styles\n * ============================================ */\n.recur-info-display {\n background: #f7fafc;\n border: 1px solid #e2e8f0;\n border-radius: 6px;\n padding: 12px;\n margin-bottom: 8px;\n}\n\n.recur-info-row {\n margin-bottom: 12px;\n}\n\n.recur-info-row:last-child {\n margin-bottom: 0;\n}\n\n.recur-info-label {\n display: block;\n font-size: 12px;\n line-height: 16px;\n color: #718096;\n margin-bottom: 2px;\n}\n\n.recur-info-value {\n display: block;\n font-size: 14px;\n line-height: 20px;\n min-height: 20px;\n color: #2d3748;\n font-weight: 500;\n word-break: break-all;\n}\n\n.recur-form-group {\n margin-bottom: 16px;\n}\n\n.recur-form-label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #2d3748;\n margin-bottom: 6px;\n}\n\n.recur-form-input {\n width: 100%;\n height: 36px;\n padding: 0 12px;\n font-size: 14px;\n border: 1px solid #d1d5db;\n border-radius: 6px;\n transition: border-color 0.2s;\n box-sizing: border-box;\n}\n\n.recur-form-input:focus {\n border-color: var(--ring, hsl(215 16% 47%));\n outline: 0;\n box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring, hsl(215 16% 47%)) 50%, transparent);\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n}\n\n.recur-form-input:hover:not(:focus) {\n border-color: #9ca3af;\n}\n\n/* ============================================\n * Card Fields Styles\n * ============================================ */\n.recur-card-field {\n margin-bottom: 16px;\n}\n\n.recur-card-field-label {\n display: block;\n font-size: 14px;\n font-weight: 500;\n color: #2d3748;\n margin-bottom: 6px;\n}\n\n.recur-payuni-iframe {\n height: 36px;\n width: 100%;\n max-width: 100%;\n box-sizing: border-box;\n touch-action: manipulation;\n}\n\n.recur-card-row {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));\n column-gap: 16px;\n max-width: 100%;\n box-sizing: border-box;\n}\n\n.recur-card-field-container {\n position: relative;\n touch-action: manipulation;\n}\n\n.recur-card-skeleton {\n position: absolute;\n top: 0;\n left: 0;\n height: 36px;\n width: 100%;\n background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 50%, #f0f0f0 100%);\n background-size: 200% 100%;\n animation: recur-skeleton-loading 1.5s ease-in-out infinite;\n border-radius: 4px;\n z-index: 1;\n pointer-events: none;\n}\n\n.recur-card-skeleton.hidden {\n display: none;\n}\n\n@keyframes recur-skeleton-loading {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n\n/* ============================================\n * Submit Button Styles\n * ============================================ */\n.recur-submit-button {\n width: 100%;\n padding: 14px 20px;\n font-size: 16px;\n font-weight: 500;\n color: #ffffff;\n background: #18181b;\n border: none;\n border-radius: 6px;\n cursor: pointer;\n transition: background-color 0.2s;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n box-sizing: border-box;\n}\n\n.recur-submit-button:hover:not(:disabled) {\n background: #27272a;\n}\n\n.recur-submit-button:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.recur-submit-button.loading {\n cursor: wait;\n}\n\n.recur-loading-spinner {\n width: 16px;\n height: 16px;\n border: 2px solid currentColor;\n border-top-color: transparent;\n border-radius: 50%;\n animation: recur-spin 0.6s linear infinite;\n}\n\n@keyframes recur-spin {\n to { transform: rotate(360deg); }\n}\n\n/* ============================================\n * Skeleton Loading Styles\n * ============================================ */\n.recur-skeleton {\n display: inline-block;\n background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 50%, #f0f0f0 100%);\n background-size: 200% 100%;\n animation: recur-skeleton-loading 1.5s ease-in-out infinite;\n border-radius: 4px;\n}\n\n/* ============================================\n * Test Mode Banner Styles\n * ============================================ */\n.recur-test-mode-banner {\n background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);\n border: 1px solid #f59e0b;\n border-radius: 8px;\n margin-bottom: 16px;\n overflow: hidden;\n}\n\n.recur-test-mode-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 10px 14px;\n cursor: pointer;\n user-select: none;\n transition: background 0.2s;\n}\n\n.recur-test-mode-header:hover {\n background: rgba(245, 158, 11, 0.1);\n}\n\n.recur-test-mode-badge {\n font-size: 13px;\n font-weight: 600;\n color: #92400e;\n}\n\n.recur-test-mode-toggle {\n font-size: 12px;\n color: #b45309;\n}\n\n.recur-test-cards {\n background: rgba(255, 255, 255, 0.7);\n padding: 12px 14px;\n border-top: 1px solid rgba(245, 158, 11, 0.3);\n}\n\n.recur-test-cards-info {\n font-size: 12px;\n color: #78350f;\n margin: 0 0 10px 0;\n}\n\n.recur-test-card {\n background: white;\n border: 1px solid #e5e7eb;\n border-radius: 6px;\n padding: 10px 12px;\n margin-bottom: 8px;\n}\n\n.recur-test-card:last-child {\n margin-bottom: 0;\n}\n\n.recur-test-card-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 4px;\n}\n\n.recur-test-card-number {\n font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;\n font-size: 14px;\n font-weight: 600;\n color: #1f2937;\n letter-spacing: 0.5px;\n}\n\n.recur-test-card-copy {\n font-size: 11px;\n font-weight: 500;\n color: #3b82f6;\n background: #eff6ff;\n border: 1px solid #bfdbfe;\n border-radius: 4px;\n padding: 3px 8px;\n cursor: pointer;\n transition: all 0.2s;\n}\n\n.recur-test-card-copy:hover {\n background: #dbeafe;\n border-color: #93c5fd;\n}\n\n.recur-test-card-copy.copied {\n background: #dcfce7;\n border-color: #86efac;\n color: #16a34a;\n}\n\n.recur-test-card-details {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n font-size: 11px;\n color: #6b7280;\n}\n\n.recur-test-card-desc {\n color: #374151;\n font-weight: 500;\n}\n\n.recur-test-card-meta {\n color: #9ca3af;\n}\n";
1052
1052
  }
1053
1053
  });
1054
1054
 
@@ -1097,6 +1097,8 @@ var init_payment_form = __esm({
1097
1097
  __publicField(this, "customStyles");
1098
1098
  __publicField(this, "_isInitializing", false);
1099
1099
  __publicField(this, "_initializationAborted", false);
1100
+ // Test mode state
1101
+ __publicField(this, "_showTestCardHints", false);
1100
1102
  // Coupon state
1101
1103
  __publicField(this, "_showCouponInput", false);
1102
1104
  __publicField(this, "_couponCode", "");
@@ -1163,6 +1165,8 @@ var init_payment_form = __esm({
1163
1165
  <style>${this.getShadowDOMStyles()}</style>
1164
1166
 
1165
1167
  <div class="payment-form-wrapper">
1168
+ <slot name="test-mode-banner"></slot>
1169
+
1166
1170
  <div class="form-header">
1167
1171
  <h2 class="form-title">訂閱付款</h2>
1168
1172
  </div>
@@ -1199,6 +1203,10 @@ var init_payment_form = __esm({
1199
1203
  ${this.getCustomStyles()}
1200
1204
  </style>
1201
1205
 
1206
+ <div slot="test-mode-banner">
1207
+ ${this.renderTestCardHints()}
1208
+ </div>
1209
+
1202
1210
  <div slot="order-summary">
1203
1211
  ${this.renderOrderSummary()}
1204
1212
  </div>
@@ -1235,6 +1243,63 @@ var init_payment_form = __esm({
1235
1243
  }
1236
1244
  }
1237
1245
  // ============================================
1246
+ // Test Mode Helpers
1247
+ // ============================================
1248
+ isTestMode() {
1249
+ const publishableKey = this.getAttribute("publishable-key") || "";
1250
+ return publishableKey.startsWith("pk_test_");
1251
+ }
1252
+ renderTestCardHints() {
1253
+ if (!this.isTestMode()) {
1254
+ return litHtml.html``;
1255
+ }
1256
+ const testCards = [
1257
+ { number: "4147 6310 0000 0001", desc: "VISA \u6E2C\u8A66\u5361\uFF08\u6388\u6B0A\u6210\u529F\uFF09", expiry: "\u4EFB\u610F\u672A\u4F86\u65E5\u671F", cvc: "\u4EFB\u610F3\u78BC" },
1258
+ { number: "3560 5110 0000 0001", desc: "JCB \u6E2C\u8A66\u5361\uFF08\u6388\u6B0A\u6210\u529F\uFF09", expiry: "\u4EFB\u610F\u672A\u4F86\u65E5\u671F", cvc: "\u4EFB\u610F3\u78BC" },
1259
+ { number: "4147 6310 0000 0002", desc: "VISA \u6E2C\u8A66\u5361\uFF083D\u9A57\u8B49\u5931\u6557\uFF09", expiry: "\u4EFB\u610F\u672A\u4F86\u65E5\u671F", cvc: "\u4EFB\u610F3\u78BC" }
1260
+ ];
1261
+ const copyToClipboard = (text, event) => {
1262
+ const btn = event.currentTarget;
1263
+ navigator.clipboard.writeText(text.replace(/\s/g, "")).then(() => {
1264
+ const originalText = btn.textContent;
1265
+ btn.textContent = "\u5DF2\u8907\u88FD!";
1266
+ btn.classList.add("copied");
1267
+ setTimeout(() => {
1268
+ btn.textContent = originalText;
1269
+ btn.classList.remove("copied");
1270
+ }, 1500);
1271
+ });
1272
+ };
1273
+ return litHtml.html`
1274
+ <div class="recur-test-mode-banner">
1275
+ <div class="recur-test-mode-header" @click=${() => {
1276
+ this._showTestCardHints = !this._showTestCardHints;
1277
+ this.renderLightDOM();
1278
+ }}>
1279
+ <span class="recur-test-mode-badge">🧪 測試模式</span>
1280
+ <span class="recur-test-mode-toggle">${this._showTestCardHints ? "\u6536\u5408 \u25B2" : "\u5C55\u958B\u6E2C\u8A66\u5361\u865F \u25BC"}</span>
1281
+ </div>
1282
+ ${this._showTestCardHints ? litHtml.html`
1283
+ <div class="recur-test-cards">
1284
+ <p class="recur-test-cards-info">使用以下測試卡號進行測試付款:</p>
1285
+ ${testCards.map((card) => litHtml.html`
1286
+ <div class="recur-test-card">
1287
+ <div class="recur-test-card-row">
1288
+ <span class="recur-test-card-number">${card.number}</span>
1289
+ <button type="button" class="recur-test-card-copy" @click=${(e) => copyToClipboard(card.number, e)}>複製</button>
1290
+ </div>
1291
+ <div class="recur-test-card-details">
1292
+ <span class="recur-test-card-desc">${card.desc}</span>
1293
+ <span class="recur-test-card-meta">到期日: ${card.expiry} / CVC: ${card.cvc}</span>
1294
+ </div>
1295
+ </div>
1296
+ `)}
1297
+ </div>
1298
+ ` : litHtml.nothing}
1299
+ </div>
1300
+ `;
1301
+ }
1302
+ // ============================================
1238
1303
  // Section Renderers (lit-html)
1239
1304
  // ============================================
1240
1305
  renderOrderSummary() {
@@ -1648,16 +1713,16 @@ var init_payment_form = __esm({
1648
1713
  }
1649
1714
  this._appliedCoupon = {
1650
1715
  code: data.discount.code,
1651
- couponName: data.discount.couponName,
1652
- discountType: data.discount.discountType,
1653
- discountAmount: data.discount.discountAmount,
1716
+ couponName: data.discount.coupon_name,
1717
+ discountType: data.discount.discount_type,
1718
+ discountAmount: data.discount.discount_amount,
1654
1719
  duration: data.discount.duration,
1655
- durationMonths: data.discount.durationMonths,
1656
- bonusTrialDays: data.discount.bonusTrialDays,
1657
- bonusMonths: data.discount.bonusMonths,
1658
- productInterval: data.discount.productInterval,
1659
- productIntervalCount: data.discount.productIntervalCount,
1660
- productPrice: data.discount.productPrice
1720
+ durationMonths: data.discount.duration_months,
1721
+ bonusTrialDays: data.discount.bonus_trial_days,
1722
+ bonusMonths: data.discount.bonus_months,
1723
+ productInterval: data.discount.product_interval,
1724
+ productIntervalCount: data.discount.product_interval_count,
1725
+ productPrice: data.discount.product_price
1661
1726
  };
1662
1727
  this._showCouponInput = false;
1663
1728
  this._couponCode = "";
@@ -2674,16 +2739,27 @@ function toCamelCase(obj) {
2674
2739
 
2675
2740
  // package.json
2676
2741
  var package_default = {
2677
- version: "0.10.8"};
2742
+ version: "0.12.0"};
2678
2743
  var SDK_VERSION = package_default.version;
2679
2744
  var SDK_TYPE = "react";
2680
2745
  var RecurContext = React.createContext(null);
2681
- function RecurProvider({ children, config: initialConfig = {} }) {
2746
+ var CustomerContext = React.createContext(null);
2747
+ function RecurProvider({ children, config: initialConfig = {}, customer: customerIdentifier }) {
2682
2748
  const [config, setConfig] = React.useState({
2683
2749
  checkoutMode: "embedded",
2684
2750
  ...initialConfig
2685
2751
  });
2686
2752
  const [isCheckingOut, setIsCheckingOut] = React.useState(false);
2753
+ const [customerData, setCustomerData] = React.useState({
2754
+ customer: null,
2755
+ subscription: null,
2756
+ entitlements: []
2757
+ });
2758
+ const [customerIsLoading, setCustomerIsLoading] = React.useState(false);
2759
+ const [customerError, setCustomerError] = React.useState(null);
2760
+ const lastSuccessfulCache = React__default.default.useRef(null);
2761
+ const isRefetching = React__default.default.useRef(false);
2762
+ const refetchDebounceTimer = React__default.default.useRef(null);
2687
2763
  React__default.default.useEffect(() => {
2688
2764
  setConfig({
2689
2765
  checkoutMode: "embedded",
@@ -2693,6 +2769,89 @@ function RecurProvider({ children, config: initialConfig = {} }) {
2693
2769
  React__default.default.useEffect(() => {
2694
2770
  setIsCheckingOut(false);
2695
2771
  }, [config.checkoutMode]);
2772
+ const fetchEntitlements = React.useCallback(async () => {
2773
+ if (!config.publishableKey) {
2774
+ console.warn("[Recur SDK] Cannot fetch entitlements: publishableKey is required");
2775
+ return;
2776
+ }
2777
+ if (!customerIdentifier?.email && !customerIdentifier?.externalId && !customerIdentifier?.id) {
2778
+ setCustomerData({ customer: null, subscription: null, entitlements: [] });
2779
+ return;
2780
+ }
2781
+ setCustomerIsLoading(true);
2782
+ setCustomerError(null);
2783
+ try {
2784
+ const baseUrl = config.baseUrl || "https://api.recur.tw";
2785
+ const params = new URLSearchParams();
2786
+ if (customerIdentifier.email) params.set("email", customerIdentifier.email);
2787
+ if (customerIdentifier.externalId) params.set("external_id", customerIdentifier.externalId);
2788
+ if (customerIdentifier.id) params.set("customer_id", customerIdentifier.id);
2789
+ const response = await fetch(`${baseUrl}/v1/customers/entitlements?${params.toString()}`, {
2790
+ method: "GET",
2791
+ headers: {
2792
+ "X-Recur-Publishable-Key": config.publishableKey,
2793
+ "X-Recur-SDK-Type": SDK_TYPE,
2794
+ "X-Recur-SDK-Version": SDK_VERSION
2795
+ }
2796
+ });
2797
+ if (!response.ok) {
2798
+ const errorData = await response.json().catch(() => ({}));
2799
+ throw new Error(errorData.error?.message || "Failed to fetch entitlements");
2800
+ }
2801
+ const rawResult = await response.json();
2802
+ const result = toCamelCase(rawResult);
2803
+ const newData = {
2804
+ customer: result.customer || null,
2805
+ subscription: result.subscription || null,
2806
+ entitlements: result.entitlements || []
2807
+ };
2808
+ setCustomerData(newData);
2809
+ lastSuccessfulCache.current = newData;
2810
+ } catch (error) {
2811
+ console.error("[Recur SDK] Failed to fetch entitlements:", error);
2812
+ setCustomerError(error instanceof Error ? error : new Error("Failed to fetch entitlements"));
2813
+ if (lastSuccessfulCache.current) {
2814
+ setCustomerData(lastSuccessfulCache.current);
2815
+ }
2816
+ } finally {
2817
+ setCustomerIsLoading(false);
2818
+ }
2819
+ }, [config.publishableKey, config.baseUrl, customerIdentifier?.email, customerIdentifier?.externalId, customerIdentifier?.id]);
2820
+ React__default.default.useEffect(() => {
2821
+ fetchEntitlements();
2822
+ }, [fetchEntitlements]);
2823
+ const refetch = React.useCallback(async () => {
2824
+ if (isRefetching.current) {
2825
+ console.log("[Recur SDK] Refetch skipped - already in progress");
2826
+ return;
2827
+ }
2828
+ if (refetchDebounceTimer.current) {
2829
+ clearTimeout(refetchDebounceTimer.current);
2830
+ }
2831
+ isRefetching.current = true;
2832
+ try {
2833
+ await fetchEntitlements();
2834
+ } finally {
2835
+ refetchDebounceTimer.current = setTimeout(() => {
2836
+ isRefetching.current = false;
2837
+ }, 300);
2838
+ }
2839
+ }, [fetchEntitlements]);
2840
+ React__default.default.useEffect(() => {
2841
+ return () => {
2842
+ if (refetchDebounceTimer.current) {
2843
+ clearTimeout(refetchDebounceTimer.current);
2844
+ }
2845
+ };
2846
+ }, []);
2847
+ const customerContextValue = React.useMemo(() => ({
2848
+ customer: customerData.customer,
2849
+ subscription: customerData.subscription,
2850
+ entitlements: customerData.entitlements,
2851
+ isLoading: customerIsLoading,
2852
+ error: customerError,
2853
+ refetch
2854
+ }), [customerData, customerIsLoading, customerError, refetch]);
2696
2855
  const updateConfig = React.useCallback((newConfig) => {
2697
2856
  setConfig((prev) => ({ ...prev, ...newConfig }));
2698
2857
  }, []);
@@ -2894,14 +3053,14 @@ function RecurProvider({ children, config: initialConfig = {} }) {
2894
3053
  }
2895
3054
  console.log("[Recur SDK] Step 1: Creating checkout session...");
2896
3055
  const checkoutRequestBody = {
2897
- customerName: options.customerName,
2898
- customerEmail: options.customerEmail
3056
+ customer_name: options.customerName,
3057
+ customer_email: options.customerEmail
2899
3058
  };
2900
- if (productId) checkoutRequestBody.productId = productId;
2901
- if (productSlug) checkoutRequestBody.productSlug = productSlug;
2902
- if (options.externalCustomerId) checkoutRequestBody.externalCustomerId = options.externalCustomerId;
2903
- if (options.successUrl) checkoutRequestBody.successUrl = options.successUrl;
2904
- if (options.cancelUrl) checkoutRequestBody.cancelUrl = options.cancelUrl;
3059
+ if (productId) checkoutRequestBody.product_id = productId;
3060
+ if (productSlug) checkoutRequestBody.product_slug = productSlug;
3061
+ if (options.externalCustomerId) checkoutRequestBody.external_customer_id = options.externalCustomerId;
3062
+ if (options.successUrl) checkoutRequestBody.success_url = options.successUrl;
3063
+ if (options.cancelUrl) checkoutRequestBody.cancel_url = options.cancelUrl;
2905
3064
  const checkoutResponse = await fetch(`${baseUrl}/v1/checkouts`, {
2906
3065
  method: "POST",
2907
3066
  headers,
@@ -3420,7 +3579,7 @@ function RecurProvider({ children, config: initialConfig = {} }) {
3420
3579
  }),
3421
3580
  [config, checkout, fetchProducts, fetchPlans, isCheckingOut, updateConfig, getCheckoutStatus]
3422
3581
  );
3423
- return /* @__PURE__ */ jsxRuntime.jsx(RecurContext.Provider, { value, children });
3582
+ return /* @__PURE__ */ jsxRuntime.jsx(RecurContext.Provider, { value, children: /* @__PURE__ */ jsxRuntime.jsx(CustomerContext.Provider, { value: customerContextValue, children }) });
3424
3583
  }
3425
3584
  function useRecur() {
3426
3585
  const context = React.useContext(RecurContext);
@@ -3510,8 +3669,84 @@ function useSubscribe(options = {}) {
3510
3669
  reset
3511
3670
  };
3512
3671
  }
3672
+ function useCustomer() {
3673
+ const context = React.useContext(CustomerContext);
3674
+ if (!context) {
3675
+ throw new Error("useCustomer must be used within a RecurProvider");
3676
+ }
3677
+ const { customer, subscription, entitlements, isLoading: isLoading2, error, refetch } = context;
3678
+ const resolveProduct = (target) => {
3679
+ if (typeof target === "string") {
3680
+ return target;
3681
+ }
3682
+ if ("product" in target) {
3683
+ return target.product;
3684
+ }
3685
+ if ("feature" in target) {
3686
+ throw new Error("Feature-based checks are not yet supported. Coming in Phase 2.");
3687
+ }
3688
+ if ("benefit" in target) {
3689
+ throw new Error("Benefit-based checks are not yet supported. Coming in Phase 2.");
3690
+ }
3691
+ throw new Error("Invalid check target");
3692
+ };
3693
+ const checkSync = React.useCallback(
3694
+ (target) => {
3695
+ const product = resolveProduct(target);
3696
+ if (!customer) {
3697
+ return { allowed: false, reason: "no_customer" };
3698
+ }
3699
+ if (entitlements.length === 0) {
3700
+ return { allowed: false, reason: "no_entitlement" };
3701
+ }
3702
+ const matchedEntitlement = entitlements.find(
3703
+ (e) => e.product === product || e.productId === product
3704
+ );
3705
+ if (!matchedEntitlement) {
3706
+ return { allowed: false, reason: "not_found" };
3707
+ }
3708
+ let matchingSubscription;
3709
+ if (subscription && (subscription.product.slug === product || subscription.product.id === product)) {
3710
+ matchingSubscription = subscription;
3711
+ }
3712
+ return {
3713
+ allowed: true,
3714
+ entitlement: matchedEntitlement,
3715
+ // Backward compatibility
3716
+ subscription: matchingSubscription
3717
+ };
3718
+ },
3719
+ [customer, entitlements, subscription]
3720
+ );
3721
+ const checkLive = React.useCallback(
3722
+ async (target) => {
3723
+ await refetch();
3724
+ return checkSync(target);
3725
+ },
3726
+ [refetch, checkSync]
3727
+ );
3728
+ const check = React.useCallback(
3729
+ (target, options) => {
3730
+ if (options?.live) {
3731
+ return checkLive(target);
3732
+ }
3733
+ return checkSync(target);
3734
+ },
3735
+ [checkSync, checkLive]
3736
+ );
3737
+ return {
3738
+ customer,
3739
+ subscription,
3740
+ entitlements,
3741
+ check,
3742
+ refetch,
3743
+ isLoading: isLoading2,
3744
+ error
3745
+ };
3746
+ }
3513
3747
 
3514
3748
  exports.RecurProvider = RecurProvider;
3749
+ exports.useCustomer = useCustomer;
3515
3750
  exports.usePlans = useProducts;
3516
3751
  exports.useProducts = useProducts;
3517
3752
  exports.useRecur = useRecur;