@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,156 @@
1
+ import { useState, useMemo, useEffect } from 'react';
2
+ import { useMemoizedFn } from 'ahooks';
3
+
4
+ import api, { API } from '../../shared/api';
5
+ import { validateForm } from '../../shared/validation';
6
+ import type { FieldConfig, CheckoutFormData } from '../types';
7
+ import { buildFields, createInitialValues, setNestedValue } from '../core/customerForm';
8
+ import type { SessionData } from './useCheckoutSession';
9
+
10
+ export interface UseCustomerFormReturn {
11
+ fields: FieldConfig[];
12
+ values: CheckoutFormData;
13
+ onChange: (field: string, value: string | boolean | Record<string, string>) => void;
14
+ errors: Partial<Record<string, string>>;
15
+ touched: Record<string, boolean>;
16
+ validate: () => Promise<boolean>;
17
+ validateField: (field: string) => Promise<void>;
18
+ /** Re-fetch customer info from backend and update form values (e.g. after login) */
19
+ refetchCustomer: () => Promise<void>;
20
+ }
21
+
22
+ export function useCustomerForm(
23
+ sessionData: SessionData | null,
24
+ currencyId: string | null,
25
+ methodId: string | null
26
+ ): UseCustomerFormReturn {
27
+ const session = sessionData?.checkoutSession;
28
+ const customer = sessionData?.customer;
29
+
30
+ const fields = useMemo(() => buildFields(session), [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
31
+
32
+ const [values, setValues] = useState<CheckoutFormData>(() => createInitialValues(session, customer));
33
+ const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
34
+ const [touched, setTouched] = useState<Record<string, boolean>>({});
35
+ const [prefetched, setPrefetched] = useState(false);
36
+
37
+ // Re-initialize when session loads
38
+ useEffect(() => {
39
+ if (session && !prefetched) {
40
+ setValues(createInitialValues(session, customer));
41
+ }
42
+ }, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
43
+
44
+ // Sync payment method/currency into values
45
+ useEffect(() => {
46
+ if (currencyId || methodId) {
47
+ setValues((prev) => ({
48
+ ...prev,
49
+ ...(currencyId && { payment_currency: currencyId }),
50
+ ...(methodId && { payment_method: methodId }),
51
+ }));
52
+ }
53
+ }, [currencyId, methodId]);
54
+
55
+ // Prefetch customer info
56
+ useEffect(() => {
57
+ if (!session || prefetched || session.status === 'complete') return;
58
+
59
+ const fetchCustomer = async () => {
60
+ try {
61
+ const { data } = await api.get(`${API.CUSTOMER_ME}?fallback=1&skipSummary=1`);
62
+ if (data) {
63
+ setPrefetched(true);
64
+ setValues((prev) => ({
65
+ ...prev,
66
+ customer_name: prev.customer_name || data.name || '',
67
+ customer_email: prev.customer_email || data.email || '',
68
+ customer_phone: prev.customer_phone || data.phone || '',
69
+ billing_address: {
70
+ ...prev.billing_address!,
71
+ country: prev.billing_address?.country || data.address?.country || '',
72
+ state: prev.billing_address?.state || data.address?.state || '',
73
+ city: prev.billing_address?.city || data.address?.city || '',
74
+ line1: prev.billing_address?.line1 || data.address?.line1 || '',
75
+ line2: prev.billing_address?.line2 || data.address?.line2 || '',
76
+ postal_code: prev.billing_address?.postal_code || data.address?.postal_code || '',
77
+ },
78
+ }));
79
+ }
80
+ } catch {
81
+ // Ignore - customer info is optional
82
+ }
83
+ };
84
+
85
+ fetchCustomer();
86
+ }, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
87
+
88
+ const onChange = useMemoizedFn((field: string, value: string | boolean | Record<string, string>) => {
89
+ setValues((prev) => setNestedValue(prev, field, value));
90
+ setTouched((prev) => ({ ...prev, [field]: true }));
91
+ });
92
+
93
+ const getValidateOptions = useMemoizedFn(() => ({
94
+ phoneEnabled: !!session?.phone_number_collection?.enabled,
95
+ addressMode: session?.billing_address_collection as 'auto' | 'required' | null,
96
+ fieldValidation: session?.metadata?.page_info?.field_validation,
97
+ }));
98
+
99
+ const validate = useMemoizedFn(async () => {
100
+ const result = await validateForm(values, getValidateOptions());
101
+ setErrors(result.errors);
102
+ return result.valid;
103
+ });
104
+
105
+ // Single-field blur validation — matches V1's trigger(fieldName)
106
+ const validateField = useMemoizedFn(async (field: string) => {
107
+ const result = await validateForm(values, getValidateOptions());
108
+ setErrors((prev) => {
109
+ const next = { ...prev };
110
+ if (result.errors[field]) {
111
+ next[field] = result.errors[field];
112
+ } else {
113
+ delete next[field];
114
+ }
115
+ return next;
116
+ });
117
+ });
118
+
119
+ // Re-fetch customer info (used after login to populate form)
120
+ const refetchCustomer = useMemoizedFn(async () => {
121
+ try {
122
+ const { data } = await api.get(`${API.CUSTOMER_ME}?fallback=1&skipSummary=1`);
123
+ if (data) {
124
+ setPrefetched(true);
125
+ setValues((prev) => ({
126
+ ...prev,
127
+ customer_name: data.name || prev.customer_name || '',
128
+ customer_email: data.email || prev.customer_email || '',
129
+ customer_phone: data.phone || prev.customer_phone || '',
130
+ billing_address: {
131
+ ...prev.billing_address!,
132
+ country: data.address?.country || prev.billing_address?.country || '',
133
+ state: data.address?.state || prev.billing_address?.state || '',
134
+ city: data.address?.city || prev.billing_address?.city || '',
135
+ line1: data.address?.line1 || prev.billing_address?.line1 || '',
136
+ line2: data.address?.line2 || prev.billing_address?.line2 || '',
137
+ postal_code: data.address?.postal_code || prev.billing_address?.postal_code || '',
138
+ },
139
+ }));
140
+ }
141
+ } catch {
142
+ // Ignore - customer info is optional
143
+ }
144
+ });
145
+
146
+ return {
147
+ fields,
148
+ values,
149
+ onChange,
150
+ errors,
151
+ touched,
152
+ validate,
153
+ validateField,
154
+ refetchCustomer,
155
+ };
156
+ }
@@ -0,0 +1,7 @@
1
+ import { useCustomerFormContext } from '../context/CustomerFormContext';
2
+ import type { UseCustomerFormReturn } from './useCustomerForm';
3
+
4
+ // Context-aware version: reads shared customer form state from CheckoutProvider
5
+ export function useCustomerFormFeature(): UseCustomerFormReturn {
6
+ return useCustomerFormContext();
7
+ }
@@ -0,0 +1,28 @@
1
+ import { useExchangeRateContext } from '../context/ExchangeRateContext';
2
+ import { formatExchangeRate, formatExchangeRateDisplay } from '../../shared/format';
3
+
4
+ export interface UseExchangeRateReturn {
5
+ value: string | null;
6
+ display: string | null;
7
+ provider: string | null;
8
+ providerDisplay: string | null;
9
+ fetchedAt: number | null;
10
+ status: 'loading' | 'available' | 'unavailable';
11
+ hasDynamicPricing: boolean;
12
+ refresh: () => Promise<void>;
13
+ }
14
+
15
+ export function useExchangeRate(): UseExchangeRateReturn {
16
+ const { rate, provider, providerDisplay, fetchedAt, status, hasDynamicPricing, refresh } = useExchangeRateContext();
17
+
18
+ return {
19
+ value: formatExchangeRate(rate),
20
+ display: rate ? formatExchangeRateDisplay(rate) : null,
21
+ provider,
22
+ providerDisplay,
23
+ fetchedAt,
24
+ status,
25
+ hasDynamicPricing,
26
+ refresh,
27
+ };
28
+ }
@@ -0,0 +1,191 @@
1
+ import { useMemo, useState, useEffect, useRef } 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 { useSessionContext } from '../context/SessionContext';
8
+ import { usePaymentMethodContext } from '../context/PaymentMethodContext';
9
+ import {
10
+ adjustQuantity,
11
+ performUpsell,
12
+ performDownsell,
13
+ changeDonationAmount,
14
+ getCrossSellItem,
15
+ } from '../core/lineItems';
16
+ import { addCrossSellItem, removeCrossSellItem, fetchCrossSellItem } from '../core/crossSell';
17
+
18
+ export interface UseLineItemsReturn {
19
+ items: Array<
20
+ TLineItemExpanded & {
21
+ adjustable_quantity?: {
22
+ enabled: boolean;
23
+ minimum?: number;
24
+ maximum?: number;
25
+ };
26
+ }
27
+ >;
28
+ /** true if all items pass inventory check (quantity_available, quantity_limit_per_checkout) */
29
+ inventoryOk: boolean;
30
+ updateQuantity: (itemId: string, qty: number) => Promise<void>;
31
+ upsell: (fromId: string, toId: string) => Promise<void>;
32
+ downsell: (priceId: string) => Promise<void>;
33
+ crossSellItem: TPrice | null;
34
+ crossSellRequired: boolean;
35
+ addCrossSell: () => Promise<void>;
36
+ removeCrossSell: () => Promise<void>;
37
+ isDonation: boolean;
38
+ setDonationAmount: (priceId: string, amount: string) => Promise<void>;
39
+ }
40
+
41
+ export function useLineItems(): UseLineItemsReturn {
42
+ const { items, session, effectiveSessionId, isDonation, refresh } = useSessionContext();
43
+ const { currency } = usePaymentMethodContext();
44
+ const currencyId = currency?.id || null;
45
+
46
+ // Default quantity from URL params: ?qty=5 or ?qty_price_xxx=10 (matching original product-item.tsx)
47
+ const defaultQtyApplied = useRef(false);
48
+ useEffect(() => {
49
+ if (defaultQtyApplied.current || !effectiveSessionId || !items.length || !currencyId || session?.status === 'complete') return;
50
+ try {
51
+ const params = new URLSearchParams(window.location.search);
52
+ for (const item of items) {
53
+ const qtyStr = params.get(`qty_${item.price_id}`) || params.get(`qty_${item.price?.id}`) || params.get('qty');
54
+ if (qtyStr) {
55
+ const qty = Math.max(1, parseInt(qtyStr, 10));
56
+ if (Number.isFinite(qty) && qty !== item.quantity) {
57
+ defaultQtyApplied.current = true;
58
+ adjustQuantity(effectiveSessionId, item.price_id, qty, currencyId, session, refresh);
59
+ break; // only apply to the first matching item for ?qty
60
+ }
61
+ }
62
+ }
63
+ } catch {
64
+ // ignore
65
+ }
66
+ }, [effectiveSessionId, items, currencyId]); // eslint-disable-line react-hooks/exhaustive-deps
67
+
68
+ const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
69
+ if (session?.status === 'complete') return;
70
+ try {
71
+ await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
72
+ } catch (err: unknown) {
73
+ console.error('Failed to update quantity:', getErrorMessage(err));
74
+ }
75
+ });
76
+
77
+ const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
78
+ if (session?.status === 'complete') return;
79
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
80
+ });
81
+
82
+ const downsell = useMemoizedFn(async (priceId: string) => {
83
+ if (session?.status === 'complete') return;
84
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
85
+ });
86
+
87
+ // Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
88
+ const embeddedCrossSellItem = useMemo(() => getCrossSellItem(items), [items]);
89
+ const [fetchedCrossSellItem, setFetchedCrossSellItem] = useState<TPrice | null>(null);
90
+
91
+ // Stable key that only changes on structural item changes (upsell/downsell/add/remove), not quantity
92
+ const itemPriceIds = useMemo(() => items.map((i) => i.price_id).join(','), [items]);
93
+
94
+ // Skip fetch when cross-sell is already added to session line items
95
+ const hasCrossSellInItems = useMemo(() => items.some((i: any) => i.cross_sell), [items]);
96
+
97
+ useEffect(() => {
98
+ if (!effectiveSessionId || !session) return undefined;
99
+ if (session.status === 'complete') return undefined;
100
+ if (embeddedCrossSellItem) {
101
+ setFetchedCrossSellItem(null); // clear stale fetched item when embedded item exists
102
+ return undefined;
103
+ }
104
+ if (hasCrossSellInItems) {
105
+ return undefined; // cross-sell already in cart, no need to fetch the offer
106
+ }
107
+
108
+ let cancelled = false;
109
+ fetchCrossSellItem(effectiveSessionId).then((item) => {
110
+ if (!cancelled) {
111
+ setFetchedCrossSellItem(item); // set null explicitly when no cross-sell for current interval
112
+ }
113
+ });
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ // Re-fetch when item structure changes (e.g. after upsell/downsell changes billing interval)
118
+ }, [effectiveSessionId, session?.id, embeddedCrossSellItem, itemPriceIds, hasCrossSellInItems]); // eslint-disable-line react-hooks/exhaustive-deps
119
+
120
+ const crossSellItem = embeddedCrossSellItem || fetchedCrossSellItem;
121
+ const crossSellRequired = useMemo(() => items.some((item) => item.cross_sell_required), [items]);
122
+
123
+ const addCrossSell = useMemoizedFn(async () => {
124
+ if (session?.status === 'complete') return;
125
+ if (!crossSellItem) return;
126
+ try {
127
+ await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
128
+ } catch (err: unknown) {
129
+ console.error('Failed to add cross-sell:', getErrorMessage(err));
130
+ }
131
+ });
132
+
133
+ const removeCrossSell = useMemoizedFn(async () => {
134
+ if (session?.status === 'complete') return;
135
+ try {
136
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
137
+ } catch (err: unknown) {
138
+ console.error('Failed to remove cross-sell:', getErrorMessage(err));
139
+ }
140
+ });
141
+
142
+ const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
143
+ if (session?.status === 'complete') return;
144
+ if (!isDonation) return;
145
+ try {
146
+ await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
147
+ } catch (err: unknown) {
148
+ console.error('Failed to change amount:', getErrorMessage(err));
149
+ }
150
+ });
151
+
152
+ const mappedItems = useMemo(
153
+ () =>
154
+ items.map((item) => ({
155
+ ...item,
156
+ adjustable_quantity: item.adjustable_quantity,
157
+ })),
158
+ [items]
159
+ );
160
+
161
+ // Inventory check: matching original formatQuantityInventory logic
162
+ const inventoryOk = useMemo(
163
+ () =>
164
+ items.every((item) => {
165
+ const { price } = item;
166
+ if (!price) return true;
167
+ const q = Number(item.quantity) || 0;
168
+ const available = price.quantity_available || 0;
169
+ const sold = price.quantity_sold || 0;
170
+ const limitPerCheckout = price.quantity_limit_per_checkout || 0;
171
+ if (available > 0 && sold + q > available) return false;
172
+ if (limitPerCheckout > 0 && limitPerCheckout < q) return false;
173
+ return true;
174
+ }),
175
+ [items]
176
+ );
177
+
178
+ return {
179
+ items: mappedItems,
180
+ inventoryOk,
181
+ updateQuantity,
182
+ upsell,
183
+ downsell,
184
+ crossSellItem,
185
+ crossSellRequired,
186
+ addCrossSell,
187
+ removeCrossSell,
188
+ isDonation,
189
+ setDonationAmount,
190
+ };
191
+ }
@@ -0,0 +1,165 @@
1
+ import { useState, useMemo, useEffect, useRef } from 'react';
2
+ import { useMemoizedFn } from 'ahooks';
3
+ import type { TPaymentMethodExpanded, TPaymentCurrency } from '@blocklet/payment-types';
4
+
5
+ import type { CheckoutSessionRuntime } from '../../types/checkout-augmented';
6
+ import { getErrorMessage } from '../../types/checkout-augmented';
7
+
8
+ import api, { API } from '../../shared/api';
9
+ import {
10
+ flattenCurrencies,
11
+ getInitialCurrencyId,
12
+ getCurrencyStorageKey,
13
+ findMethodAndCurrency,
14
+ buildPaymentTypes,
15
+ } from '../core/paymentMethod';
16
+ import type { SessionData } from './useCheckoutSession';
17
+
18
+ export interface UsePaymentMethodReturn {
19
+ current: TPaymentMethodExpanded;
20
+ currency: TPaymentCurrency;
21
+ available: TPaymentMethodExpanded[];
22
+ currencies: TPaymentCurrency[];
23
+ isStripe: boolean;
24
+ isCrypto: boolean;
25
+ isCredit: boolean;
26
+ switching: boolean;
27
+ setType: (type: 'stripe' | 'crypto') => Promise<void>;
28
+ types: Array<{
29
+ type: 'stripe' | 'crypto';
30
+ label: string;
31
+ currencies: TPaymentCurrency[];
32
+ active: boolean;
33
+ }>;
34
+ setCurrency: (currencyId: string) => Promise<void>;
35
+ stripe: {
36
+ publishableKey: string | null;
37
+ clientSecret: string | null;
38
+ status: 'idle' | 'ready' | 'processing' | 'succeeded' | 'failed';
39
+ } | null;
40
+ }
41
+
42
+ export function usePaymentMethod(
43
+ sessionData: SessionData | null,
44
+ sessionId: string,
45
+ refreshSession: (force?: boolean) => Promise<void>
46
+ ): UsePaymentMethodReturn {
47
+ const methods = sessionData?.paymentMethods || [];
48
+ const session = sessionData?.checkoutSession;
49
+
50
+ const [currencyId, setCurrencyId] = useState<string | null>(() => getInitialCurrencyId(session, methods));
51
+ const [switching, setSwitching] = useState(false);
52
+ const initialSyncedRef = useRef<string | null>(null);
53
+
54
+ // Re-initialize when session first loads
55
+ useEffect(() => {
56
+ if (session && !currencyId) {
57
+ setCurrencyId(getInitialCurrencyId(session, methods));
58
+ }
59
+ }, [session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
60
+
61
+ const { method: currentMethod, currency: currentCurrency } = useMemo(
62
+ () => findMethodAndCurrency(methods, currencyId),
63
+ [methods, currencyId]
64
+ );
65
+
66
+ const allCurrencies = useMemo(() => flattenCurrencies(methods), [methods]);
67
+
68
+ const isStripe = currentMethod?.type === 'stripe';
69
+ const isCrypto = currentMethod?.type !== 'stripe' && currentMethod?.type !== undefined;
70
+ const isCredit = currentCurrency?.type === 'credit';
71
+
72
+ const types = useMemo(() => buildPaymentTypes(methods, currentMethod), [methods, currentMethod]);
73
+
74
+ const setType = useMemoizedFn(async (type: 'stripe' | 'crypto') => {
75
+ const targetMethod = methods.find((m) => (type === 'stripe' ? m.type === 'stripe' : m.type !== 'stripe'));
76
+ if (!targetMethod) return;
77
+
78
+ const firstCurrency = targetMethod.payment_currencies?.[0];
79
+ if (firstCurrency) {
80
+ await switchCurrency(firstCurrency.id);
81
+ }
82
+ });
83
+
84
+ const switchCurrency = useMemoizedFn(async (newCurrencyId: string) => {
85
+ if (session?.status === 'complete') return;
86
+ const { method } = findMethodAndCurrency(methods, newCurrencyId);
87
+ if (!method || !sessionId) return;
88
+
89
+ setSwitching(true);
90
+ try {
91
+ await api.put(API.SWITCH_CURRENCY(sessionId), {
92
+ currency_id: newCurrencyId,
93
+ payment_method_id: method.id,
94
+ });
95
+
96
+ setCurrencyId(newCurrencyId);
97
+
98
+ // Save preference (DID-scoped key matching original payment-react)
99
+ try {
100
+ localStorage.setItem(
101
+ getCurrencyStorageKey((session as CheckoutSessionRuntime | undefined)?.user?.did),
102
+ newCurrencyId
103
+ );
104
+ } catch {
105
+ // Ignore
106
+ }
107
+
108
+ // Refresh session to get updated data (backend clears quote on currency switch)
109
+ await refreshSession(true);
110
+ } catch (err: unknown) {
111
+ console.error('Failed to switch currency:', getErrorMessage(err));
112
+ // Fallback: align with backend currency to resolve currencyMismatch
113
+ // Without this, a failed switch leaves currencyId diverged from session.currency_id,
114
+ // causing currencyMismatch (and thus `switching`) to stay true permanently
115
+ if (session?.currency_id) {
116
+ setCurrencyId(session.currency_id);
117
+ }
118
+ } finally {
119
+ setSwitching(false);
120
+ }
121
+ });
122
+
123
+ // Sync initial currency with backend if it differs from session.currency_id
124
+ // This handles the case where frontend picks a different currency (from cache/URL/no-wallet rule)
125
+ // than the session's default, ensuring backend recalculates amounts for the correct currency
126
+ useEffect(() => {
127
+ if (!session?.id || !currencyId || !methods.length) return;
128
+ // Only sync once per session
129
+ if (initialSyncedRef.current === session.id) return;
130
+ initialSyncedRef.current = session.id;
131
+
132
+ const sessionCurrencyId = session.currency_id;
133
+ if (session.status === 'complete') return;
134
+ if (sessionCurrencyId && sessionCurrencyId !== currencyId) {
135
+ switchCurrency(currencyId);
136
+ }
137
+ }, [session?.id, currencyId, methods.length]); // eslint-disable-line react-hooks/exhaustive-deps
138
+
139
+ // Stripe configuration
140
+ const stripe = useMemo(() => {
141
+ if (!currentMethod || currentMethod.type !== 'stripe') return null;
142
+
143
+ const stripeSettings = currentMethod.settings?.stripe;
144
+ return {
145
+ publishableKey: stripeSettings?.publishable_key || null,
146
+ clientSecret: (sessionData?.paymentIntent as { client_secret?: string } | undefined)?.client_secret || null,
147
+ status: 'idle' as const,
148
+ };
149
+ }, [currentMethod, sessionData?.paymentIntent]);
150
+
151
+ return {
152
+ current: currentMethod as TPaymentMethodExpanded,
153
+ currency: currentCurrency as TPaymentCurrency,
154
+ available: methods,
155
+ currencies: allCurrencies,
156
+ isStripe,
157
+ isCrypto,
158
+ isCredit,
159
+ switching,
160
+ setType,
161
+ types,
162
+ setCurrency: switchCurrency,
163
+ stripe,
164
+ };
165
+ }
@@ -0,0 +1,8 @@
1
+ import { usePaymentMethodContext } from '../context/PaymentMethodContext';
2
+ import type { UsePaymentMethodReturn } from './usePaymentMethod';
3
+
4
+ // Context-aware version: reads from PaymentMethodContext
5
+ // Same return type as the param-based usePaymentMethod
6
+ export function usePaymentMethodFeature(): UsePaymentMethodReturn {
7
+ return usePaymentMethodContext();
8
+ }