recur-tw 0.13.3 → 0.15.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/dist/index.cjs CHANGED
@@ -2814,7 +2814,7 @@ function toCamelCase(obj) {
2814
2814
 
2815
2815
  // package.json
2816
2816
  var package_default = {
2817
- version: "0.13.3"};
2817
+ version: "0.15.0"};
2818
2818
  var SDK_VERSION = package_default.version;
2819
2819
  var SDK_TYPE = "react";
2820
2820
  var RecurContext = React.createContext(null);
@@ -3611,7 +3611,7 @@ function RecurProvider({ children, config: initialConfig = {}, customer: custome
3611
3611
  const fetchPlans = React.useCallback(
3612
3612
  async () => {
3613
3613
  const result = await fetchProducts({ type: "SUBSCRIPTION" });
3614
- return { plans: result.products };
3614
+ return { plans: result.data };
3615
3615
  },
3616
3616
  [fetchProducts]
3617
3617
  );
@@ -3679,8 +3679,8 @@ function useProducts(options = {}) {
3679
3679
  setError(null);
3680
3680
  try {
3681
3681
  const result = await fetchProducts({ type });
3682
- setData(result.products);
3683
- onSuccess?.(result.products);
3682
+ setData(result.data);
3683
+ onSuccess?.(result.data);
3684
3684
  } catch (err) {
3685
3685
  const error2 = err instanceof Error ? err : new Error("Failed to fetch products");
3686
3686
  setError(error2);
@@ -3748,6 +3748,272 @@ function useSubscribe(options = {}) {
3748
3748
  reset
3749
3749
  };
3750
3750
  }
3751
+ var DEFAULT_BASE_URL = "https://api.recur.tw";
3752
+ var SDK_TYPE2 = "react";
3753
+ var SDK_VERSION2 = package_default.version;
3754
+ function usePromoCode(options = {}) {
3755
+ const context = React.useContext(RecurContext);
3756
+ const publishableKey = options.publishableKey || context?.config?.publishableKey;
3757
+ const baseUrl = options.baseUrl || context?.config?.baseUrl || DEFAULT_BASE_URL;
3758
+ if (!publishableKey) {
3759
+ throw new Error(
3760
+ 'usePromoCode requires a publishableKey. Either wrap your app in <RecurProvider> or pass publishableKey directly: usePromoCode({ publishableKey: "pk_..." })'
3761
+ );
3762
+ }
3763
+ const [status, setStatus] = React.useState("idle");
3764
+ const [code, setCode] = React.useState(null);
3765
+ const [discount, setDiscount] = React.useState(null);
3766
+ const [appliesToAllProducts, setAppliesToAllProducts] = React.useState(false);
3767
+ const [applicableProducts, setApplicableProducts] = React.useState([]);
3768
+ const [error, setError] = React.useState(null);
3769
+ const abortControllerRef = React.useRef(null);
3770
+ const apply = React.useCallback(
3771
+ async (inputCode) => {
3772
+ const trimmed = inputCode.trim();
3773
+ if (!trimmed) {
3774
+ return;
3775
+ }
3776
+ abortControllerRef.current?.abort();
3777
+ const controller = new AbortController();
3778
+ abortControllerRef.current = controller;
3779
+ setStatus("validating");
3780
+ setError(null);
3781
+ setDiscount(null);
3782
+ setCode(null);
3783
+ setAppliesToAllProducts(false);
3784
+ setApplicableProducts([]);
3785
+ try {
3786
+ const body = { code: trimmed };
3787
+ if (options.customerId) {
3788
+ body.external_id = options.customerId;
3789
+ }
3790
+ if (options.recurCustomerId) {
3791
+ body.customer_id = options.recurCustomerId;
3792
+ }
3793
+ const response = await fetch(`${baseUrl}/v1/promotion-codes/validate`, {
3794
+ method: "POST",
3795
+ headers: {
3796
+ "Content-Type": "application/json",
3797
+ "X-Recur-Publishable-Key": publishableKey,
3798
+ "X-Recur-SDK-Type": SDK_TYPE2,
3799
+ "X-Recur-SDK-Version": SDK_VERSION2,
3800
+ "X-Recur-Source": typeof window !== "undefined" ? window.location.origin : "server"
3801
+ },
3802
+ body: JSON.stringify(body),
3803
+ signal: controller.signal
3804
+ });
3805
+ if (controller.signal.aborted) return;
3806
+ if (!response.ok) {
3807
+ const errorData = await response.json().catch(() => ({}));
3808
+ const message = errorData?.error?.message || "\u9A57\u8B49\u5931\u6557\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66";
3809
+ setStatus("invalid");
3810
+ setError(message);
3811
+ return;
3812
+ }
3813
+ const raw = await response.json();
3814
+ const result = toCamelCase(raw);
3815
+ if (controller.signal.aborted) return;
3816
+ if (result.valid) {
3817
+ setStatus("valid");
3818
+ setCode(trimmed.toUpperCase());
3819
+ setDiscount(result.discount || null);
3820
+ setAppliesToAllProducts(result.appliesToAllProducts ?? false);
3821
+ setApplicableProducts(result.applicableProducts ?? []);
3822
+ } else {
3823
+ const errorStatus = result.errorCode === "PROMO_CODE_ALREADY_USED" ? "already_used" : "invalid";
3824
+ setStatus(errorStatus);
3825
+ setError(result.errorMessage || "\u6B64\u512A\u60E0\u78BC\u7121\u6548");
3826
+ }
3827
+ } catch (err) {
3828
+ if (err instanceof DOMException && err.name === "AbortError") return;
3829
+ setStatus("invalid");
3830
+ setError("\u7DB2\u8DEF\u932F\u8AA4\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66");
3831
+ }
3832
+ },
3833
+ [publishableKey, baseUrl, options.customerId, options.recurCustomerId]
3834
+ );
3835
+ const clear = React.useCallback(() => {
3836
+ abortControllerRef.current?.abort();
3837
+ setStatus("idle");
3838
+ setCode(null);
3839
+ setDiscount(null);
3840
+ setAppliesToAllProducts(false);
3841
+ setApplicableProducts([]);
3842
+ setError(null);
3843
+ }, []);
3844
+ return {
3845
+ apply,
3846
+ clear,
3847
+ status,
3848
+ isLoading: status === "validating",
3849
+ isValid: status === "valid",
3850
+ code,
3851
+ discount,
3852
+ appliesToAllProducts,
3853
+ applicableProducts,
3854
+ error
3855
+ };
3856
+ }
3857
+ var defaultStyles = {
3858
+ container: {
3859
+ display: "flex",
3860
+ flexDirection: "column",
3861
+ gap: "6px",
3862
+ fontFamily: "system-ui, -apple-system, sans-serif",
3863
+ fontSize: "14px"
3864
+ },
3865
+ row: {
3866
+ display: "flex",
3867
+ gap: "8px",
3868
+ alignItems: "center"
3869
+ },
3870
+ input: {
3871
+ flex: 1,
3872
+ padding: "8px 12px",
3873
+ border: "1px solid #d1d5db",
3874
+ borderRadius: "6px",
3875
+ fontSize: "14px",
3876
+ fontFamily: "inherit",
3877
+ outline: "none",
3878
+ textTransform: "uppercase",
3879
+ letterSpacing: "0.05em"
3880
+ },
3881
+ button: {
3882
+ padding: "8px 16px",
3883
+ border: "1px solid #d1d5db",
3884
+ borderRadius: "6px",
3885
+ fontSize: "14px",
3886
+ fontFamily: "inherit",
3887
+ cursor: "pointer",
3888
+ backgroundColor: "#fff",
3889
+ whiteSpace: "nowrap"
3890
+ },
3891
+ buttonDisabled: {
3892
+ opacity: 0.5,
3893
+ cursor: "not-allowed"
3894
+ },
3895
+ messageSuccess: {
3896
+ color: "#16a34a",
3897
+ fontSize: "13px",
3898
+ margin: 0
3899
+ },
3900
+ messageError: {
3901
+ color: "#dc2626",
3902
+ fontSize: "13px",
3903
+ margin: 0
3904
+ }
3905
+ };
3906
+ function PromoCodeInput({
3907
+ promo,
3908
+ placeholder = "\u512A\u60E0\u78BC",
3909
+ applyText = "\u5957\u7528",
3910
+ clearText = "\u79FB\u9664",
3911
+ disabled = false,
3912
+ className,
3913
+ style: containerStyle,
3914
+ inputClassName,
3915
+ inputStyle,
3916
+ buttonClassName,
3917
+ buttonStyle,
3918
+ messageClassName,
3919
+ messageStyle,
3920
+ unstyled = false
3921
+ }) {
3922
+ const [inputValue, setInputValue] = React.useState("");
3923
+ const handleApply = React.useCallback(() => {
3924
+ if (inputValue.trim()) {
3925
+ promo.apply(inputValue.trim());
3926
+ }
3927
+ }, [inputValue, promo]);
3928
+ const handleClear = React.useCallback(() => {
3929
+ setInputValue("");
3930
+ promo.clear();
3931
+ }, [promo]);
3932
+ const handleKeyDown = React.useCallback(
3933
+ (e) => {
3934
+ if (e.key === "Enter") {
3935
+ e.preventDefault();
3936
+ handleApply();
3937
+ }
3938
+ },
3939
+ [handleApply]
3940
+ );
3941
+ const isApplied = promo.isValid;
3942
+ const isDisabled = disabled || promo.isLoading;
3943
+ const s = unstyled ? null : defaultStyles;
3944
+ return /* @__PURE__ */ jsxRuntime.jsxs(
3945
+ "div",
3946
+ {
3947
+ className,
3948
+ style: s ? { ...s.container, ...containerStyle } : containerStyle,
3949
+ children: [
3950
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { style: s?.row, children: [
3951
+ /* @__PURE__ */ jsxRuntime.jsx(
3952
+ "input",
3953
+ {
3954
+ type: "text",
3955
+ value: isApplied ? promo.code || "" : inputValue,
3956
+ onChange: (e) => setInputValue(e.target.value),
3957
+ onKeyDown: handleKeyDown,
3958
+ placeholder,
3959
+ disabled: isDisabled || isApplied,
3960
+ className: inputClassName,
3961
+ style: s ? { ...s.input, ...inputStyle } : inputStyle,
3962
+ "aria-label": placeholder
3963
+ }
3964
+ ),
3965
+ isApplied ? /* @__PURE__ */ jsxRuntime.jsx(
3966
+ "button",
3967
+ {
3968
+ type: "button",
3969
+ onClick: handleClear,
3970
+ disabled,
3971
+ className: buttonClassName,
3972
+ style: s ? { ...s.button, ...buttonStyle } : buttonStyle,
3973
+ children: clearText
3974
+ }
3975
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
3976
+ "button",
3977
+ {
3978
+ type: "button",
3979
+ onClick: handleApply,
3980
+ disabled: isDisabled || !inputValue.trim(),
3981
+ className: buttonClassName,
3982
+ style: s ? {
3983
+ ...s.button,
3984
+ ...isDisabled || !inputValue.trim() ? s.buttonDisabled : {},
3985
+ ...buttonStyle
3986
+ } : buttonStyle,
3987
+ children: promo.isLoading ? "\u9A57\u8B49\u4E2D..." : applyText
3988
+ }
3989
+ )
3990
+ ] }),
3991
+ promo.isValid && promo.discount && /* @__PURE__ */ jsxRuntime.jsxs(
3992
+ "p",
3993
+ {
3994
+ className: messageClassName,
3995
+ style: s ? { ...s.messageSuccess, ...messageStyle } : messageStyle,
3996
+ role: "status",
3997
+ children: [
3998
+ promo.discount.label,
3999
+ promo.discount.bonusTrialDays && promo.discount.bonusTrialDays > 0 ? ` + ${promo.discount.bonusTrialDays} \u5929\u514D\u8CBB\u8A66\u7528` : "",
4000
+ promo.discount.bonusMonths && promo.discount.bonusMonths > 0 ? ` + \u8D08\u9001 ${promo.discount.bonusMonths} \u500B\u6708` : ""
4001
+ ]
4002
+ }
4003
+ ),
4004
+ promo.error && /* @__PURE__ */ jsxRuntime.jsx(
4005
+ "p",
4006
+ {
4007
+ className: messageClassName,
4008
+ style: s ? { ...s.messageError, ...messageStyle } : messageStyle,
4009
+ role: "alert",
4010
+ children: promo.error
4011
+ }
4012
+ )
4013
+ ]
4014
+ }
4015
+ );
4016
+ }
3751
4017
  function useCustomer() {
3752
4018
  const context = React.useContext(CustomerContext);
3753
4019
  if (!context) {
@@ -3824,9 +4090,11 @@ function useCustomer() {
3824
4090
  };
3825
4091
  }
3826
4092
 
4093
+ exports.PromoCodeInput = PromoCodeInput;
3827
4094
  exports.RecurProvider = RecurProvider;
3828
4095
  exports.useCustomer = useCustomer;
3829
4096
  exports.usePlans = useProducts;
3830
4097
  exports.useProducts = useProducts;
4098
+ exports.usePromoCode = usePromoCode;
3831
4099
  exports.useRecur = useRecur;
3832
4100
  exports.useSubscribe = useSubscribe;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import React from 'react';
2
+ import React, { CSSProperties } from 'react';
3
3
 
4
4
  /**
5
5
  * Recur Loading Spinner Web Component
@@ -517,7 +517,7 @@ interface Product {
517
517
  displayOrder: number;
518
518
  }
519
519
  interface ProductsResult {
520
- products: Product[];
520
+ data: Product[];
521
521
  }
522
522
  type Plan = Product;
523
523
  interface PlansResult {
@@ -1313,6 +1313,218 @@ interface UseSubscribeResult {
1313
1313
  */
1314
1314
  declare function useSubscribe(options?: UseSubscribeOptions): UseSubscribeResult;
1315
1315
 
1316
+ interface UsePromoCodeOptions {
1317
+ /**
1318
+ * Customer identifier from your system (external ID).
1319
+ * Required for publishable key validation — the API rejects requests without it.
1320
+ */
1321
+ customerId?: string;
1322
+ /**
1323
+ * Recur internal customer ID (alternative to customerId).
1324
+ * Use this if you already have the Recur customer ID.
1325
+ */
1326
+ recurCustomerId?: string;
1327
+ /**
1328
+ * Publishable API key. If omitted, falls back to RecurProvider context.
1329
+ * This allows standalone usage without wrapping in RecurProvider.
1330
+ *
1331
+ * @example 'pk_test_abc123...'
1332
+ */
1333
+ publishableKey?: string;
1334
+ /**
1335
+ * Base URL for API calls. Defaults to 'https://api.recur.tw'.
1336
+ */
1337
+ baseUrl?: string;
1338
+ }
1339
+ type PromoCodeStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'already_used';
1340
+ interface PromoCodeDiscount {
1341
+ /** Discount type */
1342
+ type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FIRST_PERIOD_PRICE';
1343
+ /** Raw discount value (basis points for %, TWD for fixed) */
1344
+ amount: number;
1345
+ /** Final amount after discount (0 if no amount was provided) */
1346
+ discountedAmount: number;
1347
+ /** Human-readable label (e.g., "20% off", "NT$100 off") */
1348
+ label: string;
1349
+ /** Bonus trial days */
1350
+ bonusTrialDays?: number;
1351
+ /** Bonus months */
1352
+ bonusMonths?: number;
1353
+ }
1354
+ interface ApplicableProduct {
1355
+ id: string;
1356
+ name: string;
1357
+ price: number;
1358
+ priceAfterDiscount: number;
1359
+ interval?: string | null;
1360
+ intervalCount?: number | null;
1361
+ }
1362
+ interface UsePromoCodeReturn {
1363
+ /**
1364
+ * Validate and apply a promo code.
1365
+ * Call this on button click or input blur.
1366
+ */
1367
+ apply: (code: string) => Promise<void>;
1368
+ /**
1369
+ * Clear the current promo code state.
1370
+ */
1371
+ clear: () => void;
1372
+ /** Current status */
1373
+ status: PromoCodeStatus;
1374
+ /** Whether a validation request is in progress */
1375
+ isLoading: boolean;
1376
+ /** Whether the current code is valid */
1377
+ isValid: boolean;
1378
+ /**
1379
+ * The validated promo code string.
1380
+ * Pass this directly to subscribe() — null if no valid code.
1381
+ */
1382
+ code: string | null;
1383
+ /** Discount details (when valid) */
1384
+ discount: PromoCodeDiscount | null;
1385
+ /** Whether the discount applies to all products */
1386
+ appliesToAllProducts: boolean;
1387
+ /** Applicable products with discounted prices (when restricted to specific products) */
1388
+ applicableProducts: ApplicableProduct[];
1389
+ /** Error message (when invalid) */
1390
+ error: string | null;
1391
+ }
1392
+ /**
1393
+ * usePromoCode Hook
1394
+ *
1395
+ * Validate promotion codes and get discount information.
1396
+ * Works with or without RecurProvider.
1397
+ *
1398
+ * @example With RecurProvider (publishableKey from context)
1399
+ * ```tsx
1400
+ * <RecurProvider config={{ publishableKey: 'pk_test_xxx' }}>
1401
+ * <PricingPage />
1402
+ * </RecurProvider>
1403
+ *
1404
+ * function PricingPage() {
1405
+ * const promo = usePromoCode({ customerId: 'user_123' })
1406
+ *
1407
+ * return (
1408
+ * <div>
1409
+ * <input onBlur={(e) => promo.apply(e.target.value)} />
1410
+ * {promo.isValid && <span>{promo.discount.label}</span>}
1411
+ * {promo.error && <span>{promo.error}</span>}
1412
+ * </div>
1413
+ * )
1414
+ * }
1415
+ * ```
1416
+ *
1417
+ * @example Standalone (no RecurProvider needed)
1418
+ * ```tsx
1419
+ * function PricingPage() {
1420
+ * const promo = usePromoCode({
1421
+ * publishableKey: 'pk_test_xxx',
1422
+ * customerId: 'user_123',
1423
+ * })
1424
+ * // ... same usage
1425
+ * }
1426
+ * ```
1427
+ */
1428
+ declare function usePromoCode(options?: UsePromoCodeOptions): UsePromoCodeReturn;
1429
+
1430
+ interface PromoCodeInputProps {
1431
+ /**
1432
+ * Return value from usePromoCode().
1433
+ * The component reads state and calls apply/clear from this object.
1434
+ */
1435
+ promo: UsePromoCodeReturn;
1436
+ /**
1437
+ * Input placeholder text
1438
+ * @default "優惠碼"
1439
+ */
1440
+ placeholder?: string;
1441
+ /**
1442
+ * Apply button text
1443
+ * @default "套用"
1444
+ */
1445
+ applyText?: string;
1446
+ /**
1447
+ * Clear button text (shown when a code is applied)
1448
+ * @default "移除"
1449
+ */
1450
+ clearText?: string;
1451
+ /**
1452
+ * Disable the input and button
1453
+ */
1454
+ disabled?: boolean;
1455
+ /**
1456
+ * CSS class for the outer container
1457
+ */
1458
+ className?: string;
1459
+ /**
1460
+ * Inline styles for the outer container
1461
+ */
1462
+ style?: CSSProperties;
1463
+ /**
1464
+ * CSS class for the input element
1465
+ */
1466
+ inputClassName?: string;
1467
+ /**
1468
+ * Inline styles for the input element
1469
+ */
1470
+ inputStyle?: CSSProperties;
1471
+ /**
1472
+ * CSS class for the apply/clear button
1473
+ */
1474
+ buttonClassName?: string;
1475
+ /**
1476
+ * Inline styles for the apply/clear button
1477
+ */
1478
+ buttonStyle?: CSSProperties;
1479
+ /**
1480
+ * CSS class for the status message (success/error)
1481
+ */
1482
+ messageClassName?: string;
1483
+ /**
1484
+ * Inline styles for the status message
1485
+ */
1486
+ messageStyle?: CSSProperties;
1487
+ /**
1488
+ * When true, renders without any default inline styles.
1489
+ * Use this when you want full control via className and style props.
1490
+ * @default false
1491
+ */
1492
+ unstyled?: boolean;
1493
+ }
1494
+ /**
1495
+ * PromoCodeInput
1496
+ *
1497
+ * A drop-in promo code input component that works with usePromoCode().
1498
+ *
1499
+ * @example Minimal usage
1500
+ * ```tsx
1501
+ * const promo = usePromoCode({ customerId: 'user_123' });
1502
+ * <PromoCodeInput promo={promo} />
1503
+ * ```
1504
+ *
1505
+ * @example Custom styling with className (Tailwind)
1506
+ * ```tsx
1507
+ * <PromoCodeInput
1508
+ * promo={promo}
1509
+ * unstyled
1510
+ * className="flex flex-col gap-1"
1511
+ * inputClassName="border rounded px-3 py-2 uppercase"
1512
+ * buttonClassName="bg-blue-500 text-white px-4 py-2 rounded"
1513
+ * messageClassName="text-sm"
1514
+ * />
1515
+ * ```
1516
+ *
1517
+ * @example Custom styling with style props
1518
+ * ```tsx
1519
+ * <PromoCodeInput
1520
+ * promo={promo}
1521
+ * inputStyle={{ border: '2px solid #3b82f6', borderRadius: 8 }}
1522
+ * buttonStyle={{ backgroundColor: '#3b82f6', color: 'white' }}
1523
+ * />
1524
+ * ```
1525
+ */
1526
+ declare function PromoCodeInput({ promo, placeholder, applyText, clearText, disabled, className, style: containerStyle, inputClassName, inputStyle, buttonClassName, buttonStyle, messageClassName, messageStyle, unstyled, }: PromoCodeInputProps): react_jsx_runtime.JSX.Element;
1527
+
1316
1528
  /**
1317
1529
  * useCustomer Hook
1318
1530
  *
@@ -1460,4 +1672,4 @@ declare function useSubscribe(options?: UseSubscribeOptions): UseSubscribeResult
1460
1672
  */
1461
1673
  declare function useCustomer(): UseCustomerResult;
1462
1674
 
1463
- export { type ApiErrorCode, type CheckDeniedReason, type CheckOptions, type CheckResult, type CheckoutError, type CheckoutErrorDetails, type CheckoutOptions, type CheckoutResult, type CustomerIdentifier, type DuplicateSubscriptionDetails, type Entitlement, type EntitlementCustomer, type EntitlementStatus, type EntitlementSubscription, type PaymentFailedAction, type PaymentFailureCode, type PaymentFailureDetails, type Plan, type PlansResult, type Product, type ProductMetadata, type ProductsResult, type RecurConfig, type RecurContextValue, RecurProvider, type RecurProviderProps, type SubscriptionResult, type UseCustomerResult, type UseProductsOptions as UsePlansOptions, type UseProductsResult as UsePlansResult, type UseProductsOptions, type UseProductsResult, type UseSubscribeOptions, type UseSubscribeResult, useCustomer, useProducts as usePlans, useProducts, useRecur, useSubscribe };
1675
+ export { type ApiErrorCode, type ApplicableProduct, type CheckDeniedReason, type CheckOptions, type CheckResult, type CheckoutError, type CheckoutErrorDetails, type CheckoutOptions, type CheckoutResult, type CustomerIdentifier, type DuplicateSubscriptionDetails, type Entitlement, type EntitlementCustomer, type EntitlementStatus, type EntitlementSubscription, type PaymentFailedAction, type PaymentFailureCode, type PaymentFailureDetails, type Plan, type PlansResult, type Product, type ProductMetadata, type ProductsResult, type PromoCodeDiscount, PromoCodeInput, type PromoCodeInputProps, type PromoCodeStatus, type RecurConfig, type RecurContextValue, RecurProvider, type RecurProviderProps, type SubscriptionResult, type UseCustomerResult, type UseProductsOptions as UsePlansOptions, type UseProductsResult as UsePlansResult, type UseProductsOptions, type UseProductsResult, type UsePromoCodeOptions, type UsePromoCodeReturn, type UseSubscribeOptions, type UseSubscribeResult, useCustomer, useProducts as usePlans, useProducts, usePromoCode, useRecur, useSubscribe };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
- import React from 'react';
2
+ import React, { CSSProperties } from 'react';
3
3
 
4
4
  /**
5
5
  * Recur Loading Spinner Web Component
@@ -517,7 +517,7 @@ interface Product {
517
517
  displayOrder: number;
518
518
  }
519
519
  interface ProductsResult {
520
- products: Product[];
520
+ data: Product[];
521
521
  }
522
522
  type Plan = Product;
523
523
  interface PlansResult {
@@ -1313,6 +1313,218 @@ interface UseSubscribeResult {
1313
1313
  */
1314
1314
  declare function useSubscribe(options?: UseSubscribeOptions): UseSubscribeResult;
1315
1315
 
1316
+ interface UsePromoCodeOptions {
1317
+ /**
1318
+ * Customer identifier from your system (external ID).
1319
+ * Required for publishable key validation — the API rejects requests without it.
1320
+ */
1321
+ customerId?: string;
1322
+ /**
1323
+ * Recur internal customer ID (alternative to customerId).
1324
+ * Use this if you already have the Recur customer ID.
1325
+ */
1326
+ recurCustomerId?: string;
1327
+ /**
1328
+ * Publishable API key. If omitted, falls back to RecurProvider context.
1329
+ * This allows standalone usage without wrapping in RecurProvider.
1330
+ *
1331
+ * @example 'pk_test_abc123...'
1332
+ */
1333
+ publishableKey?: string;
1334
+ /**
1335
+ * Base URL for API calls. Defaults to 'https://api.recur.tw'.
1336
+ */
1337
+ baseUrl?: string;
1338
+ }
1339
+ type PromoCodeStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'already_used';
1340
+ interface PromoCodeDiscount {
1341
+ /** Discount type */
1342
+ type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FIRST_PERIOD_PRICE';
1343
+ /** Raw discount value (basis points for %, TWD for fixed) */
1344
+ amount: number;
1345
+ /** Final amount after discount (0 if no amount was provided) */
1346
+ discountedAmount: number;
1347
+ /** Human-readable label (e.g., "20% off", "NT$100 off") */
1348
+ label: string;
1349
+ /** Bonus trial days */
1350
+ bonusTrialDays?: number;
1351
+ /** Bonus months */
1352
+ bonusMonths?: number;
1353
+ }
1354
+ interface ApplicableProduct {
1355
+ id: string;
1356
+ name: string;
1357
+ price: number;
1358
+ priceAfterDiscount: number;
1359
+ interval?: string | null;
1360
+ intervalCount?: number | null;
1361
+ }
1362
+ interface UsePromoCodeReturn {
1363
+ /**
1364
+ * Validate and apply a promo code.
1365
+ * Call this on button click or input blur.
1366
+ */
1367
+ apply: (code: string) => Promise<void>;
1368
+ /**
1369
+ * Clear the current promo code state.
1370
+ */
1371
+ clear: () => void;
1372
+ /** Current status */
1373
+ status: PromoCodeStatus;
1374
+ /** Whether a validation request is in progress */
1375
+ isLoading: boolean;
1376
+ /** Whether the current code is valid */
1377
+ isValid: boolean;
1378
+ /**
1379
+ * The validated promo code string.
1380
+ * Pass this directly to subscribe() — null if no valid code.
1381
+ */
1382
+ code: string | null;
1383
+ /** Discount details (when valid) */
1384
+ discount: PromoCodeDiscount | null;
1385
+ /** Whether the discount applies to all products */
1386
+ appliesToAllProducts: boolean;
1387
+ /** Applicable products with discounted prices (when restricted to specific products) */
1388
+ applicableProducts: ApplicableProduct[];
1389
+ /** Error message (when invalid) */
1390
+ error: string | null;
1391
+ }
1392
+ /**
1393
+ * usePromoCode Hook
1394
+ *
1395
+ * Validate promotion codes and get discount information.
1396
+ * Works with or without RecurProvider.
1397
+ *
1398
+ * @example With RecurProvider (publishableKey from context)
1399
+ * ```tsx
1400
+ * <RecurProvider config={{ publishableKey: 'pk_test_xxx' }}>
1401
+ * <PricingPage />
1402
+ * </RecurProvider>
1403
+ *
1404
+ * function PricingPage() {
1405
+ * const promo = usePromoCode({ customerId: 'user_123' })
1406
+ *
1407
+ * return (
1408
+ * <div>
1409
+ * <input onBlur={(e) => promo.apply(e.target.value)} />
1410
+ * {promo.isValid && <span>{promo.discount.label}</span>}
1411
+ * {promo.error && <span>{promo.error}</span>}
1412
+ * </div>
1413
+ * )
1414
+ * }
1415
+ * ```
1416
+ *
1417
+ * @example Standalone (no RecurProvider needed)
1418
+ * ```tsx
1419
+ * function PricingPage() {
1420
+ * const promo = usePromoCode({
1421
+ * publishableKey: 'pk_test_xxx',
1422
+ * customerId: 'user_123',
1423
+ * })
1424
+ * // ... same usage
1425
+ * }
1426
+ * ```
1427
+ */
1428
+ declare function usePromoCode(options?: UsePromoCodeOptions): UsePromoCodeReturn;
1429
+
1430
+ interface PromoCodeInputProps {
1431
+ /**
1432
+ * Return value from usePromoCode().
1433
+ * The component reads state and calls apply/clear from this object.
1434
+ */
1435
+ promo: UsePromoCodeReturn;
1436
+ /**
1437
+ * Input placeholder text
1438
+ * @default "優惠碼"
1439
+ */
1440
+ placeholder?: string;
1441
+ /**
1442
+ * Apply button text
1443
+ * @default "套用"
1444
+ */
1445
+ applyText?: string;
1446
+ /**
1447
+ * Clear button text (shown when a code is applied)
1448
+ * @default "移除"
1449
+ */
1450
+ clearText?: string;
1451
+ /**
1452
+ * Disable the input and button
1453
+ */
1454
+ disabled?: boolean;
1455
+ /**
1456
+ * CSS class for the outer container
1457
+ */
1458
+ className?: string;
1459
+ /**
1460
+ * Inline styles for the outer container
1461
+ */
1462
+ style?: CSSProperties;
1463
+ /**
1464
+ * CSS class for the input element
1465
+ */
1466
+ inputClassName?: string;
1467
+ /**
1468
+ * Inline styles for the input element
1469
+ */
1470
+ inputStyle?: CSSProperties;
1471
+ /**
1472
+ * CSS class for the apply/clear button
1473
+ */
1474
+ buttonClassName?: string;
1475
+ /**
1476
+ * Inline styles for the apply/clear button
1477
+ */
1478
+ buttonStyle?: CSSProperties;
1479
+ /**
1480
+ * CSS class for the status message (success/error)
1481
+ */
1482
+ messageClassName?: string;
1483
+ /**
1484
+ * Inline styles for the status message
1485
+ */
1486
+ messageStyle?: CSSProperties;
1487
+ /**
1488
+ * When true, renders without any default inline styles.
1489
+ * Use this when you want full control via className and style props.
1490
+ * @default false
1491
+ */
1492
+ unstyled?: boolean;
1493
+ }
1494
+ /**
1495
+ * PromoCodeInput
1496
+ *
1497
+ * A drop-in promo code input component that works with usePromoCode().
1498
+ *
1499
+ * @example Minimal usage
1500
+ * ```tsx
1501
+ * const promo = usePromoCode({ customerId: 'user_123' });
1502
+ * <PromoCodeInput promo={promo} />
1503
+ * ```
1504
+ *
1505
+ * @example Custom styling with className (Tailwind)
1506
+ * ```tsx
1507
+ * <PromoCodeInput
1508
+ * promo={promo}
1509
+ * unstyled
1510
+ * className="flex flex-col gap-1"
1511
+ * inputClassName="border rounded px-3 py-2 uppercase"
1512
+ * buttonClassName="bg-blue-500 text-white px-4 py-2 rounded"
1513
+ * messageClassName="text-sm"
1514
+ * />
1515
+ * ```
1516
+ *
1517
+ * @example Custom styling with style props
1518
+ * ```tsx
1519
+ * <PromoCodeInput
1520
+ * promo={promo}
1521
+ * inputStyle={{ border: '2px solid #3b82f6', borderRadius: 8 }}
1522
+ * buttonStyle={{ backgroundColor: '#3b82f6', color: 'white' }}
1523
+ * />
1524
+ * ```
1525
+ */
1526
+ declare function PromoCodeInput({ promo, placeholder, applyText, clearText, disabled, className, style: containerStyle, inputClassName, inputStyle, buttonClassName, buttonStyle, messageClassName, messageStyle, unstyled, }: PromoCodeInputProps): react_jsx_runtime.JSX.Element;
1527
+
1316
1528
  /**
1317
1529
  * useCustomer Hook
1318
1530
  *
@@ -1460,4 +1672,4 @@ declare function useSubscribe(options?: UseSubscribeOptions): UseSubscribeResult
1460
1672
  */
1461
1673
  declare function useCustomer(): UseCustomerResult;
1462
1674
 
1463
- export { type ApiErrorCode, type CheckDeniedReason, type CheckOptions, type CheckResult, type CheckoutError, type CheckoutErrorDetails, type CheckoutOptions, type CheckoutResult, type CustomerIdentifier, type DuplicateSubscriptionDetails, type Entitlement, type EntitlementCustomer, type EntitlementStatus, type EntitlementSubscription, type PaymentFailedAction, type PaymentFailureCode, type PaymentFailureDetails, type Plan, type PlansResult, type Product, type ProductMetadata, type ProductsResult, type RecurConfig, type RecurContextValue, RecurProvider, type RecurProviderProps, type SubscriptionResult, type UseCustomerResult, type UseProductsOptions as UsePlansOptions, type UseProductsResult as UsePlansResult, type UseProductsOptions, type UseProductsResult, type UseSubscribeOptions, type UseSubscribeResult, useCustomer, useProducts as usePlans, useProducts, useRecur, useSubscribe };
1675
+ export { type ApiErrorCode, type ApplicableProduct, type CheckDeniedReason, type CheckOptions, type CheckResult, type CheckoutError, type CheckoutErrorDetails, type CheckoutOptions, type CheckoutResult, type CustomerIdentifier, type DuplicateSubscriptionDetails, type Entitlement, type EntitlementCustomer, type EntitlementStatus, type EntitlementSubscription, type PaymentFailedAction, type PaymentFailureCode, type PaymentFailureDetails, type Plan, type PlansResult, type Product, type ProductMetadata, type ProductsResult, type PromoCodeDiscount, PromoCodeInput, type PromoCodeInputProps, type PromoCodeStatus, type RecurConfig, type RecurContextValue, RecurProvider, type RecurProviderProps, type SubscriptionResult, type UseCustomerResult, type UseProductsOptions as UsePlansOptions, type UseProductsResult as UsePlansResult, type UseProductsOptions, type UseProductsResult, type UsePromoCodeOptions, type UsePromoCodeReturn, type UseSubscribeOptions, type UseSubscribeResult, useCustomer, useProducts as usePlans, useProducts, usePromoCode, useRecur, useSubscribe };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { html, render, nothing } from 'lit-html';
2
- import React, { createContext, useState, useCallback, useMemo, useContext, useEffect } from 'react';
3
- import { jsx } from 'react/jsx-runtime';
2
+ import React, { createContext, useState, useCallback, useMemo, useContext, useEffect, useRef } from 'react';
3
+ import { jsx, jsxs } from 'react/jsx-runtime';
4
4
 
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -2808,7 +2808,7 @@ function toCamelCase(obj) {
2808
2808
 
2809
2809
  // package.json
2810
2810
  var package_default = {
2811
- version: "0.13.3"};
2811
+ version: "0.15.0"};
2812
2812
  var SDK_VERSION = package_default.version;
2813
2813
  var SDK_TYPE = "react";
2814
2814
  var RecurContext = createContext(null);
@@ -3605,7 +3605,7 @@ function RecurProvider({ children, config: initialConfig = {}, customer: custome
3605
3605
  const fetchPlans = useCallback(
3606
3606
  async () => {
3607
3607
  const result = await fetchProducts({ type: "SUBSCRIPTION" });
3608
- return { plans: result.products };
3608
+ return { plans: result.data };
3609
3609
  },
3610
3610
  [fetchProducts]
3611
3611
  );
@@ -3673,8 +3673,8 @@ function useProducts(options = {}) {
3673
3673
  setError(null);
3674
3674
  try {
3675
3675
  const result = await fetchProducts({ type });
3676
- setData(result.products);
3677
- onSuccess?.(result.products);
3676
+ setData(result.data);
3677
+ onSuccess?.(result.data);
3678
3678
  } catch (err) {
3679
3679
  const error2 = err instanceof Error ? err : new Error("Failed to fetch products");
3680
3680
  setError(error2);
@@ -3742,6 +3742,272 @@ function useSubscribe(options = {}) {
3742
3742
  reset
3743
3743
  };
3744
3744
  }
3745
+ var DEFAULT_BASE_URL = "https://api.recur.tw";
3746
+ var SDK_TYPE2 = "react";
3747
+ var SDK_VERSION2 = package_default.version;
3748
+ function usePromoCode(options = {}) {
3749
+ const context = useContext(RecurContext);
3750
+ const publishableKey = options.publishableKey || context?.config?.publishableKey;
3751
+ const baseUrl = options.baseUrl || context?.config?.baseUrl || DEFAULT_BASE_URL;
3752
+ if (!publishableKey) {
3753
+ throw new Error(
3754
+ 'usePromoCode requires a publishableKey. Either wrap your app in <RecurProvider> or pass publishableKey directly: usePromoCode({ publishableKey: "pk_..." })'
3755
+ );
3756
+ }
3757
+ const [status, setStatus] = useState("idle");
3758
+ const [code, setCode] = useState(null);
3759
+ const [discount, setDiscount] = useState(null);
3760
+ const [appliesToAllProducts, setAppliesToAllProducts] = useState(false);
3761
+ const [applicableProducts, setApplicableProducts] = useState([]);
3762
+ const [error, setError] = useState(null);
3763
+ const abortControllerRef = useRef(null);
3764
+ const apply = useCallback(
3765
+ async (inputCode) => {
3766
+ const trimmed = inputCode.trim();
3767
+ if (!trimmed) {
3768
+ return;
3769
+ }
3770
+ abortControllerRef.current?.abort();
3771
+ const controller = new AbortController();
3772
+ abortControllerRef.current = controller;
3773
+ setStatus("validating");
3774
+ setError(null);
3775
+ setDiscount(null);
3776
+ setCode(null);
3777
+ setAppliesToAllProducts(false);
3778
+ setApplicableProducts([]);
3779
+ try {
3780
+ const body = { code: trimmed };
3781
+ if (options.customerId) {
3782
+ body.external_id = options.customerId;
3783
+ }
3784
+ if (options.recurCustomerId) {
3785
+ body.customer_id = options.recurCustomerId;
3786
+ }
3787
+ const response = await fetch(`${baseUrl}/v1/promotion-codes/validate`, {
3788
+ method: "POST",
3789
+ headers: {
3790
+ "Content-Type": "application/json",
3791
+ "X-Recur-Publishable-Key": publishableKey,
3792
+ "X-Recur-SDK-Type": SDK_TYPE2,
3793
+ "X-Recur-SDK-Version": SDK_VERSION2,
3794
+ "X-Recur-Source": typeof window !== "undefined" ? window.location.origin : "server"
3795
+ },
3796
+ body: JSON.stringify(body),
3797
+ signal: controller.signal
3798
+ });
3799
+ if (controller.signal.aborted) return;
3800
+ if (!response.ok) {
3801
+ const errorData = await response.json().catch(() => ({}));
3802
+ const message = errorData?.error?.message || "\u9A57\u8B49\u5931\u6557\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66";
3803
+ setStatus("invalid");
3804
+ setError(message);
3805
+ return;
3806
+ }
3807
+ const raw = await response.json();
3808
+ const result = toCamelCase(raw);
3809
+ if (controller.signal.aborted) return;
3810
+ if (result.valid) {
3811
+ setStatus("valid");
3812
+ setCode(trimmed.toUpperCase());
3813
+ setDiscount(result.discount || null);
3814
+ setAppliesToAllProducts(result.appliesToAllProducts ?? false);
3815
+ setApplicableProducts(result.applicableProducts ?? []);
3816
+ } else {
3817
+ const errorStatus = result.errorCode === "PROMO_CODE_ALREADY_USED" ? "already_used" : "invalid";
3818
+ setStatus(errorStatus);
3819
+ setError(result.errorMessage || "\u6B64\u512A\u60E0\u78BC\u7121\u6548");
3820
+ }
3821
+ } catch (err) {
3822
+ if (err instanceof DOMException && err.name === "AbortError") return;
3823
+ setStatus("invalid");
3824
+ setError("\u7DB2\u8DEF\u932F\u8AA4\uFF0C\u8ACB\u7A0D\u5F8C\u518D\u8A66");
3825
+ }
3826
+ },
3827
+ [publishableKey, baseUrl, options.customerId, options.recurCustomerId]
3828
+ );
3829
+ const clear = useCallback(() => {
3830
+ abortControllerRef.current?.abort();
3831
+ setStatus("idle");
3832
+ setCode(null);
3833
+ setDiscount(null);
3834
+ setAppliesToAllProducts(false);
3835
+ setApplicableProducts([]);
3836
+ setError(null);
3837
+ }, []);
3838
+ return {
3839
+ apply,
3840
+ clear,
3841
+ status,
3842
+ isLoading: status === "validating",
3843
+ isValid: status === "valid",
3844
+ code,
3845
+ discount,
3846
+ appliesToAllProducts,
3847
+ applicableProducts,
3848
+ error
3849
+ };
3850
+ }
3851
+ var defaultStyles = {
3852
+ container: {
3853
+ display: "flex",
3854
+ flexDirection: "column",
3855
+ gap: "6px",
3856
+ fontFamily: "system-ui, -apple-system, sans-serif",
3857
+ fontSize: "14px"
3858
+ },
3859
+ row: {
3860
+ display: "flex",
3861
+ gap: "8px",
3862
+ alignItems: "center"
3863
+ },
3864
+ input: {
3865
+ flex: 1,
3866
+ padding: "8px 12px",
3867
+ border: "1px solid #d1d5db",
3868
+ borderRadius: "6px",
3869
+ fontSize: "14px",
3870
+ fontFamily: "inherit",
3871
+ outline: "none",
3872
+ textTransform: "uppercase",
3873
+ letterSpacing: "0.05em"
3874
+ },
3875
+ button: {
3876
+ padding: "8px 16px",
3877
+ border: "1px solid #d1d5db",
3878
+ borderRadius: "6px",
3879
+ fontSize: "14px",
3880
+ fontFamily: "inherit",
3881
+ cursor: "pointer",
3882
+ backgroundColor: "#fff",
3883
+ whiteSpace: "nowrap"
3884
+ },
3885
+ buttonDisabled: {
3886
+ opacity: 0.5,
3887
+ cursor: "not-allowed"
3888
+ },
3889
+ messageSuccess: {
3890
+ color: "#16a34a",
3891
+ fontSize: "13px",
3892
+ margin: 0
3893
+ },
3894
+ messageError: {
3895
+ color: "#dc2626",
3896
+ fontSize: "13px",
3897
+ margin: 0
3898
+ }
3899
+ };
3900
+ function PromoCodeInput({
3901
+ promo,
3902
+ placeholder = "\u512A\u60E0\u78BC",
3903
+ applyText = "\u5957\u7528",
3904
+ clearText = "\u79FB\u9664",
3905
+ disabled = false,
3906
+ className,
3907
+ style: containerStyle,
3908
+ inputClassName,
3909
+ inputStyle,
3910
+ buttonClassName,
3911
+ buttonStyle,
3912
+ messageClassName,
3913
+ messageStyle,
3914
+ unstyled = false
3915
+ }) {
3916
+ const [inputValue, setInputValue] = useState("");
3917
+ const handleApply = useCallback(() => {
3918
+ if (inputValue.trim()) {
3919
+ promo.apply(inputValue.trim());
3920
+ }
3921
+ }, [inputValue, promo]);
3922
+ const handleClear = useCallback(() => {
3923
+ setInputValue("");
3924
+ promo.clear();
3925
+ }, [promo]);
3926
+ const handleKeyDown = useCallback(
3927
+ (e) => {
3928
+ if (e.key === "Enter") {
3929
+ e.preventDefault();
3930
+ handleApply();
3931
+ }
3932
+ },
3933
+ [handleApply]
3934
+ );
3935
+ const isApplied = promo.isValid;
3936
+ const isDisabled = disabled || promo.isLoading;
3937
+ const s = unstyled ? null : defaultStyles;
3938
+ return /* @__PURE__ */ jsxs(
3939
+ "div",
3940
+ {
3941
+ className,
3942
+ style: s ? { ...s.container, ...containerStyle } : containerStyle,
3943
+ children: [
3944
+ /* @__PURE__ */ jsxs("div", { style: s?.row, children: [
3945
+ /* @__PURE__ */ jsx(
3946
+ "input",
3947
+ {
3948
+ type: "text",
3949
+ value: isApplied ? promo.code || "" : inputValue,
3950
+ onChange: (e) => setInputValue(e.target.value),
3951
+ onKeyDown: handleKeyDown,
3952
+ placeholder,
3953
+ disabled: isDisabled || isApplied,
3954
+ className: inputClassName,
3955
+ style: s ? { ...s.input, ...inputStyle } : inputStyle,
3956
+ "aria-label": placeholder
3957
+ }
3958
+ ),
3959
+ isApplied ? /* @__PURE__ */ jsx(
3960
+ "button",
3961
+ {
3962
+ type: "button",
3963
+ onClick: handleClear,
3964
+ disabled,
3965
+ className: buttonClassName,
3966
+ style: s ? { ...s.button, ...buttonStyle } : buttonStyle,
3967
+ children: clearText
3968
+ }
3969
+ ) : /* @__PURE__ */ jsx(
3970
+ "button",
3971
+ {
3972
+ type: "button",
3973
+ onClick: handleApply,
3974
+ disabled: isDisabled || !inputValue.trim(),
3975
+ className: buttonClassName,
3976
+ style: s ? {
3977
+ ...s.button,
3978
+ ...isDisabled || !inputValue.trim() ? s.buttonDisabled : {},
3979
+ ...buttonStyle
3980
+ } : buttonStyle,
3981
+ children: promo.isLoading ? "\u9A57\u8B49\u4E2D..." : applyText
3982
+ }
3983
+ )
3984
+ ] }),
3985
+ promo.isValid && promo.discount && /* @__PURE__ */ jsxs(
3986
+ "p",
3987
+ {
3988
+ className: messageClassName,
3989
+ style: s ? { ...s.messageSuccess, ...messageStyle } : messageStyle,
3990
+ role: "status",
3991
+ children: [
3992
+ promo.discount.label,
3993
+ promo.discount.bonusTrialDays && promo.discount.bonusTrialDays > 0 ? ` + ${promo.discount.bonusTrialDays} \u5929\u514D\u8CBB\u8A66\u7528` : "",
3994
+ promo.discount.bonusMonths && promo.discount.bonusMonths > 0 ? ` + \u8D08\u9001 ${promo.discount.bonusMonths} \u500B\u6708` : ""
3995
+ ]
3996
+ }
3997
+ ),
3998
+ promo.error && /* @__PURE__ */ jsx(
3999
+ "p",
4000
+ {
4001
+ className: messageClassName,
4002
+ style: s ? { ...s.messageError, ...messageStyle } : messageStyle,
4003
+ role: "alert",
4004
+ children: promo.error
4005
+ }
4006
+ )
4007
+ ]
4008
+ }
4009
+ );
4010
+ }
3745
4011
  function useCustomer() {
3746
4012
  const context = useContext(CustomerContext);
3747
4013
  if (!context) {
@@ -3818,4 +4084,4 @@ function useCustomer() {
3818
4084
  };
3819
4085
  }
3820
4086
 
3821
- export { RecurProvider, useCustomer, useProducts as usePlans, useProducts, useRecur, useSubscribe };
4087
+ export { PromoCodeInput, RecurProvider, useCustomer, useProducts as usePlans, useProducts, usePromoCode, useRecur, useSubscribe };
package/dist/recur.umd.js CHANGED
@@ -2101,7 +2101,7 @@
2101
2101
  <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
2102
2102
  <circle cx="12" cy="7" r="4"/>
2103
2103
  </svg>
2104
- `}setupEventListeners(){let t=this.shadowRoot?.querySelector("button");t&&t.addEventListener("click",this.handleClick)}redirectToPortal(t){let r=this.getAttribute("target");this.dispatchEvent(new CustomEvent("portal-redirect",{detail:{url:t},bubbles:!0,composed:!0})),r==="_blank"?window.open(t,"_blank","noopener,noreferrer"):window.location.href=t}async fetchAndRedirect(t){let r=this.getAttribute("customer-id"),o=this.getAttribute("return-url");this._isLoading=!0,this.render(),this.setupEventListeners();try{let i={};r&&(i.customerId=r),o&&(i.returnUrl=o);let n=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(!n.ok){let u=await n.json().catch(()=>({}));throw new Error(u.error?.message||u.message||`HTTP ${n.status}: Failed to create portal session`)}let a=await n.json(),c=a.url||a.portalUrl;if(!c)throw new Error("Portal URL not found in response");this._isLoading=!1,this.render(),this.setupEventListeners(),this.redirectToPortal(c)}catch(i){this._isLoading=!1,this.render(),this.setupEventListeners(),this.dispatchError(i instanceof Error?i.message:"Failed to create portal session")}}dispatchError(t){console.error("[recur-portal]",t),this.dispatchEvent(new CustomEvent("portal-error",{detail:{message:t},bubbles:!0,composed:!0}))}};typeof window<"u"&&!customElements.get("recur-portal")&&customElements.define("recur-portal",pe)});var Qt={};S(Qt,{RecurCheckout:()=>O,RecurElements:()=>z,create:()=>kt,createElements:()=>$e,default:()=>Zt,init:()=>vt});async function jt(){if(typeof window>"u"){console.warn("[Recur SDK] Cannot register Web Components: window is not defined");return}await Promise.all([Promise.resolve().then(()=>(Le(),Me)),Promise.resolve().then(()=>(He(),Ue)),Promise.resolve().then(()=>(Be(),Ne)),Promise.resolve().then(()=>(Oe(),ze)),Promise.resolve().then(()=>(tt(),et)),Promise.resolve().then(()=>(ot(),rt)),Promise.resolve().then(()=>(pt(),dt)),Promise.resolve().then(()=>(ht(),mt)),Promise.resolve().then(()=>(ft(),gt))]);let e=["recur-loading-spinner","recur-success-message","recur-error-display","recur-skeleton-loader","recur-payment-form-skeleton","recur-toast","recur-toast-container","recur-payment-form","recur-checkout","recur-portal"].filter(t=>!customElements.get(t));e.length>0&&console.warn("[Recur SDK] The following components are not registered:",e)}typeof window<"u"&&jt();function Ft(s){return s.replace(/_([a-z])/g,(e,t)=>t.toUpperCase())}function w(s){if(s==null)return s;if(Array.isArray(s))return s.map(e=>w(e));if(s instanceof Date)return s;if(typeof s=="object"){let e={};for(let[t,r]of Object.entries(s)){let o=Ft(t);e[o]=w(r)}return e}return s}var me={name:"recur-tw",version:"0.13.3",description:"React & Vanilla JS SDK for embedding subscription checkout flows (Taiwan / PAYUNi)",type:"module",private:!1,author:"Recur",license:"Elastic-2.0",keywords:["react","vanilla-js","subscription","checkout","payment","payuni","recurring-billing","taiwan","\u7E41\u9AD4\u4E2D\u6587","sdk","embedded-checkout"],homepage:"https://recur.tw/",bugs:{email:"support@recur.tw"},scripts:{build:"tsup",dev:"tsup --watch",lint:"eslint .","lint:fix":"eslint . --fix","type-check":"tsc --noEmit",examples:"npx http-server . -p 8080 -o /examples/"},main:"./dist/index.js",module:"./dist/index.js",types:"./dist/index.d.ts",exports:{".":{types:"./dist/index.d.ts",import:"./dist/index.js",require:"./dist/index.cjs"},"./server":{types:"./dist/server.d.ts",import:"./dist/server.js",require:"./dist/server.cjs"},"./vanilla":"./dist/recur.umd.js","./checkout":"./dist/checkout.js","./widget":"./dist/widget.js","./package.json":"./package.json"},unpkg:"./dist/recur.umd.js",jsdelivr:"./dist/recur.umd.js",files:["dist","README.md","AGENTS.md","LICENSE"],sideEffects:["src/components/**/*.ts","src/components/**/*.js"],peerDependencies:{react:">=18.0.0","react-dom":">=18.0.0"},peerDependenciesMeta:{react:{optional:!0},"react-dom":{optional:!0}},devDependencies:{"@eslint/js":"^9.39.1","@types/node":"^20.19.9","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@workspace/typescript-config":"workspace:*",autoprefixer:"^10.4.22",eslint:"^9","eslint-plugin-react":"^7.37.5","eslint-plugin-react-hooks":"^7.0.1",postcss:"^8.5.6",react:"^19.2.1","react-dom":"^19.2.1",tailwindcss:"^4.1.11",tsup:"^8.3.5",typescript:"^5.9.2","typescript-eslint":"^8.47.0"},dependencies:{"lit-html":"^3.3.1"}};var qt=me.version,Yt="vanilla",he=class{constructor(e){l(this,"config");if(!e.publishableKey)throw new Error("publishableKey is required");this.config={baseUrl:"https://api.recur.tw",...e}}getHeaders(){return{"Content-Type":"application/json","X-Recur-Publishable-Key":this.config.publishableKey,"X-Recur-SDK-Type":Yt,"X-Recur-SDK-Version":qt,"X-Recur-Source":typeof window<"u"?window.location.origin:"server"}}async createSubscription(e){let{customerName:t,customerEmail:r}=e,o=e.productId||e.planId,i=e.productSlug;if(!o&&!i)throw new Error("Either productId or productSlug is required");let n={customerName:t,customerEmail:r};o&&(n.productId=o),i&&(n.productSlug=i);let a=await fetch(`${this.config.baseUrl}/v1/subscriptions`,{method:"POST",headers:this.getHeaders(),body:JSON.stringify(n)});if(!a.ok){let u=await a.json().catch(()=>({}));throw{code:u.error||"CHECKOUT_FAILED",message:u.message||"Failed to initiate checkout",details:u}}let c=await a.json();return w(c)}async fetchProducts(e){let t=new URL(`${this.config.baseUrl}/v1/products`);e?.type&&t.searchParams.set("type",e.type);let r=await fetch(t.toString(),{method:"GET",headers:this.getHeaders()});if(!r.ok){let i=await r.json().catch(()=>({}));throw{code:i.error||"FETCH_PRODUCTS_FAILED",message:i.message||"Failed to fetch products",details:i}}let o=await r.json();return w(o)}async fetchPlans(){return{plans:(await this.fetchProducts({type:"SUBSCRIPTION"})).products}}getConfig(){return{...this.config}}};var ge=class{constructor(e,t){l(this,"config");l(this,"options");l(this,"container");l(this,"checkoutId",null);l(this,"sdkToken",null);l(this,"sdkTimestamp",null);l(this,"creditToken",null);l(this,"sdkEnv","S");l(this,"payuniSDK",null);l(this,"isFormValid",!1);if(this.config=e,this.options=t,typeof t.container=="string"){let r=document.querySelector(t.container);if(!r)throw new Error(`Container not found: ${t.container}`);this.container=r}else this.container=t.container}async render(){try{await this.initCheckout(),this.renderHTML(),await this.loadPayUniSDK(),await this.initPayUniSDK(),this.setupFormSubmit()}catch(e){this.handleError(e)}}getBaseUrl(){if(this.config.baseUrl)return this.config.baseUrl;if(typeof window<"u"){let e=window.location.hostname;if(e==="localhost"||e.includes(".test")||e.includes(".local")||e==="127.0.0.1")return`${window.location.protocol}//${window.location.host}/api`}return"https://api.recur.tw"}async initCheckout(){let e=this.getBaseUrl(),t=await fetch(`${e}/v1/checkouts`,{method:"POST",headers:{"Content-Type":"application/json","X-Recur-Publishable-Key":this.config.publishableKey},body:JSON.stringify({productId:this.options.productId||this.options.planId,customerEmail:this.options.customerEmail,customerName:this.options.customerName})});if(!t.ok){let i=await t.json().catch(()=>({}));throw new Error(i.error||"Failed to initialize checkout")}let r=await t.json(),o=w(r);this.checkoutId=o.checkout.id,this.sdkToken=o.sdkToken,this.sdkTimestamp=o.sdkTimestamp||null,this.creditToken=o.creditToken||null,this.sdkEnv=o.livemode?"P":"S"}renderHTML(){this.container.innerHTML=`
2104
+ `}setupEventListeners(){let t=this.shadowRoot?.querySelector("button");t&&t.addEventListener("click",this.handleClick)}redirectToPortal(t){let r=this.getAttribute("target");this.dispatchEvent(new CustomEvent("portal-redirect",{detail:{url:t},bubbles:!0,composed:!0})),r==="_blank"?window.open(t,"_blank","noopener,noreferrer"):window.location.href=t}async fetchAndRedirect(t){let r=this.getAttribute("customer-id"),o=this.getAttribute("return-url");this._isLoading=!0,this.render(),this.setupEventListeners();try{let i={};r&&(i.customerId=r),o&&(i.returnUrl=o);let n=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(!n.ok){let u=await n.json().catch(()=>({}));throw new Error(u.error?.message||u.message||`HTTP ${n.status}: Failed to create portal session`)}let a=await n.json(),c=a.url||a.portalUrl;if(!c)throw new Error("Portal URL not found in response");this._isLoading=!1,this.render(),this.setupEventListeners(),this.redirectToPortal(c)}catch(i){this._isLoading=!1,this.render(),this.setupEventListeners(),this.dispatchError(i instanceof Error?i.message:"Failed to create portal session")}}dispatchError(t){console.error("[recur-portal]",t),this.dispatchEvent(new CustomEvent("portal-error",{detail:{message:t},bubbles:!0,composed:!0}))}};typeof window<"u"&&!customElements.get("recur-portal")&&customElements.define("recur-portal",pe)});var Qt={};S(Qt,{RecurCheckout:()=>O,RecurElements:()=>z,create:()=>kt,createElements:()=>$e,default:()=>Zt,init:()=>vt});async function jt(){if(typeof window>"u"){console.warn("[Recur SDK] Cannot register Web Components: window is not defined");return}await Promise.all([Promise.resolve().then(()=>(Le(),Me)),Promise.resolve().then(()=>(He(),Ue)),Promise.resolve().then(()=>(Be(),Ne)),Promise.resolve().then(()=>(Oe(),ze)),Promise.resolve().then(()=>(tt(),et)),Promise.resolve().then(()=>(ot(),rt)),Promise.resolve().then(()=>(pt(),dt)),Promise.resolve().then(()=>(ht(),mt)),Promise.resolve().then(()=>(ft(),gt))]);let e=["recur-loading-spinner","recur-success-message","recur-error-display","recur-skeleton-loader","recur-payment-form-skeleton","recur-toast","recur-toast-container","recur-payment-form","recur-checkout","recur-portal"].filter(t=>!customElements.get(t));e.length>0&&console.warn("[Recur SDK] The following components are not registered:",e)}typeof window<"u"&&jt();function Ft(s){return s.replace(/_([a-z])/g,(e,t)=>t.toUpperCase())}function w(s){if(s==null)return s;if(Array.isArray(s))return s.map(e=>w(e));if(s instanceof Date)return s;if(typeof s=="object"){let e={};for(let[t,r]of Object.entries(s)){let o=Ft(t);e[o]=w(r)}return e}return s}var me={name:"recur-tw",version:"0.15.0",description:"React & Vanilla JS SDK for embedding subscription checkout flows (Taiwan / PAYUNi)",type:"module",private:!1,author:"Recur",license:"Elastic-2.0",keywords:["react","vanilla-js","subscription","checkout","payment","payuni","recurring-billing","taiwan","\u7E41\u9AD4\u4E2D\u6587","sdk","embedded-checkout"],homepage:"https://recur.tw/",bugs:{email:"support@recur.tw"},scripts:{build:"tsup",dev:"tsup --watch",test:"vitest run","test:watch":"vitest",lint:"eslint .","lint:fix":"eslint . --fix","type-check":"tsc --noEmit",examples:"npx http-server . -p 8080 -o /examples/"},main:"./dist/index.js",module:"./dist/index.js",types:"./dist/index.d.ts",exports:{".":{types:"./dist/index.d.ts",import:"./dist/index.js",require:"./dist/index.cjs"},"./server":{types:"./dist/server.d.ts",import:"./dist/server.js",require:"./dist/server.cjs"},"./vanilla":"./dist/recur.umd.js","./checkout":"./dist/checkout.js","./widget":"./dist/widget.js","./package.json":"./package.json"},unpkg:"./dist/recur.umd.js",jsdelivr:"./dist/recur.umd.js",files:["dist","README.md","AGENTS.md","LICENSE"],sideEffects:["src/components/**/*.ts","src/components/**/*.js"],peerDependencies:{react:">=18.0.0","react-dom":">=18.0.0"},peerDependenciesMeta:{react:{optional:!0},"react-dom":{optional:!0}},devDependencies:{"@eslint/js":"^9.39.1","@testing-library/dom":"^10.4.1","@testing-library/jest-dom":"^6.9.1","@testing-library/react":"^16.3.0","@types/node":"^20.19.9","@types/react":"^19.1.9","@types/react-dom":"^19.1.7","@workspace/typescript-config":"workspace:*",autoprefixer:"^10.4.22",eslint:"^9","eslint-plugin-react":"^7.37.5","eslint-plugin-react-hooks":"^7.0.1",jsdom:"^27.2.0",postcss:"^8.5.6",react:"^19.2.1","react-dom":"^19.2.1",tailwindcss:"^4.1.11",tsup:"^8.3.5",typescript:"^5.9.2","typescript-eslint":"^8.47.0",vitest:"^4.1.0"},dependencies:{"lit-html":"^3.3.1"}};var qt=me.version,Yt="vanilla",he=class{constructor(e){l(this,"config");if(!e.publishableKey)throw new Error("publishableKey is required");this.config={baseUrl:"https://api.recur.tw",...e}}getHeaders(){return{"Content-Type":"application/json","X-Recur-Publishable-Key":this.config.publishableKey,"X-Recur-SDK-Type":Yt,"X-Recur-SDK-Version":qt,"X-Recur-Source":typeof window<"u"?window.location.origin:"server"}}async createSubscription(e){let{customerName:t,customerEmail:r}=e,o=e.productId||e.planId,i=e.productSlug;if(!o&&!i)throw new Error("Either productId or productSlug is required");let n={customerName:t,customerEmail:r};o&&(n.productId=o),i&&(n.productSlug=i);let a=await fetch(`${this.config.baseUrl}/v1/subscriptions`,{method:"POST",headers:this.getHeaders(),body:JSON.stringify(n)});if(!a.ok){let u=await a.json().catch(()=>({}));throw{code:u.error||"CHECKOUT_FAILED",message:u.message||"Failed to initiate checkout",details:u}}let c=await a.json();return w(c)}async fetchProducts(e){let t=new URL(`${this.config.baseUrl}/v1/products`);e?.type&&t.searchParams.set("type",e.type);let r=await fetch(t.toString(),{method:"GET",headers:this.getHeaders()});if(!r.ok){let i=await r.json().catch(()=>({}));throw{code:i.error||"FETCH_PRODUCTS_FAILED",message:i.message||"Failed to fetch products",details:i}}let o=await r.json();return w(o)}async fetchPlans(){return{plans:(await this.fetchProducts({type:"SUBSCRIPTION"})).data}}getConfig(){return{...this.config}}};var ge=class{constructor(e,t){l(this,"config");l(this,"options");l(this,"container");l(this,"checkoutId",null);l(this,"sdkToken",null);l(this,"sdkTimestamp",null);l(this,"creditToken",null);l(this,"sdkEnv","S");l(this,"payuniSDK",null);l(this,"isFormValid",!1);if(this.config=e,this.options=t,typeof t.container=="string"){let r=document.querySelector(t.container);if(!r)throw new Error(`Container not found: ${t.container}`);this.container=r}else this.container=t.container}async render(){try{await this.initCheckout(),this.renderHTML(),await this.loadPayUniSDK(),await this.initPayUniSDK(),this.setupFormSubmit()}catch(e){this.handleError(e)}}getBaseUrl(){if(this.config.baseUrl)return this.config.baseUrl;if(typeof window<"u"){let e=window.location.hostname;if(e==="localhost"||e.includes(".test")||e.includes(".local")||e==="127.0.0.1")return`${window.location.protocol}//${window.location.host}/api`}return"https://api.recur.tw"}async initCheckout(){let e=this.getBaseUrl(),t=await fetch(`${e}/v1/checkouts`,{method:"POST",headers:{"Content-Type":"application/json","X-Recur-Publishable-Key":this.config.publishableKey},body:JSON.stringify({productId:this.options.productId||this.options.planId,customerEmail:this.options.customerEmail,customerName:this.options.customerName})});if(!t.ok){let i=await t.json().catch(()=>({}));throw new Error(i.error||"Failed to initialize checkout")}let r=await t.json(),o=w(r);this.checkoutId=o.checkout.id,this.sdkToken=o.sdkToken,this.sdkTimestamp=o.sdkTimestamp||null,this.creditToken=o.creditToken||null,this.sdkEnv=o.livemode?"P":"S"}renderHTML(){this.container.innerHTML=`
2105
2105
  <div class="recur-embedded-checkout" style="max-width: 400px; margin: 0 auto; padding: 20px; font-family: system-ui, -apple-system, sans-serif;">
2106
2106
  <div class="recur-checkout-header" style="margin-bottom: 24px;">
2107
2107
  <h2 style="margin: 0 0 8px 0; font-size: 20px; font-weight: 600; color: #111;">Subscribe</h2>
package/dist/server.cjs CHANGED
@@ -24,7 +24,7 @@ var RecurAPIError = class extends Error {
24
24
 
25
25
  // package.json
26
26
  var package_default = {
27
- version: "0.13.3"};
27
+ version: "0.15.0"};
28
28
 
29
29
  // src/server/resources/portal.ts
30
30
  var SDK_VERSION = package_default.version;
package/dist/server.js CHANGED
@@ -22,7 +22,7 @@ var RecurAPIError = class extends Error {
22
22
 
23
23
  // package.json
24
24
  var package_default = {
25
- version: "0.13.3"};
25
+ version: "0.15.0"};
26
26
 
27
27
  // src/server/resources/portal.ts
28
28
  var SDK_VERSION = package_default.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recur-tw",
3
- "version": "0.13.3",
3
+ "version": "0.15.0",
4
4
  "description": "React & Vanilla JS SDK for embedding subscription checkout flows (Taiwan / PAYUNi)",
5
5
  "type": "module",
6
6
  "private": false,
@@ -26,6 +26,8 @@
26
26
  "scripts": {
27
27
  "build": "tsup",
28
28
  "dev": "tsup --watch",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
29
31
  "lint": "eslint .",
30
32
  "lint:fix": "eslint . --fix",
31
33
  "type-check": "tsc --noEmit",
@@ -76,6 +78,9 @@
76
78
  },
77
79
  "devDependencies": {
78
80
  "@eslint/js": "^9.39.1",
81
+ "@testing-library/dom": "^10.4.1",
82
+ "@testing-library/jest-dom": "^6.9.1",
83
+ "@testing-library/react": "^16.3.0",
79
84
  "@types/node": "^20.19.9",
80
85
  "@types/react": "^19.1.9",
81
86
  "@types/react-dom": "^19.1.7",
@@ -84,13 +89,15 @@
84
89
  "eslint": "^9",
85
90
  "eslint-plugin-react": "^7.37.5",
86
91
  "eslint-plugin-react-hooks": "^7.0.1",
92
+ "jsdom": "^27.2.0",
87
93
  "postcss": "^8.5.6",
88
94
  "react": "^19.2.1",
89
95
  "react-dom": "^19.2.1",
90
96
  "tailwindcss": "^4.1.11",
91
97
  "tsup": "^8.3.5",
92
98
  "typescript": "^5.9.2",
93
- "typescript-eslint": "^8.47.0"
99
+ "typescript-eslint": "^8.47.0",
100
+ "vitest": "^4.1.0"
94
101
  },
95
102
  "dependencies": {
96
103
  "lit-html": "^3.3.1"