@blocklet/payment-react-headless 1.26.0 → 1.26.2
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 +16 -4
- package/es/checkout/context/SessionContext.d.ts +2 -0
- package/es/checkout/core/crossSell.d.ts +3 -2
- package/es/checkout/core/crossSell.js +24 -8
- package/es/checkout/core/lineItems.d.ts +6 -5
- package/es/checkout/core/lineItems.js +48 -17
- 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 +14 -9
- package/es/checkout/hooks/useCrossSell.js +11 -3
- package/es/checkout/hooks/useLineItems.js +36 -9
- package/es/checkout/hooks/usePaymentMethod.d.ts +1 -1
- package/es/checkout/hooks/usePaymentMethod.js +14 -4
- package/es/checkout/hooks/useUpsell.js +3 -3
- package/lib/checkout/context/CheckoutProvider.js +13 -4
- package/lib/checkout/context/SessionContext.d.ts +2 -0
- package/lib/checkout/core/crossSell.d.ts +3 -2
- package/lib/checkout/core/crossSell.js +36 -8
- package/lib/checkout/core/lineItems.d.ts +6 -5
- package/lib/checkout/core/lineItems.js +72 -18
- 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 +18 -9
- package/lib/checkout/hooks/useCrossSell.js +5 -3
- package/lib/checkout/hooks/useLineItems.js +10 -8
- package/lib/checkout/hooks/usePaymentMethod.d.ts +1 -1
- package/lib/checkout/hooks/usePaymentMethod.js +20 -4
- package/lib/checkout/hooks/useUpsell.js +5 -3
- package/package.json +3 -3
- package/src/checkout/context/CheckoutProvider.tsx +18 -5
- package/src/checkout/context/SessionContext.ts +2 -0
- package/src/checkout/core/crossSell.ts +29 -8
- package/src/checkout/core/lineItems.ts +62 -18
- package/src/checkout/core/promotion.ts +6 -2
- package/src/checkout/hooks/useBillingInterval.ts +8 -5
- package/src/checkout/hooks/useCheckout.ts +15 -10
- package/src/checkout/hooks/useCrossSell.ts +11 -3
- package/src/checkout/hooks/useLineItems.ts +42 -9
- package/src/checkout/hooks/usePaymentMethod.ts +17 -5
- package/src/checkout/hooks/useUpsell.ts +3 -3
|
@@ -4,6 +4,8 @@ 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;
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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(
|
|
@@ -77,23 +78,27 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
77
78
|
);
|
|
78
79
|
|
|
79
80
|
// 7. Line items operations
|
|
80
|
-
const items = (session?.line_items || []) as TLineItemExpanded[];
|
|
81
|
+
const items = useMemo(() => (session?.line_items || []) as TLineItemExpanded[], [session?.line_items]);
|
|
81
82
|
const currencyId = paymentMethodHook.currency?.id || null;
|
|
82
83
|
|
|
83
|
-
// Recalculate promotion when currency changes
|
|
84
|
+
// Recalculate promotion when currency changes — use response directly (no extra GET)
|
|
84
85
|
const prevCurrencyRef = useRef<string | null>(null);
|
|
85
86
|
useEffect(() => {
|
|
86
87
|
const currId = paymentMethodHook.currency?.id || null;
|
|
87
88
|
if (!currId || !session) return;
|
|
88
89
|
if (prevCurrencyRef.current === null || currId !== prevCurrencyRef.current) {
|
|
89
90
|
prevCurrencyRef.current = currId;
|
|
90
|
-
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then(() =>
|
|
91
|
+
recalculatePromotionIfNeeded(session, effectiveSessionId, currId).then((recalculated) => {
|
|
92
|
+
if (recalculated && sessionData) {
|
|
93
|
+
setSessionData({ ...sessionData, checkoutSession: recalculated, quotes: undefined });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
91
96
|
}
|
|
92
97
|
}, [paymentMethodHook.currency?.id, session?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
98
|
|
|
94
99
|
const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
|
|
95
100
|
try {
|
|
96
|
-
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
|
|
101
|
+
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
|
|
97
102
|
} catch (err: unknown) {
|
|
98
103
|
console.error('Failed to update quantity:', getErrorMessage(err));
|
|
99
104
|
}
|
|
@@ -101,7 +106,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
101
106
|
|
|
102
107
|
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
103
108
|
try {
|
|
104
|
-
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
109
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
|
|
105
110
|
} catch (err: unknown) {
|
|
106
111
|
console.error('Failed to upsell:', getErrorMessage(err));
|
|
107
112
|
}
|
|
@@ -109,7 +114,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
109
114
|
|
|
110
115
|
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
111
116
|
try {
|
|
112
|
-
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
117
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
|
|
113
118
|
} catch (err: unknown) {
|
|
114
119
|
console.error('Failed to downsell:', getErrorMessage(err));
|
|
115
120
|
}
|
|
@@ -140,7 +145,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
140
145
|
const addCrossSell = useMemoizedFn(async () => {
|
|
141
146
|
if (!crossSellItem) return;
|
|
142
147
|
try {
|
|
143
|
-
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
|
|
148
|
+
await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh, sessionData, setSessionData);
|
|
144
149
|
} catch (err: unknown) {
|
|
145
150
|
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
146
151
|
}
|
|
@@ -148,7 +153,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
148
153
|
|
|
149
154
|
const removeCrossSell = useMemoizedFn(async () => {
|
|
150
155
|
try {
|
|
151
|
-
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
156
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
|
|
152
157
|
} catch (err: unknown) {
|
|
153
158
|
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
154
159
|
}
|
|
@@ -179,7 +184,7 @@ export function useCheckout(sessionId: string): UseCheckoutReturn {
|
|
|
179
184
|
const setDonationAmount = useMemoizedFn(async (priceId: string, amount: string) => {
|
|
180
185
|
if (!isDonation) return;
|
|
181
186
|
try {
|
|
182
|
-
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
|
|
187
|
+
await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh, sessionData, setSessionData);
|
|
183
188
|
} catch (err: unknown) {
|
|
184
189
|
console.error('Failed to change amount:', getErrorMessage(err));
|
|
185
190
|
}
|
|
@@ -17,7 +17,7 @@ export interface UseCrossSellReturn {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function useCrossSell(): UseCrossSellReturn {
|
|
20
|
-
const { items, session, effectiveSessionId, refresh } = useSessionContext();
|
|
20
|
+
const { items, session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
|
|
21
21
|
const { currency } = usePaymentMethodContext();
|
|
22
22
|
const currencyId = currency?.id || null;
|
|
23
23
|
|
|
@@ -61,7 +61,15 @@ export function useCrossSell(): UseCrossSellReturn {
|
|
|
61
61
|
const crossSellItemPrice = getCrossSellItem(items);
|
|
62
62
|
if (!crossSellItemPrice) return;
|
|
63
63
|
try {
|
|
64
|
-
await addCrossSellItem(
|
|
64
|
+
await addCrossSellItem(
|
|
65
|
+
effectiveSessionId,
|
|
66
|
+
crossSellItemPrice.id,
|
|
67
|
+
session,
|
|
68
|
+
currencyId,
|
|
69
|
+
refresh,
|
|
70
|
+
sessionData,
|
|
71
|
+
setSessionData
|
|
72
|
+
);
|
|
65
73
|
} catch (err: unknown) {
|
|
66
74
|
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
67
75
|
}
|
|
@@ -70,7 +78,7 @@ export function useCrossSell(): UseCrossSellReturn {
|
|
|
70
78
|
const remove = useMemoizedFn(async () => {
|
|
71
79
|
if (session?.status === 'complete') return;
|
|
72
80
|
try {
|
|
73
|
-
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
81
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
|
|
74
82
|
} catch (err: unknown) {
|
|
75
83
|
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
76
84
|
}
|
|
@@ -39,14 +39,21 @@ export interface UseLineItemsReturn {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function useLineItems(): UseLineItemsReturn {
|
|
42
|
-
const { items, session, effectiveSessionId, isDonation, refresh } = useSessionContext();
|
|
42
|
+
const { items, session, effectiveSessionId, isDonation, refresh, sessionData, setSessionData } = useSessionContext();
|
|
43
43
|
const { currency } = usePaymentMethodContext();
|
|
44
44
|
const currencyId = currency?.id || null;
|
|
45
45
|
|
|
46
46
|
// Default quantity from URL params: ?qty=5 or ?qty_price_xxx=10 (matching original product-item.tsx)
|
|
47
47
|
const defaultQtyApplied = useRef(false);
|
|
48
48
|
useEffect(() => {
|
|
49
|
-
if (
|
|
49
|
+
if (
|
|
50
|
+
defaultQtyApplied.current ||
|
|
51
|
+
!effectiveSessionId ||
|
|
52
|
+
!items.length ||
|
|
53
|
+
!currencyId ||
|
|
54
|
+
session?.status === 'complete'
|
|
55
|
+
)
|
|
56
|
+
return;
|
|
50
57
|
try {
|
|
51
58
|
const params = new URLSearchParams(window.location.search);
|
|
52
59
|
for (const item of items) {
|
|
@@ -55,7 +62,16 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
55
62
|
const qty = Math.max(1, parseInt(qtyStr, 10));
|
|
56
63
|
if (Number.isFinite(qty) && qty !== item.quantity) {
|
|
57
64
|
defaultQtyApplied.current = true;
|
|
58
|
-
adjustQuantity(
|
|
65
|
+
adjustQuantity(
|
|
66
|
+
effectiveSessionId,
|
|
67
|
+
item.price_id,
|
|
68
|
+
qty,
|
|
69
|
+
currencyId,
|
|
70
|
+
session,
|
|
71
|
+
refresh,
|
|
72
|
+
sessionData,
|
|
73
|
+
setSessionData
|
|
74
|
+
);
|
|
59
75
|
break; // only apply to the first matching item for ?qty
|
|
60
76
|
}
|
|
61
77
|
}
|
|
@@ -68,7 +84,7 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
68
84
|
const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
|
|
69
85
|
if (session?.status === 'complete') return;
|
|
70
86
|
try {
|
|
71
|
-
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
|
|
87
|
+
await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
|
|
72
88
|
} catch (err: unknown) {
|
|
73
89
|
console.error('Failed to update quantity:', getErrorMessage(err));
|
|
74
90
|
}
|
|
@@ -76,12 +92,12 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
76
92
|
|
|
77
93
|
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
78
94
|
if (session?.status === 'complete') return;
|
|
79
|
-
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
95
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
|
|
80
96
|
});
|
|
81
97
|
|
|
82
98
|
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
83
99
|
if (session?.status === 'complete') return;
|
|
84
|
-
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
100
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
|
|
85
101
|
});
|
|
86
102
|
|
|
87
103
|
// Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
|
|
@@ -124,7 +140,15 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
124
140
|
if (session?.status === 'complete') return;
|
|
125
141
|
if (!crossSellItem) return;
|
|
126
142
|
try {
|
|
127
|
-
await addCrossSellItem(
|
|
143
|
+
await addCrossSellItem(
|
|
144
|
+
effectiveSessionId,
|
|
145
|
+
crossSellItem.id,
|
|
146
|
+
session,
|
|
147
|
+
currencyId,
|
|
148
|
+
refresh,
|
|
149
|
+
sessionData,
|
|
150
|
+
setSessionData
|
|
151
|
+
);
|
|
128
152
|
} catch (err: unknown) {
|
|
129
153
|
console.error('Failed to add cross-sell:', getErrorMessage(err));
|
|
130
154
|
}
|
|
@@ -133,7 +157,7 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
133
157
|
const removeCrossSell = useMemoizedFn(async () => {
|
|
134
158
|
if (session?.status === 'complete') return;
|
|
135
159
|
try {
|
|
136
|
-
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
|
|
160
|
+
await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
|
|
137
161
|
} catch (err: unknown) {
|
|
138
162
|
console.error('Failed to remove cross-sell:', getErrorMessage(err));
|
|
139
163
|
}
|
|
@@ -143,7 +167,16 @@ export function useLineItems(): UseLineItemsReturn {
|
|
|
143
167
|
if (session?.status === 'complete') return;
|
|
144
168
|
if (!isDonation) return;
|
|
145
169
|
try {
|
|
146
|
-
await changeDonationAmount(
|
|
170
|
+
await changeDonationAmount(
|
|
171
|
+
effectiveSessionId,
|
|
172
|
+
priceId,
|
|
173
|
+
amount,
|
|
174
|
+
session,
|
|
175
|
+
currencyId,
|
|
176
|
+
refresh,
|
|
177
|
+
sessionData,
|
|
178
|
+
setSessionData
|
|
179
|
+
);
|
|
147
180
|
} catch (err: unknown) {
|
|
148
181
|
console.error('Failed to change amount:', getErrorMessage(err));
|
|
149
182
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
findMethodAndCurrency,
|
|
14
14
|
buildPaymentTypes,
|
|
15
15
|
} from '../core/paymentMethod';
|
|
16
|
+
import { recalculatePromotionIfNeeded } from '../core/lineItems';
|
|
16
17
|
import type { SessionData } from './useCheckoutSession';
|
|
17
18
|
|
|
18
19
|
export interface UsePaymentMethodReturn {
|
|
@@ -42,9 +43,10 @@ export interface UsePaymentMethodReturn {
|
|
|
42
43
|
export function usePaymentMethod(
|
|
43
44
|
sessionData: SessionData | null,
|
|
44
45
|
sessionId: string,
|
|
45
|
-
refreshSession: (force?: boolean) => Promise<void
|
|
46
|
+
refreshSession: (force?: boolean) => Promise<void>,
|
|
47
|
+
setSessionData?: (data: SessionData) => void
|
|
46
48
|
): UsePaymentMethodReturn {
|
|
47
|
-
const methods = sessionData?.paymentMethods || [];
|
|
49
|
+
const methods = useMemo(() => sessionData?.paymentMethods || [], [sessionData?.paymentMethods]);
|
|
48
50
|
const session = sessionData?.checkoutSession;
|
|
49
51
|
|
|
50
52
|
const [currencyId, setCurrencyId] = useState<string | null>(() => getInitialCurrencyId(session, methods));
|
|
@@ -88,7 +90,7 @@ export function usePaymentMethod(
|
|
|
88
90
|
|
|
89
91
|
setSwitching(true);
|
|
90
92
|
try {
|
|
91
|
-
await api.put(API.SWITCH_CURRENCY(sessionId), {
|
|
93
|
+
const { data } = await api.put(API.SWITCH_CURRENCY(sessionId), {
|
|
92
94
|
currency_id: newCurrencyId,
|
|
93
95
|
payment_method_id: method.id,
|
|
94
96
|
});
|
|
@@ -105,8 +107,18 @@ export function usePaymentMethod(
|
|
|
105
107
|
// Ignore
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
|
117
|
+
if (sessionData && setSessionData) {
|
|
118
|
+
setSessionData({ ...sessionData, checkoutSession: finalSession, quotes: undefined });
|
|
119
|
+
} else {
|
|
120
|
+
await refreshSession(true);
|
|
121
|
+
}
|
|
110
122
|
} catch (err: unknown) {
|
|
111
123
|
console.error('Failed to switch currency:', getErrorMessage(err));
|
|
112
124
|
// Fallback: align with backend currency to resolve currencyMismatch
|
|
@@ -11,13 +11,13 @@ export interface UseUpsellReturn {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function useUpsell(): UseUpsellReturn {
|
|
14
|
-
const { session, effectiveSessionId, refresh } = useSessionContext();
|
|
14
|
+
const { session, effectiveSessionId, refresh, sessionData, setSessionData } = useSessionContext();
|
|
15
15
|
const { currency } = usePaymentMethodContext();
|
|
16
16
|
const currencyId = currency?.id || null;
|
|
17
17
|
|
|
18
18
|
const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
|
|
19
19
|
try {
|
|
20
|
-
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
|
|
20
|
+
await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
|
|
21
21
|
} catch (err: unknown) {
|
|
22
22
|
console.error('Failed to upsell:', getErrorMessage(err));
|
|
23
23
|
}
|
|
@@ -25,7 +25,7 @@ export function useUpsell(): UseUpsellReturn {
|
|
|
25
25
|
|
|
26
26
|
const downsell = useMemoizedFn(async (priceId: string) => {
|
|
27
27
|
try {
|
|
28
|
-
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
|
|
28
|
+
await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
|
|
29
29
|
} catch (err: unknown) {
|
|
30
30
|
console.error('Failed to downsell:', getErrorMessage(err));
|
|
31
31
|
}
|