recur-tw 0.21.2 → 0.21.3

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/AGENTS.md CHANGED
@@ -8,7 +8,8 @@
8
8
  recur-tw
9
9
  ├── index.js → React SDK (needs React 18+)
10
10
  ├── server.js → Server SDK (Node.js only, uses secret key)
11
- └── recur.umd.js → Vanilla JS (browser, via CDN or script tag)
11
+ ├── vanilla.js → Vanilla JS, ESM (`recur-tw/vanilla`, for bundlers)
12
+ └── recur.umd.js → Vanilla JS, UMD (script tag / CDN, global `RecurCheckout`)
12
13
  ```
13
14
 
14
15
  ## Which Module to Use?
@@ -17,28 +18,30 @@ recur-tw
17
18
  |-------------|--------|-------------|
18
19
  | React/Next.js | `import { RecurProvider, useRecur } from 'recur-tw'` | `dist/index.js` |
19
20
  | Node.js Server | `import { Recur } from 'recur-tw/server'` | `dist/server.js` |
21
+ | Vanilla JS (bundler) | `import { init } from 'recur-tw/vanilla'` | `dist/vanilla.js` (ESM, typed) |
20
22
  | Vanilla JS (CDN) | `<script src="https://unpkg.com/recur-tw/dist/recur.umd.js">` | UMD global `RecurCheckout` |
21
- | Vanilla JS (bundler) | NOT SUPPORTED as ESM - use CDN script tag | - |
22
23
 
23
- ## IMPORTANT: Vanilla JS Usage
24
+ ## Vanilla JS Usage
24
25
 
25
- The vanilla module (`recur-tw/vanilla`) exports UMD format ONLY. It is NOT an ES module.
26
+ With a bundler (Vite, webpack, Next.js):
27
+ ```javascript
28
+ import { init } from 'recur-tw/vanilla';
29
+
30
+ const recur = init({ publishableKey: 'pk_xxx' });
31
+ recur.redirectToCheckout({ productId: 'prod_xxx', successUrl: `${location.origin}/success`, cancelUrl: `${location.origin}/cancel` });
32
+ ```
26
33
 
27
- **CORRECT** - Use via script tag:
34
+ Without a bundler, load the UMD build. It exposes the same API on `window.RecurCheckout`;
35
+ don't `import` the `.umd.js` file itself, it has no module exports. `successUrl` / `cancelUrl`
36
+ must be absolute URLs (HTTPS outside local development); relative paths are rejected:
28
37
  ```html
29
38
  <script src="https://unpkg.com/recur-tw/dist/recur.umd.js"></script>
30
39
  <script>
31
40
  const recur = RecurCheckout.init({ publishableKey: 'pk_xxx' });
32
- recur.redirectToCheckout({ productId: 'prod_xxx', successUrl: '/success', cancelUrl: '/cancel' });
41
+ recur.redirectToCheckout({ productId: 'prod_xxx', successUrl: `${location.origin}/success`, cancelUrl: `${location.origin}/cancel` });
33
42
  </script>
34
43
  ```
35
44
 
36
- **WRONG** - Do NOT try to import as ESM:
37
- ```javascript
38
- // This will NOT work!
39
- import { init } from 'recur-tw/vanilla';
40
- ```
41
-
42
45
  ## React Quick Start
43
46
 
44
47
  ```tsx
@@ -54,15 +57,19 @@ function App() {
54
57
  );
55
58
  }
56
59
 
57
- // 2. Use hooks
60
+ // 2. Use hooks. Hosted Checkout (redirect) needs no extra provider config;
61
+ // checkout() opens modal/embedded and needs `checkoutMode` on the provider
62
+ // (the default, 'embedded', also needs `containerElementId`).
58
63
  function CheckoutPage() {
59
64
  const { data: products } = useProducts();
60
- const { checkout } = useRecur();
65
+ const { redirectToCheckout } = useRecur();
66
+ if (!products?.length) return null;
61
67
 
62
68
  return (
63
- <button onClick={() => checkout({
64
- planId: products[0].id,
65
- customerEmail: 'user@example.com'
69
+ <button onClick={() => redirectToCheckout({
70
+ productId: products[0].id,
71
+ successUrl: `${window.location.origin}/success`,
72
+ cancelUrl: `${window.location.origin}/pricing`,
66
73
  })}>
67
74
  Subscribe
68
75
  </button>
@@ -75,7 +82,7 @@ function CheckoutPage() {
75
82
  ```typescript
76
83
  import { Recur } from 'recur-tw/server';
77
84
 
78
- const recur = new Recur({ secretKey: 'sk_xxx' });
85
+ const recur = new Recur('sk_xxx');
79
86
 
80
87
  // Create checkout session
81
88
  const session = await recur.checkoutSessions.create({
@@ -91,18 +98,22 @@ console.log(session.url); // Redirect user here
91
98
 
92
99
  ### Main Module (`recur-tw`)
93
100
  - `RecurProvider` - React context provider
94
- - `useRecur()` - Checkout hook (checkout, isCheckingOut)
101
+ - `useRecur()` - Checkout hook: `redirectToCheckout` (hosted), `checkout` / `isCheckingOut` (modal/embedded)
102
+ - `useSubscribe()` - Modal/embedded checkout with `onPaymentComplete` / `onPaymentFailed` callbacks
95
103
  - `useProducts()` - Fetch products hook
104
+ - `useCustomer()` - Entitlements: `check()`, `refetch()`
96
105
  - Types: `RecurConfig`, `CheckoutOptions`, `Product`, etc.
97
106
 
98
107
  ### Server Module (`recur-tw/server`)
99
108
  - `Recur` - Server SDK class
100
109
  - `recur.checkoutSessions.create()` - Create checkout session
101
110
  - `recur.checkoutSessions.retrieve()` - Get session details
102
- - `recur.portal.createSession()` - Create customer portal session
111
+ - `recur.portal.sessions.create()` - Create customer portal session
112
+ - `recur.webhooks.verify(payload, signature, secret)` - Verify a webhook signature, returns the event
113
+ - `recur.entitlements.check({ product, customer })` - Server-side access check
103
114
 
104
- ### Vanilla Module (UMD global `RecurCheckout`)
105
- - `RecurCheckout.init(config)` - Initialize SDK
115
+ ### Vanilla Module (`import { init } from 'recur-tw/vanilla'`, or UMD global `RecurCheckout`)
116
+ - `init(config)` / `RecurCheckout.init(config)` - Initialize SDK
106
117
  - `recur.checkout(options)` - Embedded checkout
107
118
  - `recur.redirectToCheckout(options)` - Hosted checkout redirect
108
119
  - `recur.createCheckoutSession(options)` - Get session URL
@@ -113,12 +124,12 @@ console.log(session.url); // Redirect user here
113
124
  |---------|-----------|------------|------------|
114
125
  | Environment | Browser | Node.js | Browser |
115
126
  | Auth Key | Publishable (`pk_*`) | Secret (`sk_*`) | Publishable (`pk_*`) |
116
- | Import Style | ESM | ESM | Script tag (UMD) |
127
+ | Import Style | ESM | ESM | ESM (`recur-tw/vanilla`) or script tag (UMD) |
117
128
  | React Required | Yes | No | No |
118
129
 
119
130
  ## Common Mistakes
120
131
 
121
- 1. **Trying to import vanilla as ESM** - Use script tag instead
132
+ 1. **Importing `dist/recur.umd.js` from a bundler** - It has no module exports; import `recur-tw/vanilla` instead
122
133
  2. **Using secret key in browser** - Only use `pk_*` keys client-side
123
134
  3. **Missing RecurProvider** - React hooks need the provider wrapper
124
135
  4. **Wrong import path** - Server SDK is `recur-tw/server`, not `recur-tw`
package/dist/index.cjs CHANGED
@@ -1471,6 +1471,39 @@ var init_einvoice_section = __esmMin((() => {
1471
1471
  if (typeof window !== "undefined" && !customElements.get("recur-einvoice-section")) customElements.define("recur-einvoice-section", RecurEinvoiceSection);
1472
1472
  }));
1473
1473
 
1474
+ //#endregion
1475
+ //#region src/components/css-ident.ts
1476
+ /**
1477
+ * Escape a value for use as a CSS identifier.
1478
+ *
1479
+ * `container-id` comes from the host's own container, and ids like
1480
+ * `checkout:main` are perfectly legal HTML. Dropped into a selector unescaped
1481
+ * the colon parses as a pseudo-class, so `#checkout:main-card-no { … }` is not
1482
+ * a rule that fails loudly — it is a rule that silently never matches, which is
1483
+ * how a `height` the card fields depend on can go missing on one integration
1484
+ * and nowhere else.
1485
+ *
1486
+ * Prefers the platform's own `CSS.escape`; the fallback covers the identifier
1487
+ * cases that matter here (jsdom has no CSS object at all).
1488
+ */
1489
+ function cssIdent(value) {
1490
+ const native = globalThis.CSS?.escape;
1491
+ if (typeof native === "function") return native(value);
1492
+ let escaped = "";
1493
+ for (const character of value) {
1494
+ if (/[\w-]/.test(character)) {
1495
+ escaped += character;
1496
+ continue;
1497
+ }
1498
+ const code = character.codePointAt(0);
1499
+ escaped += code > 127 || code < 32 || code === 127 ? `\\${code.toString(16)} ` : `\\${character}`;
1500
+ }
1501
+ if (/^\d/.test(escaped)) escaped = `\\3${escaped[0]} ${escaped.slice(1)}`;
1502
+ else if (/^-\d/.test(escaped)) escaped = `-\\3${escaped[1]} ${escaped.slice(2)}`;
1503
+ return escaped;
1504
+ }
1505
+ var init_css_ident = __esmMin((() => {}));
1506
+
1474
1507
  //#endregion
1475
1508
  //#region src/components/styles/payment-form-shadow.css?css-text
1476
1509
  var payment_form_shadow_default;
@@ -1482,7 +1515,7 @@ var init_payment_form_shadow = __esmMin((() => {
1482
1515
  //#region src/components/styles/payment-form-light.css?css-text
1483
1516
  var payment_form_light_default;
1484
1517
  var init_payment_form_light = __esmMin((() => {
1485
- 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/* Card verification notice ($0 total, card still bound for future charges) */\n.recur-verification-notice {\n border: 1px solid #bfdbfe;\n background-color: #eff6ff;\n color: #1e40af;\n border-radius: 8px;\n padding: 12px 16px;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 16px;\n}\n\n.recur-verification-notice.hidden {\n display: none;\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";
1518
+ 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 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: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/* Card verification notice ($0 total, card still bound for future charges) */\n.recur-verification-notice {\n border: 1px solid #bfdbfe;\n background-color: #eff6ff;\n color: #1e40af;\n border-radius: 8px;\n padding: 12px 16px;\n font-size: 14px;\n font-weight: 500;\n margin-bottom: 16px;\n}\n\n.recur-verification-notice.hidden {\n display: none;\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";
1486
1519
  }));
1487
1520
 
1488
1521
  //#endregion
@@ -1598,15 +1631,10 @@ var init_checkout_display = __esmMin((() => {}));
1598
1631
 
1599
1632
  //#endregion
1600
1633
  //#region src/components/payment-form.ts
1601
- /**
1602
- * Recur Payment Form Web Component (混合模式)
1603
- *
1604
- * 使用 Shadow DOM 包裝外層 UI,PAYUNi 容器保持在 Light DOM
1605
- * 這樣既能享受樣式隔離,又能讓 PAYUNi SDK 正常工作
1606
- */
1607
1634
  var payment_form_exports = /* @__PURE__ */ __exportAll({ RecurPaymentForm: () => RecurPaymentForm });
1608
1635
  var icons, RecurPaymentForm;
1609
1636
  var init_payment_form = __esmMin((() => {
1637
+ init_css_ident();
1610
1638
  init_payment_form_shadow();
1611
1639
  init_payment_form_light();
1612
1640
  init_checkout_display();
@@ -1652,6 +1680,7 @@ var init_payment_form = __esmMin((() => {
1652
1680
  _defineProperty(this, "_couponCode", "");
1653
1681
  _defineProperty(this, "_couponLoading", false);
1654
1682
  _defineProperty(this, "_couponError", null);
1683
+ _defineProperty(this, "_submitState", "idle");
1655
1684
  _defineProperty(this, "_appliedCoupon", null);
1656
1685
  this.containerId = this.getAttribute("container-id") || `recur-${Date.now()}`;
1657
1686
  this.customStyles = this.getAttribute("custom-styles") || "";
@@ -1692,7 +1721,7 @@ var init_payment_form = __esmMin((() => {
1692
1721
  this.updateCustomStyles();
1693
1722
  } else if (name === "einvoice-enabled" || name === "einvoice-defaults") {
1694
1723
  if (this.isConnected) this.render();
1695
- } else this.updateOrderSummaryDOM();
1724
+ } else this.renderLightDOM();
1696
1725
  }
1697
1726
  isEinvoiceEnabled() {
1698
1727
  return this.getAttribute("einvoice-enabled") === "true";
@@ -1750,11 +1779,16 @@ var init_payment_form = __esmMin((() => {
1750
1779
  `, this.shadowRoot);
1751
1780
  }
1752
1781
  renderLightDOM() {
1782
+ if (!this.isConnected) return;
1753
1783
  (0, lit_html.render)(lit_html.html`
1754
- <style id="${this.containerId}-styles">
1755
- ${this.getLightDOMStyles()}
1756
- ${this.getCustomStyles()}
1757
- </style>
1784
+ <!-- Deliberately empty: the stylesheet is written imperatively below.
1785
+ Interpolating it here would make the element's content a lit
1786
+ ChildPart, and updateCustomStyles() writes to that element whenever
1787
+ the host changes custom-styles — which would delete the markers lit
1788
+ renders through and make the next render throw. With no expression
1789
+ inside, lit owns the element but not its content, and the write is
1790
+ harmless. -->
1791
+ <style id="${this.containerId}-styles"></style>
1758
1792
 
1759
1793
  <div slot="test-mode-banner">
1760
1794
  ${this.renderTestCardHints()}
@@ -1785,21 +1819,33 @@ var init_payment_form = __esmMin((() => {
1785
1819
  ${this.renderActions()}
1786
1820
  </div>
1787
1821
  `, this);
1822
+ this.applyStyles();
1823
+ }
1824
+ /**
1825
+ * Fill the stylesheet lit deliberately leaves empty.
1826
+ *
1827
+ * Safe precisely because the template interpolates nothing into that
1828
+ * element: there are no marker nodes inside it to eject.
1829
+ */
1830
+ applyStyles() {
1831
+ const styleId = `${this.containerId}-styles`;
1832
+ const styleElement = [...this.children].find((child) => child instanceof HTMLStyleElement && child.id === styleId);
1833
+ if (styleElement) styleElement.textContent = this.getLightDOMStyles() + this.getCustomStyles();
1788
1834
  }
1789
1835
  getCustomStyles() {
1836
+ const field = (suffix) => `#${cssIdent(`${this.containerId}-${suffix}`)}`;
1790
1837
  return `
1791
1838
  /* PAYUNi iframe container height */
1792
- #${this.containerId}-card-no,
1793
- #${this.containerId}-card-exp,
1794
- #${this.containerId}-card-cvc {
1839
+ ${field("card-no")},
1840
+ ${field("card-exp")},
1841
+ ${field("card-cvc")} {
1795
1842
  height: 36px !important;
1796
1843
  }
1797
1844
  ${this.customStyles}
1798
1845
  `;
1799
1846
  }
1800
1847
  updateCustomStyles() {
1801
- const styleElement = this.querySelector(`#${this.containerId}-styles`);
1802
- if (styleElement) styleElement.textContent = this.getLightDOMStyles() + this.getCustomStyles();
1848
+ this.applyStyles();
1803
1849
  }
1804
1850
  isTestMode() {
1805
1851
  return (this.getAttribute("publishable-key") || "").startsWith("pk_test_");
@@ -2032,7 +2078,7 @@ var init_payment_form = __esmMin((() => {
2032
2078
  showCouponInput() {
2033
2079
  this._showCouponInput = true;
2034
2080
  this._couponError = null;
2035
- this.updateCouponSectionDOM();
2081
+ this.renderLightDOM();
2036
2082
  requestAnimationFrame(() => {
2037
2083
  document.getElementById(`${this.containerId}-coupon-input`)?.focus();
2038
2084
  });
@@ -2041,18 +2087,18 @@ var init_payment_form = __esmMin((() => {
2041
2087
  this._showCouponInput = false;
2042
2088
  this._couponCode = "";
2043
2089
  this._couponError = null;
2044
- this.updateCouponSectionDOM();
2090
+ this.renderLightDOM();
2045
2091
  }
2046
2092
  clearCouponInput() {
2047
2093
  this._couponCode = "";
2048
2094
  this._couponError = null;
2049
- this.updateCouponSectionDOM();
2095
+ this.renderLightDOM();
2050
2096
  requestAnimationFrame(() => {
2051
2097
  document.getElementById(`${this.containerId}-coupon-input`)?.focus();
2052
2098
  });
2053
2099
  }
2054
2100
  handleCouponInputChange(e) {
2055
- this._couponCode = e.target.value.toUpperCase();
2101
+ this._couponCode = e.target.value;
2056
2102
  this._couponError = null;
2057
2103
  this.updateApplyButtonState();
2058
2104
  }
@@ -2066,131 +2112,23 @@ var init_payment_form = __esmMin((() => {
2066
2112
  const btn = this.querySelector(".recur-coupon__btn--apply");
2067
2113
  if (btn) btn.disabled = this._couponLoading || !this._couponCode.trim();
2068
2114
  }
2069
- updateCouponSectionDOM() {
2070
- const containerId = `${this.containerId}-coupon`;
2071
- const container = document.getElementById(containerId);
2072
- if (!container) return;
2073
- if (this._appliedCoupon) {
2074
- container.innerHTML = this.getCouponAppliedHTML();
2075
- this.bindCouponAppliedEvents(container);
2076
- } else if (this._showCouponInput) {
2077
- container.innerHTML = this.getCouponInputHTML();
2078
- this.bindCouponInputEvents(container);
2079
- } else {
2080
- container.innerHTML = this.getCouponTriggerHTML();
2081
- this.bindCouponTriggerEvents(container);
2082
- }
2083
- }
2084
- getCouponTriggerHTML() {
2085
- return `
2086
- <button type="button" class="recur-coupon__trigger">
2087
- <svg class="recur-coupon__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2088
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
2089
- </svg>
2090
- 有優惠碼?
2091
- </button>
2092
- `;
2093
- }
2094
- getCouponInputHTML() {
2095
- const errorHTML = this._couponError ? `<div class="recur-coupon__error">${this._couponError}</div>` : "";
2096
- const clearBtnHTML = this._couponCode && !this._couponLoading ? `<button type="button" class="recur-coupon__clear" aria-label="清除">
2097
- <svg class="recur-coupon__clear-icon" width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2098
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
2099
- </svg>
2100
- </button>` : "";
2101
- return `
2102
- <div style="width: 100%;">
2103
- <div class="recur-coupon__input-row">
2104
- <div class="recur-coupon__input-wrapper">
2105
- <input
2106
- type="text"
2107
- class="recur-coupon__input"
2108
- id="${this.containerId}-coupon-input"
2109
- placeholder="輸入優惠碼"
2110
- value="${this._couponCode}"
2111
- ${this._couponLoading ? "disabled" : ""}
2112
- />
2113
- ${clearBtnHTML}
2114
- </div>
2115
- <button
2116
- type="button"
2117
- class="recur-coupon__btn recur-coupon__btn--apply"
2118
- ${this._couponLoading || !this._couponCode.trim() ? "disabled" : ""}
2119
- >
2120
- ${this._couponLoading ? "<span class=\"recur-coupon__spinner\"></span>" : "套用"}
2121
- </button>
2122
- </div>
2123
- ${errorHTML}
2124
- </div>
2125
- `;
2126
- }
2127
- getCouponAppliedHTML() {
2128
- const coupon = this._appliedCoupon;
2129
- const nameHTML = coupon.couponName ? `<span class="recur-coupon__applied-name">${coupon.couponName}</span><span class="recur-coupon__applied-code">(${coupon.code})</span>` : `<span class="recur-coupon__applied-name">${coupon.code}</span>`;
2130
- const display = this.getDiscountDisplay();
2131
- const amountHTML = coupon.discountAmount > 0 ? `<span class="recur-coupon__applied-amount">-NT$ ${coupon.discountAmount.toLocaleString()}</span>` : "";
2132
- const line1HTML = display?.line1 ? `<p class="recur-coupon__description">${display.line1}</p>` : "";
2133
- const line2HTML = display?.line2 ? `<p class="recur-coupon__description">${display.line2}</p>` : "";
2134
- return `
2135
- <div class="recur-coupon__applied-wrapper">
2136
- <div class="recur-coupon__applied">
2137
- <div class="recur-coupon__applied-info">
2138
- <svg class="recur-coupon__applied-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2139
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
2140
- </svg>
2141
- ${nameHTML}
2142
- <button type="button" class="recur-coupon__remove" ${this._couponLoading ? "disabled" : ""}>
2143
- ${this._couponLoading ? "<span class=\"recur-coupon__spinner\"></span>" : "<svg class=\"recur-coupon__remove-icon\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\" /></svg>"}
2144
- </button>
2145
- </div>
2146
- ${amountHTML}
2147
- </div>
2148
- ${line1HTML}
2149
- ${line2HTML}
2150
- </div>
2151
- `;
2152
- }
2153
- bindCouponTriggerEvents(container) {
2154
- container.querySelector(".recur-coupon__trigger")?.addEventListener("click", () => this.showCouponInput());
2155
- }
2156
- bindCouponInputEvents(container) {
2157
- const input = container.querySelector(".recur-coupon__input");
2158
- const applyBtn = container.querySelector(".recur-coupon__btn--apply");
2159
- const clearBtn = container.querySelector(".recur-coupon__clear");
2160
- input?.addEventListener("input", (e) => this.handleCouponInputChange(e));
2161
- input?.addEventListener("keydown", (e) => this.handleCouponKeydown(e));
2162
- applyBtn?.addEventListener("click", () => this.applyCoupon());
2163
- clearBtn?.addEventListener("click", () => this.clearCouponInput());
2164
- }
2165
- bindCouponAppliedEvents(container) {
2166
- container.querySelector(".recur-coupon__remove")?.addEventListener("click", () => this.removeCoupon());
2167
- }
2168
- updateOrderSummaryDOM() {
2169
- const summaryContainer = document.getElementById(`${this.containerId}-order-summary`);
2170
- if (!summaryContainer) return;
2171
- const finalAmount = Number(this.getAttribute("amount") || 0) - (this._appliedCoupon?.discountAmount || 0);
2172
- const totalValue = summaryContainer.querySelector(".recur-order-summary__row--total")?.querySelector(".recur-order-summary__total");
2173
- if (totalValue) totalValue.textContent = `NT$ ${finalAmount.toLocaleString()}`;
2174
- this.updateCouponSectionDOM();
2175
- this.updateVerificationUI();
2176
- }
2177
2115
  async applyCoupon() {
2178
2116
  const code = this._couponCode.trim();
2179
2117
  if (!code) {
2180
2118
  this._couponError = "請輸入優惠碼";
2181
- this.updateCouponSectionDOM();
2119
+ this.renderLightDOM();
2182
2120
  return;
2183
2121
  }
2184
2122
  const checkoutId = this.getAttribute("checkout-id");
2185
2123
  const apiBaseUrl = this.getAttribute("api-base-url") || "";
2186
2124
  if (!checkoutId) {
2187
2125
  this._couponError = "無法套用優惠碼";
2188
- this.updateCouponSectionDOM();
2126
+ this.renderLightDOM();
2189
2127
  return;
2190
2128
  }
2191
2129
  this._couponLoading = true;
2192
2130
  this._couponError = null;
2193
- this.updateCouponSectionDOM();
2131
+ this.renderLightDOM();
2194
2132
  try {
2195
2133
  const response = await fetch(`${apiBaseUrl}/v1/checkouts/${checkoutId}/coupon`, {
2196
2134
  method: "POST",
@@ -2201,7 +2139,7 @@ var init_payment_form = __esmMin((() => {
2201
2139
  if (!response.ok) {
2202
2140
  this._couponError = data.error?.message || "優惠碼無效";
2203
2141
  this._couponLoading = false;
2204
- this.updateCouponSectionDOM();
2142
+ this.renderLightDOM();
2205
2143
  return;
2206
2144
  }
2207
2145
  this._appliedCoupon = {
@@ -2221,7 +2159,7 @@ var init_payment_form = __esmMin((() => {
2221
2159
  this._showCouponInput = false;
2222
2160
  this._couponCode = "";
2223
2161
  this._couponLoading = false;
2224
- this.updateOrderSummaryDOM();
2162
+ this.renderLightDOM();
2225
2163
  this.dispatchEvent(new CustomEvent("coupon-applied", {
2226
2164
  detail: { coupon: this._appliedCoupon },
2227
2165
  bubbles: true,
@@ -2230,7 +2168,7 @@ var init_payment_form = __esmMin((() => {
2230
2168
  } catch {
2231
2169
  this._couponError = "網路錯誤,請稍後再試";
2232
2170
  this._couponLoading = false;
2233
- this.updateCouponSectionDOM();
2171
+ this.renderLightDOM();
2234
2172
  }
2235
2173
  }
2236
2174
  async removeCoupon() {
@@ -2238,20 +2176,20 @@ var init_payment_form = __esmMin((() => {
2238
2176
  const apiBaseUrl = this.getAttribute("api-base-url") || "";
2239
2177
  if (!checkoutId) return;
2240
2178
  this._couponLoading = true;
2241
- this.updateCouponSectionDOM();
2179
+ this.renderLightDOM();
2242
2180
  try {
2243
2181
  const response = await fetch(`${apiBaseUrl}/v1/checkouts/${checkoutId}/coupon`, { method: "DELETE" });
2244
2182
  if (!response.ok) {
2245
2183
  const data = await response.json();
2246
2184
  this._couponError = data.error?.message || "移除失敗";
2247
2185
  this._couponLoading = false;
2248
- this.updateCouponSectionDOM();
2186
+ this.renderLightDOM();
2249
2187
  return;
2250
2188
  }
2251
2189
  const removedCoupon = this._appliedCoupon;
2252
2190
  this._appliedCoupon = null;
2253
2191
  this._couponLoading = false;
2254
- this.updateOrderSummaryDOM();
2192
+ this.renderLightDOM();
2255
2193
  this.dispatchEvent(new CustomEvent("coupon-removed", {
2256
2194
  detail: { coupon: removedCoupon },
2257
2195
  bubbles: true,
@@ -2260,7 +2198,7 @@ var init_payment_form = __esmMin((() => {
2260
2198
  } catch {
2261
2199
  this._couponError = "網路錯誤,請稍後再試";
2262
2200
  this._couponLoading = false;
2263
- this.updateCouponSectionDOM();
2201
+ this.renderLightDOM();
2264
2202
  }
2265
2203
  }
2266
2204
  /**
@@ -2330,13 +2268,6 @@ var init_payment_form = __esmMin((() => {
2330
2268
  getSubmitLabel() {
2331
2269
  return this.isCardVerificationMode() ? "驗證卡片" : "確認付款";
2332
2270
  }
2333
- /** Sync the verification notice + submit label with the current amount. */
2334
- updateVerificationUI() {
2335
- const notice = document.getElementById(`${this.containerId}-verification-notice`);
2336
- if (notice) notice.classList.toggle("hidden", !this.isCardVerificationMode());
2337
- const submitText = document.getElementById(`${this.containerId}-submit-text`);
2338
- if (submitText) submitText.textContent = this.getSubmitLabel();
2339
- }
2340
2271
  renderCardFields() {
2341
2272
  return lit_html.html`
2342
2273
  <div
@@ -2391,10 +2322,22 @@ var init_payment_form = __esmMin((() => {
2391
2322
  class="recur-submit-button"
2392
2323
  disabled
2393
2324
  >
2394
- <span id="${this.containerId}-submit-text">${this.getSubmitLabel()}</span>
2325
+ ${this.renderSubmitContent()}
2395
2326
  </button>
2396
2327
  `;
2397
2328
  }
2329
+ /**
2330
+ * The submit button's content belongs to lit like everything else in the light
2331
+ * DOM: writing it by hand detaches the label lit renders into, and a later
2332
+ * full render (test-card banner, einvoice change) then either throws or keeps
2333
+ * updating a node nobody can see. The button's `disabled` and `loading` class
2334
+ * stay imperative — the template sets them statically, so lit never rewrites them.
2335
+ */
2336
+ renderSubmitContent() {
2337
+ if (this._submitState === "processing") return lit_html.html`<span class="recur-loading-spinner"></span><span>處理中...</span>`;
2338
+ if (this._submitState === "verifying") return lit_html.html`<span class="recur-loading-spinner"></span><span>3D 驗證中...</span>`;
2339
+ return lit_html.html`<span id="${this.containerId}-submit-text">${this.getSubmitLabel()}</span>`;
2340
+ }
2398
2341
  async initializePayment(sdkToken, environment) {
2399
2342
  if (this._initializationAborted) {
2400
2343
  console.log("[PaymentForm] Initialization aborted before start");
@@ -2539,18 +2482,10 @@ var init_payment_form = __esmMin((() => {
2539
2482
  setButtonLoading(loading) {
2540
2483
  const submitBtn = document.getElementById(`${this.containerId}-submit-btn`);
2541
2484
  if (!submitBtn) return;
2542
- if (loading) {
2543
- submitBtn.classList.add("loading");
2544
- submitBtn.disabled = true;
2545
- submitBtn.innerHTML = `
2546
- <span class="recur-loading-spinner"></span>
2547
- <span>處理中...</span>
2548
- `;
2549
- } else {
2550
- submitBtn.classList.remove("loading");
2551
- submitBtn.disabled = false;
2552
- submitBtn.innerHTML = "<span id=\"" + this.containerId + "-submit-text\">" + this.getSubmitLabel() + "</span>";
2553
- }
2485
+ this._submitState = loading ? "processing" : "idle";
2486
+ submitBtn.classList.toggle("loading", loading);
2487
+ submitBtn.disabled = loading;
2488
+ this.renderLightDOM();
2554
2489
  }
2555
2490
  resetButton() {
2556
2491
  this.setButtonLoading(false);
@@ -2559,12 +2494,10 @@ var init_payment_form = __esmMin((() => {
2559
2494
  const submitBtn = document.getElementById(`${this.containerId}-submit-btn`);
2560
2495
  if (!submitBtn) return;
2561
2496
  if (verifying) {
2497
+ this._submitState = "verifying";
2562
2498
  submitBtn.classList.add("loading");
2563
2499
  submitBtn.disabled = true;
2564
- submitBtn.innerHTML = `
2565
- <span class="recur-loading-spinner"></span>
2566
- <span>3D 驗證中...</span>
2567
- `;
2500
+ this.renderLightDOM();
2568
2501
  } else this.setButtonLoading(false);
2569
2502
  }
2570
2503
  /**
@@ -3249,7 +3182,7 @@ function toCamelCase(obj) {
3249
3182
 
3250
3183
  //#endregion
3251
3184
  //#region package.json
3252
- var version = "0.21.2";
3185
+ var version = "0.21.3";
3253
3186
 
3254
3187
  //#endregion
3255
3188
  //#region src/context.tsx
@@ -4406,7 +4339,7 @@ function usePromoCode(options = {}) {
4406
4339
  if (controller.signal.aborted) return;
4407
4340
  if (result.valid) {
4408
4341
  setStatus("valid");
4409
- setCode(trimmed.toUpperCase());
4342
+ setCode(result.promotionCode?.code || trimmed);
4410
4343
  setDiscount(result.discount || null);
4411
4344
  setAppliesToAllProducts(result.appliesToAllProducts ?? false);
4412
4345
  setApplicableProducts(result.applicableProducts ?? []);
@@ -4469,7 +4402,6 @@ const defaultStyles = {
4469
4402
  fontSize: "14px",
4470
4403
  fontFamily: "inherit",
4471
4404
  outline: "none",
4472
- textTransform: "uppercase",
4473
4405
  letterSpacing: "0.05em"
4474
4406
  },
4475
4407
  button: {
@@ -4514,7 +4446,7 @@ const defaultStyles = {
4514
4446
  * promo={promo}
4515
4447
  * unstyled
4516
4448
  * className="flex flex-col gap-1"
4517
- * inputClassName="border rounded px-3 py-2 uppercase"
4449
+ * inputClassName="border rounded px-3 py-2"
4518
4450
  * buttonClassName="bg-blue-500 text-white px-4 py-2 rounded"
4519
4451
  * messageClassName="text-sm"
4520
4452
  * />