@blocklet/payment-react-headless 1.26.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/.eslintrc.js +18 -0
- package/build.config.ts +30 -0
- package/es/checkout/context/CheckoutProvider.d.ts +6 -0
- package/es/checkout/context/CheckoutProvider.js +209 -0
- package/es/checkout/context/CustomerFormContext.d.ts +4 -0
- package/es/checkout/context/CustomerFormContext.js +9 -0
- package/es/checkout/context/ExchangeRateContext.d.ts +11 -0
- package/es/checkout/context/ExchangeRateContext.js +9 -0
- package/es/checkout/context/PaymentMethodContext.d.ts +26 -0
- package/es/checkout/context/PaymentMethodContext.js +9 -0
- package/es/checkout/context/SessionContext.d.ts +45 -0
- package/es/checkout/context/SessionContext.js +9 -0
- package/es/checkout/context/SubmitContext.d.ts +4 -0
- package/es/checkout/context/SubmitContext.js +9 -0
- package/es/checkout/context/index.d.ts +6 -0
- package/es/checkout/context/index.js +6 -0
- package/es/checkout/core/billingInterval.d.ts +15 -0
- package/es/checkout/core/billingInterval.js +36 -0
- package/es/checkout/core/crossSell.d.ts +4 -0
- package/es/checkout/core/crossSell.js +30 -0
- package/es/checkout/core/customerForm.d.ts +5 -0
- package/es/checkout/core/customerForm.js +105 -0
- package/es/checkout/core/exchangeRate.d.ts +11 -0
- package/es/checkout/core/exchangeRate.js +25 -0
- package/es/checkout/core/index.d.ts +10 -0
- package/es/checkout/core/index.js +55 -0
- package/es/checkout/core/lineItems.d.ts +7 -0
- package/es/checkout/core/lineItems.js +59 -0
- package/es/checkout/core/paymentMethod.d.ts +23 -0
- package/es/checkout/core/paymentMethod.js +85 -0
- package/es/checkout/core/pricing.d.ts +32 -0
- package/es/checkout/core/pricing.js +221 -0
- package/es/checkout/core/promotion.d.ts +10 -0
- package/es/checkout/core/promotion.js +39 -0
- package/es/checkout/core/session.d.ts +26 -0
- package/es/checkout/core/session.js +50 -0
- package/es/checkout/core/submit.d.ts +40 -0
- package/es/checkout/core/submit.js +66 -0
- package/es/checkout/hooks/index.d.ts +34 -0
- package/es/checkout/hooks/index.js +19 -0
- package/es/checkout/hooks/useBillingInterval.d.ts +14 -0
- package/es/checkout/hooks/useBillingInterval.js +50 -0
- package/es/checkout/hooks/useCheckout.d.ts +2 -0
- package/es/checkout/hooks/useCheckout.js +212 -0
- package/es/checkout/hooks/useCheckoutSession.d.ts +58 -0
- package/es/checkout/hooks/useCheckoutSession.js +107 -0
- package/es/checkout/hooks/useCheckoutStatus.d.ts +10 -0
- package/es/checkout/hooks/useCheckoutStatus.js +16 -0
- package/es/checkout/hooks/useCrossSell.d.ts +8 -0
- package/es/checkout/hooks/useCrossSell.js +57 -0
- package/es/checkout/hooks/useCustomerForm.d.ts +14 -0
- package/es/checkout/hooks/useCustomerForm.js +116 -0
- package/es/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
- package/es/checkout/hooks/useCustomerFormFeature.js +4 -0
- package/es/checkout/hooks/useExchangeRate.d.ts +11 -0
- package/es/checkout/hooks/useExchangeRate.js +15 -0
- package/es/checkout/hooks/useLineItems.d.ts +22 -0
- package/es/checkout/hooks/useLineItems.js +139 -0
- package/es/checkout/hooks/usePaymentMethod.d.ts +26 -0
- package/es/checkout/hooks/usePaymentMethod.js +101 -0
- package/es/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
- package/es/checkout/hooks/usePaymentMethodFeature.js +4 -0
- package/es/checkout/hooks/usePricing.d.ts +57 -0
- package/es/checkout/hooks/usePricing.js +174 -0
- package/es/checkout/hooks/usePricingFeature.d.ts +28 -0
- package/es/checkout/hooks/usePricingFeature.js +36 -0
- package/es/checkout/hooks/useProduct.d.ts +32 -0
- package/es/checkout/hooks/useProduct.js +5 -0
- package/es/checkout/hooks/usePromotion.d.ts +12 -0
- package/es/checkout/hooks/usePromotion.js +48 -0
- package/es/checkout/hooks/useSlippage.d.ts +8 -0
- package/es/checkout/hooks/useSlippage.js +29 -0
- package/es/checkout/hooks/useSubmit.d.ts +38 -0
- package/es/checkout/hooks/useSubmit.js +493 -0
- package/es/checkout/hooks/useSubmitFeature.d.ts +2 -0
- package/es/checkout/hooks/useSubmitFeature.js +4 -0
- package/es/checkout/hooks/useUpsell.d.ts +5 -0
- package/es/checkout/hooks/useUpsell.js +25 -0
- package/es/checkout/index.d.ts +37 -0
- package/es/checkout/index.js +28 -0
- package/es/checkout/types.d.ts +262 -0
- package/es/checkout/types.js +0 -0
- package/es/index.d.ts +1 -0
- package/es/index.js +28 -0
- package/es/shared/api.d.ts +41 -0
- package/es/shared/api.js +81 -0
- package/es/shared/format.d.ts +38 -0
- package/es/shared/format.js +229 -0
- package/es/shared/polling.d.ts +15 -0
- package/es/shared/polling.js +20 -0
- package/es/shared/types.d.ts +10 -0
- package/es/shared/types.js +0 -0
- package/es/shared/validation.d.ts +38 -0
- package/es/shared/validation.js +190 -0
- package/es/types/checkout-augmented.d.ts +42 -0
- package/es/types/checkout-augmented.js +17 -0
- package/es/types/external.d.ts +18 -0
- package/examples/01-basic-checkout.tsx +159 -0
- package/examples/01-credit-recharge.tsx +19 -0
- package/examples/02-subscription.tsx +40 -0
- package/examples/03-upsell.tsx +60 -0
- package/examples/04-cross-sell.tsx +54 -0
- package/examples/05-full-checkout.tsx +126 -0
- package/jest.config.js +15 -0
- package/lib/checkout/context/CheckoutProvider.d.ts +6 -0
- package/lib/checkout/context/CheckoutProvider.js +181 -0
- package/lib/checkout/context/CustomerFormContext.d.ts +4 -0
- package/lib/checkout/context/CustomerFormContext.js +16 -0
- package/lib/checkout/context/ExchangeRateContext.d.ts +11 -0
- package/lib/checkout/context/ExchangeRateContext.js +16 -0
- package/lib/checkout/context/PaymentMethodContext.d.ts +26 -0
- package/lib/checkout/context/PaymentMethodContext.js +16 -0
- package/lib/checkout/context/SessionContext.d.ts +45 -0
- package/lib/checkout/context/SessionContext.js +16 -0
- package/lib/checkout/context/SubmitContext.d.ts +4 -0
- package/lib/checkout/context/SubmitContext.js +16 -0
- package/lib/checkout/context/index.d.ts +6 -0
- package/lib/checkout/context/index.js +77 -0
- package/lib/checkout/core/billingInterval.d.ts +15 -0
- package/lib/checkout/core/billingInterval.js +42 -0
- package/lib/checkout/core/crossSell.d.ts +4 -0
- package/lib/checkout/core/crossSell.js +43 -0
- package/lib/checkout/core/customerForm.d.ts +5 -0
- package/lib/checkout/core/customerForm.js +106 -0
- package/lib/checkout/core/exchangeRate.d.ts +11 -0
- package/lib/checkout/core/exchangeRate.js +45 -0
- package/lib/checkout/core/index.d.ts +10 -0
- package/lib/checkout/core/index.js +297 -0
- package/lib/checkout/core/lineItems.d.ts +7 -0
- package/lib/checkout/core/lineItems.js +76 -0
- package/lib/checkout/core/paymentMethod.d.ts +23 -0
- package/lib/checkout/core/paymentMethod.js +114 -0
- package/lib/checkout/core/pricing.d.ts +32 -0
- package/lib/checkout/core/pricing.js +216 -0
- package/lib/checkout/core/promotion.d.ts +10 -0
- package/lib/checkout/core/promotion.js +62 -0
- package/lib/checkout/core/session.d.ts +26 -0
- package/lib/checkout/core/session.js +58 -0
- package/lib/checkout/core/submit.d.ts +40 -0
- package/lib/checkout/core/submit.js +84 -0
- package/lib/checkout/hooks/index.d.ts +34 -0
- package/lib/checkout/hooks/index.js +138 -0
- package/lib/checkout/hooks/useBillingInterval.d.ts +14 -0
- package/lib/checkout/hooks/useBillingInterval.js +63 -0
- package/lib/checkout/hooks/useCheckout.d.ts +2 -0
- package/lib/checkout/hooks/useCheckout.js +190 -0
- package/lib/checkout/hooks/useCheckoutSession.d.ts +58 -0
- package/lib/checkout/hooks/useCheckoutSession.js +119 -0
- package/lib/checkout/hooks/useCheckoutStatus.d.ts +10 -0
- package/lib/checkout/hooks/useCheckoutStatus.js +28 -0
- package/lib/checkout/hooks/useCrossSell.d.ts +8 -0
- package/lib/checkout/hooks/useCrossSell.js +75 -0
- package/lib/checkout/hooks/useCustomerForm.d.ts +14 -0
- package/lib/checkout/hooks/useCustomerForm.js +135 -0
- package/lib/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
- package/lib/checkout/hooks/useCustomerFormFeature.js +10 -0
- package/lib/checkout/hooks/useExchangeRate.d.ts +11 -0
- package/lib/checkout/hooks/useExchangeRate.js +29 -0
- package/lib/checkout/hooks/useLineItems.d.ts +22 -0
- package/lib/checkout/hooks/useLineItems.js +142 -0
- package/lib/checkout/hooks/usePaymentMethod.d.ts +26 -0
- package/lib/checkout/hooks/usePaymentMethod.js +101 -0
- package/lib/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
- package/lib/checkout/hooks/usePaymentMethodFeature.js +10 -0
- package/lib/checkout/hooks/usePricing.d.ts +57 -0
- package/lib/checkout/hooks/usePricing.js +168 -0
- package/lib/checkout/hooks/usePricingFeature.d.ts +28 -0
- package/lib/checkout/hooks/usePricingFeature.js +48 -0
- package/lib/checkout/hooks/useProduct.d.ts +32 -0
- package/lib/checkout/hooks/useProduct.js +21 -0
- package/lib/checkout/hooks/usePromotion.d.ts +12 -0
- package/lib/checkout/hooks/usePromotion.js +57 -0
- package/lib/checkout/hooks/useSlippage.d.ts +8 -0
- package/lib/checkout/hooks/useSlippage.js +39 -0
- package/lib/checkout/hooks/useSubmit.d.ts +38 -0
- package/lib/checkout/hooks/useSubmit.js +504 -0
- package/lib/checkout/hooks/useSubmitFeature.d.ts +2 -0
- package/lib/checkout/hooks/useSubmitFeature.js +10 -0
- package/lib/checkout/hooks/useUpsell.d.ts +5 -0
- package/lib/checkout/hooks/useUpsell.js +40 -0
- package/lib/checkout/index.d.ts +37 -0
- package/lib/checkout/index.js +182 -0
- package/lib/checkout/types.d.ts +262 -0
- package/lib/checkout/types.js +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +162 -0
- package/lib/shared/api.d.ts +41 -0
- package/lib/shared/api.js +88 -0
- package/lib/shared/format.d.ts +38 -0
- package/lib/shared/format.js +262 -0
- package/lib/shared/polling.d.ts +15 -0
- package/lib/shared/polling.js +32 -0
- package/lib/shared/types.d.ts +10 -0
- package/lib/shared/types.js +1 -0
- package/lib/shared/validation.d.ts +38 -0
- package/lib/shared/validation.js +212 -0
- package/lib/types/checkout-augmented.d.ts +42 -0
- package/lib/types/checkout-augmented.js +24 -0
- package/lib/types/external.d.ts +18 -0
- package/package.json +64 -0
- package/src/checkout/context/CheckoutProvider.tsx +269 -0
- package/src/checkout/context/CustomerFormContext.ts +14 -0
- package/src/checkout/context/ExchangeRateContext.ts +21 -0
- package/src/checkout/context/PaymentMethodContext.ts +36 -0
- package/src/checkout/context/SessionContext.ts +49 -0
- package/src/checkout/context/SubmitContext.ts +14 -0
- package/src/checkout/context/index.ts +6 -0
- package/src/checkout/core/billingInterval.ts +62 -0
- package/src/checkout/core/crossSell.ts +52 -0
- package/src/checkout/core/customerForm.ts +122 -0
- package/src/checkout/core/exchangeRate.ts +38 -0
- package/src/checkout/core/index.ts +60 -0
- package/src/checkout/core/lineItems.ts +106 -0
- package/src/checkout/core/paymentMethod.ts +113 -0
- package/src/checkout/core/pricing.ts +347 -0
- package/src/checkout/core/promotion.ts +59 -0
- package/src/checkout/core/session.ts +62 -0
- package/src/checkout/core/submit.ts +109 -0
- package/src/checkout/hooks/index.ts +41 -0
- package/src/checkout/hooks/useBillingInterval.ts +71 -0
- package/src/checkout/hooks/useCheckout.ts +267 -0
- package/src/checkout/hooks/useCheckoutSession.ts +217 -0
- package/src/checkout/hooks/useCheckoutStatus.ts +31 -0
- package/src/checkout/hooks/useCrossSell.ts +80 -0
- package/src/checkout/hooks/useCustomerForm.ts +156 -0
- package/src/checkout/hooks/useCustomerFormFeature.ts +7 -0
- package/src/checkout/hooks/useExchangeRate.ts +28 -0
- package/src/checkout/hooks/useLineItems.ts +191 -0
- package/src/checkout/hooks/usePaymentMethod.ts +165 -0
- package/src/checkout/hooks/usePaymentMethodFeature.ts +8 -0
- package/src/checkout/hooks/usePricing.ts +274 -0
- package/src/checkout/hooks/usePricingFeature.ts +73 -0
- package/src/checkout/hooks/useProduct.ts +32 -0
- package/src/checkout/hooks/usePromotion.ts +67 -0
- package/src/checkout/hooks/useSlippage.ts +39 -0
- package/src/checkout/hooks/useSubmit.ts +684 -0
- package/src/checkout/hooks/useSubmitFeature.ts +7 -0
- package/src/checkout/hooks/useUpsell.ts +35 -0
- package/src/checkout/index.ts +65 -0
- package/src/checkout/types.ts +292 -0
- package/src/index.ts +64 -0
- package/src/shared/api.ts +118 -0
- package/src/shared/format.ts +318 -0
- package/src/shared/polling.ts +49 -0
- package/src/shared/types.ts +13 -0
- package/src/shared/validation.ts +254 -0
- package/src/types/checkout-augmented.ts +77 -0
- package/src/types/external.d.ts +18 -0
- package/tools/jest.js +1 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { useMemo, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import type { TLineItemExpanded, TPrice } from '@blocklet/payment-types';
|
|
4
|
+
|
|
5
|
+
import { getErrorMessage } from '../../types/checkout-augmented';
|
|
6
|
+
|
|
7
|
+
import type { UseCheckoutReturn } from '../types';
|
|
8
|
+
import { useCheckoutSession } from './useCheckoutSession';
|
|
9
|
+
import { usePaymentMethod } from './usePaymentMethod';
|
|
10
|
+
import { usePricing } from './usePricing';
|
|
11
|
+
import { useCustomerForm } from './useCustomerForm';
|
|
12
|
+
import { useSubmit } from './useSubmit';
|
|
13
|
+
import {
|
|
14
|
+
recalculatePromotionIfNeeded,
|
|
15
|
+
adjustQuantity,
|
|
16
|
+
performUpsell,
|
|
17
|
+
performDownsell,
|
|
18
|
+
changeDonationAmount,
|
|
19
|
+
getCrossSellItem,
|
|
20
|
+
} from '../core/lineItems';
|
|
21
|
+
import { addCrossSellItem, removeCrossSellItem, fetchCrossSellItem } from '../core/crossSell';
|
|
22
|
+
import { parseBillingInterval } from '../core/billingInterval';
|
|
23
|
+
|
|
24
|
+
export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
25
|
+
// 1. Session
|
|
26
|
+
const {
|
|
27
|
+
isLoading,
|
|
28
|
+
error,
|
|
29
|
+
errorCode,
|
|
30
|
+
refresh,
|
|
31
|
+
sessionData,
|
|
32
|
+
resolvedSessionId,
|
|
33
|
+
vendorCount,
|
|
34
|
+
product,
|
|
35
|
+
subscription,
|
|
36
|
+
pageInfo,
|
|
37
|
+
} = useCheckoutSession(sessionId);
|
|
38
|
+
|
|
39
|
+
const session = sessionData?.checkoutSession;
|
|
40
|
+
// Use resolved ID (cs_xxx) for all API calls - handles plink_ → cs_ resolution
|
|
41
|
+
const effectiveSessionId = resolvedSessionId || sessionId;
|
|
42
|
+
|
|
43
|
+
// 2. Payment method
|
|
44
|
+
const paymentMethodHook = usePaymentMethod(sessionData, effectiveSessionId, refresh);
|
|
45
|
+
|
|
46
|
+
// 3. Pricing
|
|
47
|
+
const pricingHook = usePricing(
|
|
48
|
+
sessionData,
|
|
49
|
+
effectiveSessionId,
|
|
50
|
+
paymentMethodHook.currency,
|
|
51
|
+
paymentMethodHook.isStripe,
|
|
52
|
+
refresh,
|
|
53
|
+
paymentMethodHook.current?.type || null
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// 4. Customer form
|
|
57
|
+
const formHook = useCustomerForm(
|
|
58
|
+
sessionData,
|
|
59
|
+
paymentMethodHook.currency?.id || null,
|
|
60
|
+
paymentMethodHook.current?.id || null
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// 5. Determine isDonation
|
|
64
|
+
const isDonation = session?.submit_type === 'donate';
|
|
65
|
+
|
|
66
|
+
// 6. Submit
|
|
67
|
+
const submitHook = useSubmit(
|
|
68
|
+
sessionData,
|
|
69
|
+
effectiveSessionId,
|
|
70
|
+
paymentMethodHook.currency?.id || null,
|
|
71
|
+
paymentMethodHook.isStripe,
|
|
72
|
+
paymentMethodHook.isCredit,
|
|
73
|
+
isDonation,
|
|
74
|
+
formHook.values,
|
|
75
|
+
formHook.validate,
|
|
76
|
+
refresh
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 7. Line items operations
|
|
80
|
+
const items = (session?.line_items || []) as TLineItemExpanded[];
|
|
81
|
+
const currencyId = paymentMethodHook.currency?.id || null;
|
|
82
|
+
|
|
83
|
+
// Recalculate promotion when currency changes or on initial load
|
|
84
|
+
const prevCurrencyRef = useRef<string | null>(null);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const currId = paymentMethodHook.currency?.id || null;
|
|
87
|
+
if (!currId || !session) return;
|
|
88
|
+
if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
|
|
89
|
+
prevCurrencyRef.current = currId;
|
|
90
|
+
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() => refresh(true));
|
|
91
|
+
}
|
|
92
|
+
}, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
|
+
|
|
94
|
+
const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
|
|
95
|
+
try {
|
|
96
|
+
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
|
|
97
|
+
} catch (err: unknown) {
|
|
98
|
+
console.error('Failed to update quantity:', getErrorMessage(err));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
103
|
+
try {
|
|
104
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
105
|
+
} catch (err: unknown) {
|
|
106
|
+
console.error('Failed to upsell:', getErrorMessage(err));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
111
|
+
try {
|
|
112
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
113
|
+
} catch (err: unknown) {
|
|
114
|
+
console.error('Failed to downsell:', getErrorMessage(err));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
|
|
119
|
+
const embeddedCrossSellItem = useMemo(() => getCrossSellItem(items), [items]);
|
|
120
|
+
const [fetchedCrossSellItem, setFetchedCrossSellItem] = useState<TPrice | null>(null);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!effectiveSessionId || !session) return undefined;
|
|
124
|
+
if (embeddedCrossSellItem) return undefined;
|
|
125
|
+
|
|
126
|
+
let cancelled = false;
|
|
127
|
+
fetchCrossSellItem(effectiveSessionId).then((item) => {
|
|
128
|
+
if (!cancelled && item) {
|
|
129
|
+
setFetchedCrossSellItem(item);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return () => {
|
|
133
|
+
cancelled = true;
|
|
134
|
+
};
|
|
135
|
+
}, [effectiveSessionId, session?.id, embeddedCrossSellItem]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
136
|
+
|
|
137
|
+
const crossSellItem = embeddedCrossSellItem || fetchedCrossSellItem;
|
|
138
|
+
const crossSellRequired = useMemo(() => items.some((item) => item.cross_sell_required), [items]);
|
|
139
|
+
|
|
140
|
+
const addCrossSell = useMemoizedFn(async () => {
|
|
141
|
+
if (!crossSellItem) return;
|
|
142
|
+
try {
|
|
143
|
+
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const removeCrossSell = useMemoizedFn(async () => {
|
|
150
|
+
try {
|
|
151
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
152
|
+
} catch (err: unknown) {
|
|
153
|
+
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Billing interval
|
|
158
|
+
const billingInterval = useMemo(() => {
|
|
159
|
+
const parsed = parseBillingInterval(items);
|
|
160
|
+
if (!parsed) return null;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
current: parsed.current,
|
|
164
|
+
available: parsed.available,
|
|
165
|
+
switch: async (interval: 'month' | 'year' | 'week' | 'day') => {
|
|
166
|
+
const target = parsed.available.find((a) => a.interval === interval);
|
|
167
|
+
if (!target) return;
|
|
168
|
+
|
|
169
|
+
if (!parsed.firstItem.upsell_price_id && target.priceId) {
|
|
170
|
+
await upsell(parsed.firstItem.price_id, target.priceId);
|
|
171
|
+
} else if (parsed.firstItem.upsell_price_id) {
|
|
172
|
+
await downsell(parsed.firstItem.upsell_price_id);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}, [items, effectiveSessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
177
|
+
|
|
178
|
+
// Donation: custom amount change
|
|
179
|
+
const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
|
|
180
|
+
if (!isDonation) return;
|
|
181
|
+
try {
|
|
182
|
+
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
|
|
183
|
+
} catch (err: unknown) {
|
|
184
|
+
console.error('Failed to change amount:', getErrorMessage(err));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Convenience flags
|
|
189
|
+
const canSubmit = submitHook.status === 'idle' && !isLoading && !error && session?.status === 'open';
|
|
190
|
+
|
|
191
|
+
const isCompleted = submitHook.status === 'completed';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
isLoading,
|
|
195
|
+
error,
|
|
196
|
+
errorCode,
|
|
197
|
+
refresh: async () => {
|
|
198
|
+
await refresh();
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
product,
|
|
202
|
+
vendorCount,
|
|
203
|
+
|
|
204
|
+
lineItems: {
|
|
205
|
+
items: items.map((item) => ({
|
|
206
|
+
...item,
|
|
207
|
+
adjustable_quantity: item.adjustable_quantity,
|
|
208
|
+
})),
|
|
209
|
+
updateQuantity,
|
|
210
|
+
upsell,
|
|
211
|
+
downsell,
|
|
212
|
+
billingInterval,
|
|
213
|
+
crossSellItem,
|
|
214
|
+
crossSellRequired,
|
|
215
|
+
addCrossSell,
|
|
216
|
+
removeCrossSell,
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
paymentMethod: {
|
|
220
|
+
current: paymentMethodHook.current,
|
|
221
|
+
currency: paymentMethodHook.currency,
|
|
222
|
+
available: paymentMethodHook.available,
|
|
223
|
+
currencies: paymentMethodHook.currencies,
|
|
224
|
+
isStripe: paymentMethodHook.isStripe,
|
|
225
|
+
isCrypto: paymentMethodHook.isCrypto,
|
|
226
|
+
isCredit: paymentMethodHook.isCredit,
|
|
227
|
+
setType: paymentMethodHook.setType,
|
|
228
|
+
types: paymentMethodHook.types,
|
|
229
|
+
setCurrency: paymentMethodHook.setCurrency,
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
stripe: paymentMethodHook.stripe,
|
|
233
|
+
|
|
234
|
+
pricing: pricingHook,
|
|
235
|
+
|
|
236
|
+
form: formHook,
|
|
237
|
+
|
|
238
|
+
submit: submitHook,
|
|
239
|
+
|
|
240
|
+
subscription,
|
|
241
|
+
pageInfo,
|
|
242
|
+
|
|
243
|
+
canSubmit,
|
|
244
|
+
isCompleted,
|
|
245
|
+
isDonation,
|
|
246
|
+
setDonationAmount,
|
|
247
|
+
|
|
248
|
+
customer: sessionData?.customer
|
|
249
|
+
? {
|
|
250
|
+
name: sessionData.customer.name || '',
|
|
251
|
+
email: sessionData.customer.email || '',
|
|
252
|
+
phone: sessionData.customer.phone || '',
|
|
253
|
+
address: {
|
|
254
|
+
country: sessionData.customer.address?.country || '',
|
|
255
|
+
state: sessionData.customer.address?.state || '',
|
|
256
|
+
city: sessionData.customer.address?.city || '',
|
|
257
|
+
line1: sessionData.customer.address?.line1 || '',
|
|
258
|
+
line2: sessionData.customer.address?.line2 || '',
|
|
259
|
+
postal_code: sessionData.customer.address?.postal_code || '',
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
: null,
|
|
263
|
+
|
|
264
|
+
livemode: !!session?.livemode,
|
|
265
|
+
session: session || null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import type {
|
|
4
|
+
TCheckoutSessionExpanded,
|
|
5
|
+
TLineItemExpanded,
|
|
6
|
+
TPaymentMethodExpanded,
|
|
7
|
+
TPaymentIntent,
|
|
8
|
+
TCustomer,
|
|
9
|
+
} from '@blocklet/payment-types';
|
|
10
|
+
|
|
11
|
+
import api, { API } from '../../shared/api';
|
|
12
|
+
import { getAxiosErrorDetails } from '../../types/checkout-augmented';
|
|
13
|
+
import { parseProduct, parseSubscription, parsePageInfo } from '../core/session';
|
|
14
|
+
|
|
15
|
+
export interface SessionData {
|
|
16
|
+
checkoutSession: TCheckoutSessionExpanded;
|
|
17
|
+
paymentMethods: TPaymentMethodExpanded[];
|
|
18
|
+
paymentIntent?: TPaymentIntent;
|
|
19
|
+
customer?: TCustomer;
|
|
20
|
+
quotes?: Record<
|
|
21
|
+
string,
|
|
22
|
+
{
|
|
23
|
+
quote_id: string;
|
|
24
|
+
expires_at: number;
|
|
25
|
+
quoted_amount: string;
|
|
26
|
+
exchange_rate?: string;
|
|
27
|
+
rate_provider_name?: string;
|
|
28
|
+
rate_provider_id?: string;
|
|
29
|
+
}
|
|
30
|
+
>;
|
|
31
|
+
rateUnavailable?: boolean;
|
|
32
|
+
rateError?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UseCheckoutSessionReturn {
|
|
36
|
+
// Loading
|
|
37
|
+
isLoading: boolean;
|
|
38
|
+
error: string | null;
|
|
39
|
+
/** Error code for structured error handling: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null */
|
|
40
|
+
errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
|
|
41
|
+
refresh: (forceRefresh?: boolean) => Promise<void>;
|
|
42
|
+
/** Directly update session data (e.g. after completion polling returns fresh data) */
|
|
43
|
+
setSessionData: (data: SessionData) => void;
|
|
44
|
+
|
|
45
|
+
// Raw session data (internal, for sub-hooks)
|
|
46
|
+
sessionData: SessionData | null;
|
|
47
|
+
|
|
48
|
+
// Resolved session ID (cs_xxx, resolved from plink_ if needed)
|
|
49
|
+
resolvedSessionId: string;
|
|
50
|
+
|
|
51
|
+
// Vendor info for completion polling
|
|
52
|
+
vendorCount: number;
|
|
53
|
+
|
|
54
|
+
// Parsed product
|
|
55
|
+
product: {
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
images: string[];
|
|
59
|
+
features: Array<{ name: string; icon?: string }>;
|
|
60
|
+
billing: {
|
|
61
|
+
mode: 'payment' | 'subscription';
|
|
62
|
+
interval: 'month' | 'year' | 'week' | 'day' | null;
|
|
63
|
+
intervalCount: number;
|
|
64
|
+
displayInterval: string;
|
|
65
|
+
};
|
|
66
|
+
metadata: Record<string, string>;
|
|
67
|
+
} | null;
|
|
68
|
+
|
|
69
|
+
// Parsed subscription config
|
|
70
|
+
subscription: {
|
|
71
|
+
mode: 'payment' | 'subscription' | 'setup';
|
|
72
|
+
showStake: boolean;
|
|
73
|
+
confirmMessage: string;
|
|
74
|
+
} | null;
|
|
75
|
+
|
|
76
|
+
// Parsed page info
|
|
77
|
+
pageInfo: {
|
|
78
|
+
formPurposeDescription?: { en: string; zh: string };
|
|
79
|
+
showProductFeatures: boolean;
|
|
80
|
+
} | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useCheckoutSession(sessionId: string): UseCheckoutSessionReturn {
|
|
84
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
const [sessionData, setSessionData] = useState<SessionData | null>(null);
|
|
87
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
88
|
+
const mountedRef = useRef(true);
|
|
89
|
+
// For plink_ → cs_ resolution
|
|
90
|
+
const resolvedIdRef = useRef<string | null>(null);
|
|
91
|
+
|
|
92
|
+
const isPaymentLink = sessionId?.startsWith('plink_');
|
|
93
|
+
|
|
94
|
+
// Resolve plink_ to cs_ via POST /start/:id
|
|
95
|
+
const resolveSessionId = useMemoizedFn(async (): Promise<string> => {
|
|
96
|
+
if (!isPaymentLink) return sessionId;
|
|
97
|
+
if (resolvedIdRef.current) return resolvedIdRef.current;
|
|
98
|
+
|
|
99
|
+
const { data } = await api.post(API.START_PAYMENT_LINK(sessionId));
|
|
100
|
+
const resolved = data?.checkoutSession?.id;
|
|
101
|
+
if (!resolved) throw new Error('Failed to start payment link session');
|
|
102
|
+
resolvedIdRef.current = resolved;
|
|
103
|
+
return resolved;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const fetchSession = useMemoizedFn(async (forceRefresh = false) => {
|
|
107
|
+
// Cancel previous request
|
|
108
|
+
if (abortRef.current) {
|
|
109
|
+
abortRef.current.abort();
|
|
110
|
+
}
|
|
111
|
+
abortRef.current = new AbortController();
|
|
112
|
+
|
|
113
|
+
if (!sessionId) {
|
|
114
|
+
setIsLoading(false);
|
|
115
|
+
setError('Session ID is required');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Only show loading skeleton on initial load, not on refreshes
|
|
121
|
+
if (!forceRefresh) {
|
|
122
|
+
setIsLoading(true);
|
|
123
|
+
}
|
|
124
|
+
setError(null);
|
|
125
|
+
|
|
126
|
+
// Resolve plink_ to real session id
|
|
127
|
+
const realId = await resolveSessionId();
|
|
128
|
+
|
|
129
|
+
const url = forceRefresh ? `${API.RETRIEVE_SESSION(realId)}?forceRefresh=1` : API.RETRIEVE_SESSION(realId);
|
|
130
|
+
|
|
131
|
+
const { data } = await api.get(url, {
|
|
132
|
+
signal: abortRef.current.signal,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (!mountedRef.current) return;
|
|
136
|
+
|
|
137
|
+
// Validate session (matching original payment-react checks)
|
|
138
|
+
const cs = data?.checkoutSession;
|
|
139
|
+
if (cs) {
|
|
140
|
+
// Session expired check — skip for already-completed sessions
|
|
141
|
+
if (cs.status !== 'complete' && cs.expires_at && cs.expires_at <= Math.round(Date.now() / 1000)) {
|
|
142
|
+
setError('SESSION_EXPIRED');
|
|
143
|
+
setIsLoading(false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Empty line_items check
|
|
147
|
+
if (!cs.line_items?.length) {
|
|
148
|
+
setError('EMPTY_LINE_ITEMS');
|
|
149
|
+
setIsLoading(false);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
setSessionData(data);
|
|
155
|
+
setIsLoading(false);
|
|
156
|
+
} catch (err: unknown) {
|
|
157
|
+
if (!mountedRef.current) return;
|
|
158
|
+
const { isCancelled, message } = getAxiosErrorDetails(err);
|
|
159
|
+
if (isCancelled) return;
|
|
160
|
+
|
|
161
|
+
setError(message || 'Failed to load checkout session');
|
|
162
|
+
setIsLoading(false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const refresh = useCallback(
|
|
167
|
+
async (forceRefresh = false) => {
|
|
168
|
+
await fetchSession(forceRefresh);
|
|
169
|
+
},
|
|
170
|
+
[fetchSession]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Initial load
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
mountedRef.current = true;
|
|
176
|
+
fetchSession();
|
|
177
|
+
return () => {
|
|
178
|
+
mountedRef.current = false;
|
|
179
|
+
if (abortRef.current) {
|
|
180
|
+
abortRef.current.abort();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
184
|
+
|
|
185
|
+
const session = sessionData?.checkoutSession;
|
|
186
|
+
|
|
187
|
+
// Expose the resolved session ID (cs_xxx) for downstream hooks
|
|
188
|
+
const resolvedSessionId = resolvedIdRef.current || (isPaymentLink ? '' : sessionId);
|
|
189
|
+
|
|
190
|
+
// Determine error code for structured handling
|
|
191
|
+
let errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null = null;
|
|
192
|
+
if (error === 'SESSION_EXPIRED') {
|
|
193
|
+
errorCode = 'SESSION_EXPIRED';
|
|
194
|
+
} else if (error === 'EMPTY_LINE_ITEMS') {
|
|
195
|
+
errorCode = 'EMPTY_LINE_ITEMS';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Count vendor configs for post-payment polling
|
|
199
|
+
const vendorCount =
|
|
200
|
+
(session?.line_items as TLineItemExpanded[] | undefined)?.reduce((count: number, item) => {
|
|
201
|
+
return count + (item?.price?.product?.vendor_config?.length || 0);
|
|
202
|
+
}, 0) || 0;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
isLoading,
|
|
206
|
+
error,
|
|
207
|
+
errorCode,
|
|
208
|
+
refresh,
|
|
209
|
+
setSessionData,
|
|
210
|
+
sessionData,
|
|
211
|
+
resolvedSessionId,
|
|
212
|
+
vendorCount,
|
|
213
|
+
product: session ? parseProduct(session) : null,
|
|
214
|
+
subscription: session ? parseSubscription(session) : null,
|
|
215
|
+
pageInfo: session ? parsePageInfo(session) : null,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useSessionContext } from '../context/SessionContext';
|
|
2
|
+
|
|
3
|
+
export interface UseCheckoutStatusReturn {
|
|
4
|
+
isLoading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
|
|
7
|
+
canSubmit: boolean;
|
|
8
|
+
isCompleted: boolean;
|
|
9
|
+
isDonation: boolean;
|
|
10
|
+
livemode: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useCheckoutStatus(): UseCheckoutStatusReturn {
|
|
14
|
+
const { isLoading, error, errorCode, session, isDonation } = useSessionContext();
|
|
15
|
+
|
|
16
|
+
// Note: canSubmit requires submit status which lives outside context
|
|
17
|
+
// For full canSubmit, use in combination with useSubmit
|
|
18
|
+
const canSubmit = !isLoading && !error && session?.status === 'open';
|
|
19
|
+
const isCompleted = session?.status === 'complete';
|
|
20
|
+
const livemode = !!session?.livemode;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
isLoading,
|
|
24
|
+
error,
|
|
25
|
+
errorCode,
|
|
26
|
+
canSubmit,
|
|
27
|
+
isCompleted,
|
|
28
|
+
isDonation,
|
|
29
|
+
livemode,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect } from 'react';
|
|
2
|
+
import { useMemoizedFn } from 'ahooks';
|
|
3
|
+
import type { TPrice } from '@blocklet/payment-types';
|
|
4
|
+
|
|
5
|
+
import { getErrorMessage } from '../../types/checkout-augmented';
|
|
6
|
+
|
|
7
|
+
import { useSessionContext } from '../context/SessionContext';
|
|
8
|
+
import { usePaymentMethodContext } from '../context/PaymentMethodContext';
|
|
9
|
+
import { getCrossSellItem } from '../core/lineItems';
|
|
10
|
+
import { addCrossSellItem, removeCrossSellItem, fetchCrossSellItem } from '../core/crossSell';
|
|
11
|
+
|
|
12
|
+
export interface UseCrossSellReturn {
|
|
13
|
+
item: TPrice | null;
|
|
14
|
+
required: boolean;
|
|
15
|
+
add: () => Promise<void>;
|
|
16
|
+
remove: () => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useCrossSell(): UseCrossSellReturn {
|
|
20
|
+
const { items, session, effectiveSessionId, refresh } = useSessionContext();
|
|
21
|
+
const { currency } = usePaymentMethodContext();
|
|
22
|
+
const currencyId = currency?.id || null;
|
|
23
|
+
|
|
24
|
+
const embeddedItem = useMemo(() => getCrossSellItem(items), [items]);
|
|
25
|
+
const [fetchedItem, setFetchedItem] = useState<TPrice | null>(null);
|
|
26
|
+
|
|
27
|
+
// Stable key that only changes on structural item changes (upsell/downsell/add/remove), not quantity
|
|
28
|
+
const itemPriceIds = useMemo(() => items.map((i) => i.price_id).join(','), [items]);
|
|
29
|
+
|
|
30
|
+
// Skip fetch when cross-sell is already added to session line items
|
|
31
|
+
const hasCrossSellInItems = useMemo(() => items.some((i: any) => i.cross_sell), [items]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!effectiveSessionId || !session) return undefined;
|
|
35
|
+
if (session.status === 'complete') return undefined;
|
|
36
|
+
if (embeddedItem) {
|
|
37
|
+
setFetchedItem(null); // clear stale fetched item when embedded item exists
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (hasCrossSellInItems) {
|
|
41
|
+
return undefined; // cross-sell already in cart, no need to fetch the offer
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let cancelled = false;
|
|
45
|
+
fetchCrossSellItem(effectiveSessionId).then((item) => {
|
|
46
|
+
if (!cancelled) {
|
|
47
|
+
setFetchedItem(item); // set null explicitly when no cross-sell for current interval
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
// Re-fetch when item structure changes (e.g. after upsell/downsell changes billing interval)
|
|
54
|
+
}, [effectiveSessionId, session?.id, embeddedItem, itemPriceIds, hasCrossSellInItems]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
55
|
+
|
|
56
|
+
const item = embeddedItem || fetchedItem;
|
|
57
|
+
const required = useMemo(() => items.some((i) => i.cross_sell_required), [items]);
|
|
58
|
+
|
|
59
|
+
const add = useMemoizedFn(async () => {
|
|
60
|
+
if (session?.status === 'complete') return;
|
|
61
|
+
const crossSellItemPrice = getCrossSellItem(items);
|
|
62
|
+
if (!crossSellItemPrice) return;
|
|
63
|
+
try {
|
|
64
|
+
await addCrossSellItem(effectiveSessionId, crossSellItemPrice.id, session, currencyId, refresh);
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const remove = useMemoizedFn(async () => {
|
|
71
|
+
if (session?.status === 'complete') return;
|
|
72
|
+
try {
|
|
73
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { item, required, add, remove };
|
|
80
|
+
}
|