@blocklet/payment-react-headless 1.26.1 → 1.26.3

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 (75) hide show
  1. package/es/checkout/context/CheckoutProvider.js +26 -10
  2. package/es/checkout/context/SessionContext.d.ts +3 -1
  3. package/es/checkout/core/crossSell.d.ts +3 -2
  4. package/es/checkout/core/crossSell.js +24 -8
  5. package/es/checkout/core/customerForm.js +0 -6
  6. package/es/checkout/core/lineItems.d.ts +6 -5
  7. package/es/checkout/core/lineItems.js +48 -17
  8. package/es/checkout/core/paymentMethod.js +15 -7
  9. package/es/checkout/core/pricing.js +1 -2
  10. package/es/checkout/core/promotion.d.ts +1 -1
  11. package/es/checkout/core/promotion.js +2 -1
  12. package/es/checkout/hooks/useBillingInterval.js +8 -5
  13. package/es/checkout/hooks/useCheckout.js +17 -11
  14. package/es/checkout/hooks/useCheckoutSession.d.ts +2 -2
  15. package/es/checkout/hooks/useCheckoutSession.js +7 -0
  16. package/es/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  17. package/es/checkout/hooks/useCrossSell.js +11 -3
  18. package/es/checkout/hooks/useCustomerForm.d.ts +4 -0
  19. package/es/checkout/hooks/useCustomerForm.js +6 -0
  20. package/es/checkout/hooks/useLineItems.js +36 -9
  21. package/es/checkout/hooks/usePaymentMethod.d.ts +1 -1
  22. package/es/checkout/hooks/usePaymentMethod.js +8 -4
  23. package/es/checkout/hooks/usePricing.d.ts +1 -1
  24. package/es/checkout/hooks/usePricing.js +4 -4
  25. package/es/checkout/hooks/useSubmit.js +6 -1
  26. package/es/checkout/hooks/useUpsell.js +3 -3
  27. package/es/checkout/types.d.ts +5 -3
  28. package/lib/checkout/context/CheckoutProvider.js +21 -12
  29. package/lib/checkout/context/SessionContext.d.ts +3 -1
  30. package/lib/checkout/core/crossSell.d.ts +3 -2
  31. package/lib/checkout/core/crossSell.js +36 -8
  32. package/lib/checkout/core/customerForm.js +0 -5
  33. package/lib/checkout/core/lineItems.d.ts +6 -5
  34. package/lib/checkout/core/lineItems.js +72 -18
  35. package/lib/checkout/core/paymentMethod.js +14 -8
  36. package/lib/checkout/core/pricing.js +1 -2
  37. package/lib/checkout/core/promotion.d.ts +1 -1
  38. package/lib/checkout/core/promotion.js +4 -1
  39. package/lib/checkout/hooks/useBillingInterval.js +10 -5
  40. package/lib/checkout/hooks/useCheckout.js +16 -11
  41. package/lib/checkout/hooks/useCheckoutSession.d.ts +2 -2
  42. package/lib/checkout/hooks/useCheckoutSession.js +7 -0
  43. package/lib/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  44. package/lib/checkout/hooks/useCrossSell.js +5 -3
  45. package/lib/checkout/hooks/useCustomerForm.d.ts +4 -0
  46. package/lib/checkout/hooks/useCustomerForm.js +6 -0
  47. package/lib/checkout/hooks/useLineItems.js +10 -8
  48. package/lib/checkout/hooks/usePaymentMethod.d.ts +1 -1
  49. package/lib/checkout/hooks/usePaymentMethod.js +14 -4
  50. package/lib/checkout/hooks/usePricing.d.ts +1 -1
  51. package/lib/checkout/hooks/usePricing.js +4 -4
  52. package/lib/checkout/hooks/useSubmit.js +8 -1
  53. package/lib/checkout/hooks/useUpsell.js +5 -3
  54. package/lib/checkout/types.d.ts +5 -3
  55. package/package.json +3 -3
  56. package/src/checkout/context/CheckoutProvider.tsx +38 -17
  57. package/src/checkout/context/SessionContext.ts +3 -1
  58. package/src/checkout/core/crossSell.ts +29 -8
  59. package/src/checkout/core/customerForm.ts +0 -6
  60. package/src/checkout/core/lineItems.ts +62 -18
  61. package/src/checkout/core/paymentMethod.ts +24 -7
  62. package/src/checkout/core/pricing.ts +1 -2
  63. package/src/checkout/core/promotion.ts +6 -2
  64. package/src/checkout/hooks/useBillingInterval.ts +8 -5
  65. package/src/checkout/hooks/useCheckout.ts +20 -12
  66. package/src/checkout/hooks/useCheckoutSession.ts +12 -3
  67. package/src/checkout/hooks/useCheckoutStatus.ts +1 -1
  68. package/src/checkout/hooks/useCrossSell.ts +11 -3
  69. package/src/checkout/hooks/useCustomerForm.ts +12 -0
  70. package/src/checkout/hooks/useLineItems.ts +42 -9
  71. package/src/checkout/hooks/usePaymentMethod.ts +13 -5
  72. package/src/checkout/hooks/usePricing.ts +5 -4
  73. package/src/checkout/hooks/useSubmit.ts +13 -1
  74. package/src/checkout/hooks/useUpsell.ts +3 -3
  75. package/src/checkout/types.ts +4 -2
@@ -17,7 +17,7 @@ export interface UseCrossSellReturn {
17
17
  }
18
18
 
19
19
  export function useCrossSell(): UseCrossSellReturn {
20
- const { items, session, effectiveSessionId, refresh } = useSessionContext();
20
+ const { items, session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
21
21
  const { currency } = usePaymentMethodContext();
22
22
  const currencyId = currency?.id || null;
23
23
 
@@ -61,7 +61,15 @@ export function useCrossSell(): UseCrossSellReturn {
61
61
  const crossSellItemPrice = getCrossSellItem(items);
62
62
  if (!crossSellItemPrice) return;
63
63
  try {
64
- await addCrossSellItem(effectiveSessionId, crossSellItemPrice.id, session, currencyId, refresh);
64
+ await addCrossSellItem(
65
+ effectiveSessionId,
66
+ crossSellItemPrice.id,
67
+ session,
68
+ currencyId,
69
+ refresh,
70
+ sessionData,
71
+ setSessionData
72
+ );
65
73
  } catch (err: unknown) {
66
74
  console.error('Failed to add cross-sell:', getErrorMessage(err));
67
75
  }
@@ -70,7 +78,7 @@ export function useCrossSell(): UseCrossSellReturn {
70
78
  const remove = useMemoizedFn(async () => {
71
79
  if (session?.status === 'complete') return;
72
80
  try {
73
- await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
81
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
74
82
  } catch (err: unknown) {
75
83
  console.error('Failed to remove cross-sell:', getErrorMessage(err));
76
84
  }
@@ -14,7 +14,11 @@ export interface UseCustomerFormReturn {
14
14
  errors: Partial<Record<string, string>>;
15
15
  touched: Record<string, boolean>;
16
16
  validate: () => Promise<boolean>;
17
+ /** Silent validation — returns valid/invalid without setting error messages on the form */
18
+ checkValid: () => Promise<boolean>;
17
19
  validateField: (field: string) => Promise<void>;
20
+ /** Whether customer info has been prefetched from backend */
21
+ prefetched: boolean;
18
22
  /** Re-fetch customer info from backend and update form values (e.g. after login) */
19
23
  refetchCustomer: () => Promise<void>;
20
24
  }
@@ -102,6 +106,12 @@ export function useCustomerForm(
102
106
  return result.valid;
103
107
  });
104
108
 
109
+ // Silent validation — check validity without setting error messages on the form
110
+ const checkValid = useMemoizedFn(async () => {
111
+ const result = await validateForm(values, getValidateOptions());
112
+ return result.valid;
113
+ });
114
+
105
115
  // Single-field blur validation — matches V1's trigger(fieldName)
106
116
  const validateField = useMemoizedFn(async (field: string) => {
107
117
  const result = await validateForm(values, getValidateOptions());
@@ -150,7 +160,9 @@ export function useCustomerForm(
150
160
  errors,
151
161
  touched,
152
162
  validate,
163
+ checkValid,
153
164
  validateField,
165
+ prefetched,
154
166
  refetchCustomer,
155
167
  };
156
168
  }
@@ -39,14 +39,21 @@ export interface UseLineItemsReturn {
39
39
  }
40
40
 
41
41
  export function useLineItems(): UseLineItemsReturn {
42
- const { items, session, effectiveSessionId, isDonation, refresh } = useSessionContext();
42
+ const { items, session, effectiveSessionId, isDonation, refresh, sessionData, setSessionData } = useSessionContext();
43
43
  const { currency } = usePaymentMethodContext();
44
44
  const currencyId = currency?.id || null;
45
45
 
46
46
  // Default quantity from URL params: ?qty=5 or ?qty_price_xxx=10 (matching original product-item.tsx)
47
47
  const defaultQtyApplied = useRef(false);
48
48
  useEffect(() => {
49
- if (defaultQtyApplied.current || !effectiveSessionId || !items.length || !currencyId || session?.status === 'complete') return;
49
+ if (
50
+ defaultQtyApplied.current ||
51
+ !effectiveSessionId ||
52
+ !items.length ||
53
+ !currencyId ||
54
+ session?.status === 'complete'
55
+ )
56
+ return;
50
57
  try {
51
58
  const params = new URLSearchParams(window.location.search);
52
59
  for (const item of items) {
@@ -55,7 +62,16 @@ export function useLineItems(): UseLineItemsReturn {
55
62
  const qty = Math.max(1, parseInt(qtyStr, 10));
56
63
  if (Number.isFinite(qty) && qty !== item.quantity) {
57
64
  defaultQtyApplied.current = true;
58
- adjustQuantity(effectiveSessionId, item.price_id, qty, currencyId, session, refresh);
65
+ adjustQuantity(
66
+ effectiveSessionId,
67
+ item.price_id,
68
+ qty,
69
+ currencyId,
70
+ session,
71
+ refresh,
72
+ sessionData,
73
+ setSessionData
74
+ );
59
75
  break; // only apply to the first matching item for ?qty
60
76
  }
61
77
  }
@@ -68,7 +84,7 @@ export function useLineItems(): UseLineItemsReturn {
68
84
  const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
69
85
  if (session?.status === 'complete') return;
70
86
  try {
71
- await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
87
+ await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
72
88
  } catch (err: unknown) {
73
89
  console.error('Failed to update quantity:', getErrorMessage(err));
74
90
  }
@@ -76,12 +92,12 @@ export function useLineItems(): UseLineItemsReturn {
76
92
 
77
93
  const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
78
94
  if (session?.status === 'complete') return;
79
- await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
95
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
80
96
  });
81
97
 
82
98
  const downsell = useMemoizedFn(async (priceId: string) => {
83
99
  if (session?.status === 'complete') return;
84
- await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
100
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
85
101
  });
86
102
 
87
103
  // Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
@@ -124,7 +140,15 @@ export function useLineItems(): UseLineItemsReturn {
124
140
  if (session?.status === 'complete') return;
125
141
  if (!crossSellItem) return;
126
142
  try {
127
- await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
143
+ await addCrossSellItem(
144
+ effectiveSessionId,
145
+ crossSellItem.id,
146
+ session,
147
+ currencyId,
148
+ refresh,
149
+ sessionData,
150
+ setSessionData
151
+ );
128
152
  } catch (err: unknown) {
129
153
  console.error('Failed to add cross-sell:', getErrorMessage(err));
130
154
  }
@@ -133,7 +157,7 @@ export function useLineItems(): UseLineItemsReturn {
133
157
  const removeCrossSell = useMemoizedFn(async () => {
134
158
  if (session?.status === 'complete') return;
135
159
  try {
136
- await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
160
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
137
161
  } catch (err: unknown) {
138
162
  console.error('Failed to remove cross-sell:', getErrorMessage(err));
139
163
  }
@@ -143,7 +167,16 @@ export function useLineItems(): UseLineItemsReturn {
143
167
  if (session?.status === 'complete') return;
144
168
  if (!isDonation) return;
145
169
  try {
146
- await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
170
+ await changeDonationAmount(
171
+ effectiveSessionId,
172
+ priceId,
173
+ amount,
174
+ session,
175
+ currencyId,
176
+ refresh,
177
+ sessionData,
178
+ setSessionData
179
+ );
147
180
  } catch (err: unknown) {
148
181
  console.error('Failed to change amount:', getErrorMessage(err));
149
182
  }
@@ -42,9 +42,10 @@ export interface UsePaymentMethodReturn {
42
42
  export function usePaymentMethod(
43
43
  sessionData: SessionData | null,
44
44
  sessionId: string,
45
- refreshSession: (force?: boolean) => Promise<void>
45
+ refreshSession: (force?: boolean) => Promise<void>,
46
+ setSessionData?: (data: SessionData) => void
46
47
  ): UsePaymentMethodReturn {
47
- const methods = sessionData?.paymentMethods || [];
48
+ const methods = useMemo(() => sessionData?.paymentMethods || [], [sessionData?.paymentMethods]);
48
49
  const session = sessionData?.checkoutSession;
49
50
 
50
51
  const [currencyId, setCurrencyId] = useState<string | null>(() => getInitialCurrencyId(session, methods));
@@ -88,7 +89,7 @@ export function usePaymentMethod(
88
89
 
89
90
  setSwitching(true);
90
91
  try {
91
- await api.put(API.SWITCH_CURRENCY(sessionId), {
92
+ const { data } = await api.put(API.SWITCH_CURRENCY(sessionId), {
92
93
  currency_id: newCurrencyId,
93
94
  payment_method_id: method.id,
94
95
  });
@@ -105,8 +106,14 @@ export function usePaymentMethod(
105
106
  // Ignore
106
107
  }
107
108
 
108
- // Refresh session to get updated data (backend clears quote on currency switch)
109
- await refreshSession(true);
109
+ // Clear quotes they are currency-specific and stale after switch.
110
+ // Do NOT recalculate promotion here; CheckoutProvider's useEffect handles it
111
+ // after currency change settles, avoiding stale-closure overwrites.
112
+ if (sessionData && setSessionData) {
113
+ setSessionData({ ...sessionData, checkoutSession: data, quotes: undefined });
114
+ } else {
115
+ await refreshSession(true);
116
+ }
110
117
  } catch (err: unknown) {
111
118
  console.error('Failed to switch currency:', getErrorMessage(err));
112
119
  // Fallback: align with backend currency to resolve currencyMismatch
@@ -131,6 +138,7 @@ export function usePaymentMethod(
131
138
 
132
139
  const sessionCurrencyId = session.currency_id;
133
140
  if (session.status === 'complete') return;
141
+
134
142
  if (sessionCurrencyId && sessionCurrencyId !== currencyId) {
135
143
  switchCurrency(currencyId);
136
144
  }
@@ -71,7 +71,8 @@ export function usePricing(
71
71
  currency: TPaymentCurrency | null,
72
72
  isStripe: boolean,
73
73
  refreshSession: (force?: boolean) => Promise<void>,
74
- paymentMethodType?: string | null
74
+ paymentMethodType?: string | null,
75
+ switching?: boolean
75
76
  ): UsePricingReturn {
76
77
  const session = sessionData?.checkoutSession;
77
78
  const items: TLineItemExpanded[] = (session?.line_items || []) as TLineItemExpanded[];
@@ -98,7 +99,7 @@ export function usePricing(
98
99
 
99
100
  // Fetch exchange rate
100
101
  const fetchRate = useMemoizedFn(async () => {
101
- if (!sessionId || !hasDynamicPricing || isStripe) {
102
+ if (!sessionId || !hasDynamicPricing || isStripe || switching) {
102
103
  setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
103
104
  if (isStripe) {
104
105
  setExchangeRate(null);
@@ -136,7 +137,7 @@ export function usePricing(
136
137
  useEffect(() => {
137
138
  mountedRef.current = true;
138
139
 
139
- if (!hasDynamicPricing || isStripe || !sessionId || session?.status === 'complete') {
140
+ if (!hasDynamicPricing || isStripe || switching || !sessionId || session?.status === 'complete') {
140
141
  // Clear stale rate when switching to Stripe
141
142
  if (isStripe) {
142
143
  setExchangeRate(null);
@@ -178,7 +179,7 @@ export function usePricing(
178
179
  if (pollingRef.current) clearTimeout(pollingRef.current);
179
180
  document.removeEventListener('visibilitychange', handleVisibility);
180
181
  };
181
- }, [hasDynamicPricing, isStripe, sessionId, currency?.id, session?.status]); // eslint-disable-line react-hooks/exhaustive-deps
182
+ }, [hasDynamicPricing, isStripe, switching, sessionId, currency?.id, session?.status]); // eslint-disable-line react-hooks/exhaustive-deps
182
183
 
183
184
  // Calculate amounts
184
185
  const amounts = useMemo(
@@ -479,6 +479,13 @@ export function useSubmit(
479
479
  return;
480
480
  }
481
481
 
482
+ // STOP_ACCEPTING_ORDERS — show dedicated dialog instead of toast
483
+ if (errorCode === 'STOP_ACCEPTING_ORDERS') {
484
+ setStatus('service_suspended');
485
+ setContext({ type: 'service_suspended' });
486
+ return;
487
+ }
488
+
482
489
  // UNIFIED_APP_REQUIRED / CUSTOMER_LIMITED
483
490
  if (errorCode === 'UNIFIED_APP_REQUIRED' || errorCode === 'CUSTOMER_LIMITED') {
484
491
  setStatus('failed');
@@ -625,7 +632,12 @@ export function useSubmit(
625
632
 
626
633
  // Cancel
627
634
  const cancel = useMemoizedFn(() => {
628
- if (status === 'confirming_price' || status === 'confirming_fast_pay' || status === 'credit_insufficient') {
635
+ if (
636
+ status === 'confirming_price' ||
637
+ status === 'confirming_fast_pay' ||
638
+ status === 'credit_insufficient' ||
639
+ status === 'service_suspended'
640
+ ) {
629
641
  setStatus('idle');
630
642
  setContext(null);
631
643
  unlock();
@@ -11,13 +11,13 @@ export interface UseUpsellReturn {
11
11
  }
12
12
 
13
13
  export function useUpsell(): UseUpsellReturn {
14
- const { session, effectiveSessionId, refresh } = useSessionContext();
14
+ const { session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
15
15
  const { currency } = usePaymentMethodContext();
16
16
  const currencyId = currency?.id || null;
17
17
 
18
18
  const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
19
19
  try {
20
- await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
20
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
21
21
  } catch (err: unknown) {
22
22
  console.error('Failed to upsell:', getErrorMessage(err));
23
23
  }
@@ -25,7 +25,7 @@ export function useUpsell(): UseUpsellReturn {
25
25
 
26
26
  const downsell = useMemoizedFn(async (priceId: string) => {
27
27
  try {
28
- await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
28
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
29
29
  } catch (err: unknown) {
30
30
  console.error('Failed to downsell:', getErrorMessage(err));
31
31
  }
@@ -16,6 +16,7 @@ export type SubmitStatus =
16
16
  | 'confirming_price'
17
17
  | 'confirming_fast_pay'
18
18
  | 'credit_insufficient'
19
+ | 'service_suspended'
19
20
  | 'waiting_did'
20
21
  | 'waiting_stripe'
21
22
  | 'completed'
@@ -28,6 +29,7 @@ export type SubmitContext =
28
29
  | { type: 'stripe'; clientSecret: string; intentType?: 'payment_intent' | 'setup_intent' }
29
30
  | { type: 'did_connect'; action: string; checkpointId: string; extraParams: Record<string, unknown> }
30
31
  | { type: 'error'; message: string; code?: string }
32
+ | { type: 'service_suspended' }
31
33
  | null;
32
34
 
33
35
  // ── Form ──
@@ -83,8 +85,8 @@ export interface UseCheckoutReturn {
83
85
  // Loading state
84
86
  isLoading: boolean;
85
87
  error: string | null;
86
- /** Structured error code: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null */
87
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
88
+ /** Structured error code */
89
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
88
90
  refresh: () => Promise<void>;
89
91
 
90
92
  // Vendor count (for post-payment vendor order polling)