recur-tw 0.0.4-beta.3 → 0.1.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
@@ -8,6 +8,7 @@ Taiwan-specific subscription checkout SDK with embedded payment forms powered by
8
8
 
9
9
  ## 🚀 Features
10
10
 
11
+ - ✅ **Hosted Checkout** - Zero-backend integration with `<recur-checkout>` Web Component
11
12
  - ✅ **React SDK** - Full React integration with hooks (`usePlans`, `useRecur`)
12
13
  - ✅ **Vanilla JS** - Use with plain HTML/JavaScript (no framework required)
13
14
  - ✅ **Embedded Checkout** - Native payment forms with PAYUNi credit card fields
@@ -130,6 +131,43 @@ Perfect for landing pages, marketing sites, or any static HTML:
130
131
  </html>
131
132
  ```
132
133
 
134
+ ### Option 3: Hosted Checkout (Zero Backend)
135
+
136
+ **NEW!** The simplest integration - redirect to Recur's hosted checkout page:
137
+
138
+ ```html
139
+ <!DOCTYPE html>
140
+ <html>
141
+ <body>
142
+ <!-- Drop-in checkout button - no JavaScript required! -->
143
+ <recur-checkout
144
+ publishable-key="pk_test_xxx"
145
+ product-id="prod_xxx"
146
+ success-url="https://yoursite.com/success"
147
+ cancel-url="https://yoursite.com/cancel">
148
+ 訂閱方案
149
+ </recur-checkout>
150
+
151
+ <script src="https://unpkg.com/recur-tw@latest/dist/recur.umd.js"></script>
152
+ </body>
153
+ </html>
154
+ ```
155
+
156
+ Or use the JavaScript API:
157
+
158
+ ```javascript
159
+ const recur = RecurCheckout.init({
160
+ publishableKey: 'pk_test_xxx'
161
+ });
162
+
163
+ // Redirect to hosted checkout
164
+ await recur.redirectToCheckout({
165
+ productId: 'prod_xxx',
166
+ successUrl: 'https://yoursite.com/success',
167
+ cancelUrl: 'https://yoursite.com/cancel',
168
+ });
169
+ ```
170
+
133
171
  ---
134
172
 
135
173
  ## 🔑 Get Your API Key
@@ -182,6 +220,85 @@ await checkout({
182
220
  });
183
221
  ```
184
222
 
223
+ #### Hosted Checkout Mode (NEW!)
224
+
225
+ The simplest integration - no iframe or embedded form needed:
226
+
227
+ ```tsx
228
+ const recur = RecurCheckout.init({
229
+ publishableKey: 'pk_test_xxx'
230
+ });
231
+
232
+ // Method 1: Redirect to hosted checkout
233
+ await recur.redirectToCheckout({
234
+ productId: 'prod_xxx',
235
+ successUrl: 'https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}',
236
+ cancelUrl: 'https://yoursite.com/cancel',
237
+ customerEmail: 'user@example.com', // optional, pre-fill email
238
+ mode: 'SUBSCRIPTION', // PAYMENT, SUBSCRIPTION, or SETUP
239
+ });
240
+
241
+ // Method 2: Get session URL without redirecting
242
+ const session = await recur.createCheckoutSession({
243
+ productId: 'prod_xxx',
244
+ successUrl: 'https://yoursite.com/success',
245
+ cancelUrl: 'https://yoursite.com/cancel',
246
+ });
247
+ console.log(session.url); // https://checkout.recur.tw/cs_xxx
248
+ console.log(session.expiresAt); // Session expires in 30 minutes
249
+ ```
250
+
251
+ **Checkout Modes:**
252
+ - `PAYMENT` - One-time payment
253
+ - `SUBSCRIPTION` - Recurring subscription (default)
254
+ - `SETUP` - Save card for future charges
255
+
256
+ ---
257
+
258
+ ### `<recur-checkout>` Web Component
259
+
260
+ A drop-in checkout button that requires zero JavaScript:
261
+
262
+ ```html
263
+ <recur-checkout
264
+ publishable-key="pk_test_xxx"
265
+ product-id="prod_xxx"
266
+ success-url="/success"
267
+ cancel-url="/cancel"
268
+ customer-email="user@example.com"
269
+ mode="SUBSCRIPTION"
270
+ button-text="Subscribe Now"
271
+ button-style="gradient">
272
+ </recur-checkout>
273
+ ```
274
+
275
+ **Attributes:**
276
+
277
+ | Attribute | Required | Description |
278
+ |-----------|----------|-------------|
279
+ | `publishable-key` | Yes | Your Recur publishable key |
280
+ | `product-id` | Yes | The product ID to purchase |
281
+ | `success-url` | Yes | Redirect URL after successful payment |
282
+ | `cancel-url` | Yes | Redirect URL if user cancels |
283
+ | `customer-email` | No | Pre-fill customer email |
284
+ | `mode` | No | `PAYMENT`, `SUBSCRIPTION`, or `SETUP` |
285
+ | `button-text` | No | Button label (default: button content) |
286
+ | `button-style` | No | `primary`, `outline`, or `gradient` |
287
+ | `disabled` | No | Disable the button |
288
+ | `api-base-url` | No | Custom API URL (for development) |
289
+
290
+ **Events:**
291
+
292
+ ```javascript
293
+ document.querySelector('recur-checkout').addEventListener('checkout-started', (e) => {
294
+ console.log('Redirecting to:', e.detail.url);
295
+ });
296
+
297
+ document.querySelector('recur-checkout').addEventListener('checkout-error', (e) => {
298
+ console.error('Error:', e.detail.message);
299
+ });
300
+ ```
301
+
185
302
  ---
186
303
 
187
304
  ### React Hooks
package/dist/index.cjs CHANGED
@@ -1885,6 +1885,265 @@ var init_payment_form = __esm({
1885
1885
  }
1886
1886
  });
1887
1887
 
1888
+ // src/components/checkout-button.ts
1889
+ var checkout_button_exports = {};
1890
+ __export(checkout_button_exports, {
1891
+ RecurCheckoutButton: () => RecurCheckoutButton
1892
+ });
1893
+ var RecurCheckoutButton;
1894
+ var init_checkout_button = __esm({
1895
+ "src/components/checkout-button.ts"() {
1896
+ RecurCheckoutButton = class extends HTMLElement {
1897
+ constructor() {
1898
+ super();
1899
+ __publicField(this, "_isLoading", false);
1900
+ __publicField(this, "handleClick", async (e) => {
1901
+ e.preventDefault();
1902
+ if (this._isLoading || this.hasAttribute("disabled")) {
1903
+ return;
1904
+ }
1905
+ const publishableKey = this.getAttribute("publishable-key");
1906
+ const productId = this.getAttribute("product-id");
1907
+ const successUrl = this.getAttribute("success-url");
1908
+ const cancelUrl = this.getAttribute("cancel-url");
1909
+ if (!publishableKey) {
1910
+ this.dispatchError("Missing required attribute: publishable-key");
1911
+ return;
1912
+ }
1913
+ if (!productId) {
1914
+ this.dispatchError("Missing required attribute: product-id");
1915
+ return;
1916
+ }
1917
+ if (!successUrl) {
1918
+ this.dispatchError("Missing required attribute: success-url");
1919
+ return;
1920
+ }
1921
+ if (!cancelUrl) {
1922
+ this.dispatchError("Missing required attribute: cancel-url");
1923
+ return;
1924
+ }
1925
+ this._isLoading = true;
1926
+ this.render();
1927
+ this.setupEventListeners();
1928
+ try {
1929
+ const session = await this.createCheckoutSession({
1930
+ publishableKey,
1931
+ productId,
1932
+ successUrl: this.resolveUrl(successUrl),
1933
+ cancelUrl: this.resolveUrl(cancelUrl),
1934
+ customerEmail: this.getAttribute("customer-email") || void 0,
1935
+ mode: this.getAttribute("mode")
1936
+ });
1937
+ this.dispatchEvent(new CustomEvent("checkout-started", {
1938
+ detail: { sessionId: session.id, url: session.url },
1939
+ bubbles: true,
1940
+ composed: true
1941
+ }));
1942
+ window.location.href = session.url;
1943
+ } catch (error) {
1944
+ this._isLoading = false;
1945
+ this.render();
1946
+ this.setupEventListeners();
1947
+ this.dispatchError(error.message || "Failed to create checkout session");
1948
+ }
1949
+ });
1950
+ this.attachShadow({ mode: "open" });
1951
+ }
1952
+ static get observedAttributes() {
1953
+ return [
1954
+ "publishable-key",
1955
+ "product-id",
1956
+ "success-url",
1957
+ "cancel-url",
1958
+ "customer-email",
1959
+ "mode",
1960
+ "button-text",
1961
+ "button-style",
1962
+ "disabled"
1963
+ ];
1964
+ }
1965
+ connectedCallback() {
1966
+ this.render();
1967
+ this.setupEventListeners();
1968
+ }
1969
+ disconnectedCallback() {
1970
+ const button = this.shadowRoot?.querySelector("button");
1971
+ if (button) {
1972
+ button.removeEventListener("click", this.handleClick);
1973
+ }
1974
+ }
1975
+ attributeChangedCallback(_name, oldValue, newValue) {
1976
+ if (oldValue !== newValue) {
1977
+ this.render();
1978
+ }
1979
+ }
1980
+ render() {
1981
+ const buttonText = this.getAttribute("button-text") || this.textContent?.trim() || "\u8A02\u95B1";
1982
+ const buttonStyle = this.getAttribute("button-style") || "primary";
1983
+ const isDisabled = this.hasAttribute("disabled") || this._isLoading;
1984
+ this.shadowRoot.innerHTML = `
1985
+ <style>
1986
+ :host {
1987
+ display: inline-block;
1988
+ }
1989
+
1990
+ button {
1991
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1992
+ font-size: 16px;
1993
+ font-weight: 500;
1994
+ padding: 12px 24px;
1995
+ border-radius: 8px;
1996
+ cursor: pointer;
1997
+ transition: all 0.2s ease;
1998
+ display: inline-flex;
1999
+ align-items: center;
2000
+ justify-content: center;
2001
+ gap: 8px;
2002
+ min-width: 120px;
2003
+ border: none;
2004
+ }
2005
+
2006
+ button:disabled {
2007
+ opacity: 0.6;
2008
+ cursor: not-allowed;
2009
+ }
2010
+
2011
+ /* Primary style (default) */
2012
+ button.primary {
2013
+ background: #18181b;
2014
+ color: #ffffff;
2015
+ }
2016
+
2017
+ button.primary:hover:not(:disabled) {
2018
+ background: #27272a;
2019
+ }
2020
+
2021
+ button.primary:active:not(:disabled) {
2022
+ background: #3f3f46;
2023
+ }
2024
+
2025
+ /* Outline style */
2026
+ button.outline {
2027
+ background: transparent;
2028
+ color: #18181b;
2029
+ border: 2px solid #18181b;
2030
+ }
2031
+
2032
+ button.outline:hover:not(:disabled) {
2033
+ background: #f4f4f5;
2034
+ }
2035
+
2036
+ button.outline:active:not(:disabled) {
2037
+ background: #e4e4e7;
2038
+ }
2039
+
2040
+ /* Gradient style */
2041
+ button.gradient {
2042
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
2043
+ color: #ffffff;
2044
+ }
2045
+
2046
+ button.gradient:hover:not(:disabled) {
2047
+ opacity: 0.9;
2048
+ }
2049
+
2050
+ button.gradient:active:not(:disabled) {
2051
+ opacity: 0.8;
2052
+ }
2053
+
2054
+ /* Loading spinner */
2055
+ .spinner {
2056
+ width: 16px;
2057
+ height: 16px;
2058
+ border: 2px solid currentColor;
2059
+ border-top-color: transparent;
2060
+ border-radius: 50%;
2061
+ animation: spin 0.6s linear infinite;
2062
+ }
2063
+
2064
+ @keyframes spin {
2065
+ to { transform: rotate(360deg); }
2066
+ }
2067
+
2068
+ /* Focus state */
2069
+ button:focus-visible {
2070
+ outline: 2px solid #3b82f6;
2071
+ outline-offset: 2px;
2072
+ }
2073
+ </style>
2074
+
2075
+ <button
2076
+ class="${buttonStyle}"
2077
+ ${isDisabled ? "disabled" : ""}
2078
+ aria-busy="${this._isLoading}"
2079
+ >
2080
+ ${this._isLoading ? '<span class="spinner"></span>' : ""}
2081
+ <span>${this._isLoading ? "\u8655\u7406\u4E2D..." : buttonText}</span>
2082
+ </button>
2083
+ `;
2084
+ }
2085
+ setupEventListeners() {
2086
+ const button = this.shadowRoot?.querySelector("button");
2087
+ if (button) {
2088
+ button.addEventListener("click", this.handleClick);
2089
+ }
2090
+ }
2091
+ async createCheckoutSession(options) {
2092
+ const baseUrl = this.getApiBaseUrl();
2093
+ const response = await fetch(`${baseUrl}/v1/checkout/sessions`, {
2094
+ method: "POST",
2095
+ headers: {
2096
+ "Content-Type": "application/json",
2097
+ "X-Recur-Publishable-Key": options.publishableKey
2098
+ },
2099
+ body: JSON.stringify({
2100
+ productId: options.productId,
2101
+ mode: options.mode,
2102
+ successUrl: options.successUrl,
2103
+ cancelUrl: options.cancelUrl,
2104
+ customerEmail: options.customerEmail
2105
+ })
2106
+ });
2107
+ if (!response.ok) {
2108
+ const error = await response.json().catch(() => ({}));
2109
+ throw new Error(error.error?.message || `HTTP ${response.status}: Failed to create checkout session`);
2110
+ }
2111
+ return response.json();
2112
+ }
2113
+ getApiBaseUrl() {
2114
+ const customBaseUrl = this.getAttribute("api-base-url");
2115
+ if (customBaseUrl) {
2116
+ return customBaseUrl;
2117
+ }
2118
+ if (typeof window !== "undefined") {
2119
+ const hostname = window.location.hostname;
2120
+ if (hostname === "localhost" || hostname.includes(".test") || hostname.includes(".local") || hostname === "127.0.0.1") {
2121
+ return `${window.location.protocol}//${window.location.host}/api`;
2122
+ }
2123
+ }
2124
+ return "https://api.recur.tw";
2125
+ }
2126
+ resolveUrl(url) {
2127
+ if (url.startsWith("/")) {
2128
+ return `${window.location.origin}${url}`;
2129
+ }
2130
+ return url;
2131
+ }
2132
+ dispatchError(message) {
2133
+ console.error("[recur-checkout]", message);
2134
+ this.dispatchEvent(new CustomEvent("checkout-error", {
2135
+ detail: { message },
2136
+ bubbles: true,
2137
+ composed: true
2138
+ }));
2139
+ }
2140
+ };
2141
+ if (typeof window !== "undefined" && !customElements.get("recur-checkout")) {
2142
+ customElements.define("recur-checkout", RecurCheckoutButton);
2143
+ }
2144
+ }
2145
+ });
2146
+
1888
2147
  // src/components/index.ts
1889
2148
  async function registerComponents() {
1890
2149
  if (typeof window === "undefined") {
@@ -1898,7 +2157,8 @@ async function registerComponents() {
1898
2157
  Promise.resolve().then(() => (init_skeleton_loader(), skeleton_loader_exports)),
1899
2158
  Promise.resolve().then(() => (init_payment_form_skeleton(), payment_form_skeleton_exports)),
1900
2159
  Promise.resolve().then(() => (init_toast(), toast_exports)),
1901
- Promise.resolve().then(() => (init_payment_form(), payment_form_exports))
2160
+ Promise.resolve().then(() => (init_payment_form(), payment_form_exports)),
2161
+ Promise.resolve().then(() => (init_checkout_button(), checkout_button_exports))
1902
2162
  ]);
1903
2163
  const components = [
1904
2164
  "recur-loading-spinner",
@@ -1908,7 +2168,8 @@ async function registerComponents() {
1908
2168
  "recur-payment-form-skeleton",
1909
2169
  "recur-toast",
1910
2170
  "recur-toast-container",
1911
- "recur-payment-form"
2171
+ "recur-payment-form",
2172
+ "recur-checkout"
1912
2173
  ];
1913
2174
  const unregistered = components.filter((name) => !customElements.get(name));
1914
2175
  if (unregistered.length > 0) {