@blocklet/payment-react-headless 1.26.2 → 1.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/checkout/context/CheckoutProvider.js +12 -8
- package/es/checkout/context/SessionContext.d.ts +1 -1
- package/es/checkout/core/customerForm.js +1 -7
- package/es/checkout/core/paymentMethod.js +15 -7
- package/es/checkout/core/pricing.js +1 -2
- package/es/checkout/hooks/useCheckout.js +5 -4
- package/es/checkout/hooks/useCheckoutSession.d.ts +2 -2
- package/es/checkout/hooks/useCheckoutSession.js +7 -0
- package/es/checkout/hooks/useCheckoutStatus.d.ts +1 -1
- package/es/checkout/hooks/useCustomerForm.d.ts +4 -0
- package/es/checkout/hooks/useCustomerForm.js +6 -0
- package/es/checkout/hooks/usePaymentMethod.js +1 -7
- package/es/checkout/hooks/usePricing.d.ts +1 -1
- package/es/checkout/hooks/usePricing.js +4 -4
- package/es/checkout/hooks/useSubmit.js +6 -1
- package/es/checkout/types.d.ts +5 -3
- package/lib/checkout/context/CheckoutProvider.js +14 -14
- package/lib/checkout/context/SessionContext.d.ts +1 -1
- package/lib/checkout/core/customerForm.js +1 -6
- package/lib/checkout/core/paymentMethod.js +14 -8
- package/lib/checkout/core/pricing.js +1 -2
- package/lib/checkout/hooks/useCheckout.js +4 -8
- package/lib/checkout/hooks/useCheckoutSession.d.ts +2 -2
- package/lib/checkout/hooks/useCheckoutSession.js +7 -0
- package/lib/checkout/hooks/useCheckoutStatus.d.ts +1 -1
- package/lib/checkout/hooks/useCustomerForm.d.ts +4 -0
- package/lib/checkout/hooks/useCustomerForm.js +6 -0
- package/lib/checkout/hooks/usePaymentMethod.js +1 -7
- package/lib/checkout/hooks/usePricing.d.ts +1 -1
- package/lib/checkout/hooks/usePricing.js +4 -4
- package/lib/checkout/hooks/useSubmit.js +8 -1
- package/lib/checkout/types.d.ts +5 -3
- package/package.json +3 -3
- package/src/checkout/context/CheckoutProvider.tsx +23 -15
- package/src/checkout/context/SessionContext.ts +1 -1
- package/src/checkout/core/customerForm.ts +1 -7
- package/src/checkout/core/paymentMethod.ts +24 -7
- package/src/checkout/core/pricing.ts +1 -2
- package/src/checkout/hooks/useCheckout.ts +8 -5
- package/src/checkout/hooks/useCheckoutSession.ts +12 -3
- package/src/checkout/hooks/useCheckoutStatus.ts +1 -1
- package/src/checkout/hooks/useCustomerForm.ts +12 -0
- package/src/checkout/hooks/usePaymentMethod.ts +5 -9
- package/src/checkout/hooks/usePricing.ts +5 -4
- package/src/checkout/hooks/useSubmit.ts +13 -1
- package/src/checkout/types.ts +4 -2
|
@@ -83,6 +83,10 @@ function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
83
83
|
setErrors(result.errors);
|
|
84
84
|
return result.valid;
|
|
85
85
|
});
|
|
86
|
+
const checkValid = (0, _ahooks.useMemoizedFn)(async () => {
|
|
87
|
+
const result = await (0, _validation.validateForm)(values, getValidateOptions());
|
|
88
|
+
return result.valid;
|
|
89
|
+
});
|
|
86
90
|
const validateField = (0, _ahooks.useMemoizedFn)(async field => {
|
|
87
91
|
const result = await (0, _validation.validateForm)(values, getValidateOptions());
|
|
88
92
|
setErrors(prev => {
|
|
@@ -129,7 +133,9 @@ function useCustomerForm(sessionData, currencyId, methodId) {
|
|
|
129
133
|
errors,
|
|
130
134
|
touched,
|
|
131
135
|
validate,
|
|
136
|
+
checkValid,
|
|
132
137
|
validateField,
|
|
138
|
+
prefetched,
|
|
133
139
|
refetchCustomer
|
|
134
140
|
};
|
|
135
141
|
}
|
|
@@ -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:
|
|
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();
|
package/lib/checkout/types.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "1.26.4",
|
|
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.
|
|
38
|
+
"@blocklet/payment-types": "1.26.4",
|
|
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": "
|
|
63
|
+
"gitHead": "90fb554f2edfcd1c141e3887eef835b1a545f5ad"
|
|
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
|
|
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
|
|
93
|
-
|
|
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 (
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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',
|
|
@@ -98,7 +92,7 @@ export function createInitialValues(
|
|
|
98
92
|
payment_method: '',
|
|
99
93
|
payment_currency: session?.currency_id || '',
|
|
100
94
|
billing_address: {
|
|
101
|
-
country: customer?.address?.country || '',
|
|
95
|
+
country: customer?.address?.country || 'us',
|
|
102
96
|
state: customer?.address?.state || '',
|
|
103
97
|
city: customer?.address?.city || '',
|
|
104
98
|
line1: customer?.address?.line1 || '',
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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: `${
|
|
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
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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 (
|
|
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();
|
package/src/checkout/types.ts
CHANGED
|
@@ -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
|
|
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)
|