@blocklet/payment-react 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 (43) hide show
  1. package/es/checkout-v2/components/left/cross-sell-card.js +3 -3
  2. package/es/checkout-v2/components/left/product-item-card.js +13 -7
  3. package/es/checkout-v2/components/left/promotion-input.d.ts +3 -1
  4. package/es/checkout-v2/components/left/promotion-input.js +4 -2
  5. package/es/checkout-v2/components/right/submit-button.js +3 -1
  6. package/es/checkout-v2/panels/left/composite-panel.js +27 -6
  7. package/es/checkout-v2/panels/left/credit-topup-panel.js +1 -5
  8. package/es/checkout-v2/panels/right/payment-panel.js +37 -8
  9. package/es/checkout-v2/utils/format.d.ts +1 -1
  10. package/es/checkout-v2/utils/format.js +3 -2
  11. package/es/checkout-v2/views/error-view.js +2 -0
  12. package/es/checkout-v2/views/success-view.js +3 -1
  13. package/es/components/over-due-invoice-payment.js +5 -3
  14. package/es/libs/util.d.ts +8 -0
  15. package/es/libs/util.js +3 -0
  16. package/lib/checkout-v2/components/left/cross-sell-card.js +2 -2
  17. package/lib/checkout-v2/components/left/product-item-card.js +13 -6
  18. package/lib/checkout-v2/components/left/promotion-input.d.ts +3 -1
  19. package/lib/checkout-v2/components/left/promotion-input.js +7 -2
  20. package/lib/checkout-v2/components/right/submit-button.js +3 -1
  21. package/lib/checkout-v2/panels/left/composite-panel.js +20 -5
  22. package/lib/checkout-v2/panels/left/credit-topup-panel.js +1 -5
  23. package/lib/checkout-v2/panels/right/payment-panel.js +43 -6
  24. package/lib/checkout-v2/utils/format.d.ts +1 -1
  25. package/lib/checkout-v2/utils/format.js +9 -2
  26. package/lib/checkout-v2/views/error-view.js +2 -0
  27. package/lib/checkout-v2/views/success-view.js +2 -0
  28. package/lib/components/over-due-invoice-payment.js +12 -2
  29. package/lib/libs/util.d.ts +8 -0
  30. package/lib/libs/util.js +4 -0
  31. package/package.json +4 -4
  32. package/src/checkout-v2/components/left/cross-sell-card.tsx +3 -3
  33. package/src/checkout-v2/components/left/product-item-card.tsx +30 -12
  34. package/src/checkout-v2/components/left/promotion-input.tsx +11 -3
  35. package/src/checkout-v2/components/right/submit-button.tsx +2 -0
  36. package/src/checkout-v2/panels/left/composite-panel.tsx +28 -6
  37. package/src/checkout-v2/panels/left/credit-topup-panel.tsx +1 -5
  38. package/src/checkout-v2/panels/right/payment-panel.tsx +30 -5
  39. package/src/checkout-v2/utils/format.ts +5 -2
  40. package/src/checkout-v2/views/error-view.tsx +2 -0
  41. package/src/checkout-v2/views/success-view.tsx +3 -1
  42. package/src/components/over-due-invoice-payment.tsx +6 -3
  43. package/src/libs/util.ts +7 -0
@@ -61,6 +61,9 @@ function CompositePanel() {
61
61
  const canUpsell = nonCrossSellItems.length <= 1;
62
62
  const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ["subscription", "setup"].includes(mode);
63
63
  const isUpselled = !!upsellPrimaryItem?.upsell_price;
64
+ const [upsellSwitching, setUpsellSwitching] = (0, _react.useState)(false);
65
+ const [pendingUpsell, setPendingUpsell] = (0, _react.useState)(null);
66
+ const visualIsUpselled = pendingUpsell !== null ? pendingUpsell : isUpselled;
64
67
  const currentInterval = hasTopUpsell ? upsellPrimaryItem.price?.recurring?.interval : null;
65
68
  const upsellInterval = hasTopUpsell ? upsellTarget?.recurring?.interval : null;
66
69
  let upsellSavings = 0;
@@ -88,7 +91,7 @@ function CompositePanel() {
88
91
  const isMultiItem = lineItems.items.length > 1;
89
92
  const activeSx = {
90
93
  bgcolor: "primary.main",
91
- color: "#fff",
94
+ color: theme => (0, _format.primaryContrastColor)(theme),
92
95
  boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)"
93
96
  };
94
97
  const inactiveSx = {
@@ -277,15 +280,21 @@ function CompositePanel() {
277
280
  },
278
281
  children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
279
282
  onClick: async () => {
280
- if (isUpselled) {
283
+ if (isUpselled && !upsellSwitching) {
284
+ setPendingUpsell(false);
285
+ setUpsellSwitching(true);
281
286
  try {
282
287
  await lineItems.downsell(upsellPrimaryItem.upsell_price?.id || upsellPrimaryItem.price_id);
283
288
  } catch (err) {
289
+ setPendingUpsell(null);
284
290
  _Toast.default.error(err?.response?.data?.error || err?.message || "Failed");
291
+ } finally {
292
+ setUpsellSwitching(false);
293
+ setPendingUpsell(null);
285
294
  }
286
295
  }
287
296
  },
288
- sx: capsuleBtnSx(!isUpselled),
297
+ sx: capsuleBtnSx(!visualIsUpselled),
289
298
  children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
290
299
  component: "span",
291
300
  sx: {
@@ -298,15 +307,21 @@ function CompositePanel() {
298
307
  })
299
308
  }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
300
309
  onClick: async () => {
301
- if (!isUpselled) {
310
+ if (!isUpselled && !upsellSwitching) {
311
+ setPendingUpsell(true);
312
+ setUpsellSwitching(true);
302
313
  try {
303
314
  await lineItems.upsell(upsellPrimaryItem.price_id, upsellTarget.id);
304
315
  } catch (err) {
316
+ setPendingUpsell(null);
305
317
  _Toast.default.error(err?.response?.data?.error || err?.message || "Failed");
318
+ } finally {
319
+ setUpsellSwitching(false);
320
+ setPendingUpsell(null);
306
321
  }
307
322
  }
308
323
  },
309
- sx: capsuleBtnSx(isUpselled),
324
+ sx: capsuleBtnSx(visualIsUpselled),
310
325
  children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Typography, {
311
326
  component: "span",
312
327
  sx: {
@@ -144,12 +144,8 @@ function CreditTopupPanel() {
144
144
  interval: intervalDisplay
145
145
  });
146
146
  }
147
- const productDesc = product?.description || "";
148
- if (productDesc && productDesc.length > 10 && productDesc !== creditName) {
149
- return productDesc;
150
- }
151
147
  return "";
152
- }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, creditName, product, t]);
148
+ }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, t]);
153
149
  const validityText = (0, _react.useMemo)(() => {
154
150
  if (!hasExpiry) return "";
155
151
  return t("payment.checkout.creditTopup.validFor", {
@@ -561,7 +561,8 @@ function PaymentPanel() {
561
561
  remove: promotion.remove
562
562
  },
563
563
  discounts,
564
- discountAmount: pricing.discount
564
+ discountAmount: pricing.discount,
565
+ isAmountLoading
565
566
  })]
566
567
  }), (() => {
567
568
  const totalStr = pricing.total || "0";
@@ -710,7 +711,7 @@ function PaymentPanel() {
710
711
  })]
711
712
  })]
712
713
  });
713
- })(), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Button, {
714
+ })(), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Button, {
714
715
  variant: "contained",
715
716
  size: "large",
716
717
  fullWidth: true,
@@ -720,15 +721,51 @@ function PaymentPanel() {
720
721
  size: 20,
721
722
  color: "inherit"
722
723
  }) : null,
723
- endIcon: !isProcessing ? /* @__PURE__ */(0, _jsxRuntime.jsx)(_ArrowForward.default, {}) : void 0,
724
724
  sx: {
725
725
  py: 1.5,
726
726
  fontSize: "1.1rem",
727
727
  fontWeight: 600,
728
728
  textTransform: "none",
729
- borderRadius: "12px"
729
+ borderRadius: "12px",
730
+ color: theme => (0, _format.primaryContrastColor)(theme),
731
+ position: "relative",
732
+ overflow: "hidden",
733
+ "&:hover": {
734
+ bgcolor: "primary.main"
735
+ },
736
+ "&:hover .arrow-icon": {
737
+ transform: "translateX(4px)"
738
+ },
739
+ "&:hover .shine-layer": {
740
+ transform: "translateX(100%)"
741
+ }
730
742
  },
731
- children: isProcessing ? `${t("payment.checkout.processing")}...` : buttonLabel
743
+ children: [/* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
744
+ component: "span",
745
+ sx: {
746
+ position: "relative",
747
+ zIndex: 1
748
+ },
749
+ children: isProcessing ? `${t("payment.checkout.processing")}...` : buttonLabel
750
+ }), !isProcessing && /* @__PURE__ */(0, _jsxRuntime.jsx)(_ArrowForward.default, {
751
+ className: "arrow-icon",
752
+ sx: {
753
+ ml: 1,
754
+ position: "relative",
755
+ zIndex: 1,
756
+ transition: "transform 0.2s ease"
757
+ }
758
+ }), /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Box, {
759
+ className: "shine-layer",
760
+ sx: {
761
+ position: "absolute",
762
+ inset: 0,
763
+ background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent)",
764
+ transform: "translateX(-100%)",
765
+ transition: "transform 0.7s ease",
766
+ pointerEvents: "none"
767
+ }
768
+ })]
732
769
  }), isMobile && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
733
770
  direction: "row",
734
771
  alignItems: "center",
@@ -828,7 +865,7 @@ function PaymentPanel() {
828
865
  mode,
829
866
  subscription,
830
867
  staking: pricing.staking,
831
- appName: session?.metadata?.app_name || "New Payment Kit"
868
+ appName: (0, _util.getStatementDescriptor)(session?.line_items || [])
832
869
  }), !isMobile && /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Stack, {
833
870
  direction: "row",
834
871
  alignItems: "center",
@@ -1,4 +1,5 @@
1
1
  import type { TPaymentCurrency } from '@blocklet/payment-types';
2
+ export { primaryContrastColor } from '../../libs/util';
2
3
  export declare const INTERVAL_LOCALE_KEY: Record<string, string>;
3
4
  export declare function countryCodeToFlag(code: string): string;
4
5
  export declare function formatTokenAmount(unitAmount: string | number | bigint, currency: TPaymentCurrency | null): string;
@@ -56,4 +57,3 @@ interface ItemMeta {
56
57
  * Works for the "primary product" header above the item list.
57
58
  */
58
59
  export declare function getSessionHeaderMeta(t: TFn, session: any, product: any, items: any[]): ItemMeta;
59
- export {};
@@ -10,9 +10,16 @@ exports.formatTokenAmount = formatTokenAmount;
10
10
  exports.formatTrialText = formatTrialText;
11
11
  exports.getSessionHeaderMeta = getSessionHeaderMeta;
12
12
  exports.getUnitAmountForCurrency = getUnitAmountForCurrency;
13
+ Object.defineProperty(exports, "primaryContrastColor", {
14
+ enumerable: true,
15
+ get: function () {
16
+ return _util2.primaryContrastColor;
17
+ }
18
+ });
13
19
  exports.tSafe = tSafe;
14
20
  exports.whiteTooltipSx = void 0;
15
21
  var _util = require("@ocap/util");
22
+ var _util2 = require("../../libs/util");
16
23
  const INTERVAL_LOCALE_KEY = exports.INTERVAL_LOCALE_KEY = {
17
24
  day: "common.daily",
18
25
  week: "common.weekly",
@@ -35,7 +42,7 @@ function formatTokenAmount(unitAmount, currency) {
35
42
  minimumFractionDigits: 0,
36
43
  maximumFractionDigits: precision
37
44
  });
38
- return formatted.replace(/\.?0+$/, "") || "0";
45
+ return formatted.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
39
46
  } catch {
40
47
  return "0";
41
48
  }
@@ -69,7 +76,7 @@ function formatDynamicUnitPrice(price, currency, exchangeRate) {
69
76
  return tokenAmount.toLocaleString("en-US", {
70
77
  minimumFractionDigits: 0,
71
78
  maximumFractionDigits: precision
72
- }).replace(/\.?0+$/, "") || "0";
79
+ }).replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, "") || "0";
73
80
  }
74
81
  }
75
82
  }
@@ -10,6 +10,7 @@ var _styles = require("@mui/material/styles");
10
10
  var _ArrowBack = _interopRequireDefault(require("@mui/icons-material/ArrowBack"));
11
11
  var _Header = _interopRequireDefault(require("@blocklet/ui-react/lib/Header"));
12
12
  var _context = require("@arcblock/ux/lib/Locale/context");
13
+ var _format = require("../utils/format");
13
14
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
15
  function GeometricDecoration() {
15
16
  const theme = (0, _styles.useTheme)();
@@ -223,6 +224,7 @@ function ErrorContent({
223
224
  fontWeight: 600,
224
225
  fontSize: 16,
225
226
  letterSpacing: "0.02em",
227
+ color: th => (0, _format.primaryContrastColor)(th),
226
228
  boxShadow: `0 8px 32px -4px ${(0, _styles.alpha)(primaryColor, 0.3)}`,
227
229
  "&:hover": {
228
230
  boxShadow: `0 12px 40px -4px ${(0, _styles.alpha)(primaryColor, 0.4)}`,
@@ -537,6 +537,7 @@ function SubscriptionLinks({
537
537
  md: 17
538
538
  },
539
539
  letterSpacing: "0.02em",
540
+ color: theme => (0, _format.primaryContrastColor)(theme),
540
541
  boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)",
541
542
  "&:hover": {
542
543
  boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)"
@@ -577,6 +578,7 @@ function InvoiceLink({
577
578
  md: 17
578
579
  },
579
580
  letterSpacing: "0.02em",
581
+ color: theme => (0, _format.primaryContrastColor)(theme),
580
582
  boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)",
581
583
  "&:hover": {
582
584
  boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)"
@@ -335,11 +335,15 @@ function OverdueInvoicePayment({
335
335
  } = item;
336
336
  const inProcess = payLoading && selectCurrencyId === currency.id;
337
337
  const status = paymentStatus[currency.id] || "idle";
338
+ const containedColorSx = (options?.variant || "contained") === "contained" ? {
339
+ color: th => (0, _util.primaryContrastColor)(th)
340
+ } : {};
338
341
  if (status === "success") {
339
342
  return /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Button, {
340
343
  variant: options?.variant || "contained",
341
344
  size: "small",
342
345
  onClick: () => checkAndHandleInvoicePaid(currency.id),
346
+ sx: containedColorSx,
343
347
  ...(primaryButton ? {} : {
344
348
  color: "success",
345
349
  startIcon: /* @__PURE__ */(0, _jsxRuntime.jsx)(_iconsMaterial.CheckCircle, {})
@@ -378,7 +382,10 @@ function OverdueInvoicePayment({
378
382
  disabled: paying || status === "processing",
379
383
  loading: paying || status === "processing",
380
384
  onClick: onPay,
381
- sx: options?.sx,
385
+ sx: {
386
+ ...containedColorSx,
387
+ ...(options?.sx || {})
388
+ },
382
389
  children: buttonText
383
390
  })
384
391
  });
@@ -389,7 +396,10 @@ function OverdueInvoicePayment({
389
396
  disabled: inProcess,
390
397
  loading: inProcess,
391
398
  onClick: () => handlePay(item),
392
- sx: options?.sx,
399
+ sx: {
400
+ ...containedColorSx,
401
+ ...(options?.sx || {})
402
+ },
393
403
  children: status === "error" ? t("payment.subscription.overdue.retry") : t("payment.subscription.overdue.payNow")
394
404
  });
395
405
  };
@@ -194,3 +194,11 @@ export declare function getTokenBalanceLink(method: TPaymentMethod, address: str
194
194
  export declare function isCreditMetered(price: TPrice): boolean;
195
195
  export declare function showStaking(method: TPaymentMethod, currency: TPaymentCurrency, noStake: boolean): boolean;
196
196
  export declare function formatLinkWithLocale(url: string, locale?: string): string;
197
+ export declare function primaryContrastColor(theme: {
198
+ palette: {
199
+ primary: {
200
+ main: string;
201
+ };
202
+ getContrastText: (bg: string) => string;
203
+ };
204
+ }): string;
package/lib/libs/util.js CHANGED
@@ -75,6 +75,7 @@ exports.lazyLoad = lazyLoad;
75
75
  exports.mergeExtraParams = void 0;
76
76
  exports.openDonationSettings = openDonationSettings;
77
77
  exports.parseMarkedText = parseMarkedText;
78
+ exports.primaryContrastColor = primaryContrastColor;
78
79
  exports.showStaking = showStaking;
79
80
  exports.sleep = sleep;
80
81
  exports.stopEvent = stopEvent;
@@ -1644,4 +1645,7 @@ function formatLinkWithLocale(url, locale) {
1644
1645
  const separator = url.includes("?") ? "&" : "?";
1645
1646
  return `${url}${separator}locale=${locale}`;
1646
1647
  }
1648
+ }
1649
+ function primaryContrastColor(theme) {
1650
+ return theme.palette.getContrastText(theme.palette.primary.main);
1647
1651
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/payment-react",
3
- "version": "1.26.0",
3
+ "version": "1.26.2",
4
4
  "description": "Reusable react components for payment kit v2",
5
5
  "keywords": [
6
6
  "react",
@@ -59,7 +59,7 @@
59
59
  "@arcblock/react-hooks": "^3.5.1",
60
60
  "@arcblock/ux": "^3.5.1",
61
61
  "@arcblock/ws": "^1.28.5",
62
- "@blocklet/payment-react-headless": "1.26.0",
62
+ "@blocklet/payment-react-headless": "1.26.2",
63
63
  "@blocklet/theme": "^3.5.1",
64
64
  "@blocklet/ui-react": "^3.5.1",
65
65
  "@mui/icons-material": "^7.1.2",
@@ -97,7 +97,7 @@
97
97
  "@babel/core": "^7.27.4",
98
98
  "@babel/preset-env": "^7.27.2",
99
99
  "@babel/preset-react": "^7.27.1",
100
- "@blocklet/payment-types": "1.26.0",
100
+ "@blocklet/payment-types": "1.26.2",
101
101
  "@storybook/addon-essentials": "^7.6.20",
102
102
  "@storybook/addon-interactions": "^7.6.20",
103
103
  "@storybook/addon-links": "^7.6.20",
@@ -128,5 +128,5 @@
128
128
  "vite-plugin-babel": "^1.3.1",
129
129
  "vite-plugin-node-polyfills": "^0.23.0"
130
130
  },
131
- "gitHead": "9585ec8bc077fc5f8a8c5946d05436b10576e145"
131
+ "gitHead": "71242a68d27d56666487176425153dc08071960f"
132
132
  }
@@ -3,7 +3,7 @@ import ShoppingCartCheckoutIcon from '@mui/icons-material/ShoppingCartCheckout';
3
3
  import { Avatar, Box, Button, Chip, Stack, Typography } from '@mui/material';
4
4
  import type { TPaymentCurrency, TPrice } from '@blocklet/payment-types';
5
5
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
6
- import { formatDynamicUnitPrice, tSafe, INTERVAL_LOCALE_KEY } from '../../utils/format';
6
+ import { formatDynamicUnitPrice, tSafe, INTERVAL_LOCALE_KEY, primaryContrastColor } from '../../utils/format';
7
7
 
8
8
  interface CrossSellCardProps {
9
9
  crossSellItem: TPrice;
@@ -53,7 +53,7 @@ export default function CrossSellCard({
53
53
  fontWeight: 900,
54
54
  letterSpacing: '0.12em',
55
55
  bgcolor: 'primary.main',
56
- color: '#fff',
56
+ color: (theme: any) => primaryContrastColor(theme),
57
57
  boxShadow: '0 4px 12px rgba(45,124,243,0.2)',
58
58
  '& .MuiChip-label': { px: 1.5 },
59
59
  }}
@@ -150,7 +150,7 @@ export default function CrossSellCard({
150
150
  transition: 'all 0.2s',
151
151
  '&:hover': {
152
152
  bgcolor: 'primary.main',
153
- color: '#fff',
153
+ color: (theme: any) => primaryContrastColor(theme),
154
154
  borderColor: 'primary.main',
155
155
  },
156
156
  '&:active': { transform: 'scale(0.95)' },
@@ -22,7 +22,13 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
22
22
  import type { TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
23
23
  import { getPriceUnitAmountByCurrency } from '@blocklet/payment-react-headless';
24
24
  import Toast from '@arcblock/ux/lib/Toast';
25
- import { INTERVAL_LOCALE_KEY, formatDynamicUnitPrice, formatTokenAmount, formatTrialText } from '../../utils/format';
25
+ import {
26
+ INTERVAL_LOCALE_KEY,
27
+ formatDynamicUnitPrice,
28
+ formatTokenAmount,
29
+ formatTrialText,
30
+ primaryContrastColor,
31
+ } from '../../utils/format';
26
32
 
27
33
  interface ProductItemCardProps {
28
34
  item: TLineItemExpanded & { adjustable_quantity?: { enabled: boolean; minimum?: number; maximum?: number } };
@@ -109,7 +115,8 @@ export default function ProductItemCard({
109
115
  return (
110
116
  tokenAmount
111
117
  .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
112
- .replace(/\.?0+$/, '') || '0'
118
+ .replace(/(\.\d*?)0+$/, '$1')
119
+ .replace(/\.$/, '') || '0'
113
120
  );
114
121
  }
115
122
  }
@@ -147,7 +154,12 @@ export default function ProductItemCard({
147
154
  const discAmount = (numericTotal * couponDetails.percent_off) / 100;
148
155
  const abs = Math.abs(discAmount);
149
156
  const precision = abs > 0 && abs < 0.01 ? 6 : 2;
150
- return `${discAmount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision }).replace(/\.?0+$/, '') || '0'} ${currency?.symbol || ''}`;
157
+ return `${
158
+ discAmount
159
+ .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
160
+ .replace(/(\.\d*?)0+$/, '$1')
161
+ .replace(/\.$/, '') || '0'
162
+ } ${currency?.symbol || ''}`;
151
163
  }
152
164
  }
153
165
  if ((item as any).discount_amounts?.length > 0 && currency) {
@@ -207,7 +219,8 @@ export default function ProductItemCard({
207
219
  const formatted =
208
220
  tokenAmount
209
221
  .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
210
- .replace(/\.?0+$/, '') || '0';
222
+ .replace(/(\.\d*?)0+$/, '$1')
223
+ .replace(/\.$/, '') || '0';
211
224
  return `${formatted} ${currency?.symbol || ''} ${slashText}`;
212
225
  }
213
226
  }
@@ -238,7 +251,8 @@ export default function ProductItemCard({
238
251
  const formatted =
239
252
  tokenAmount
240
253
  .toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: precision })
241
- .replace(/\.?0+$/, '') || '0';
254
+ .replace(/(\.\d*?)0+$/, '$1')
255
+ .replace(/\.$/, '') || '0';
242
256
  return `${formatted} ${currency?.symbol || ''} ${originalSlash}`;
243
257
  }
244
258
  }
@@ -284,7 +298,7 @@ export default function ProductItemCard({
284
298
  fontWeight: 900,
285
299
  letterSpacing: '0.12em',
286
300
  bgcolor: 'primary.main',
287
- color: '#fff',
301
+ color: (th: any) => primaryContrastColor(th),
288
302
  boxShadow: '0 4px 12px rgba(45,124,243,0.2)',
289
303
  '& .MuiChip-label': { px: 1.5 },
290
304
  }}
@@ -459,12 +473,16 @@ export default function ProductItemCard({
459
473
  {/* Discount chip */}
460
474
  {discountCode && perItemDiscount && (
461
475
  <Box sx={{ mt: 1.5 }}>
462
- <Chip
463
- icon={<LocalOfferIcon sx={{ color: 'warning.main', fontSize: 'small' }} />}
464
- label={`${discountCode} (-${perItemDiscount})`}
465
- size="small"
466
- sx={{ height: 22, borderRadius: '6px', '& .MuiChip-label': { fontSize: 12 } }}
467
- />
476
+ {isRateLoading ? (
477
+ <Skeleton variant="rounded" width={160} height={22} sx={{ borderRadius: '6px' }} />
478
+ ) : (
479
+ <Chip
480
+ icon={<LocalOfferIcon sx={{ color: 'warning.main', fontSize: 'small' }} />}
481
+ label={`${discountCode} (-${perItemDiscount})`}
482
+ size="small"
483
+ sx={{ height: 22, borderRadius: '6px', '& .MuiChip-label': { fontSize: 12 } }}
484
+ />
485
+ )}
468
486
  </Box>
469
487
  )}
470
488
 
@@ -9,6 +9,7 @@ import {
9
9
  CircularProgress,
10
10
  IconButton,
11
11
  InputAdornment,
12
+ Skeleton,
12
13
  Stack,
13
14
  TextField,
14
15
  Typography,
@@ -28,6 +29,8 @@ interface PromotionInputProps {
28
29
  discountAmount: string | null;
29
30
  /** Start with input field visible (skip the "Add promotion code" button) */
30
31
  initialShowInput?: boolean;
32
+ /** Show skeleton for the discount amount while switching */
33
+ isAmountLoading?: boolean;
31
34
  }
32
35
 
33
36
  export default function PromotionInput({
@@ -35,6 +38,7 @@ export default function PromotionInput({
35
38
  discounts,
36
39
  discountAmount,
37
40
  initialShowInput = false,
41
+ isAmountLoading = false,
38
42
  }: PromotionInputProps) {
39
43
  const { t } = useLocaleContext();
40
44
  const [showInput, setShowInput] = useState(false);
@@ -115,9 +119,13 @@ export default function PromotionInput({
115
119
  <CloseIcon sx={{ fontSize: 12, color: '#12b886' }} />
116
120
  </IconButton>
117
121
  </Stack>
118
- <Typography sx={{ color: 'text.primary', fontWeight: 600, fontSize: 14 }}>
119
- -{discountAmount || '0'}
120
- </Typography>
122
+ {isAmountLoading ? (
123
+ <Skeleton variant="text" width={80} height={22} />
124
+ ) : (
125
+ <Typography sx={{ color: 'text.primary', fontWeight: 600, fontSize: 14 }}>
126
+ -{discountAmount || '0'}
127
+ </Typography>
128
+ )}
121
129
  </Stack>
122
130
  );
123
131
  })}
@@ -1,4 +1,5 @@
1
1
  import { Button, CircularProgress } from '@mui/material';
2
+ import { primaryContrastColor } from '../../utils/format';
2
3
 
3
4
  interface SubmitButtonProps {
4
5
  canSubmit: boolean;
@@ -30,6 +31,7 @@ export default function SubmitButton({
30
31
  fontSize: '1.3rem',
31
32
  fontWeight: 600,
32
33
  textTransform: 'none',
34
+ color: (theme) => primaryContrastColor(theme),
33
35
  }}>
34
36
  {isProcessing ? processingLabel : label}
35
37
  </Button>
@@ -17,7 +17,13 @@ import {
17
17
  } from '@blocklet/payment-react-headless';
18
18
 
19
19
  import { useMobile } from '../../../hooks/mobile';
20
- import { INTERVAL_LOCALE_KEY, formatTrialText, getSessionHeaderMeta, tSafe } from '../../utils/format';
20
+ import {
21
+ INTERVAL_LOCALE_KEY,
22
+ formatTrialText,
23
+ getSessionHeaderMeta,
24
+ tSafe,
25
+ primaryContrastColor,
26
+ } from '../../utils/format';
21
27
  import ProductItemCard from '../../components/left/product-item-card';
22
28
  import BillingToggle from '../../components/left/billing-toggle';
23
29
  import CrossSellCard from '../../components/left/cross-sell-card';
@@ -60,6 +66,10 @@ export default function CompositePanel() {
60
66
  const canUpsell = nonCrossSellItems.length <= 1;
61
67
  const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ['subscription', 'setup'].includes(mode);
62
68
  const isUpselled = !!(upsellPrimaryItem as any)?.upsell_price;
69
+ const [upsellSwitching, setUpsellSwitching] = useState(false);
70
+ // Optimistic: track which tab the user clicked so highlight switches immediately
71
+ const [pendingUpsell, setPendingUpsell] = useState<boolean | null>(null);
72
+ const visualIsUpselled = pendingUpsell !== null ? pendingUpsell : isUpselled;
63
73
 
64
74
  // Intervals for capsule toggle
65
75
  const currentInterval = hasTopUpsell ? (upsellPrimaryItem!.price as any)?.recurring?.interval : null;
@@ -94,7 +104,7 @@ export default function CompositePanel() {
94
104
  // Capsule button sx helper
95
105
  const activeSx = {
96
106
  bgcolor: 'primary.main',
97
- color: '#fff',
107
+ color: (theme: any) => primaryContrastColor(theme),
98
108
  boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)',
99
109
  };
100
110
  const inactiveSx = {
@@ -260,17 +270,23 @@ export default function CompositePanel() {
260
270
  {/* Current interval */}
261
271
  <Box
262
272
  onClick={async () => {
263
- if (isUpselled) {
273
+ if (isUpselled && !upsellSwitching) {
274
+ setPendingUpsell(false);
275
+ setUpsellSwitching(true);
264
276
  try {
265
277
  await lineItems.downsell(
266
278
  (upsellPrimaryItem as any).upsell_price?.id || upsellPrimaryItem!.price_id
267
279
  );
268
280
  } catch (err: any) {
281
+ setPendingUpsell(null);
269
282
  Toast.error(err?.response?.data?.error || err?.message || 'Failed');
283
+ } finally {
284
+ setUpsellSwitching(false);
285
+ setPendingUpsell(null);
270
286
  }
271
287
  }
272
288
  }}
273
- sx={capsuleBtnSx(!isUpselled)}>
289
+ sx={capsuleBtnSx(!visualIsUpselled)}>
274
290
  <Typography component="span" sx={{ fontSize: 14, fontWeight: 700, color: 'inherit', lineHeight: 1 }}>
275
291
  {t(INTERVAL_LOCALE_KEY[currentInterval!] || '')}
276
292
  </Typography>
@@ -278,15 +294,21 @@ export default function CompositePanel() {
278
294
  {/* Upsell interval */}
279
295
  <Box
280
296
  onClick={async () => {
281
- if (!isUpselled) {
297
+ if (!isUpselled && !upsellSwitching) {
298
+ setPendingUpsell(true);
299
+ setUpsellSwitching(true);
282
300
  try {
283
301
  await lineItems.upsell(upsellPrimaryItem!.price_id, upsellTarget.id);
284
302
  } catch (err: any) {
303
+ setPendingUpsell(null);
285
304
  Toast.error(err?.response?.data?.error || err?.message || 'Failed');
305
+ } finally {
306
+ setUpsellSwitching(false);
307
+ setPendingUpsell(null);
286
308
  }
287
309
  }
288
310
  }}
289
- sx={capsuleBtnSx(isUpselled)}>
311
+ sx={capsuleBtnSx(visualIsUpselled)}>
290
312
  <Typography component="span" sx={{ fontSize: 14, fontWeight: 700, color: 'inherit', lineHeight: 1 }}>
291
313
  {t(INTERVAL_LOCALE_KEY[upsellInterval!] || '')}
292
314
  </Typography>
@@ -154,12 +154,8 @@ export default function CreditTopupPanel() {
154
154
  ? t('payment.checkout.credit.schedule.withRefresh', { amount: formattedAmount, interval: intervalDisplay })
155
155
  : t('payment.checkout.credit.schedule.periodic', { amount: formattedAmount, interval: intervalDisplay });
156
156
  }
157
- const productDesc = product?.description || '';
158
- if (productDesc && productDesc.length > 10 && productDesc !== creditName) {
159
- return productDesc;
160
- }
161
157
  return '';
162
- }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, creditName, product, t]);
158
+ }, [creditAmount, currencySymbol, hasSchedule, scheduleConfig, t]);
163
159
 
164
160
  // Validity text: "Credits are valid for X days after purchase."
165
161
  const validityText = useMemo(() => {