@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.
- package/es/checkout/context/CheckoutProvider.js +26 -10
- package/es/checkout/context/SessionContext.d.ts +3 -1
- package/es/checkout/core/crossSell.d.ts +3 -2
- package/es/checkout/core/crossSell.js +24 -8
- package/es/checkout/core/customerForm.js +0 -6
- package/es/checkout/core/lineItems.d.ts +6 -5
- package/es/checkout/core/lineItems.js +48 -17
- package/es/checkout/core/paymentMethod.js +15 -7
- package/es/checkout/core/pricing.js +1 -2
- package/es/checkout/core/promotion.d.ts +1 -1
- package/es/checkout/core/promotion.js +2 -1
- package/es/checkout/hooks/useBillingInterval.js +8 -5
- package/es/checkout/hooks/useCheckout.js +17 -11
- 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/useCrossSell.js +11 -3
- package/es/checkout/hooks/useCustomerForm.d.ts +4 -0
- package/es/checkout/hooks/useCustomerForm.js +6 -0
- package/es/checkout/hooks/useLineItems.js +36 -9
- package/es/checkout/hooks/usePaymentMethod.d.ts +1 -1
- package/es/checkout/hooks/usePaymentMethod.js +8 -4
- 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/hooks/useUpsell.js +3 -3
- package/es/checkout/types.d.ts +5 -3
- package/lib/checkout/context/CheckoutProvider.js +21 -12
- package/lib/checkout/context/SessionContext.d.ts +3 -1
- package/lib/checkout/core/crossSell.d.ts +3 -2
- package/lib/checkout/core/crossSell.js +36 -8
- package/lib/checkout/core/customerForm.js +0 -5
- package/lib/checkout/core/lineItems.d.ts +6 -5
- package/lib/checkout/core/lineItems.js +72 -18
- package/lib/checkout/core/paymentMethod.js +14 -8
- package/lib/checkout/core/pricing.js +1 -2
- package/lib/checkout/core/promotion.d.ts +1 -1
- package/lib/checkout/core/promotion.js +4 -1
- package/lib/checkout/hooks/useBillingInterval.js +10 -5
- package/lib/checkout/hooks/useCheckout.js +16 -11
- 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/useCrossSell.js +5 -3
- package/lib/checkout/hooks/useCustomerForm.d.ts +4 -0
- package/lib/checkout/hooks/useCustomerForm.js +6 -0
- package/lib/checkout/hooks/useLineItems.js +10 -8
- package/lib/checkout/hooks/usePaymentMethod.d.ts +1 -1
- package/lib/checkout/hooks/usePaymentMethod.js +14 -4
- 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/hooks/useUpsell.js +5 -3
- package/lib/checkout/types.d.ts +5 -3
- package/package.json +3 -3
- package/src/checkout/context/CheckoutProvider.tsx +38 -17
- package/src/checkout/context/SessionContext.ts +3 -1
- package/src/checkout/core/crossSell.ts +29 -8
- package/src/checkout/core/customerForm.ts +0 -6
- package/src/checkout/core/lineItems.ts +62 -18
- package/src/checkout/core/paymentMethod.ts +24 -7
- package/src/checkout/core/pricing.ts +1 -2
- package/src/checkout/core/promotion.ts +6 -2
- package/src/checkout/hooks/useBillingInterval.ts +8 -5
- package/src/checkout/hooks/useCheckout.ts +20 -12
- package/src/checkout/hooks/useCheckoutSession.ts +12 -3
- package/src/checkout/hooks/useCheckoutStatus.ts +1 -1
- package/src/checkout/hooks/useCrossSell.ts +11 -3
- package/src/checkout/hooks/useCustomerForm.ts +12 -0
- package/src/checkout/hooks/useLineItems.ts +42 -9
- package/src/checkout/hooks/usePaymentMethod.ts +13 -5
- package/src/checkout/hooks/usePricing.ts +5 -4
- package/src/checkout/hooks/useSubmit.ts +13 -1
- package/src/checkout/hooks/useUpsell.ts +3 -3
- package/src/checkout/types.ts +4 -2
|
@@ -38,12 +38,13 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
38
38
|
|
|
39
39
|
const session = sessionData?.checkoutSession;
|
|
40
40
|
const effectiveSessionId = resolvedSessionId || sessionId;
|
|
41
|
-
const items = (session?.line_items || []) as TLineItemExpanded[];
|
|
41
|
+
const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
|
|
42
42
|
const isDonation = session?.submit_type === 'donate';
|
|
43
43
|
|
|
44
44
|
const sessionValue = useMemo<SessionContextValue>(
|
|
45
45
|
() => ({
|
|
46
46
|
sessionData,
|
|
47
|
+
setSessionData,
|
|
47
48
|
sessionId,
|
|
48
49
|
effectiveSessionId,
|
|
49
50
|
isLoading,
|
|
@@ -60,6 +61,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
60
61
|
}),
|
|
61
62
|
[
|
|
62
63
|
sessionData,
|
|
64
|
+
setSessionData,
|
|
63
65
|
sessionId,
|
|
64
66
|
effectiveSessionId,
|
|
65
67
|
isLoading,
|
|
@@ -77,16 +79,22 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
77
79
|
);
|
|
78
80
|
|
|
79
81
|
// 2. Payment method layer
|
|
80
|
-
const paymentMethodHook = usePaymentMethodHook(sessionData, effectiveSessionId, refresh);
|
|
82
|
+
const paymentMethodHook = usePaymentMethodHook(sessionData, effectiveSessionId, refresh, setSessionData);
|
|
81
83
|
|
|
82
|
-
// 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.
|
|
83
87
|
const prevCurrencyRef = useRef<string | null>(null);
|
|
84
88
|
useEffect(() => {
|
|
85
89
|
const currId = paymentMethodHook.currency?.id || null;
|
|
86
90
|
if (!currId || !session || session.status === 'complete') return;
|
|
87
91
|
if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
|
|
88
92
|
prevCurrencyRef.current = currId;
|
|
89
|
-
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() =>
|
|
93
|
+
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
|
|
94
|
+
if (recalculated) {
|
|
95
|
+
refresh(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
90
98
|
}
|
|
91
99
|
}, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
92
100
|
|
|
@@ -105,6 +113,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
105
113
|
setCurrency: paymentMethodHook.setCurrency,
|
|
106
114
|
stripe: paymentMethodHook.stripe,
|
|
107
115
|
}),
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
108
117
|
[
|
|
109
118
|
paymentMethodHook.current,
|
|
110
119
|
paymentMethodHook.currency,
|
|
@@ -134,7 +143,7 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
134
143
|
const hasDynamicPricing = useMemo(() => checkHasDynamicPricing(items), [items]);
|
|
135
144
|
|
|
136
145
|
const fetchRate = useMemoizedFn(async () => {
|
|
137
|
-
if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe) {
|
|
146
|
+
if (!effectiveSessionId || !hasDynamicPricing || paymentMethodHook.isStripe || paymentMethodHook.switching) {
|
|
138
147
|
setRateStatus(hasDynamicPricing ? 'unavailable' : 'available');
|
|
139
148
|
if (paymentMethodHook.isStripe) {
|
|
140
149
|
setExchangeRate(null);
|
|
@@ -175,7 +184,13 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
175
184
|
useEffect(() => {
|
|
176
185
|
mountedRef.current = true;
|
|
177
186
|
|
|
178
|
-
if (
|
|
187
|
+
if (
|
|
188
|
+
!hasDynamicPricing ||
|
|
189
|
+
paymentMethodHook.isStripe ||
|
|
190
|
+
paymentMethodHook.switching ||
|
|
191
|
+
!effectiveSessionId ||
|
|
192
|
+
session?.status === 'complete'
|
|
193
|
+
) {
|
|
179
194
|
// Clear stale rate when switching to Stripe
|
|
180
195
|
if (paymentMethodHook.isStripe) {
|
|
181
196
|
setExchangeRate(null);
|
|
@@ -183,16 +198,14 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
183
198
|
setRateProviderDisplay(null);
|
|
184
199
|
setRateFetchedAt(null);
|
|
185
200
|
}
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
} else {
|
|
195
|
-
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
|
+
}
|
|
196
209
|
}
|
|
197
210
|
return undefined;
|
|
198
211
|
}
|
|
@@ -219,7 +232,15 @@ export function CheckoutProvider({ sessionId, children }: CheckoutProviderProps)
|
|
|
219
232
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
220
233
|
document.removeEventListener('visibilitychange', handleVisibility);
|
|
221
234
|
};
|
|
222
|
-
|
|
235
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
236
|
+
}, [
|
|
237
|
+
hasDynamicPricing,
|
|
238
|
+
paymentMethodHook.isStripe,
|
|
239
|
+
paymentMethodHook.switching,
|
|
240
|
+
effectiveSessionId,
|
|
241
|
+
paymentMethodHook.currency?.id,
|
|
242
|
+
session?.status,
|
|
243
|
+
]);
|
|
223
244
|
|
|
224
245
|
const exchangeRateValue = useMemo<ExchangeRateContextValue>(
|
|
225
246
|
() => ({
|
|
@@ -4,11 +4,13 @@ import type { SessionData } from '../hooks/useCheckoutSession';
|
|
|
4
4
|
|
|
5
5
|
export interface SessionContextValue {
|
|
6
6
|
sessionData: SessionData | null;
|
|
7
|
+
/** Directly replace session data (e.g. using PUT response to skip redundant GET) */
|
|
8
|
+
setSessionData: (data: SessionData) => void;
|
|
7
9
|
sessionId: string;
|
|
8
10
|
effectiveSessionId: string;
|
|
9
11
|
isLoading: boolean;
|
|
10
12
|
error: string | null;
|
|
11
|
-
errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
|
|
13
|
+
errorCode: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
|
|
12
14
|
refresh: (forceRefresh?: boolean) => Promise<void>;
|
|
13
15
|
items: TLineItemExpanded[];
|
|
14
16
|
session: TCheckoutSessionExpanded | null | undefined;
|
|
@@ -2,28 +2,49 @@ import type { TCheckoutSessionExpanded, TPrice } from '@blocklet/payment-types';
|
|
|
2
2
|
|
|
3
3
|
import api, { API } from '../../shared/api';
|
|
4
4
|
import { recalculatePromotionIfNeeded } from './lineItems';
|
|
5
|
+
import type { SessionData } from '../hooks/useCheckoutSession';
|
|
5
6
|
|
|
6
7
|
export async function addCrossSellItem(
|
|
7
8
|
sessionId: string,
|
|
8
9
|
crossSellItemId: string,
|
|
9
10
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
10
11
|
currencyId: string | null | undefined,
|
|
11
|
-
refresh: (force?: boolean) => Promise<void
|
|
12
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
13
|
+
sessionData?: SessionData | null,
|
|
14
|
+
setSessionData?: (data: SessionData) => void
|
|
12
15
|
): Promise<void> {
|
|
13
|
-
await api.put(API.CROSS_SELL(sessionId), { to: crossSellItemId });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
const { data } = await api.put(API.CROSS_SELL(sessionId), { to: crossSellItemId });
|
|
17
|
+
let finalSession = data;
|
|
18
|
+
if (data.discounts?.length) {
|
|
19
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
20
|
+
if (recalculated) finalSession = recalculated;
|
|
21
|
+
}
|
|
22
|
+
if (sessionData && setSessionData) {
|
|
23
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
24
|
+
} else {
|
|
25
|
+
await refresh(true);
|
|
26
|
+
}
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export async function removeCrossSellItem(
|
|
19
30
|
sessionId: string,
|
|
20
31
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
21
32
|
currencyId: string | null | undefined,
|
|
22
|
-
refresh: (force?: boolean) => Promise<void
|
|
33
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
34
|
+
sessionData?: SessionData | null,
|
|
35
|
+
setSessionData?: (data: SessionData) => void
|
|
23
36
|
): Promise<void> {
|
|
24
|
-
await api.delete(API.CROSS_SELL(sessionId));
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
const { data } = await api.delete(API.CROSS_SELL(sessionId));
|
|
38
|
+
let finalSession = data;
|
|
39
|
+
if (data.discounts?.length) {
|
|
40
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
41
|
+
if (recalculated) finalSession = recalculated;
|
|
42
|
+
}
|
|
43
|
+
if (sessionData && setSessionData) {
|
|
44
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
45
|
+
} else {
|
|
46
|
+
await refresh(true);
|
|
47
|
+
}
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
// Dedup concurrent fetchCrossSellItem calls (multiple useLineItems instances share one in-flight request)
|
|
@@ -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',
|
|
@@ -3,17 +3,19 @@ import type { TCheckoutSessionExpanded, TLineItemExpanded, TPrice } from '@block
|
|
|
3
3
|
import api, { API } from '../../shared/api';
|
|
4
4
|
import { recalculatePromotion, hasAppliedDiscounts } from './promotion';
|
|
5
5
|
import type { PriceWithCrossSell } from '../../types/checkout-augmented';
|
|
6
|
+
import type { SessionData } from '../hooks/useCheckoutSession';
|
|
6
7
|
|
|
7
8
|
export async function recalculatePromotionIfNeeded(
|
|
8
9
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
9
10
|
sessionId: string,
|
|
10
11
|
currencyId: string | null | undefined
|
|
11
|
-
): Promise<
|
|
12
|
-
if (!hasAppliedDiscounts(session)) return;
|
|
12
|
+
): Promise<TCheckoutSessionExpanded | null> {
|
|
13
|
+
if (!hasAppliedDiscounts(session)) return null;
|
|
13
14
|
try {
|
|
14
|
-
await recalculatePromotion(sessionId, currencyId);
|
|
15
|
+
return await recalculatePromotion(sessionId, currencyId);
|
|
15
16
|
} catch {
|
|
16
17
|
// Ignore recalculation error
|
|
18
|
+
return null;
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -23,15 +25,25 @@ export async function adjustQuantity(
|
|
|
23
25
|
qty: number,
|
|
24
26
|
currencyId: string | null | undefined,
|
|
25
27
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
26
|
-
refresh: (force?: boolean) => Promise<void
|
|
28
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
29
|
+
sessionData?: SessionData | null,
|
|
30
|
+
setSessionData?: (data: SessionData) => void
|
|
27
31
|
): Promise<void> {
|
|
28
|
-
await api.put(API.ADJUST_QUANTITY(sessionId), {
|
|
32
|
+
const { data } = await api.put(API.ADJUST_QUANTITY(sessionId), {
|
|
29
33
|
itemId,
|
|
30
34
|
quantity: qty,
|
|
31
35
|
currency_id: currencyId,
|
|
32
36
|
});
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
let finalSession = data;
|
|
38
|
+
if (data.discounts?.length || hasAppliedDiscounts(session)) {
|
|
39
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
40
|
+
if (recalculated) finalSession = recalculated;
|
|
41
|
+
}
|
|
42
|
+
if (sessionData && setSessionData) {
|
|
43
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
44
|
+
} else {
|
|
45
|
+
await refresh(true);
|
|
46
|
+
}
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export async function performUpsell(
|
|
@@ -40,7 +52,9 @@ export async function performUpsell(
|
|
|
40
52
|
toId: string,
|
|
41
53
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
42
54
|
currencyId: string | null | undefined,
|
|
43
|
-
refresh: (force?: boolean) => Promise<void
|
|
55
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
56
|
+
sessionData?: SessionData | null,
|
|
57
|
+
setSessionData?: (data: SessionData) => void
|
|
44
58
|
): Promise<void> {
|
|
45
59
|
// Backend rejects upsell with multiple line items — auto-remove cross-sell first
|
|
46
60
|
// Always try DELETE when multiple items exist (cross_sell flag may not be set)
|
|
@@ -51,9 +65,19 @@ export async function performUpsell(
|
|
|
51
65
|
// No cross-sell to remove — will proceed and let upsell API decide
|
|
52
66
|
}
|
|
53
67
|
}
|
|
54
|
-
await api.put(API.UPSELL(sessionId), { from: fromId, to: toId });
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
const { data } = await api.put(API.UPSELL(sessionId), { from: fromId, to: toId });
|
|
69
|
+
|
|
70
|
+
// Use PUT response directly; if discounts exist, recalculate and use that response
|
|
71
|
+
let finalSession = data;
|
|
72
|
+
if (data.discounts?.length || hasAppliedDiscounts(session)) {
|
|
73
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
74
|
+
if (recalculated) finalSession = recalculated;
|
|
75
|
+
}
|
|
76
|
+
if (sessionData && setSessionData) {
|
|
77
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
78
|
+
} else {
|
|
79
|
+
await refresh(true);
|
|
80
|
+
}
|
|
57
81
|
}
|
|
58
82
|
|
|
59
83
|
export async function performDownsell(
|
|
@@ -61,7 +85,9 @@ export async function performDownsell(
|
|
|
61
85
|
priceId: string,
|
|
62
86
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
63
87
|
currencyId: string | null | undefined,
|
|
64
|
-
refresh: (force?: boolean) => Promise<void
|
|
88
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
89
|
+
sessionData?: SessionData | null,
|
|
90
|
+
setSessionData?: (data: SessionData) => void
|
|
65
91
|
): Promise<void> {
|
|
66
92
|
// Auto-remove cross-sell to keep interval consistency (cross-sell is interval-aware)
|
|
67
93
|
if ((session?.line_items?.length || 0) > 1) {
|
|
@@ -71,9 +97,19 @@ export async function performDownsell(
|
|
|
71
97
|
// No cross-sell to remove — will proceed and let downsell API decide
|
|
72
98
|
}
|
|
73
99
|
}
|
|
74
|
-
await api.put(API.DOWNSELL(sessionId), { from: priceId });
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
const { data } = await api.put(API.DOWNSELL(sessionId), { from: priceId });
|
|
101
|
+
|
|
102
|
+
// Use PUT response directly; if discounts exist, recalculate and use that response
|
|
103
|
+
let finalSession = data;
|
|
104
|
+
if (data.discounts?.length || hasAppliedDiscounts(session)) {
|
|
105
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
106
|
+
if (recalculated) finalSession = recalculated;
|
|
107
|
+
}
|
|
108
|
+
if (sessionData && setSessionData) {
|
|
109
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
110
|
+
} else {
|
|
111
|
+
await refresh(true);
|
|
112
|
+
}
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
export async function changeDonationAmount(
|
|
@@ -82,16 +118,24 @@ export async function changeDonationAmount(
|
|
|
82
118
|
amount: string,
|
|
83
119
|
session: TCheckoutSessionExpanded | undefined | null,
|
|
84
120
|
currencyId: string | null | undefined,
|
|
85
|
-
refresh: (force?: boolean) => Promise<void
|
|
121
|
+
refresh: (force?: boolean) => Promise<void>,
|
|
122
|
+
sessionData?: SessionData | null,
|
|
123
|
+
setSessionData?: (data: SessionData) => void
|
|
86
124
|
): Promise<void> {
|
|
87
125
|
const { data } = await api.put(API.CHANGE_AMOUNT(sessionId), {
|
|
88
126
|
priceId,
|
|
89
127
|
amount,
|
|
90
128
|
});
|
|
129
|
+
let finalSession = data;
|
|
91
130
|
if (data?.discounts?.length) {
|
|
92
|
-
await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
131
|
+
const recalculated = await recalculatePromotionIfNeeded(session, sessionId, currencyId);
|
|
132
|
+
if (recalculated) finalSession = recalculated;
|
|
133
|
+
}
|
|
134
|
+
if (sessionData && setSessionData) {
|
|
135
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
136
|
+
} else {
|
|
137
|
+
await refresh(true);
|
|
93
138
|
}
|
|
94
|
-
await refresh(true);
|
|
95
139
|
}
|
|
96
140
|
|
|
97
141
|
// Extract cross-sell price from items
|
|
@@ -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,
|
|
@@ -43,10 +43,14 @@ export async function removePromotionCode(sessionId: string): Promise<void> {
|
|
|
43
43
|
await api.delete(API.REMOVE_PROMOTION(sessionId));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export async function recalculatePromotion(
|
|
47
|
-
|
|
46
|
+
export async function recalculatePromotion(
|
|
47
|
+
sessionId: string,
|
|
48
|
+
currencyId: string | null | undefined
|
|
49
|
+
): Promise<TCheckoutSessionExpanded> {
|
|
50
|
+
const { data } = await api.post(API.RECALCULATE_PROMOTION_SESSION(sessionId), {
|
|
48
51
|
currency_id: currencyId,
|
|
49
52
|
});
|
|
53
|
+
return data;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
export function isPromotionActive(session: TCheckoutSessionExpanded | undefined | null): boolean {
|
|
@@ -22,14 +22,15 @@ export interface BillingIntervalData {
|
|
|
22
22
|
export type UseBillingIntervalReturn = BillingIntervalData | null;
|
|
23
23
|
|
|
24
24
|
export function useBillingInterval(): UseBillingIntervalReturn {
|
|
25
|
-
const { items, session, effectiveSessionId, refresh } = useSessionContext();
|
|
25
|
+
const { items, session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
|
|
26
26
|
const { currency } = usePaymentMethodContext();
|
|
27
27
|
const currencyId = currency?.id || null;
|
|
28
28
|
const [switching, setSwitching] = useState(false);
|
|
29
|
+
const [pendingInterval, setPendingInterval] = useState<BillingIntervalType | null>(null);
|
|
29
30
|
|
|
30
31
|
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
31
32
|
try {
|
|
32
|
-
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
33
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
|
|
33
34
|
} catch (err: unknown) {
|
|
34
35
|
console.error('Failed to upsell:', getErrorMessage(err));
|
|
35
36
|
}
|
|
@@ -37,7 +38,7 @@ export function useBillingInterval(): UseBillingIntervalReturn {
|
|
|
37
38
|
|
|
38
39
|
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
39
40
|
try {
|
|
40
|
-
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
41
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
|
|
41
42
|
} catch (err: unknown) {
|
|
42
43
|
console.error('Failed to downsell:', getErrorMessage(err));
|
|
43
44
|
}
|
|
@@ -48,13 +49,14 @@ export function useBillingInterval(): UseBillingIntervalReturn {
|
|
|
48
49
|
if (!parsed) return null;
|
|
49
50
|
|
|
50
51
|
return {
|
|
51
|
-
current: parsed.current,
|
|
52
|
+
current: pendingInterval || parsed.current,
|
|
52
53
|
available: parsed.available,
|
|
53
54
|
switching,
|
|
54
55
|
switch: async (interval: BillingIntervalType) => {
|
|
55
56
|
const target = parsed.available.find((a) => a.interval === interval);
|
|
56
57
|
if (!target || switching) return;
|
|
57
58
|
|
|
59
|
+
setPendingInterval(interval);
|
|
58
60
|
setSwitching(true);
|
|
59
61
|
try {
|
|
60
62
|
if (!parsed.firstItem.upsell_price_id && target.priceId) {
|
|
@@ -64,8 +66,9 @@ export function useBillingInterval(): UseBillingIntervalReturn {
|
|
|
64
66
|
}
|
|
65
67
|
} finally {
|
|
66
68
|
setSwitching(false);
|
|
69
|
+
setPendingInterval(null);
|
|
67
70
|
}
|
|
68
71
|
},
|
|
69
72
|
};
|
|
70
|
-
}, [items, effectiveSessionId, switching]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
73
|
+
}, [items, effectiveSessionId, switching, pendingInterval]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
71
74
|
}
|
|
@@ -28,6 +28,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
28
28
|
error,
|
|
29
29
|
errorCode,
|
|
30
30
|
refresh,
|
|
31
|
+
setSessionData,
|
|
31
32
|
sessionData,
|
|
32
33
|
resolvedSessionId,
|
|
33
34
|
vendorCount,
|
|
@@ -41,7 +42,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
41
42
|
const effectiveSessionId = resolvedSessionId || sessionId;
|
|
42
43
|
|
|
43
44
|
// 2. Payment method
|
|
44
|
-
const paymentMethodHook = usePaymentMethod(sessionData, effectiveSessionId, refresh);
|
|
45
|
+
const paymentMethodHook = usePaymentMethod(sessionData, effectiveSessionId, refresh, setSessionData);
|
|
45
46
|
|
|
46
47
|
// 3. Pricing
|
|
47
48
|
const pricingHook = usePricing(
|
|
@@ -50,7 +51,8 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
50
51
|
paymentMethodHook.currency,
|
|
51
52
|
paymentMethodHook.isStripe,
|
|
52
53
|
refresh,
|
|
53
|
-
paymentMethodHook.current?.type || null
|
|
54
|
+
paymentMethodHook.current?.type || null,
|
|
55
|
+
paymentMethodHook.switching
|
|
54
56
|
);
|
|
55
57
|
|
|
56
58
|
// 4. Customer form
|
|
@@ -77,23 +79,29 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
77
79
|
);
|
|
78
80
|
|
|
79
81
|
// 7. Line items operations
|
|
80
|
-
const items = (session?.line_items || []) as TLineItemExpanded[];
|
|
82
|
+
const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
|
|
81
83
|
const currencyId = paymentMethodHook.currency?.id || null;
|
|
82
84
|
|
|
83
|
-
// Recalculate promotion when currency changes or on initial
|
|
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.
|
|
84
88
|
const prevCurrencyRef = useRef<string | null>(null);
|
|
85
89
|
useEffect(() => {
|
|
86
90
|
const currId = paymentMethodHook.currency?.id || null;
|
|
87
|
-
if (!currId || !session) return;
|
|
91
|
+
if (!currId || !session || session.status === 'complete') return;
|
|
88
92
|
if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
|
|
89
93
|
prevCurrencyRef.current = currId;
|
|
90
|
-
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() =>
|
|
94
|
+
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
|
|
95
|
+
if (recalculated) {
|
|
96
|
+
refresh(true);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
91
99
|
}
|
|
92
100
|
}, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
101
|
|
|
94
102
|
const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
|
|
95
103
|
try {
|
|
96
|
-
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
|
|
104
|
+
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
|
|
97
105
|
} catch (err: unknown) {
|
|
98
106
|
console.error('Failed to update quantity:', getErrorMessage(err));
|
|
99
107
|
}
|
|
@@ -101,7 +109,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
101
109
|
|
|
102
110
|
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
103
111
|
try {
|
|
104
|
-
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
112
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
|
|
105
113
|
} catch (err: unknown) {
|
|
106
114
|
console.error('Failed to upsell:', getErrorMessage(err));
|
|
107
115
|
}
|
|
@@ -109,7 +117,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
109
117
|
|
|
110
118
|
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
111
119
|
try {
|
|
112
|
-
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
120
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
|
|
113
121
|
} catch (err: unknown) {
|
|
114
122
|
console.error('Failed to downsell:', getErrorMessage(err));
|
|
115
123
|
}
|
|
@@ -140,7 +148,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
140
148
|
const addCrossSell = useMemoizedFn(async () => {
|
|
141
149
|
if (!crossSellItem) return;
|
|
142
150
|
try {
|
|
143
|
-
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
|
|
151
|
+
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh, sessionData, setSessionData);
|
|
144
152
|
} catch (err: unknown) {
|
|
145
153
|
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
146
154
|
}
|
|
@@ -148,7 +156,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
148
156
|
|
|
149
157
|
const removeCrossSell = useMemoizedFn(async () => {
|
|
150
158
|
try {
|
|
151
|
-
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
159
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
|
|
152
160
|
} catch (err: unknown) {
|
|
153
161
|
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
154
162
|
}
|
|
@@ -179,7 +187,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
179
187
|
const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
|
|
180
188
|
if (!isDonation) return;
|
|
181
189
|
try {
|
|
182
|
-
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
|
|
190
|
+
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh, sessionData, setSessionData);
|
|
183
191
|
} catch (err: unknown) {
|
|
184
192
|
console.error('Failed to change amount:', getErrorMessage(err));
|
|
185
193
|
}
|
|
@@ -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;
|