@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.
Files changed (250) hide show
  1. package/.eslintrc.js +18 -0
  2. package/build.config.ts +30 -0
  3. package/es/checkout/context/CheckoutProvider.d.ts +6 -0
  4. package/es/checkout/context/CheckoutProvider.js +209 -0
  5. package/es/checkout/context/CustomerFormContext.d.ts +4 -0
  6. package/es/checkout/context/CustomerFormContext.js +9 -0
  7. package/es/checkout/context/ExchangeRateContext.d.ts +11 -0
  8. package/es/checkout/context/ExchangeRateContext.js +9 -0
  9. package/es/checkout/context/PaymentMethodContext.d.ts +26 -0
  10. package/es/checkout/context/PaymentMethodContext.js +9 -0
  11. package/es/checkout/context/SessionContext.d.ts +45 -0
  12. package/es/checkout/context/SessionContext.js +9 -0
  13. package/es/checkout/context/SubmitContext.d.ts +4 -0
  14. package/es/checkout/context/SubmitContext.js +9 -0
  15. package/es/checkout/context/index.d.ts +6 -0
  16. package/es/checkout/context/index.js +6 -0
  17. package/es/checkout/core/billingInterval.d.ts +15 -0
  18. package/es/checkout/core/billingInterval.js +36 -0
  19. package/es/checkout/core/crossSell.d.ts +4 -0
  20. package/es/checkout/core/crossSell.js +30 -0
  21. package/es/checkout/core/customerForm.d.ts +5 -0
  22. package/es/checkout/core/customerForm.js +105 -0
  23. package/es/checkout/core/exchangeRate.d.ts +11 -0
  24. package/es/checkout/core/exchangeRate.js +25 -0
  25. package/es/checkout/core/index.d.ts +10 -0
  26. package/es/checkout/core/index.js +55 -0
  27. package/es/checkout/core/lineItems.d.ts +7 -0
  28. package/es/checkout/core/lineItems.js +59 -0
  29. package/es/checkout/core/paymentMethod.d.ts +23 -0
  30. package/es/checkout/core/paymentMethod.js +85 -0
  31. package/es/checkout/core/pricing.d.ts +32 -0
  32. package/es/checkout/core/pricing.js +221 -0
  33. package/es/checkout/core/promotion.d.ts +10 -0
  34. package/es/checkout/core/promotion.js +39 -0
  35. package/es/checkout/core/session.d.ts +26 -0
  36. package/es/checkout/core/session.js +50 -0
  37. package/es/checkout/core/submit.d.ts +40 -0
  38. package/es/checkout/core/submit.js +66 -0
  39. package/es/checkout/hooks/index.d.ts +34 -0
  40. package/es/checkout/hooks/index.js +19 -0
  41. package/es/checkout/hooks/useBillingInterval.d.ts +14 -0
  42. package/es/checkout/hooks/useBillingInterval.js +50 -0
  43. package/es/checkout/hooks/useCheckout.d.ts +2 -0
  44. package/es/checkout/hooks/useCheckout.js +212 -0
  45. package/es/checkout/hooks/useCheckoutSession.d.ts +58 -0
  46. package/es/checkout/hooks/useCheckoutSession.js +107 -0
  47. package/es/checkout/hooks/useCheckoutStatus.d.ts +10 -0
  48. package/es/checkout/hooks/useCheckoutStatus.js +16 -0
  49. package/es/checkout/hooks/useCrossSell.d.ts +8 -0
  50. package/es/checkout/hooks/useCrossSell.js +57 -0
  51. package/es/checkout/hooks/useCustomerForm.d.ts +14 -0
  52. package/es/checkout/hooks/useCustomerForm.js +116 -0
  53. package/es/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
  54. package/es/checkout/hooks/useCustomerFormFeature.js +4 -0
  55. package/es/checkout/hooks/useExchangeRate.d.ts +11 -0
  56. package/es/checkout/hooks/useExchangeRate.js +15 -0
  57. package/es/checkout/hooks/useLineItems.d.ts +22 -0
  58. package/es/checkout/hooks/useLineItems.js +139 -0
  59. package/es/checkout/hooks/usePaymentMethod.d.ts +26 -0
  60. package/es/checkout/hooks/usePaymentMethod.js +101 -0
  61. package/es/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
  62. package/es/checkout/hooks/usePaymentMethodFeature.js +4 -0
  63. package/es/checkout/hooks/usePricing.d.ts +57 -0
  64. package/es/checkout/hooks/usePricing.js +174 -0
  65. package/es/checkout/hooks/usePricingFeature.d.ts +28 -0
  66. package/es/checkout/hooks/usePricingFeature.js +36 -0
  67. package/es/checkout/hooks/useProduct.d.ts +32 -0
  68. package/es/checkout/hooks/useProduct.js +5 -0
  69. package/es/checkout/hooks/usePromotion.d.ts +12 -0
  70. package/es/checkout/hooks/usePromotion.js +48 -0
  71. package/es/checkout/hooks/useSlippage.d.ts +8 -0
  72. package/es/checkout/hooks/useSlippage.js +29 -0
  73. package/es/checkout/hooks/useSubmit.d.ts +38 -0
  74. package/es/checkout/hooks/useSubmit.js +493 -0
  75. package/es/checkout/hooks/useSubmitFeature.d.ts +2 -0
  76. package/es/checkout/hooks/useSubmitFeature.js +4 -0
  77. package/es/checkout/hooks/useUpsell.d.ts +5 -0
  78. package/es/checkout/hooks/useUpsell.js +25 -0
  79. package/es/checkout/index.d.ts +37 -0
  80. package/es/checkout/index.js +28 -0
  81. package/es/checkout/types.d.ts +262 -0
  82. package/es/checkout/types.js +0 -0
  83. package/es/index.d.ts +1 -0
  84. package/es/index.js +28 -0
  85. package/es/shared/api.d.ts +41 -0
  86. package/es/shared/api.js +81 -0
  87. package/es/shared/format.d.ts +38 -0
  88. package/es/shared/format.js +229 -0
  89. package/es/shared/polling.d.ts +15 -0
  90. package/es/shared/polling.js +20 -0
  91. package/es/shared/types.d.ts +10 -0
  92. package/es/shared/types.js +0 -0
  93. package/es/shared/validation.d.ts +38 -0
  94. package/es/shared/validation.js +190 -0
  95. package/es/types/checkout-augmented.d.ts +42 -0
  96. package/es/types/checkout-augmented.js +17 -0
  97. package/es/types/external.d.ts +18 -0
  98. package/examples/01-basic-checkout.tsx +159 -0
  99. package/examples/01-credit-recharge.tsx +19 -0
  100. package/examples/02-subscription.tsx +40 -0
  101. package/examples/03-upsell.tsx +60 -0
  102. package/examples/04-cross-sell.tsx +54 -0
  103. package/examples/05-full-checkout.tsx +126 -0
  104. package/jest.config.js +15 -0
  105. package/lib/checkout/context/CheckoutProvider.d.ts +6 -0
  106. package/lib/checkout/context/CheckoutProvider.js +181 -0
  107. package/lib/checkout/context/CustomerFormContext.d.ts +4 -0
  108. package/lib/checkout/context/CustomerFormContext.js +16 -0
  109. package/lib/checkout/context/ExchangeRateContext.d.ts +11 -0
  110. package/lib/checkout/context/ExchangeRateContext.js +16 -0
  111. package/lib/checkout/context/PaymentMethodContext.d.ts +26 -0
  112. package/lib/checkout/context/PaymentMethodContext.js +16 -0
  113. package/lib/checkout/context/SessionContext.d.ts +45 -0
  114. package/lib/checkout/context/SessionContext.js +16 -0
  115. package/lib/checkout/context/SubmitContext.d.ts +4 -0
  116. package/lib/checkout/context/SubmitContext.js +16 -0
  117. package/lib/checkout/context/index.d.ts +6 -0
  118. package/lib/checkout/context/index.js +77 -0
  119. package/lib/checkout/core/billingInterval.d.ts +15 -0
  120. package/lib/checkout/core/billingInterval.js +42 -0
  121. package/lib/checkout/core/crossSell.d.ts +4 -0
  122. package/lib/checkout/core/crossSell.js +43 -0
  123. package/lib/checkout/core/customerForm.d.ts +5 -0
  124. package/lib/checkout/core/customerForm.js +106 -0
  125. package/lib/checkout/core/exchangeRate.d.ts +11 -0
  126. package/lib/checkout/core/exchangeRate.js +45 -0
  127. package/lib/checkout/core/index.d.ts +10 -0
  128. package/lib/checkout/core/index.js +297 -0
  129. package/lib/checkout/core/lineItems.d.ts +7 -0
  130. package/lib/checkout/core/lineItems.js +76 -0
  131. package/lib/checkout/core/paymentMethod.d.ts +23 -0
  132. package/lib/checkout/core/paymentMethod.js +114 -0
  133. package/lib/checkout/core/pricing.d.ts +32 -0
  134. package/lib/checkout/core/pricing.js +216 -0
  135. package/lib/checkout/core/promotion.d.ts +10 -0
  136. package/lib/checkout/core/promotion.js +62 -0
  137. package/lib/checkout/core/session.d.ts +26 -0
  138. package/lib/checkout/core/session.js +58 -0
  139. package/lib/checkout/core/submit.d.ts +40 -0
  140. package/lib/checkout/core/submit.js +84 -0
  141. package/lib/checkout/hooks/index.d.ts +34 -0
  142. package/lib/checkout/hooks/index.js +138 -0
  143. package/lib/checkout/hooks/useBillingInterval.d.ts +14 -0
  144. package/lib/checkout/hooks/useBillingInterval.js +63 -0
  145. package/lib/checkout/hooks/useCheckout.d.ts +2 -0
  146. package/lib/checkout/hooks/useCheckout.js +190 -0
  147. package/lib/checkout/hooks/useCheckoutSession.d.ts +58 -0
  148. package/lib/checkout/hooks/useCheckoutSession.js +119 -0
  149. package/lib/checkout/hooks/useCheckoutStatus.d.ts +10 -0
  150. package/lib/checkout/hooks/useCheckoutStatus.js +28 -0
  151. package/lib/checkout/hooks/useCrossSell.d.ts +8 -0
  152. package/lib/checkout/hooks/useCrossSell.js +75 -0
  153. package/lib/checkout/hooks/useCustomerForm.d.ts +14 -0
  154. package/lib/checkout/hooks/useCustomerForm.js +135 -0
  155. package/lib/checkout/hooks/useCustomerFormFeature.d.ts +2 -0
  156. package/lib/checkout/hooks/useCustomerFormFeature.js +10 -0
  157. package/lib/checkout/hooks/useExchangeRate.d.ts +11 -0
  158. package/lib/checkout/hooks/useExchangeRate.js +29 -0
  159. package/lib/checkout/hooks/useLineItems.d.ts +22 -0
  160. package/lib/checkout/hooks/useLineItems.js +142 -0
  161. package/lib/checkout/hooks/usePaymentMethod.d.ts +26 -0
  162. package/lib/checkout/hooks/usePaymentMethod.js +101 -0
  163. package/lib/checkout/hooks/usePaymentMethodFeature.d.ts +2 -0
  164. package/lib/checkout/hooks/usePaymentMethodFeature.js +10 -0
  165. package/lib/checkout/hooks/usePricing.d.ts +57 -0
  166. package/lib/checkout/hooks/usePricing.js +168 -0
  167. package/lib/checkout/hooks/usePricingFeature.d.ts +28 -0
  168. package/lib/checkout/hooks/usePricingFeature.js +48 -0
  169. package/lib/checkout/hooks/useProduct.d.ts +32 -0
  170. package/lib/checkout/hooks/useProduct.js +21 -0
  171. package/lib/checkout/hooks/usePromotion.d.ts +12 -0
  172. package/lib/checkout/hooks/usePromotion.js +57 -0
  173. package/lib/checkout/hooks/useSlippage.d.ts +8 -0
  174. package/lib/checkout/hooks/useSlippage.js +39 -0
  175. package/lib/checkout/hooks/useSubmit.d.ts +38 -0
  176. package/lib/checkout/hooks/useSubmit.js +504 -0
  177. package/lib/checkout/hooks/useSubmitFeature.d.ts +2 -0
  178. package/lib/checkout/hooks/useSubmitFeature.js +10 -0
  179. package/lib/checkout/hooks/useUpsell.d.ts +5 -0
  180. package/lib/checkout/hooks/useUpsell.js +40 -0
  181. package/lib/checkout/index.d.ts +37 -0
  182. package/lib/checkout/index.js +182 -0
  183. package/lib/checkout/types.d.ts +262 -0
  184. package/lib/checkout/types.js +1 -0
  185. package/lib/index.d.ts +1 -0
  186. package/lib/index.js +162 -0
  187. package/lib/shared/api.d.ts +41 -0
  188. package/lib/shared/api.js +88 -0
  189. package/lib/shared/format.d.ts +38 -0
  190. package/lib/shared/format.js +262 -0
  191. package/lib/shared/polling.d.ts +15 -0
  192. package/lib/shared/polling.js +32 -0
  193. package/lib/shared/types.d.ts +10 -0
  194. package/lib/shared/types.js +1 -0
  195. package/lib/shared/validation.d.ts +38 -0
  196. package/lib/shared/validation.js +212 -0
  197. package/lib/types/checkout-augmented.d.ts +42 -0
  198. package/lib/types/checkout-augmented.js +24 -0
  199. package/lib/types/external.d.ts +18 -0
  200. package/package.json +64 -0
  201. package/src/checkout/context/CheckoutProvider.tsx +269 -0
  202. package/src/checkout/context/CustomerFormContext.ts +14 -0
  203. package/src/checkout/context/ExchangeRateContext.ts +21 -0
  204. package/src/checkout/context/PaymentMethodContext.ts +36 -0
  205. package/src/checkout/context/SessionContext.ts +49 -0
  206. package/src/checkout/context/SubmitContext.ts +14 -0
  207. package/src/checkout/context/index.ts +6 -0
  208. package/src/checkout/core/billingInterval.ts +62 -0
  209. package/src/checkout/core/crossSell.ts +52 -0
  210. package/src/checkout/core/customerForm.ts +122 -0
  211. package/src/checkout/core/exchangeRate.ts +38 -0
  212. package/src/checkout/core/index.ts +60 -0
  213. package/src/checkout/core/lineItems.ts +106 -0
  214. package/src/checkout/core/paymentMethod.ts +113 -0
  215. package/src/checkout/core/pricing.ts +347 -0
  216. package/src/checkout/core/promotion.ts +59 -0
  217. package/src/checkout/core/session.ts +62 -0
  218. package/src/checkout/core/submit.ts +109 -0
  219. package/src/checkout/hooks/index.ts +41 -0
  220. package/src/checkout/hooks/useBillingInterval.ts +71 -0
  221. package/src/checkout/hooks/useCheckout.ts +267 -0
  222. package/src/checkout/hooks/useCheckoutSession.ts +217 -0
  223. package/src/checkout/hooks/useCheckoutStatus.ts +31 -0
  224. package/src/checkout/hooks/useCrossSell.ts +80 -0
  225. package/src/checkout/hooks/useCustomerForm.ts +156 -0
  226. package/src/checkout/hooks/useCustomerFormFeature.ts +7 -0
  227. package/src/checkout/hooks/useExchangeRate.ts +28 -0
  228. package/src/checkout/hooks/useLineItems.ts +191 -0
  229. package/src/checkout/hooks/usePaymentMethod.ts +165 -0
  230. package/src/checkout/hooks/usePaymentMethodFeature.ts +8 -0
  231. package/src/checkout/hooks/usePricing.ts +274 -0
  232. package/src/checkout/hooks/usePricingFeature.ts +73 -0
  233. package/src/checkout/hooks/useProduct.ts +32 -0
  234. package/src/checkout/hooks/usePromotion.ts +67 -0
  235. package/src/checkout/hooks/useSlippage.ts +39 -0
  236. package/src/checkout/hooks/useSubmit.ts +684 -0
  237. package/src/checkout/hooks/useSubmitFeature.ts +7 -0
  238. package/src/checkout/hooks/useUpsell.ts +35 -0
  239. package/src/checkout/index.ts +65 -0
  240. package/src/checkout/types.ts +292 -0
  241. package/src/index.ts +64 -0
  242. package/src/shared/api.ts +118 -0
  243. package/src/shared/format.ts +318 -0
  244. package/src/shared/polling.ts +49 -0
  245. package/src/shared/types.ts +13 -0
  246. package/src/shared/validation.ts +254 -0
  247. package/src/types/checkout-augmented.ts +77 -0
  248. package/src/types/external.d.ts +18 -0
  249. package/tools/jest.js +1 -0
  250. 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
+ }