recur-tw 0.6.1 → 0.7.1

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/dist/index.cjs CHANGED
@@ -2232,6 +2232,271 @@ var init_checkout_button = __esm({
2232
2232
  }
2233
2233
  });
2234
2234
 
2235
+ // src/components/portal-button.ts
2236
+ var portal_button_exports = {};
2237
+ __export(portal_button_exports, {
2238
+ RecurPortalButton: () => RecurPortalButton
2239
+ });
2240
+ var RecurPortalButton;
2241
+ var init_portal_button = __esm({
2242
+ "src/components/portal-button.ts"() {
2243
+ RecurPortalButton = class extends HTMLElement {
2244
+ constructor() {
2245
+ super();
2246
+ __publicField(this, "_isLoading", false);
2247
+ __publicField(this, "handleClick", async (e) => {
2248
+ e.preventDefault();
2249
+ if (this._isLoading || this.hasAttribute("disabled")) {
2250
+ return;
2251
+ }
2252
+ const portalUrl = this.getAttribute("portal-url");
2253
+ const apiEndpoint = this.getAttribute("api-endpoint");
2254
+ if (portalUrl) {
2255
+ this.redirectToPortal(portalUrl);
2256
+ return;
2257
+ }
2258
+ if (apiEndpoint) {
2259
+ await this.fetchAndRedirect(apiEndpoint);
2260
+ return;
2261
+ }
2262
+ this.dispatchError("Missing required attribute: either portal-url or api-endpoint must be provided");
2263
+ });
2264
+ this.attachShadow({ mode: "open" });
2265
+ }
2266
+ static get observedAttributes() {
2267
+ return [
2268
+ "portal-url",
2269
+ "api-endpoint",
2270
+ "customer-id",
2271
+ "return-url",
2272
+ "button-text",
2273
+ "button-style",
2274
+ "disabled",
2275
+ "target"
2276
+ ];
2277
+ }
2278
+ connectedCallback() {
2279
+ this.render();
2280
+ this.setupEventListeners();
2281
+ }
2282
+ disconnectedCallback() {
2283
+ const button = this.shadowRoot?.querySelector("button");
2284
+ if (button) {
2285
+ button.removeEventListener("click", this.handleClick);
2286
+ }
2287
+ }
2288
+ attributeChangedCallback(_name, oldValue, newValue) {
2289
+ if (oldValue !== newValue) {
2290
+ this.render();
2291
+ }
2292
+ }
2293
+ render() {
2294
+ const buttonText = this.getAttribute("button-text") || this.textContent?.trim() || "\u7BA1\u7406\u8A02\u95B1";
2295
+ const buttonStyle = this.getAttribute("button-style") || "primary";
2296
+ const isDisabled = this.hasAttribute("disabled") || this._isLoading;
2297
+ this.shadowRoot.innerHTML = `
2298
+ <style>
2299
+ :host {
2300
+ display: inline-block;
2301
+ }
2302
+
2303
+ button {
2304
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2305
+ font-size: 16px;
2306
+ font-weight: 500;
2307
+ padding: 12px 24px;
2308
+ border-radius: 8px;
2309
+ cursor: pointer;
2310
+ transition: all 0.2s ease;
2311
+ display: inline-flex;
2312
+ align-items: center;
2313
+ justify-content: center;
2314
+ gap: 8px;
2315
+ min-width: 120px;
2316
+ border: none;
2317
+ }
2318
+
2319
+ button:disabled {
2320
+ opacity: 0.6;
2321
+ cursor: not-allowed;
2322
+ }
2323
+
2324
+ /* Primary style (default) */
2325
+ button.primary {
2326
+ background: #18181b;
2327
+ color: #ffffff;
2328
+ }
2329
+
2330
+ button.primary:hover:not(:disabled) {
2331
+ background: #27272a;
2332
+ }
2333
+
2334
+ button.primary:active:not(:disabled) {
2335
+ background: #3f3f46;
2336
+ }
2337
+
2338
+ /* Outline style */
2339
+ button.outline {
2340
+ background: transparent;
2341
+ color: #18181b;
2342
+ border: 2px solid #18181b;
2343
+ }
2344
+
2345
+ button.outline:hover:not(:disabled) {
2346
+ background: #f4f4f5;
2347
+ }
2348
+
2349
+ button.outline:active:not(:disabled) {
2350
+ background: #e4e4e7;
2351
+ }
2352
+
2353
+ /* Gradient style */
2354
+ button.gradient {
2355
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2356
+ color: #ffffff;
2357
+ }
2358
+
2359
+ button.gradient:hover:not(:disabled) {
2360
+ opacity: 0.9;
2361
+ }
2362
+
2363
+ button.gradient:active:not(:disabled) {
2364
+ opacity: 0.8;
2365
+ }
2366
+
2367
+ /* Link style */
2368
+ button.link {
2369
+ background: transparent;
2370
+ color: #3b82f6;
2371
+ padding: 4px 8px;
2372
+ min-width: auto;
2373
+ text-decoration: underline;
2374
+ }
2375
+
2376
+ button.link:hover:not(:disabled) {
2377
+ color: #2563eb;
2378
+ }
2379
+
2380
+ button.link:active:not(:disabled) {
2381
+ color: #1d4ed8;
2382
+ }
2383
+
2384
+ /* Loading spinner */
2385
+ .spinner {
2386
+ width: 16px;
2387
+ height: 16px;
2388
+ border: 2px solid currentColor;
2389
+ border-top-color: transparent;
2390
+ border-radius: 50%;
2391
+ animation: spin 0.6s linear infinite;
2392
+ }
2393
+
2394
+ @keyframes spin {
2395
+ to { transform: rotate(360deg); }
2396
+ }
2397
+
2398
+ /* Focus state */
2399
+ button:focus-visible {
2400
+ outline: 2px solid #3b82f6;
2401
+ outline-offset: 2px;
2402
+ }
2403
+
2404
+ /* Portal icon */
2405
+ .portal-icon {
2406
+ width: 18px;
2407
+ height: 18px;
2408
+ }
2409
+ </style>
2410
+
2411
+ <button
2412
+ class="${buttonStyle}"
2413
+ ${isDisabled ? "disabled" : ""}
2414
+ aria-busy="${this._isLoading}"
2415
+ >
2416
+ ${this._isLoading ? '<span class="spinner"></span>' : this.getPortalIcon()}
2417
+ <span>${this._isLoading ? "\u8655\u7406\u4E2D..." : buttonText}</span>
2418
+ </button>
2419
+ `;
2420
+ }
2421
+ getPortalIcon() {
2422
+ return `
2423
+ <svg class="portal-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2424
+ <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
2425
+ <circle cx="12" cy="7" r="4"/>
2426
+ </svg>
2427
+ `;
2428
+ }
2429
+ setupEventListeners() {
2430
+ const button = this.shadowRoot?.querySelector("button");
2431
+ if (button) {
2432
+ button.addEventListener("click", this.handleClick);
2433
+ }
2434
+ }
2435
+ redirectToPortal(url) {
2436
+ const target = this.getAttribute("target");
2437
+ this.dispatchEvent(new CustomEvent("portal-redirect", {
2438
+ detail: { url },
2439
+ bubbles: true,
2440
+ composed: true
2441
+ }));
2442
+ if (target === "_blank") {
2443
+ window.open(url, "_blank", "noopener,noreferrer");
2444
+ } else {
2445
+ window.location.href = url;
2446
+ }
2447
+ }
2448
+ async fetchAndRedirect(apiEndpoint) {
2449
+ const customerId = this.getAttribute("customer-id");
2450
+ const returnUrl = this.getAttribute("return-url");
2451
+ this._isLoading = true;
2452
+ this.render();
2453
+ this.setupEventListeners();
2454
+ try {
2455
+ const requestBody = {};
2456
+ if (customerId) requestBody.customerId = customerId;
2457
+ if (returnUrl) requestBody.returnUrl = returnUrl;
2458
+ const response = await fetch(apiEndpoint, {
2459
+ method: "POST",
2460
+ headers: {
2461
+ "Content-Type": "application/json"
2462
+ },
2463
+ body: JSON.stringify(requestBody)
2464
+ });
2465
+ if (!response.ok) {
2466
+ const error = await response.json().catch(() => ({}));
2467
+ throw new Error(error.error?.message || error.message || `HTTP ${response.status}: Failed to create portal session`);
2468
+ }
2469
+ const data = await response.json();
2470
+ const portalUrl = data.url || data.portalUrl;
2471
+ if (!portalUrl) {
2472
+ throw new Error("Portal URL not found in response");
2473
+ }
2474
+ this._isLoading = false;
2475
+ this.render();
2476
+ this.setupEventListeners();
2477
+ this.redirectToPortal(portalUrl);
2478
+ } catch (error) {
2479
+ this._isLoading = false;
2480
+ this.render();
2481
+ this.setupEventListeners();
2482
+ this.dispatchError(error.message || "Failed to create portal session");
2483
+ }
2484
+ }
2485
+ dispatchError(message) {
2486
+ console.error("[recur-portal]", message);
2487
+ this.dispatchEvent(new CustomEvent("portal-error", {
2488
+ detail: { message },
2489
+ bubbles: true,
2490
+ composed: true
2491
+ }));
2492
+ }
2493
+ };
2494
+ if (typeof window !== "undefined" && !customElements.get("recur-portal")) {
2495
+ customElements.define("recur-portal", RecurPortalButton);
2496
+ }
2497
+ }
2498
+ });
2499
+
2235
2500
  // src/components/index.ts
2236
2501
  async function registerComponents() {
2237
2502
  if (typeof window === "undefined") {
@@ -2246,7 +2511,8 @@ async function registerComponents() {
2246
2511
  Promise.resolve().then(() => (init_payment_form_skeleton(), payment_form_skeleton_exports)),
2247
2512
  Promise.resolve().then(() => (init_toast(), toast_exports)),
2248
2513
  Promise.resolve().then(() => (init_payment_form(), payment_form_exports)),
2249
- Promise.resolve().then(() => (init_checkout_button(), checkout_button_exports))
2514
+ Promise.resolve().then(() => (init_checkout_button(), checkout_button_exports)),
2515
+ Promise.resolve().then(() => (init_portal_button(), portal_button_exports))
2250
2516
  ]);
2251
2517
  const components = [
2252
2518
  "recur-loading-spinner",
@@ -2257,7 +2523,8 @@ async function registerComponents() {
2257
2523
  "recur-toast",
2258
2524
  "recur-toast-container",
2259
2525
  "recur-payment-form",
2260
- "recur-checkout"
2526
+ "recur-checkout",
2527
+ "recur-portal"
2261
2528
  ];
2262
2529
  const unregistered = components.filter((name) => !customElements.get(name));
2263
2530
  if (unregistered.length > 0) {
package/dist/index.d.cts CHANGED
@@ -205,11 +205,24 @@ interface RecurConfig {
205
205
  }
206
206
  interface CheckoutOptions {
207
207
  /**
208
- * Plan ID to subscribe to
208
+ * Product ID to purchase
209
+ * Either productId or productSlug must be provided
209
210
  */
210
- planId: string;
211
+ productId?: string;
212
+ /**
213
+ * Product slug to purchase (alternative to productId)
214
+ * Either productId or productSlug must be provided
215
+ *
216
+ * @example 'premium-monthly', 'basic-yearly'
217
+ */
218
+ productSlug?: string;
211
219
  /**
212
- * Customer information
220
+ * @deprecated Use productId instead
221
+ * Plan ID to subscribe to (kept for backward compatibility)
222
+ */
223
+ planId?: string;
224
+ /**
225
+ * Customer name
213
226
  */
214
227
  customerName?: string;
215
228
  /**
@@ -530,6 +543,61 @@ declare global {
530
543
  }
531
544
  }
532
545
 
546
+ /**
547
+ * Recur Portal Button Web Component
548
+ *
549
+ * A simple, drop-in button for redirecting customers to the Customer Portal.
550
+ *
551
+ * Usage patterns:
552
+ *
553
+ * 1. Direct URL mode (recommended for server-rendered apps):
554
+ * Use when you already have the portal URL from your server.
555
+ * ```html
556
+ * <recur-portal
557
+ * portal-url="https://portal.recur.tw/s/ps_xxx">
558
+ * 管理訂閱
559
+ * </recur-portal>
560
+ * ```
561
+ *
562
+ * 2. API mode (for dynamic portal session creation):
563
+ * Calls your backend API endpoint to create a portal session.
564
+ * ```html
565
+ * <recur-portal
566
+ * api-endpoint="/api/create-portal-session"
567
+ * customer-id="cus_xxx">
568
+ * 管理訂閱
569
+ * </recur-portal>
570
+ * ```
571
+ *
572
+ * Styling:
573
+ * - button-style: "primary" | "outline" | "gradient" | "link"
574
+ * - button-text: Override button text (default: slot content or "管理訂閱")
575
+ *
576
+ * Events:
577
+ * - portal-redirect: Fired before redirecting to portal
578
+ * - portal-error: Fired when an error occurs
579
+ */
580
+ declare class RecurPortalButton extends HTMLElement {
581
+ private _isLoading;
582
+ static get observedAttributes(): string[];
583
+ constructor();
584
+ connectedCallback(): void;
585
+ disconnectedCallback(): void;
586
+ attributeChangedCallback(_name: string, oldValue: string, newValue: string): void;
587
+ private render;
588
+ private getPortalIcon;
589
+ private setupEventListeners;
590
+ private handleClick;
591
+ private redirectToPortal;
592
+ private fetchAndRedirect;
593
+ private dispatchError;
594
+ }
595
+ declare global {
596
+ interface HTMLElementTagNameMap {
597
+ 'recur-portal': RecurPortalButton;
598
+ }
599
+ }
600
+
533
601
  interface RecurProviderProps {
534
602
  children: React.ReactNode;
535
603
  config?: RecurConfig;
package/dist/index.d.ts CHANGED
@@ -205,11 +205,24 @@ interface RecurConfig {
205
205
  }
206
206
  interface CheckoutOptions {
207
207
  /**
208
- * Plan ID to subscribe to
208
+ * Product ID to purchase
209
+ * Either productId or productSlug must be provided
209
210
  */
210
- planId: string;
211
+ productId?: string;
212
+ /**
213
+ * Product slug to purchase (alternative to productId)
214
+ * Either productId or productSlug must be provided
215
+ *
216
+ * @example 'premium-monthly', 'basic-yearly'
217
+ */
218
+ productSlug?: string;
211
219
  /**
212
- * Customer information
220
+ * @deprecated Use productId instead
221
+ * Plan ID to subscribe to (kept for backward compatibility)
222
+ */
223
+ planId?: string;
224
+ /**
225
+ * Customer name
213
226
  */
214
227
  customerName?: string;
215
228
  /**
@@ -530,6 +543,61 @@ declare global {
530
543
  }
531
544
  }
532
545
 
546
+ /**
547
+ * Recur Portal Button Web Component
548
+ *
549
+ * A simple, drop-in button for redirecting customers to the Customer Portal.
550
+ *
551
+ * Usage patterns:
552
+ *
553
+ * 1. Direct URL mode (recommended for server-rendered apps):
554
+ * Use when you already have the portal URL from your server.
555
+ * ```html
556
+ * <recur-portal
557
+ * portal-url="https://portal.recur.tw/s/ps_xxx">
558
+ * 管理訂閱
559
+ * </recur-portal>
560
+ * ```
561
+ *
562
+ * 2. API mode (for dynamic portal session creation):
563
+ * Calls your backend API endpoint to create a portal session.
564
+ * ```html
565
+ * <recur-portal
566
+ * api-endpoint="/api/create-portal-session"
567
+ * customer-id="cus_xxx">
568
+ * 管理訂閱
569
+ * </recur-portal>
570
+ * ```
571
+ *
572
+ * Styling:
573
+ * - button-style: "primary" | "outline" | "gradient" | "link"
574
+ * - button-text: Override button text (default: slot content or "管理訂閱")
575
+ *
576
+ * Events:
577
+ * - portal-redirect: Fired before redirecting to portal
578
+ * - portal-error: Fired when an error occurs
579
+ */
580
+ declare class RecurPortalButton extends HTMLElement {
581
+ private _isLoading;
582
+ static get observedAttributes(): string[];
583
+ constructor();
584
+ connectedCallback(): void;
585
+ disconnectedCallback(): void;
586
+ attributeChangedCallback(_name: string, oldValue: string, newValue: string): void;
587
+ private render;
588
+ private getPortalIcon;
589
+ private setupEventListeners;
590
+ private handleClick;
591
+ private redirectToPortal;
592
+ private fetchAndRedirect;
593
+ private dispatchError;
594
+ }
595
+ declare global {
596
+ interface HTMLElementTagNameMap {
597
+ 'recur-portal': RecurPortalButton;
598
+ }
599
+ }
600
+
533
601
  interface RecurProviderProps {
534
602
  children: React.ReactNode;
535
603
  config?: RecurConfig;