@blocklet/payment-react-headless 1.26.2 → 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 (46) hide show
  1. package/es/checkout/context/CheckoutProvider.js +12 -8
  2. package/es/checkout/context/SessionContext.d.ts +1 -1
  3. package/es/checkout/core/customerForm.js +0 -6
  4. package/es/checkout/core/paymentMethod.js +15 -7
  5. package/es/checkout/core/pricing.js +1 -2
  6. package/es/checkout/hooks/useCheckout.js +5 -4
  7. package/es/checkout/hooks/useCheckoutSession.d.ts +2 -2
  8. package/es/checkout/hooks/useCheckoutSession.js +7 -0
  9. package/es/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  10. package/es/checkout/hooks/useCustomerForm.d.ts +4 -0
  11. package/es/checkout/hooks/useCustomerForm.js +6 -0
  12. package/es/checkout/hooks/usePaymentMethod.js +1 -7
  13. package/es/checkout/hooks/usePricing.d.ts +1 -1
  14. package/es/checkout/hooks/usePricing.js +4 -4
  15. package/es/checkout/hooks/useSubmit.js +6 -1
  16. package/es/checkout/types.d.ts +5 -3
  17. package/lib/checkout/context/CheckoutProvider.js +14 -14
  18. package/lib/checkout/context/SessionContext.d.ts +1 -1
  19. package/lib/checkout/core/customerForm.js +0 -5
  20. package/lib/checkout/core/paymentMethod.js +14 -8
  21. package/lib/checkout/core/pricing.js +1 -2
  22. package/lib/checkout/hooks/useCheckout.js +4 -8
  23. package/lib/checkout/hooks/useCheckoutSession.d.ts +2 -2
  24. package/lib/checkout/hooks/useCheckoutSession.js +7 -0
  25. package/lib/checkout/hooks/useCheckoutStatus.d.ts +1 -1
  26. package/lib/checkout/hooks/useCustomerForm.d.ts +4 -0
  27. package/lib/checkout/hooks/useCustomerForm.js +6 -0
  28. package/lib/checkout/hooks/usePaymentMethod.js +1 -7
  29. package/lib/checkout/hooks/usePricing.d.ts +1 -1
  30. package/lib/checkout/hooks/usePricing.js +4 -4
  31. package/lib/checkout/hooks/useSubmit.js +8 -1
  32. package/lib/checkout/types.d.ts +5 -3
  33. package/package.json +3 -3
  34. package/src/checkout/context/CheckoutProvider.tsx +23 -15
  35. package/src/checkout/context/SessionContext.ts +1 -1
  36. package/src/checkout/core/customerForm.ts +0 -6
  37. package/src/checkout/core/paymentMethod.ts +24 -7
  38. package/src/checkout/core/pricing.ts +1 -2
  39. package/src/checkout/hooks/useCheckout.ts +8 -5
  40. package/src/checkout/hooks/useCheckoutSession.ts +12 -3
  41. package/src/checkout/hooks/useCheckoutStatus.ts +1 -1
  42. package/src/checkout/hooks/useCustomerForm.ts +12 -0
  43. package/src/checkout/hooks/usePaymentMethod.ts +5 -9
  44. package/src/checkout/hooks/usePricing.ts +5 -4
  45. package/src/checkout/hooks/useSubmit.ts +13 -1
  46. package/src/checkout/types.ts +4 -2
@@ -9,7 +9,6 @@ var _ahooks = require("ahooks");
9
9
  var _checkoutAugmented = require("../../types/checkout-augmented");
10
10
  var _api = _interopRequireWildcard(require("../../shared/api"));
11
11
  var _paymentMethod = require("../core/paymentMethod");
12
- var _lineItems = require("../core/lineItems");
13
12
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
14
13
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
15
14
  function usePaymentMethod(sessionData, sessionId, refreshSession, setSessionData) {
@@ -58,15 +57,10 @@ function usePaymentMethod(sessionData, sessionId, refreshSession, setSessionData
58
57
  try {
59
58
  localStorage.setItem((0, _paymentMethod.getCurrencyStorageKey)(session?.user?.did), newCurrencyId);
60
59
  } catch {}
61
- let finalSession = data;
62
- if (data.discounts?.length) {
63
- const recalculated = await (0, _lineItems.recalculatePromotionIfNeeded)(session, sessionId, newCurrencyId);
64
- if (recalculated) finalSession = recalculated;
65
- }
66
60
  if (sessionData && setSessionData) {
67
61
  setSessionData({
68
62
  ...sessionData,
69
- checkoutSession: finalSession,
63
+ checkoutSession: data,
70
64
  quotes: void 0
71
65
  });
72
66
  } else {
@@ -54,4 +54,4 @@ export interface UsePricingReturn {
54
54
  afterTrialInterval: string | null;
55
55
  };
56
56
  }
57
- export declare function usePricing(sessionData: SessionData | null, sessionId: string, currency: TPaymentCurrency | null, isStripe: boolean, refreshSession: (force?: boolean) => Promise<void>, paymentMethodType?: string | null): UsePricingReturn;
57
+ export declare function usePricing(sessionData: SessionData | null, sessionId: string, currency: TPaymentCurrency | null, isStripe: boolean, refreshSession: (force?: boolean) => Promise<void>, paymentMethodType?: string | null, switching?: boolean): UsePricingReturn;
@@ -12,7 +12,7 @@ var _exchangeRate = require("../core/exchangeRate");
12
12
  var _pricing = require("../core/pricing");
13
13
  var _promotion = require("../core/promotion");
14
14
  var _submit = require("../core/submit");
15
- function usePricing(sessionData, sessionId, currency, isStripe, refreshSession, paymentMethodType) {
15
+ function usePricing(sessionData, sessionId, currency, isStripe, refreshSession, paymentMethodType, switching) {
16
16
  const session = sessionData?.checkoutSession;
17
17
  const items = session?.line_items || [];
18
18
  const [exchangeRate, setExchangeRate] = (0, _react.useState)(null);
@@ -31,7 +31,7 @@ function usePricing(sessionData, sessionId, currency, isStripe, refreshSession,
31
31
  const mountedRef = (0, _react.useRef)(true);
32
32
  const hasDynamicPricing = (0, _react.useMemo)(() => (0, _exchangeRate.checkHasDynamicPricing)(items), [items]);
33
33
  const fetchRate = (0, _ahooks.useMemoizedFn)(async () => {
34
- if (!sessionId || !hasDynamicPricing || isStripe) {
34
+ if (!sessionId || !hasDynamicPricing || isStripe || switching) {
35
35
  setRateStatus(hasDynamicPricing ? "unavailable" : "available");
36
36
  if (isStripe) {
37
37
  setExchangeRate(null);
@@ -63,7 +63,7 @@ function usePricing(sessionData, sessionId, currency, isStripe, refreshSession,
63
63
  });
64
64
  (0, _react.useEffect)(() => {
65
65
  mountedRef.current = true;
66
- if (!hasDynamicPricing || isStripe || !sessionId || session?.status === "complete") {
66
+ if (!hasDynamicPricing || isStripe || switching || !sessionId || session?.status === "complete") {
67
67
  if (isStripe) {
68
68
  setExchangeRate(null);
69
69
  setRateProvider(null);
@@ -96,7 +96,7 @@ function usePricing(sessionData, sessionId, currency, isStripe, refreshSession,
96
96
  if (pollingRef.current) clearTimeout(pollingRef.current);
97
97
  document.removeEventListener("visibilitychange", handleVisibility);
98
98
  };
99
- }, [hasDynamicPricing, isStripe, sessionId, currency?.id, session?.status]);
99
+ }, [hasDynamicPricing, isStripe, switching, sessionId, currency?.id, session?.status]);
100
100
  const amounts = (0, _react.useMemo)(() => (0, _pricing.calculateAmounts)(items, currency, session, exchangeRate, hasDynamicPricing, paymentMethodType), [items, currency, exchangeRate, hasDynamicPricing, session, paymentMethodType]);
101
101
  const quoteMeta = (0, _react.useMemo)(() => (0, _pricing.calculateQuoteMeta)(items, hasDynamicPricing, sessionData), [items, hasDynamicPricing, sessionData]);
102
102
  const setSlippage = (0, _ahooks.useMemoizedFn)(async config => {
@@ -330,6 +330,13 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
330
330
  });
331
331
  return;
332
332
  }
333
+ if (errorCode === "STOP_ACCEPTING_ORDERS") {
334
+ setStatus("service_suspended");
335
+ setContext({
336
+ type: "service_suspended"
337
+ });
338
+ return;
339
+ }
333
340
  if (errorCode === "UNIFIED_APP_REQUIRED" || errorCode === "CUSTOMER_LIMITED") {
334
341
  setStatus("failed");
335
342
  setContext({
@@ -454,7 +461,7 @@ function useSubmit(sessionData, sessionId, currencyId, isStripe, isCredit, isDon
454
461
  }
455
462
  });
456
463
  const cancel = (0, _ahooks.useMemoizedFn)(() => {
457
- if (status === "confirming_price" || status === "confirming_fast_pay" || status === "credit_insufficient") {
464
+ if (status === "confirming_price" || status === "confirming_fast_pay" || status === "credit_insufficient" || status === "service_suspended") {
458
465
  setStatus("idle");
459
466
  setContext(null);
460
467
  unlock();
@@ -1,5 +1,5 @@
1
1
  import type { TLineItemExpanded, TPaymentMethodExpanded, TPaymentCurrency, TPrice, TCheckoutSessionExpanded, TPaymentIntent, TCustomer } from '@blocklet/payment-types';
2
- export type SubmitStatus = 'idle' | 'submitting' | 'confirming_price' | 'confirming_fast_pay' | 'credit_insufficient' | 'waiting_did' | 'waiting_stripe' | 'completed' | 'failed';
2
+ export type SubmitStatus = 'idle' | 'submitting' | 'confirming_price' | 'confirming_fast_pay' | 'credit_insufficient' | 'service_suspended' | 'waiting_did' | 'waiting_stripe' | 'completed' | 'failed';
3
3
  export type SubmitContext = {
4
4
  type: 'price_change';
5
5
  changePercent: number;
@@ -25,6 +25,8 @@ export type SubmitContext = {
25
25
  type: 'error';
26
26
  message: string;
27
27
  code?: string;
28
+ } | {
29
+ type: 'service_suspended';
28
30
  } | null;
29
31
  export interface FieldConfig {
30
32
  name: string;
@@ -69,8 +71,8 @@ export type CheckoutResult = {
69
71
  export interface UseCheckoutReturn {
70
72
  isLoading: boolean;
71
73
  error: string | null;
72
- /** Structured error code: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null */
73
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
74
+ /** Structured error code */
75
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
74
76
  refresh: () => Promise<void>;
75
77
  vendorCount: number;
76
78
  product: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react-headless",
3
- "version": "1.26.2",
3
+ "version": "1.26.3",
4
4
  "description": "Headless React hooks for payment-kit checkout",
5
5
  "keywords": [
6
6
  "react",
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "@arcblock/ws": "^1.28.5",
37
37
  "@blocklet/js-sdk": "workspace:*",
38
- "@blocklet/payment-types": "1.26.2",
38
+ "@blocklet/payment-types": "1.26.3",
39
39
  "@ocap/util": "^1.28.5",
40
40
  "ahooks": "^3.8.5",
41
41
  "google-libphonenumber": "^3.2.42",
@@ -60,5 +60,5 @@
60
60
  "publishConfig": {
61
61
  "access": "public"
62
62
  },
63
- "gitHead": "71242a68d27d56666487176425153dc08071960f"
63
+ "gitHead": "18c5d045139c572b52465e15c4c63b3e327efab5"
64
64
  }
@@ -81,7 +81,9 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
81
81
  // 2. Payment method layer
82
82
  const paymentMethodHook = usePaymentMethodHook(sessionData, effectiveSessionId, refresh, setSessionData);
83
83
 
84
- // Recalculate promotion when currency changes use response directly (no extra GET)
84
+ // Recalculate promotion when currency changes or on initial mount (if discounts exist).
85
+ // Uses refresh() (fresh GET) instead of POST response to avoid stale-closure overwrites
86
+ // when switch-currency and recalculate-promotion race. Matches V1 pattern.
85
87
  const prevCurrencyRef = useRef<string | null>(null);
86
88
  useEffect(() => {
87
89
  const currId = paymentMethodHook.currency?.id || null;
@@ -89,8 +91,8 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
89
91
  if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
90
92
  prevCurrencyRef.current = currId;
91
93
  recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
92
- if (recalculated && sessionData) {
93
- setSessionData({ ...sessionData, checkoutSession: recalculated, quotes: undefined });
94
+ if (recalculated) {
95
+ refresh(true);
94
96
  }
95
97
  });
96
98
  }
@@ -111,6 +113,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
111
113
  setCurrency: paymentMethodHook.setCurrency,
112
114
  stripe: paymentMethodHook.stripe,
113
115
  }),
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
117
  [
115
118
  paymentMethodHook.current,
116
119
  paymentMethodHook.currency,
@@ -140,7 +143,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
140
143
  const hasDynamicPricing = useMemo(() => checkHasDynamicPricing(items), [items]);
141
144
 
142
145
  const fetchRate = useMemoizedFn(async () => {
143
- if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe) {
146
+ if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe || paymentMethodHook.switching) {
144
147
  setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
145
148
  if (paymentMethodHook.isStripe) {
146
149
  setExchangeRate(null);
@@ -181,7 +184,13 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
181
184
  useEffect(() => {
182
185
  mountedRef.current = true;
183
186
 
184
- if (!hasDynamicPricing || paymentMethodHook.isStripe || !effectiveSessionId || session?.status === 'complete') {
187
+ if (
188
+ !hasDynamicPricing ||
189
+ paymentMethodHook.isStripe ||
190
+ paymentMethodHook.switching ||
191
+ !effectiveSessionId ||
192
+ session?.status === 'complete'
193
+ ) {
185
194
  // Clear stale rate when switching to Stripe
186
195
  if (paymentMethodHook.isStripe) {
187
196
  setExchangeRate(null);
@@ -189,16 +198,14 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
189
198
  setRateProviderDisplay(null);
190
199
  setRateFetchedAt(null);
191
200
  }
192
- // When session is complete and we already have a rate, keep it as 'available'
193
- // (the rate used at payment time is still valid for display).
194
- // Otherwise set appropriate status:
195
- // - non-dynamic 'available' (no rate needed)
196
- // - dynamic but Stripe or no session → 'unavailable'
197
- if (session?.status === 'complete') {
198
- // Completed session: rate is irrelevant, suppress unavailable warnings
199
- setRateStatus('available');
200
- } else {
201
- setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
201
+ // Don't change rate status during currency switch keep current rate visible
202
+ // to avoid triggering "unavailable" toast while switch is in progress.
203
+ if (!paymentMethodHook.switching) {
204
+ if (session?.status === 'complete') {
205
+ setRateStatus('available');
206
+ } else {
207
+ setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
208
+ }
202
209
  }
203
210
  return undefined;
204
211
  }
@@ -229,6 +236,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
229
236
  }, [
230
237
  hasDynamicPricing,
231
238
  paymentMethodHook.isStripe,
239
+ paymentMethodHook.switching,
232
240
  effectiveSessionId,
233
241
  paymentMethodHook.currency?.id,
234
242
  session?.status,
@@ -10,7 +10,7 @@ export interface SessionContextValue {
10
10
  effectiveSessionId: string;
11
11
  isLoading: boolean;
12
12
  error: string | null;
13
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
13
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
14
14
  refresh: (forceRefresh?: boolean) => Promise<void>;
15
15
  items: TLineItemExpanded[];
16
16
  session: TCheckoutSessionExpanded | null | undefined;
@@ -41,12 +41,6 @@ export function buildFields(session: TCheckoutSessionExpanded | undefined | null
41
41
  required: true,
42
42
  group: 'address',
43
43
  },
44
- {
45
- name: 'billing_address.line2',
46
- type: 'text',
47
- required: false,
48
- group: 'address',
49
- },
50
44
  {
51
45
  name: 'billing_address.city',
52
46
  type: 'text',
@@ -36,11 +36,22 @@ export function getInitialCurrencyId(
36
36
  session: TCheckoutSessionExpanded | undefined | null,
37
37
  methods: TPaymentMethodExpanded[]
38
38
  ): string | null {
39
+ const availableCurrencyIds = new Set(
40
+ methods.flatMap((m) => (m.payment_currencies || []).map((c) => c.id)).filter(Boolean)
41
+ );
42
+ const isAvailable = (currencyId?: string | null) => !!currencyId && availableCurrencyIds.has(currencyId);
43
+
44
+ // Keep session currency stable when promotion is already applied.
45
+ // Auto-switching on refresh may trigger recalculate-promotion and remove discount.
46
+ if (session?.discounts?.length && isAvailable(session.currency_id)) {
47
+ return session.currency_id;
48
+ }
49
+
39
50
  if (typeof window !== 'undefined') {
40
51
  try {
41
52
  const params = new URLSearchParams(window.location.search);
42
53
  const urlCurrency = params.get('currencyId') || params.get('currency_id');
43
- if (urlCurrency) return urlCurrency;
54
+ if (isAvailable(urlCurrency)) return urlCurrency;
44
55
  } catch {
45
56
  // Ignore
46
57
  }
@@ -49,28 +60,34 @@ export function getInitialCurrencyId(
49
60
  if (user && !hasDidWallet(user)) {
50
61
  const stripeMethod = methods.find((m) => m.type === 'stripe');
51
62
  const stripeCurrency = stripeMethod?.payment_currencies?.[0];
52
- if (stripeCurrency) return stripeCurrency.id;
63
+ if (isAvailable(stripeCurrency?.id)) return stripeCurrency?.id as string;
53
64
  }
54
65
 
55
66
  try {
56
67
  const did = (session as CheckoutSessionRuntime | undefined)?.user?.did;
57
68
  const stored = localStorage.getItem(getCurrencyStorageKey(did));
58
- if (stored) return stored;
69
+ if (isAvailable(stored)) return stored;
59
70
  } catch {
60
71
  // Ignore
61
72
  }
62
73
  }
63
- return session?.currency_id || null;
74
+
75
+ if (isAvailable(session?.currency_id)) {
76
+ return session?.currency_id as string;
77
+ }
78
+
79
+ return methods[0]?.payment_currencies?.[0]?.id || null;
64
80
  }
65
81
 
66
82
  export function findMethodAndCurrency(
67
83
  methods: TPaymentMethodExpanded[],
68
84
  currencyId: string | null
69
85
  ): { method: TPaymentMethodExpanded | null; currency: TPaymentCurrency | null } {
86
+ // Return null when currencyId hasn't been initialized yet.
87
+ // Prevents premature fallback to the first currency (which may differ from session.currency_id),
88
+ // avoiding a wrong-currency recalculate-promotion call that removes fixed-amount discounts.
70
89
  if (!currencyId) {
71
- const first = methods[0];
72
- const firstCurrency = first?.payment_currencies?.[0] || null;
73
- return { method: first || null, currency: firstCurrency };
90
+ return { method: null, currency: null };
74
91
  }
75
92
 
76
93
  for (const method of methods) {
@@ -141,7 +141,6 @@ export function calculateAmounts(
141
141
  });
142
142
 
143
143
  const subtotalBN = new BN(result.total);
144
- const subtotalFormatted = formatDynamicPrice(fromUnitToToken(subtotalBN, currency.decimal), hasDynamicPricing);
145
144
 
146
145
  // Calculate discount client-side from coupon data
147
146
  const discountBN = calculateCouponDiscount(items, currency, session, hasDynamicPricing, exchangeRate, trialing, false);
@@ -216,7 +215,7 @@ export function calculateAmounts(
216
215
 
217
216
  return {
218
217
  subtotal: `${displaySubtotalFormatted} ${currency.symbol}`,
219
- paymentAmount: `${subtotalFormatted} ${currency.symbol}`,
218
+ paymentAmount: `${totalFormatted} ${currency.symbol}`,
220
219
  total: `${totalFormatted} ${currency.symbol}`,
221
220
  discount: discount ? `${discount} ${currency.symbol}` : null,
222
221
  tax,
@@ -51,7 +51,8 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
51
51
  paymentMethodHook.currency,
52
52
  paymentMethodHook.isStripe,
53
53
  refresh,
54
- paymentMethodHook.current?.type || null
54
+ paymentMethodHook.current?.type || null,
55
+ paymentMethodHook.switching
55
56
  );
56
57
 
57
58
  // 4. Customer form
@@ -81,16 +82,18 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
81
82
  const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
82
83
  const currencyId = paymentMethodHook.currency?.id || null;
83
84
 
84
- // Recalculate promotion when currency changes use response directly (no extra GET)
85
+ // Recalculate promotion when currency changes or on initial mount (if discounts exist).
86
+ // Uses refresh() (fresh GET) instead of POST response to avoid stale-closure overwrites
87
+ // when switch-currency and recalculate-promotion race. Matches V1 pattern.
85
88
  const prevCurrencyRef = useRef<string | null>(null);
86
89
  useEffect(() => {
87
90
  const currId = paymentMethodHook.currency?.id || null;
88
- if (!currId || !session) return;
91
+ if (!currId || !session || session.status === 'complete') return;
89
92
  if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
90
93
  prevCurrencyRef.current = currId;
91
94
  recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
92
- if (recalculated && sessionData) {
93
- setSessionData({ ...sessionData, checkoutSession: recalculated, quotes: undefined });
95
+ if (recalculated) {
96
+ refresh(true);
94
97
  }
95
98
  });
96
99
  }
@@ -36,8 +36,8 @@ export interface UseCheckoutSessionReturn {
36
36
  // Loading
37
37
  isLoading: boolean;
38
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;
39
+ /** Error code for structured error handling */
40
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
41
41
  refresh: (forceRefresh?: boolean) => Promise<void>;
42
42
  /** Directly update session data (e.g. after completion polling returns fresh data) */
43
43
  setSessionData: (data: SessionData) => void;
@@ -151,6 +151,13 @@ export function useCheckoutSession(sessionId: string): UseCheckoutSessionReturn
151
151
  }
152
152
  }
153
153
 
154
+ // System-level order acceptance check (only block non-completed sessions)
155
+ if (data?.stopAcceptingOrders && cs?.status !== 'complete') {
156
+ setError('STOP_ACCEPTING_ORDERS');
157
+ setIsLoading(false);
158
+ return;
159
+ }
160
+
154
161
  setSessionData(data);
155
162
  setIsLoading(false);
156
163
  } catch (err: unknown) {
@@ -188,11 +195,13 @@ export function useCheckoutSession(sessionId: string): UseCheckoutSessionReturn
188
195
  const resolvedSessionId = resolvedIdRef.current || (isPaymentLink ? '' : sessionId);
189
196
 
190
197
  // Determine error code for structured handling
191
- let errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null = null;
198
+ let errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null = null;
192
199
  if (error === 'SESSION_EXPIRED') {
193
200
  errorCode = 'SESSION_EXPIRED';
194
201
  } else if (error === 'EMPTY_LINE_ITEMS') {
195
202
  errorCode = 'EMPTY_LINE_ITEMS';
203
+ } else if (error === 'STOP_ACCEPTING_ORDERS') {
204
+ errorCode = 'STOP_ACCEPTING_ORDERS';
196
205
  }
197
206
 
198
207
  // Count vendor configs for post-payment polling
@@ -3,7 +3,7 @@ import { useSessionContext } from '../context/SessionContext';
3
3
  export interface UseCheckoutStatusReturn {
4
4
  isLoading: boolean;
5
5
  error: string | null;
6
- errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
6
+ errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
7
7
  canSubmit: boolean;
8
8
  isCompleted: boolean;
9
9
  isDonation: boolean;
@@ -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
  }
@@ -13,7 +13,6 @@ import {
13
13
  findMethodAndCurrency,
14
14
  buildPaymentTypes,
15
15
  } from '../core/paymentMethod';
16
- import { recalculatePromotionIfNeeded } from '../core/lineItems';
17
16
  import type { SessionData } from './useCheckoutSession';
18
17
 
19
18
  export interface UsePaymentMethodReturn {
@@ -107,15 +106,11 @@ export function usePaymentMethod(
107
106
  // Ignore
108
107
  }
109
108
 
110
- // Use PUT response directly; if discounts exist, recalculate within switching state
111
- let finalSession = data;
112
- if (data.discounts?.length) {
113
- const recalculated = await recalculatePromotionIfNeeded(session, sessionId, newCurrencyId);
114
- if (recalculated) finalSession = recalculated;
115
- }
116
- // Clear quotes — they are currency-specific and stale after switch
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.
117
112
  if (sessionData && setSessionData) {
118
- setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
113
+ setSessionData({ ...sessionData, checkoutSession: data, quotes: undefined });
119
114
  } else {
120
115
  await refreshSession(true);
121
116
  }
@@ -143,6 +138,7 @@ export function usePaymentMethod(
143
138
 
144
139
  const sessionCurrencyId = session.currency_id;
145
140
  if (session.status === 'complete') return;
141
+
146
142
  if (sessionCurrencyId && sessionCurrencyId !== currencyId) {
147
143
  switchCurrency(currencyId);
148
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();
@@ -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)