recur-tw 0.14.0 → 0.16.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);
@@ -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
@@ -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 };