@blocklet/payment-react 1.20.11 → 1.20.12

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 (41) hide show
  1. package/es/components/promotion-code.d.ts +19 -0
  2. package/es/components/promotion-code.js +153 -0
  3. package/es/contexts/payment.d.ts +8 -0
  4. package/es/contexts/payment.js +10 -1
  5. package/es/index.d.ts +2 -1
  6. package/es/index.js +3 -1
  7. package/es/libs/util.d.ts +5 -1
  8. package/es/libs/util.js +23 -0
  9. package/es/locales/en.js +25 -0
  10. package/es/locales/zh.js +29 -0
  11. package/es/payment/form/index.js +7 -1
  12. package/es/payment/index.js +19 -0
  13. package/es/payment/product-item.js +32 -3
  14. package/es/payment/summary.d.ts +5 -2
  15. package/es/payment/summary.js +193 -16
  16. package/lib/components/promotion-code.d.ts +19 -0
  17. package/lib/components/promotion-code.js +155 -0
  18. package/lib/contexts/payment.d.ts +8 -0
  19. package/lib/contexts/payment.js +13 -1
  20. package/lib/index.d.ts +2 -1
  21. package/lib/index.js +8 -0
  22. package/lib/libs/util.d.ts +5 -1
  23. package/lib/libs/util.js +29 -0
  24. package/lib/locales/en.js +25 -0
  25. package/lib/locales/zh.js +29 -0
  26. package/lib/payment/form/index.js +8 -1
  27. package/lib/payment/index.js +23 -0
  28. package/lib/payment/product-item.js +46 -0
  29. package/lib/payment/summary.d.ts +5 -2
  30. package/lib/payment/summary.js +153 -11
  31. package/package.json +9 -9
  32. package/src/components/promotion-code.tsx +184 -0
  33. package/src/contexts/payment.tsx +15 -0
  34. package/src/index.ts +2 -0
  35. package/src/libs/util.ts +35 -0
  36. package/src/locales/en.tsx +25 -0
  37. package/src/locales/zh.tsx +29 -0
  38. package/src/payment/form/index.tsx +10 -1
  39. package/src/payment/index.tsx +22 -0
  40. package/src/payment/product-item.tsx +37 -2
  41. package/src/payment/summary.tsx +201 -16
@@ -122,7 +122,8 @@ function PaymentForm({
122
122
  const {
123
123
  session,
124
124
  connect,
125
- payable
125
+ payable,
126
+ setPaymentState
126
127
  } = (0, _payment.usePaymentContext)();
127
128
  const subscription = (0, _subscription.useSubscription)("events");
128
129
  const formErrorPosition = "bottom";
@@ -174,6 +175,12 @@ function PaymentForm({
174
175
  subscription.on("checkout.session.completed", onCheckoutComplete);
175
176
  }
176
177
  }, [subscription]);
178
+ (0, _react.useEffect)(() => {
179
+ setPaymentState({
180
+ paying: state.submitting || state.paying,
181
+ stripePaying: state.stripePaying
182
+ });
183
+ }, [state.submitting, state.paying, state.stripePaying]);
177
184
  const mergeUserInfo = (customerInfo, userInfo) => {
178
185
  return {
179
186
  ...(userInfo || {}),
@@ -149,6 +149,13 @@ function PaymentInner({
149
149
  if (onChange) {
150
150
  onChange(methods.getValues());
151
151
  }
152
+ if (state.checkoutSession?.discounts?.length) {
153
+ _api.default.post(`/api/checkout-sessions/${state.checkoutSession.id}/recalculate-promotion`, {
154
+ currency_id: currencyId
155
+ }).then(() => {
156
+ onPromotionUpdate();
157
+ });
158
+ }
152
159
  }, [currencyId]);
153
160
  const onUpsell = async (from, to) => {
154
161
  try {
@@ -244,6 +251,19 @@ function PaymentInner({
244
251
  _Toast.default.error((0, _util2.formatError)(err));
245
252
  }
246
253
  };
254
+ const onPromotionUpdate = async () => {
255
+ try {
256
+ const {
257
+ data
258
+ } = await _api.default.get(`/api/checkout-sessions/retrieve/${state.checkoutSession.id}`);
259
+ setState({
260
+ checkoutSession: data.checkoutSession
261
+ });
262
+ } catch (err) {
263
+ console.error(err);
264
+ _Toast.default.error((0, _util2.formatError)(err));
265
+ }
266
+ };
247
267
  const handlePaid = result => {
248
268
  setState({
249
269
  checkoutSession: result.checkoutSession
@@ -292,6 +312,9 @@ function PaymentInner({
292
312
  donationSettings: paymentLink?.donation_settings,
293
313
  action,
294
314
  completed,
315
+ checkoutSession: state.checkoutSession,
316
+ onPromotionUpdate,
317
+ paymentMethods,
295
318
  showFeatures
296
319
  }), mode === "standalone" && !isMobile && /* @__PURE__ */(0, _jsxRuntime.jsx)(_footer.default, {
297
320
  className: "cko-footer",
@@ -183,6 +183,52 @@ function ProductItem({
183
183
  children: pricing.secondary
184
184
  })]
185
185
  })]
186
+ }), item.discount_amounts && item.discount_amounts.length > 0 && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Stack, {
187
+ direction: "row",
188
+ spacing: 1,
189
+ sx: {
190
+ mt: 1,
191
+ alignItems: "center"
192
+ },
193
+ children: item.discount_amounts.map(discountAmount => /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Chip, {
194
+ icon: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.LocalOffer, {
195
+ sx: {
196
+ fontSize: "0.8rem !important"
197
+ }
198
+ }),
199
+ label: /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Box, {
200
+ sx: {
201
+ display: "flex",
202
+ alignItems: "center",
203
+ gap: 0.5
204
+ },
205
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
206
+ component: "span",
207
+ sx: {
208
+ fontSize: "0.75rem",
209
+ fontWeight: "medium"
210
+ },
211
+ children: discountAmount.promotion_code?.code || "DISCOUNT"
212
+ }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
213
+ component: "span",
214
+ sx: {
215
+ fontSize: "0.75rem"
216
+ },
217
+ children: ["(-", (0, _util.formatAmount)(discountAmount.amount || "0", currency.decimal), " ", currency.symbol, ")"]
218
+ })]
219
+ }),
220
+ size: "small",
221
+ variant: "filled",
222
+ sx: {
223
+ height: 20,
224
+ "& .MuiChip-icon": {
225
+ color: "warning.main"
226
+ },
227
+ "& .MuiChip-label": {
228
+ px: 1
229
+ }
230
+ }
231
+ }, discountAmount.promotion_code))
186
232
  }), showFeatures && features.length > 0 && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
187
233
  sx: {
188
234
  display: "flex",
@@ -1,4 +1,4 @@
1
- import type { DonationSettings, TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
1
+ import type { DonationSettings, TLineItemExpanded, TPaymentCurrency, TCheckoutSession, TPaymentMethodExpanded } from '@blocklet/payment-types';
2
2
  type Props = {
3
3
  items: TLineItemExpanded[];
4
4
  currency: TPaymentCurrency;
@@ -17,7 +17,10 @@ type Props = {
17
17
  donationSettings?: DonationSettings;
18
18
  action?: string;
19
19
  completed?: boolean;
20
+ checkoutSession?: TCheckoutSession;
21
+ onPromotionUpdate?: () => void;
22
+ paymentMethods?: TPaymentMethodExpanded[];
20
23
  showFeatures?: boolean;
21
24
  };
22
- export default function PaymentSummary({ items, currency, trialInDays, billingThreshold, onUpsell, onDownsell, onQuantityChange, onApplyCrossSell, onCancelCrossSell, onChangeAmount, checkoutSessionId, crossSellBehavior, showStaking, donationSettings, action, trialEnd, completed, showFeatures, ...rest }: Props): import("react").JSX.Element;
25
+ export default function PaymentSummary({ items, currency, trialInDays, billingThreshold, onUpsell, onDownsell, onQuantityChange, onApplyCrossSell, onCancelCrossSell, onChangeAmount, checkoutSessionId, crossSellBehavior, showStaking, donationSettings, action, trialEnd, completed, checkoutSession, paymentMethods, onPromotionUpdate, showFeatures, ...rest }: Props): import("react").JSX.Element;
23
26
  export {};
@@ -24,6 +24,7 @@ var _livemode = _interopRequireDefault(require("../components/livemode"));
24
24
  var _payment = require("../contexts/payment");
25
25
  var _mobile = require("../hooks/mobile");
26
26
  var _loadingButton = _interopRequireDefault(require("../components/loading-button"));
27
+ var _promotionCode = _interopRequireDefault(require("../components/promotion-code"));
27
28
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
28
29
  const ExpandMore = (0, _styles.styled)(props => {
29
30
  const {
@@ -101,6 +102,9 @@ function PaymentSummary({
101
102
  action = "",
102
103
  trialEnd = 0,
103
104
  completed = false,
105
+ checkoutSession = void 0,
106
+ paymentMethods = [],
107
+ onPromotionUpdate = _noop.default,
104
108
  showFeatures = false,
105
109
  ...rest
106
110
  }) {
@@ -111,7 +115,10 @@ function PaymentSummary({
111
115
  const {
112
116
  isMobile
113
117
  } = (0, _mobile.useMobile)();
114
- const settings = (0, _payment.usePaymentContext)();
118
+ const {
119
+ paymentState,
120
+ ...settings
121
+ } = (0, _payment.usePaymentContext)();
115
122
  const [state, setState] = (0, _ahooks.useSetState)({
116
123
  loading: false,
117
124
  shake: false,
@@ -121,12 +128,40 @@ function PaymentSummary({
121
128
  data,
122
129
  runAsync
123
130
  } = (0, _ahooks.useRequest)(() => checkoutSessionId ? fetchCrossSell(checkoutSessionId) : Promise.resolve(null));
124
- const headlines = (0, _util2.formatCheckoutHeadlines)(items, currency, {
131
+ const sessionDiscounts = checkoutSession?.discounts || [];
132
+ const allowPromotionCodes = !!checkoutSession?.allow_promotion_codes;
133
+ const hasDiscounts = sessionDiscounts?.length > 0;
134
+ const discountCurrency = paymentMethods && checkoutSession ? (0, _util2.findCurrency)(paymentMethods, hasDiscounts ? checkoutSession?.currency_id || currency.id : currency.id) || settings.settings?.baseCurrency : currency;
135
+ const headlines = (0, _util2.formatCheckoutHeadlines)(items, discountCurrency, {
125
136
  trialEnd,
126
137
  trialInDays
127
138
  }, locale);
128
- const staking = showStaking ? getStakingSetup(items, currency, billingThreshold) : "0";
129
- const totalAmount = (0, _util.fromUnitToToken)(new _util.BN((0, _util.fromTokenToUnit)(headlines.actualAmount, currency?.decimal)).add(new _util.BN(staking)).toString(), currency?.decimal);
139
+ const staking = showStaking ? getStakingSetup(items, discountCurrency, billingThreshold) : "0";
140
+ const getAppliedPromotionCodes = () => {
141
+ if (!sessionDiscounts?.length) return [];
142
+ return sessionDiscounts.map(discount => ({
143
+ id: discount.promotion_code || discount.coupon,
144
+ code: discount.verification_data?.code || "APPLIED",
145
+ discount_amount: discount.discount_amount
146
+ }));
147
+ };
148
+ const handlePromotionUpdate = () => {
149
+ onPromotionUpdate?.();
150
+ };
151
+ const handleRemovePromotion = async sessionId => {
152
+ if (paymentState.paying || paymentState.stripePaying) {
153
+ return;
154
+ }
155
+ try {
156
+ await _api.default.delete(`/api/checkout-sessions/${sessionId}/remove-promotion`);
157
+ onPromotionUpdate?.();
158
+ } catch (err) {
159
+ console.error("Failed to remove promotion code:", err);
160
+ }
161
+ };
162
+ const discountAmount = new _util.BN(checkoutSession?.total_details?.amount_discount || "0");
163
+ const subtotalAmount = (0, _util.fromUnitToToken)(new _util.BN((0, _util.fromTokenToUnit)(headlines.actualAmount, discountCurrency?.decimal)).add(new _util.BN(staking)).toString(), discountCurrency?.decimal);
164
+ const totalAmount = (0, _util.fromUnitToToken)(new _util.BN((0, _util.fromTokenToUnit)(subtotalAmount, discountCurrency?.decimal)).sub(discountAmount).toString(), discountCurrency?.decimal);
130
165
  (0, _useBus.default)("error.REQUIRE_CROSS_SELL", () => {
131
166
  setState({
132
167
  shake: true
@@ -194,13 +229,13 @@ function PaymentSummary({
194
229
  item: x,
195
230
  settings: donationSettings,
196
231
  onChange: onChangeAmount,
197
- currency
198
- }, `${x.price_id}-${currency.id}`) : /* @__PURE__ */(0, _jsxRuntime.jsx)(_productItem.default, {
232
+ currency: discountCurrency
233
+ }, `${x.price_id}-${discountCurrency.id}`) : /* @__PURE__ */(0, _jsxRuntime.jsx)(_productItem.default, {
199
234
  item: x,
200
235
  items,
201
236
  trialInDays,
202
237
  trialEnd,
203
- currency,
238
+ currency: discountCurrency,
204
239
  onUpsell: handleUpsell,
205
240
  onDownsell: handleDownsell,
206
241
  adjustableQuantity: x.adjustable_quantity,
@@ -225,7 +260,7 @@ function PaymentSummary({
225
260
  children: t("payment.checkout.cross_sell.remove")
226
261
  })]
227
262
  })
228
- }, `${x.price_id}-${currency.id}`))
263
+ }, `${x.price_id}-${discountCurrency.id}`))
229
264
  }), data && items.some(x => x.price_id === data.id) === false && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Grow, {
230
265
  in: true,
231
266
  children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Stack, {
@@ -241,7 +276,7 @@ function PaymentSummary({
241
276
  },
242
277
  items,
243
278
  trialInDays,
244
- currency,
279
+ currency: discountCurrency,
245
280
  trialEnd,
246
281
  onUpsell: _noop.default,
247
282
  onDownsell: _noop.default,
@@ -400,9 +435,116 @@ function PaymentSummary({
400
435
  })
401
436
  })]
402
437
  }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
403
- children: [(0, _util2.formatAmount)(staking, currency.decimal), " ", currency.symbol]
438
+ children: [(0, _util2.formatAmount)(staking, discountCurrency.decimal), " ", discountCurrency.symbol]
404
439
  })]
405
440
  })]
441
+ }), (allowPromotionCodes || hasDiscounts) && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
442
+ direction: "row",
443
+ spacing: 1,
444
+ sx: {
445
+ justifyContent: "space-between",
446
+ alignItems: "center",
447
+ ...(staking > 0 && {
448
+ borderTop: "1px solid",
449
+ borderColor: "divider",
450
+ pt: 1,
451
+ mt: 1
452
+ })
453
+ },
454
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
455
+ className: "base-label",
456
+ children: t("common.subtotal")
457
+ }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
458
+ children: [(0, _util2.formatNumber)(subtotalAmount), " ", discountCurrency.symbol]
459
+ })]
460
+ }), allowPromotionCodes && !hasDiscounts && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
461
+ sx: {
462
+ mt: 1
463
+ },
464
+ children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_promotionCode.default, {
465
+ checkoutSessionId: checkoutSession.id,
466
+ initialAppliedCodes: getAppliedPromotionCodes(),
467
+ disabled: completed,
468
+ onUpdate: handlePromotionUpdate,
469
+ currencyId: currency.id
470
+ })
471
+ }), hasDiscounts && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
472
+ sx: {
473
+ py: 1.5
474
+ },
475
+ children: sessionDiscounts.map(discount => {
476
+ const promotionCodeInfo = discount.promotion_code_details;
477
+ const couponInfo = discount.coupon_details;
478
+ const discountDescription = couponInfo ? (0, _util2.formatCouponTerms)(couponInfo, discountCurrency, locale) : "";
479
+ const notSupported = discountDescription === t("payment.checkout.coupon.noDiscount");
480
+ return /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
481
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
482
+ direction: "row",
483
+ spacing: 1,
484
+ sx: {
485
+ justifyContent: "space-between",
486
+ alignItems: "center"
487
+ },
488
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
489
+ direction: "row",
490
+ spacing: 1,
491
+ sx: {
492
+ alignItems: "center",
493
+ backgroundColor: "grey.100",
494
+ width: "fit-content",
495
+ px: 1,
496
+ py: 0.5,
497
+ borderRadius: 1
498
+ },
499
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
500
+ sx: {
501
+ fontWeight: "medium",
502
+ fontSize: "small",
503
+ display: "flex",
504
+ alignItems: "center",
505
+ gap: 0.5
506
+ },
507
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.LocalOffer, {
508
+ sx: {
509
+ color: "warning.main",
510
+ fontSize: "small"
511
+ }
512
+ }), promotionCodeInfo?.code || discount.verification_data?.code || t("payment.checkout.discount")]
513
+ }), !completed && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Button, {
514
+ size: "small",
515
+ disabled: paymentState.paying || paymentState.stripePaying,
516
+ onClick: () => handleRemovePromotion(checkoutSessionId),
517
+ sx: {
518
+ minWidth: "auto",
519
+ width: 16,
520
+ height: 16,
521
+ color: "text.secondary",
522
+ "&.Mui-disabled": {
523
+ color: "text.disabled"
524
+ }
525
+ },
526
+ children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.Close, {
527
+ sx: {
528
+ fontSize: 14
529
+ }
530
+ })
531
+ })]
532
+ }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
533
+ sx: {
534
+ color: "text.secondary"
535
+ },
536
+ children: ["-", (0, _util2.formatAmount)(discount.discount_amount || "0", discountCurrency.decimal), " ", discountCurrency.symbol]
537
+ })]
538
+ }), discountDescription && /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
539
+ sx: {
540
+ fontSize: "small",
541
+ color: notSupported ? "error.main" : "text.secondary",
542
+ mt: 0.5
543
+ },
544
+ children: discountDescription
545
+ })]
546
+ }, discount.promotion_code || discount.coupon || `discount-${discount.discount_amount}`);
547
+ })
406
548
  }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
407
549
  sx: {
408
550
  display: "flex",
@@ -415,7 +557,7 @@ function PaymentSummary({
415
557
  className: "base-label",
416
558
  children: [t("common.total"), " "]
417
559
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_amount.default, {
418
- amount: `${totalAmount} ${currency.symbol}`,
560
+ amount: `${totalAmount} ${discountCurrency.symbol}`,
419
561
  sx: {
420
562
  fontSize: "16px"
421
563
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react",
3
- "version": "1.20.11",
3
+ "version": "1.20.12",
4
4
  "description": "Reusable react components for payment kit v2",
5
5
  "keywords": [
6
6
  "react",
@@ -54,16 +54,16 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@arcblock/did-connect-react": "^3.1.40",
58
- "@arcblock/ux": "^3.1.40",
59
- "@arcblock/ws": "^1.24.9",
60
- "@blocklet/theme": "^3.1.40",
61
- "@blocklet/ui-react": "^3.1.40",
57
+ "@arcblock/did-connect-react": "^3.1.41",
58
+ "@arcblock/ux": "^3.1.41",
59
+ "@arcblock/ws": "^1.25.1",
60
+ "@blocklet/theme": "^3.1.41",
61
+ "@blocklet/ui-react": "^3.1.41",
62
62
  "@mui/icons-material": "^7.1.2",
63
63
  "@mui/lab": "7.0.0-beta.14",
64
64
  "@mui/material": "^7.1.2",
65
65
  "@mui/system": "^7.1.1",
66
- "@ocap/util": "^1.24.9",
66
+ "@ocap/util": "^1.25.1",
67
67
  "@stripe/react-stripe-js": "^2.9.0",
68
68
  "@stripe/stripe-js": "^2.4.0",
69
69
  "@vitejs/plugin-legacy": "^7.0.0",
@@ -94,7 +94,7 @@
94
94
  "@babel/core": "^7.27.4",
95
95
  "@babel/preset-env": "^7.27.2",
96
96
  "@babel/preset-react": "^7.27.1",
97
- "@blocklet/payment-types": "1.20.11",
97
+ "@blocklet/payment-types": "1.20.12",
98
98
  "@storybook/addon-essentials": "^7.6.20",
99
99
  "@storybook/addon-interactions": "^7.6.20",
100
100
  "@storybook/addon-links": "^7.6.20",
@@ -125,5 +125,5 @@
125
125
  "vite-plugin-babel": "^1.3.1",
126
126
  "vite-plugin-node-polyfills": "^0.23.0"
127
127
  },
128
- "gitHead": "b69becabf1721f6238cf24004537bee1e4ade860"
128
+ "gitHead": "e6c432f89c2bf305efc903c0809f4a0557c07774"
129
129
  }
@@ -0,0 +1,184 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { TextField, Button, Alert, Box, InputAdornment } from '@mui/material';
3
+ import { Add } from '@mui/icons-material';
4
+ import { useState } from 'react';
5
+ import LoadingButton from './loading-button';
6
+ import api from '../libs/api';
7
+ import { usePaymentContext } from '../contexts/payment';
8
+
9
+ export interface AppliedPromoCode {
10
+ id: string;
11
+ code: string;
12
+ discount_amount?: string;
13
+ }
14
+
15
+ interface PromotionCodeProps {
16
+ checkoutSessionId: string;
17
+ initialAppliedCodes?: AppliedPromoCode[];
18
+ disabled?: boolean;
19
+ className?: string;
20
+ placeholder?: string;
21
+ currencyId: string;
22
+ onUpdate?: (data: { appliedCodes: AppliedPromoCode[]; discountAmount: string }) => void;
23
+ }
24
+
25
+ export default function PromotionCode({
26
+ checkoutSessionId,
27
+ initialAppliedCodes = [],
28
+ disabled = false,
29
+ className = '',
30
+ placeholder = '',
31
+ onUpdate = undefined,
32
+ currencyId,
33
+ }: PromotionCodeProps) {
34
+ const { t } = useLocaleContext();
35
+ const [showInput, setShowInput] = useState(false);
36
+ const [code, setCode] = useState('');
37
+ const [error, setError] = useState('');
38
+ const [applying, setApplying] = useState(false);
39
+ const [appliedCodes, setAppliedCodes] = useState<AppliedPromoCode[]>(initialAppliedCodes);
40
+ const { session, paymentState } = usePaymentContext();
41
+
42
+ const handleLoginCheck = () => {
43
+ if (!session.user) {
44
+ session?.login(() => {
45
+ handleApply();
46
+ });
47
+ } else {
48
+ handleApply();
49
+ }
50
+ };
51
+
52
+ const handleApply = async () => {
53
+ if (!code.trim()) return;
54
+
55
+ // Prevent applying promotion during payment process
56
+ if (paymentState.paying || paymentState.stripePaying) {
57
+ return;
58
+ }
59
+
60
+ setApplying(true);
61
+ setError('');
62
+
63
+ try {
64
+ const response = await api.post(`/api/checkout-sessions/${checkoutSessionId}/apply-promotion`, {
65
+ promotion_code: code.trim(),
66
+ currency_id: currencyId,
67
+ });
68
+
69
+ const discounts = response.data.discounts || [];
70
+ const appliedDiscount = discounts[0];
71
+
72
+ if (appliedDiscount) {
73
+ const newCode: AppliedPromoCode = {
74
+ id: appliedDiscount.promotion_code || appliedDiscount.coupon,
75
+ code: code.trim(),
76
+ discount_amount: appliedDiscount.discount_amount,
77
+ };
78
+
79
+ setAppliedCodes([newCode]);
80
+ setCode('');
81
+ setShowInput(false);
82
+
83
+ onUpdate?.({
84
+ appliedCodes: [newCode],
85
+ discountAmount: appliedDiscount.discount_amount,
86
+ });
87
+ }
88
+ } catch (err: any) {
89
+ const errorMessage = err.response?.data?.error || err.message;
90
+ setError(errorMessage);
91
+ } finally {
92
+ setApplying(false);
93
+ }
94
+ };
95
+
96
+ const handleKeyPress = (event: React.KeyboardEvent) => {
97
+ if (event.key === 'Enter' && !applying && code.trim()) {
98
+ handleLoginCheck();
99
+ }
100
+ };
101
+
102
+ const isPaymentInProgress = paymentState.paying || paymentState.stripePaying;
103
+
104
+ return (
105
+ <Box className={className}>
106
+ {/* Input field or add button - only show if no codes applied */}
107
+ {appliedCodes.length === 0 &&
108
+ !disabled &&
109
+ !isPaymentInProgress &&
110
+ (showInput ? (
111
+ <Box
112
+ onBlur={() => {
113
+ if (!code.trim()) {
114
+ setShowInput(false);
115
+ }
116
+ }}>
117
+ <TextField
118
+ fullWidth
119
+ value={code}
120
+ onChange={(e) => setCode(e.target.value)}
121
+ onKeyPress={handleKeyPress}
122
+ placeholder={placeholder || t('payment.checkout.promotion.placeholder')}
123
+ variant="outlined"
124
+ size="small"
125
+ disabled={applying}
126
+ autoFocus
127
+ slotProps={{
128
+ input: {
129
+ endAdornment: (
130
+ <InputAdornment position="end">
131
+ <LoadingButton
132
+ size="small"
133
+ onClick={handleLoginCheck}
134
+ loading={applying}
135
+ disabled={!code.trim()}
136
+ variant="text"
137
+ sx={{
138
+ color: 'primary.main',
139
+ fontSize: 'small',
140
+ }}>
141
+ {t('payment.checkout.promotion.apply')}
142
+ </LoadingButton>
143
+ </InputAdornment>
144
+ ),
145
+ },
146
+ }}
147
+ sx={{
148
+ '& .MuiOutlinedInput-root': {
149
+ pr: 1,
150
+ },
151
+ }}
152
+ />
153
+
154
+ {error && (
155
+ <Alert
156
+ severity="error"
157
+ sx={{
158
+ my: 1,
159
+ }}>
160
+ {error}
161
+ </Alert>
162
+ )}
163
+ </Box>
164
+ ) : (
165
+ <Button
166
+ onClick={() => setShowInput(true)}
167
+ startIcon={<Add fontSize="small" />}
168
+ variant="text"
169
+ sx={{
170
+ fontWeight: 'normal',
171
+ textTransform: 'none',
172
+ justifyContent: 'flex-start',
173
+ p: 0,
174
+ '&:hover': {
175
+ backgroundColor: 'transparent',
176
+ textDecoration: 'underline',
177
+ },
178
+ }}>
179
+ {t('payment.checkout.promotion.add_code')}
180
+ </Button>
181
+ ))}
182
+ </Box>
183
+ );
184
+ }
@@ -29,6 +29,11 @@ export type PaymentContextType = {
29
29
  api: Axios;
30
30
  payable: boolean;
31
31
  setPayable: (status: boolean) => void;
32
+ paymentState: {
33
+ paying: boolean;
34
+ stripePaying: boolean;
35
+ };
36
+ setPaymentState: (state: Partial<{ paying: boolean; stripePaying: boolean }>) => void;
32
37
  };
33
38
 
34
39
  export type PaymentContextProps = {
@@ -188,6 +193,14 @@ function PaymentProvider({
188
193
 
189
194
  const prefix = getPrefix();
190
195
  const [payable, setPayable] = useState(true);
196
+ const [paymentState, setPaymentState] = useState({
197
+ paying: false,
198
+ stripePaying: false,
199
+ });
200
+
201
+ const updatePaymentState = (state: Partial<{ paying: boolean; stripePaying: boolean }>) => {
202
+ setPaymentState((prev) => ({ ...prev, ...state }));
203
+ };
191
204
 
192
205
  if (error) {
193
206
  return <Alert severity="error">{error.message}</Alert>;
@@ -212,6 +225,8 @@ function PaymentProvider({
212
225
  api,
213
226
  payable,
214
227
  setPayable,
228
+ paymentState,
229
+ setPaymentState: updatePaymentState,
215
230
  }}>
216
231
  {children}
217
232
  </Provider>
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ import DateRangePicker from './components/date-range-picker';
39
39
  import AutoTopupModal from './components/auto-topup/modal';
40
40
  import AutoTopup from './components/auto-topup';
41
41
  import Collapse from './components/collapse';
42
+ import PromotionCode from './components/promotion-code';
42
43
 
43
44
  export { PaymentThemeProvider } from './theme';
44
45
 
@@ -102,4 +103,5 @@ export {
102
103
  AutoTopupModal,
103
104
  AutoTopup,
104
105
  Collapse,
106
+ PromotionCode,
105
107
  };