@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.
Files changed (42) hide show
  1. package/es/checkout/context/CheckoutProvider.js +16 -4
  2. package/es/checkout/context/SessionContext.d.ts +2 -0
  3. package/es/checkout/core/crossSell.d.ts +3 -2
  4. package/es/checkout/core/crossSell.js +24 -8
  5. package/es/checkout/core/lineItems.d.ts +6 -5
  6. package/es/checkout/core/lineItems.js +48 -17
  7. package/es/checkout/core/promotion.d.ts +1 -1
  8. package/es/checkout/core/promotion.js +2 -1
  9. package/es/checkout/hooks/useBillingInterval.js +8 -5
  10. package/es/checkout/hooks/useCheckout.js +14 -9
  11. package/es/checkout/hooks/useCrossSell.js +11 -3
  12. package/es/checkout/hooks/useLineItems.js +36 -9
  13. package/es/checkout/hooks/usePaymentMethod.d.ts +1 -1
  14. package/es/checkout/hooks/usePaymentMethod.js +14 -4
  15. package/es/checkout/hooks/useUpsell.js +3 -3
  16. package/lib/checkout/context/CheckoutProvider.js +13 -4
  17. package/lib/checkout/context/SessionContext.d.ts +2 -0
  18. package/lib/checkout/core/crossSell.d.ts +3 -2
  19. package/lib/checkout/core/crossSell.js +36 -8
  20. package/lib/checkout/core/lineItems.d.ts +6 -5
  21. package/lib/checkout/core/lineItems.js +72 -18
  22. package/lib/checkout/core/promotion.d.ts +1 -1
  23. package/lib/checkout/core/promotion.js +4 -1
  24. package/lib/checkout/hooks/useBillingInterval.js +10 -5
  25. package/lib/checkout/hooks/useCheckout.js +18 -9
  26. package/lib/checkout/hooks/useCrossSell.js +5 -3
  27. package/lib/checkout/hooks/useLineItems.js +10 -8
  28. package/lib/checkout/hooks/usePaymentMethod.d.ts +1 -1
  29. package/lib/checkout/hooks/usePaymentMethod.js +20 -4
  30. package/lib/checkout/hooks/useUpsell.js +5 -3
  31. package/package.json +3 -3
  32. package/src/checkout/context/CheckoutProvider.tsx +18 -5
  33. package/src/checkout/context/SessionContext.ts +2 -0
  34. package/src/checkout/core/crossSell.ts +29 -8
  35. package/src/checkout/core/lineItems.ts +62 -18
  36. package/src/checkout/core/promotion.ts +6 -2
  37. package/src/checkout/hooks/useBillingInterval.ts +8 -5
  38. package/src/checkout/hooks/useCheckout.ts +15 -10
  39. package/src/checkout/hooks/useCrossSell.ts +11 -3
  40. package/src/checkout/hooks/useLineItems.ts +42 -9
  41. package/src/checkout/hooks/usePaymentMethod.ts +17 -5
  42. 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
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
15
- await refresh(true);
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
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
26
- await refresh(true);
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<void> {
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
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
34
- await refresh(true);
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
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
56
- await refresh(true);
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
- await recalculatePromotionIfNeeded(session, sessionId, currencyId);
76
- await refresh(true);
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(sessionId: string, currencyId: string | null | undefined): Promise<void> {
47
- await api.post(API.RECALCULATE_PROMOTION_SESSION(sessionId), {
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 or on initial load
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(() => refresh(true));
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(effectiveSessionId, crossSellItemPrice.id, session, currencyId, refresh);
64
+ await addCrossSellItem(
65
+ effectiveSessionId,
66
+ crossSellItemPrice.id,
67
+ session,
68
+ currencyId,
69
+ refresh,
70
+ sessionData,
71
+ setSessionData
72
+ );
65
73
  } catch (err: unknown) {
66
74
  console.error('Failed to add cross-sell:', getErrorMessage(err));
67
75
  }
@@ -70,7 +78,7 @@ export function useCrossSell(): UseCrossSellReturn {
70
78
  const remove = useMemoizedFn(async () => {
71
79
  if (session?.status === 'complete') return;
72
80
  try {
73
- await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
81
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
74
82
  } catch (err: unknown) {
75
83
  console.error('Failed to remove cross-sell:', getErrorMessage(err));
76
84
  }
@@ -39,14 +39,21 @@ export interface UseLineItemsReturn {
39
39
  }
40
40
 
41
41
  export function useLineItems(): UseLineItemsReturn {
42
- const { items, session, effectiveSessionId, isDonation, refresh } = useSessionContext();
42
+ const { items, session, effectiveSessionId, isDonation, refresh, sessionData, setSessionData } = useSessionContext();
43
43
  const { currency } = usePaymentMethodContext();
44
44
  const currencyId = currency?.id || null;
45
45
 
46
46
  // Default quantity from URL params: ?qty=5 or ?qty_price_xxx=10 (matching original product-item.tsx)
47
47
  const defaultQtyApplied = useRef(false);
48
48
  useEffect(() => {
49
- if (defaultQtyApplied.current || !effectiveSessionId || !items.length || !currencyId || session?.status === 'complete') return;
49
+ if (
50
+ defaultQtyApplied.current ||
51
+ !effectiveSessionId ||
52
+ !items.length ||
53
+ !currencyId ||
54
+ session?.status === 'complete'
55
+ )
56
+ return;
50
57
  try {
51
58
  const params = new URLSearchParams(window.location.search);
52
59
  for (const item of items) {
@@ -55,7 +62,16 @@ export function useLineItems(): UseLineItemsReturn {
55
62
  const qty = Math.max(1, parseInt(qtyStr, 10));
56
63
  if (Number.isFinite(qty) && qty !== item.quantity) {
57
64
  defaultQtyApplied.current = true;
58
- adjustQuantity(effectiveSessionId, item.price_id, qty, currencyId, session, refresh);
65
+ adjustQuantity(
66
+ effectiveSessionId,
67
+ item.price_id,
68
+ qty,
69
+ currencyId,
70
+ session,
71
+ refresh,
72
+ sessionData,
73
+ setSessionData
74
+ );
59
75
  break; // only apply to the first matching item for ?qty
60
76
  }
61
77
  }
@@ -68,7 +84,7 @@ export function useLineItems(): UseLineItemsReturn {
68
84
  const updateQuantity = useMemoizedFn(async (itemId: string, qty: number) => {
69
85
  if (session?.status === 'complete') return;
70
86
  try {
71
- await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh);
87
+ await adjustQuantity(effectiveSessionId, itemId, qty, currencyId, session, refresh, sessionData, setSessionData);
72
88
  } catch (err: unknown) {
73
89
  console.error('Failed to update quantity:', getErrorMessage(err));
74
90
  }
@@ -76,12 +92,12 @@ export function useLineItems(): UseLineItemsReturn {
76
92
 
77
93
  const upsell = useMemoizedFn(async (fromId: string, toId: string) => {
78
94
  if (session?.status === 'complete') return;
79
- await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh);
95
+ await performUpsell(effectiveSessionId, fromId, toId, session, currencyId, refresh, sessionData, setSessionData);
80
96
  });
81
97
 
82
98
  const downsell = useMemoizedFn(async (priceId: string) => {
83
99
  if (session?.status === 'complete') return;
84
- await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh);
100
+ await performDownsell(effectiveSessionId, priceId, session, currencyId, refresh, sessionData, setSessionData);
85
101
  });
86
102
 
87
103
  // Cross-sell item detection (must be before addCrossSell so it can reference crossSellItem)
@@ -124,7 +140,15 @@ export function useLineItems(): UseLineItemsReturn {
124
140
  if (session?.status === 'complete') return;
125
141
  if (!crossSellItem) return;
126
142
  try {
127
- await addCrossSellItem(effectiveSessionId, crossSellItem.id, session, currencyId, refresh);
143
+ await addCrossSellItem(
144
+ effectiveSessionId,
145
+ crossSellItem.id,
146
+ session,
147
+ currencyId,
148
+ refresh,
149
+ sessionData,
150
+ setSessionData
151
+ );
128
152
  } catch (err: unknown) {
129
153
  console.error('Failed to add cross-sell:', getErrorMessage(err));
130
154
  }
@@ -133,7 +157,7 @@ export function useLineItems(): UseLineItemsReturn {
133
157
  const removeCrossSell = useMemoizedFn(async () => {
134
158
  if (session?.status === 'complete') return;
135
159
  try {
136
- await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh);
160
+ await removeCrossSellItem(effectiveSessionId, session, currencyId, refresh, sessionData, setSessionData);
137
161
  } catch (err: unknown) {
138
162
  console.error('Failed to remove cross-sell:', getErrorMessage(err));
139
163
  }
@@ -143,7 +167,16 @@ export function useLineItems(): UseLineItemsReturn {
143
167
  if (session?.status === 'complete') return;
144
168
  if (!isDonation) return;
145
169
  try {
146
- await changeDonationAmount(effectiveSessionId, priceId, amount, session, currencyId, refresh);
170
+ await changeDonationAmount(
171
+ effectiveSessionId,
172
+ priceId,
173
+ amount,
174
+ session,
175
+ currencyId,
176
+ refresh,
177
+ sessionData,
178
+ setSessionData
179
+ );
147
180
  } catch (err: unknown) {
148
181
  console.error('Failed to change amount:', getErrorMessage(err));
149
182
  }
@@ -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
- // Refresh session to get updated data (backend clears quote on currency switch)
109
- await refreshSession(true);
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
  }