@blocklet/payment-react 1.26.1 → 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 (40) 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 +9 -3
  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/right/payment-panel.js +37 -8
  8. package/es/checkout-v2/utils/format.d.ts +1 -1
  9. package/es/checkout-v2/utils/format.js +1 -0
  10. package/es/checkout-v2/views/error-view.js +2 -0
  11. package/es/checkout-v2/views/success-view.js +3 -1
  12. package/es/components/over-due-invoice-payment.js +5 -3
  13. package/es/libs/util.d.ts +8 -0
  14. package/es/libs/util.js +3 -0
  15. package/lib/checkout-v2/components/left/cross-sell-card.js +2 -2
  16. package/lib/checkout-v2/components/left/product-item-card.js +9 -2
  17. package/lib/checkout-v2/components/left/promotion-input.d.ts +3 -1
  18. package/lib/checkout-v2/components/left/promotion-input.js +7 -2
  19. package/lib/checkout-v2/components/right/submit-button.js +3 -1
  20. package/lib/checkout-v2/panels/left/composite-panel.js +20 -5
  21. package/lib/checkout-v2/panels/right/payment-panel.js +43 -6
  22. package/lib/checkout-v2/utils/format.d.ts +1 -1
  23. package/lib/checkout-v2/utils/format.js +7 -0
  24. package/lib/checkout-v2/views/error-view.js +2 -0
  25. package/lib/checkout-v2/views/success-view.js +2 -0
  26. package/lib/components/over-due-invoice-payment.js +12 -2
  27. package/lib/libs/util.d.ts +8 -0
  28. package/lib/libs/util.js +4 -0
  29. package/package.json +4 -4
  30. package/src/checkout-v2/components/left/cross-sell-card.tsx +3 -3
  31. package/src/checkout-v2/components/left/product-item-card.tsx +18 -8
  32. package/src/checkout-v2/components/left/promotion-input.tsx +11 -3
  33. package/src/checkout-v2/components/right/submit-button.tsx +2 -0
  34. package/src/checkout-v2/panels/left/composite-panel.tsx +28 -6
  35. package/src/checkout-v2/panels/right/payment-panel.tsx +30 -5
  36. package/src/checkout-v2/utils/format.ts +2 -0
  37. package/src/checkout-v2/views/error-view.tsx +2 -0
  38. package/src/checkout-v2/views/success-view.tsx +3 -1
  39. package/src/components/over-due-invoice-payment.tsx +6 -3
  40. package/src/libs/util.ts +7 -0
@@ -3,7 +3,7 @@ import AddShoppingCartIcon from "@mui/icons-material/AddShoppingCart";
3
3
  import ShoppingCartCheckoutIcon from "@mui/icons-material/ShoppingCartCheckout";
4
4
  import { Avatar, Box, Button, Chip, Stack, Typography } from "@mui/material";
5
5
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
6
- import { formatDynamicUnitPrice, tSafe, INTERVAL_LOCALE_KEY } from "../../utils/format.js";
6
+ import { formatDynamicUnitPrice, tSafe, INTERVAL_LOCALE_KEY, primaryContrastColor } from "../../utils/format.js";
7
7
  export default function CrossSellCard({
8
8
  crossSellItem,
9
9
  currency,
@@ -40,7 +40,7 @@ export default function CrossSellCard({
40
40
  fontWeight: 900,
41
41
  letterSpacing: "0.12em",
42
42
  bgcolor: "primary.main",
43
- color: "#fff",
43
+ color: (theme) => primaryContrastColor(theme),
44
44
  boxShadow: "0 4px 12px rgba(45,124,243,0.2)",
45
45
  "& .MuiChip-label": { px: 1.5 }
46
46
  }
@@ -147,7 +147,7 @@ export default function CrossSellCard({
147
147
  transition: "all 0.2s",
148
148
  "&:hover": {
149
149
  bgcolor: "primary.main",
150
- color: "#fff",
150
+ color: (theme) => primaryContrastColor(theme),
151
151
  borderColor: "primary.main"
152
152
  },
153
153
  "&:active": { transform: "scale(0.95)" }
@@ -22,7 +22,13 @@ import {
22
22
  import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
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.js";
25
+ import {
26
+ INTERVAL_LOCALE_KEY,
27
+ formatDynamicUnitPrice,
28
+ formatTokenAmount,
29
+ formatTrialText,
30
+ primaryContrastColor
31
+ } from "../../utils/format.js";
26
32
  export default function ProductItemCard({
27
33
  item,
28
34
  currency,
@@ -217,7 +223,7 @@ export default function ProductItemCard({
217
223
  fontWeight: 900,
218
224
  letterSpacing: "0.12em",
219
225
  bgcolor: "primary.main",
220
- color: "#fff",
226
+ color: (th) => primaryContrastColor(th),
221
227
  boxShadow: "0 4px 12px rgba(45,124,243,0.2)",
222
228
  "& .MuiChip-label": { px: 1.5 }
223
229
  }
@@ -388,7 +394,7 @@ export default function ProductItemCard({
388
394
  ]
389
395
  }
390
396
  ),
391
- discountCode && perItemDiscount && /* @__PURE__ */ jsx(Box, { sx: { mt: 1.5 }, children: /* @__PURE__ */ jsx(
397
+ discountCode && perItemDiscount && /* @__PURE__ */ jsx(Box, { sx: { mt: 1.5 }, children: isRateLoading ? /* @__PURE__ */ jsx(Skeleton, { variant: "rounded", width: 160, height: 22, sx: { borderRadius: "6px" } }) : /* @__PURE__ */ jsx(
392
398
  Chip,
393
399
  {
394
400
  icon: /* @__PURE__ */ jsx(LocalOfferIcon, { sx: { color: "warning.main", fontSize: "small" } }),
@@ -14,6 +14,8 @@ interface PromotionInputProps {
14
14
  discountAmount: string | null;
15
15
  /** Start with input field visible (skip the "Add promotion code" button) */
16
16
  initialShowInput?: boolean;
17
+ /** Show skeleton for the discount amount while switching */
18
+ isAmountLoading?: boolean;
17
19
  }
18
- export default function PromotionInput({ promotion, discounts, discountAmount, initialShowInput, }: PromotionInputProps): import("react").JSX.Element | null;
20
+ export default function PromotionInput({ promotion, discounts, discountAmount, initialShowInput, isAmountLoading, }: PromotionInputProps): import("react").JSX.Element | null;
19
21
  export {};
@@ -10,6 +10,7 @@ import {
10
10
  CircularProgress,
11
11
  IconButton,
12
12
  InputAdornment,
13
+ Skeleton,
13
14
  Stack,
14
15
  TextField,
15
16
  Typography
@@ -19,7 +20,8 @@ export default function PromotionInput({
19
20
  promotion,
20
21
  discounts,
21
22
  discountAmount,
22
- initialShowInput = false
23
+ initialShowInput = false,
24
+ isAmountLoading = false
23
25
  }) {
24
26
  const { t } = useLocaleContext();
25
27
  const [showInput, setShowInput] = useState(false);
@@ -91,7 +93,7 @@ export default function PromotionInput({
91
93
  ]
92
94
  }
93
95
  ),
94
- /* @__PURE__ */ jsxs(Typography, { sx: { color: "text.primary", fontWeight: 600, fontSize: 14 }, children: [
96
+ isAmountLoading ? /* @__PURE__ */ jsx(Skeleton, { variant: "text", width: 80, height: 22 }) : /* @__PURE__ */ jsxs(Typography, { sx: { color: "text.primary", fontWeight: 600, fontSize: 14 }, children: [
95
97
  "-",
96
98
  discountAmount || "0"
97
99
  ] })
@@ -1,5 +1,6 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { Button, CircularProgress } from "@mui/material";
3
+ import { primaryContrastColor } from "../../utils/format.js";
3
4
  export default function SubmitButton({
4
5
  canSubmit,
5
6
  isProcessing,
@@ -21,7 +22,8 @@ export default function SubmitButton({
21
22
  py: 1.5,
22
23
  fontSize: "1.3rem",
23
24
  fontWeight: 600,
24
- textTransform: "none"
25
+ textTransform: "none",
26
+ color: (theme) => primaryContrastColor(theme)
25
27
  },
26
28
  children: isProcessing ? processingLabel : label
27
29
  }
@@ -17,7 +17,13 @@ import {
17
17
  useProduct
18
18
  } from "@blocklet/payment-react-headless";
19
19
  import { useMobile } from "../../../hooks/mobile.js";
20
- import { INTERVAL_LOCALE_KEY, formatTrialText, getSessionHeaderMeta, tSafe } from "../../utils/format.js";
20
+ import {
21
+ INTERVAL_LOCALE_KEY,
22
+ formatTrialText,
23
+ getSessionHeaderMeta,
24
+ tSafe,
25
+ primaryContrastColor
26
+ } from "../../utils/format.js";
21
27
  import ProductItemCard from "../../components/left/product-item-card.js";
22
28
  import BillingToggle from "../../components/left/billing-toggle.js";
23
29
  import CrossSellCard from "../../components/left/cross-sell-card.js";
@@ -51,6 +57,9 @@ export default function CompositePanel() {
51
57
  const canUpsell = nonCrossSellItems.length <= 1;
52
58
  const hasTopUpsell = canUpsell && !!upsellPrimaryItem && ["subscription", "setup"].includes(mode);
53
59
  const isUpselled = !!upsellPrimaryItem?.upsell_price;
60
+ const [upsellSwitching, setUpsellSwitching] = useState(false);
61
+ const [pendingUpsell, setPendingUpsell] = useState(null);
62
+ const visualIsUpselled = pendingUpsell !== null ? pendingUpsell : isUpselled;
54
63
  const currentInterval = hasTopUpsell ? upsellPrimaryItem.price?.recurring?.interval : null;
55
64
  const upsellInterval = hasTopUpsell ? upsellTarget?.recurring?.interval : null;
56
65
  let upsellSavings = 0;
@@ -75,7 +84,7 @@ export default function CompositePanel() {
75
84
  const isMultiItem = lineItems.items.length > 1;
76
85
  const activeSx = {
77
86
  bgcolor: "primary.main",
78
- color: "#fff",
87
+ color: (theme) => primaryContrastColor(theme),
79
88
  boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)"
80
89
  };
81
90
  const inactiveSx = {
@@ -223,17 +232,23 @@ export default function CompositePanel() {
223
232
  Box,
224
233
  {
225
234
  onClick: async () => {
226
- if (isUpselled) {
235
+ if (isUpselled && !upsellSwitching) {
236
+ setPendingUpsell(false);
237
+ setUpsellSwitching(true);
227
238
  try {
228
239
  await lineItems.downsell(
229
240
  upsellPrimaryItem.upsell_price?.id || upsellPrimaryItem.price_id
230
241
  );
231
242
  } catch (err) {
243
+ setPendingUpsell(null);
232
244
  Toast.error(err?.response?.data?.error || err?.message || "Failed");
245
+ } finally {
246
+ setUpsellSwitching(false);
247
+ setPendingUpsell(null);
233
248
  }
234
249
  }
235
250
  },
236
- sx: capsuleBtnSx(!isUpselled),
251
+ sx: capsuleBtnSx(!visualIsUpselled),
237
252
  children: /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 14, fontWeight: 700, color: "inherit", lineHeight: 1 }, children: t(INTERVAL_LOCALE_KEY[currentInterval] || "") })
238
253
  }
239
254
  ),
@@ -241,15 +256,21 @@ export default function CompositePanel() {
241
256
  Box,
242
257
  {
243
258
  onClick: async () => {
244
- if (!isUpselled) {
259
+ if (!isUpselled && !upsellSwitching) {
260
+ setPendingUpsell(true);
261
+ setUpsellSwitching(true);
245
262
  try {
246
263
  await lineItems.upsell(upsellPrimaryItem.price_id, upsellTarget.id);
247
264
  } catch (err) {
265
+ setPendingUpsell(null);
248
266
  Toast.error(err?.response?.data?.error || err?.message || "Failed");
267
+ } finally {
268
+ setUpsellSwitching(false);
269
+ setPendingUpsell(null);
249
270
  }
250
271
  }
251
272
  },
252
- sx: capsuleBtnSx(isUpselled),
273
+ sx: capsuleBtnSx(visualIsUpselled),
253
274
  children: /* @__PURE__ */ jsx(Typography, { component: "span", sx: { fontSize: 14, fontWeight: 700, color: "inherit", lineHeight: 1 }, children: t(INTERVAL_LOCALE_KEY[upsellInterval] || "") })
254
275
  }
255
276
  )
@@ -40,9 +40,9 @@ import {
40
40
  import { joinURL } from "ufo";
41
41
  import { usePaymentContext } from "../../../contexts/payment.js";
42
42
  import { useMobile } from "../../../hooks/mobile.js";
43
- import { getPrefix } from "../../../libs/util.js";
43
+ import { getPrefix, getStatementDescriptor } from "../../../libs/util.js";
44
44
  import OverdueInvoicePayment from "../../../components/over-due-invoice-payment.js";
45
- import { tSafe, whiteTooltipSx } from "../../utils/format.js";
45
+ import { tSafe, whiteTooltipSx, primaryContrastColor } from "../../utils/format.js";
46
46
  import CustomerInfoCard from "../../components/right/customer-info-card.js";
47
47
  import SubscriptionDisclaimer from "../../components/right/subscription-disclaimer.js";
48
48
  import StatusFeedback from "../../components/right/status-feedback.js";
@@ -412,7 +412,8 @@ export default function PaymentPanel() {
412
412
  remove: promotion.remove
413
413
  },
414
414
  discounts,
415
- discountAmount: pricing.discount
415
+ discountAmount: pricing.discount,
416
+ isAmountLoading
416
417
  }
417
418
  )
418
419
  ] }),
@@ -502,7 +503,7 @@ export default function PaymentPanel() {
502
503
  ] })
503
504
  ] });
504
505
  })(),
505
- /* @__PURE__ */ jsx(
506
+ /* @__PURE__ */ jsxs(
506
507
  Button,
507
508
  {
508
509
  variant: "contained",
@@ -511,15 +512,43 @@ export default function PaymentPanel() {
511
512
  disabled: !canSubmit || submit.status === "waiting_stripe",
512
513
  onClick: handleAction,
513
514
  startIcon: isProcessing ? /* @__PURE__ */ jsx(CircularProgress, { size: 20, color: "inherit" }) : null,
514
- endIcon: !isProcessing ? /* @__PURE__ */ jsx(ArrowForwardIcon, {}) : void 0,
515
515
  sx: {
516
516
  py: 1.5,
517
517
  fontSize: "1.1rem",
518
518
  fontWeight: 600,
519
519
  textTransform: "none",
520
- borderRadius: "12px"
520
+ borderRadius: "12px",
521
+ color: (theme) => primaryContrastColor(theme),
522
+ position: "relative",
523
+ overflow: "hidden",
524
+ "&:hover": { bgcolor: "primary.main" },
525
+ "&:hover .arrow-icon": { transform: "translateX(4px)" },
526
+ "&:hover .shine-layer": { transform: "translateX(100%)" }
521
527
  },
522
- children: isProcessing ? `${t("payment.checkout.processing")}...` : buttonLabel
528
+ children: [
529
+ /* @__PURE__ */ jsx(Box, { component: "span", sx: { position: "relative", zIndex: 1 }, children: isProcessing ? `${t("payment.checkout.processing")}...` : buttonLabel }),
530
+ !isProcessing && /* @__PURE__ */ jsx(
531
+ ArrowForwardIcon,
532
+ {
533
+ className: "arrow-icon",
534
+ sx: { ml: 1, position: "relative", zIndex: 1, transition: "transform 0.2s ease" }
535
+ }
536
+ ),
537
+ /* @__PURE__ */ jsx(
538
+ Box,
539
+ {
540
+ className: "shine-layer",
541
+ sx: {
542
+ position: "absolute",
543
+ inset: 0,
544
+ background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent)",
545
+ transform: "translateX(-100%)",
546
+ transition: "transform 0.7s ease",
547
+ pointerEvents: "none"
548
+ }
549
+ }
550
+ )
551
+ ]
523
552
  }
524
553
  ),
525
554
  isMobile && /* @__PURE__ */ jsxs(
@@ -588,7 +617,7 @@ export default function PaymentPanel() {
588
617
  mode,
589
618
  subscription,
590
619
  staking: pricing.staking,
591
- appName: session?.metadata?.app_name || "New Payment Kit"
620
+ appName: getStatementDescriptor(session?.line_items || [])
592
621
  }
593
622
  ),
594
623
  !isMobile && /* @__PURE__ */ jsxs(
@@ -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 {};
@@ -1,4 +1,5 @@
1
1
  import { fromUnitToToken } from "@ocap/util";
2
+ export { primaryContrastColor } from "../../libs/util.js";
2
3
  export const INTERVAL_LOCALE_KEY = {
3
4
  day: "common.daily",
4
5
  week: "common.weekly",
@@ -4,6 +4,7 @@ import { alpha, useTheme } from "@mui/material/styles";
4
4
  import ArrowBackIcon from "@mui/icons-material/ArrowBack";
5
5
  import Header from "@blocklet/ui-react/lib/Header";
6
6
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
7
+ import { primaryContrastColor } from "../utils/format.js";
7
8
  function GeometricDecoration() {
8
9
  const theme = useTheme();
9
10
  const gridColor = alpha(theme.palette.primary.main, 0.06);
@@ -173,6 +174,7 @@ function ErrorContent({ error, errorCode = void 0 }) {
173
174
  fontWeight: 600,
174
175
  fontSize: 16,
175
176
  letterSpacing: "0.02em",
177
+ color: (th) => primaryContrastColor(th),
176
178
  boxShadow: `0 8px 32px -4px ${alpha(primaryColor, 0.3)}`,
177
179
  "&:hover": {
178
180
  boxShadow: `0 12px 40px -4px ${alpha(primaryColor, 0.4)}`,
@@ -22,7 +22,7 @@ import { joinURL } from "ufo";
22
22
  import { useLocaleContext } from "@arcblock/ux/lib/Locale/context";
23
23
  import { usePaymentMethodContext } from "@blocklet/payment-react-headless";
24
24
  import { getPrefix } from "../../libs/util.js";
25
- import { formatTokenAmount } from "../utils/format.js";
25
+ import { formatTokenAmount, primaryContrastColor } from "../utils/format.js";
26
26
  const scaleIn = keyframes`
27
27
  from { transform: scale(0); opacity: 0; }
28
28
  60% { transform: scale(1.15); }
@@ -452,6 +452,7 @@ function SubscriptionLinks({
452
452
  fontWeight: 700,
453
453
  fontSize: { xs: 16, md: 17 },
454
454
  letterSpacing: "0.02em",
455
+ color: (theme) => primaryContrastColor(theme),
455
456
  boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)",
456
457
  "&:hover": {
457
458
  boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)"
@@ -486,6 +487,7 @@ function InvoiceLink({
486
487
  fontWeight: 700,
487
488
  fontSize: { xs: 16, md: 17 },
488
489
  letterSpacing: "0.02em",
490
+ color: (theme) => primaryContrastColor(theme),
489
491
  boxShadow: "0 8px 24px -4px rgba(59,130,246,0.25)",
490
492
  "&:hover": {
491
493
  boxShadow: "0 12px 28px -4px rgba(59,130,246,0.35)"
@@ -11,7 +11,7 @@ import Dialog from "@arcblock/ux/lib/Dialog/dialog";
11
11
  import { CheckCircle as CheckCircleIcon } from "@mui/icons-material";
12
12
  import debounce from "lodash/debounce";
13
13
  import { usePaymentContext } from "../contexts/payment.js";
14
- import { formatAmount, formatError, getPrefix, isCrossOrigin } from "../libs/util.js";
14
+ import { formatAmount, formatError, getPrefix, isCrossOrigin, primaryContrastColor } from "../libs/util.js";
15
15
  import { useSubscription } from "../hooks/subscription.js";
16
16
  import api from "../libs/api.js";
17
17
  import LoadingButton from "./loading-button.js";
@@ -290,6 +290,7 @@ function OverdueInvoicePayment({
290
290
  const { currency } = item;
291
291
  const inProcess = payLoading && selectCurrencyId === currency.id;
292
292
  const status = paymentStatus[currency.id] || "idle";
293
+ const containedColorSx = (options?.variant || "contained") === "contained" ? { color: (th) => primaryContrastColor(th) } : {};
293
294
  if (status === "success") {
294
295
  return /* @__PURE__ */ jsx(
295
296
  Button,
@@ -297,6 +298,7 @@ function OverdueInvoicePayment({
297
298
  variant: options?.variant || "contained",
298
299
  size: "small",
299
300
  onClick: () => checkAndHandleInvoicePaid(currency.id),
301
+ sx: containedColorSx,
300
302
  ...primaryButton ? {} : {
301
303
  color: "success",
302
304
  startIcon: /* @__PURE__ */ jsx(CheckCircleIcon, {})
@@ -334,7 +336,7 @@ function OverdueInvoicePayment({
334
336
  disabled: paying || status === "processing",
335
337
  loading: paying || status === "processing",
336
338
  onClick: onPay,
337
- sx: options?.sx,
339
+ sx: { ...containedColorSx, ...options?.sx || {} },
338
340
  children: buttonText
339
341
  }
340
342
  )
@@ -349,7 +351,7 @@ function OverdueInvoicePayment({
349
351
  disabled: inProcess,
350
352
  loading: inProcess,
351
353
  onClick: () => handlePay(item),
352
- sx: options?.sx,
354
+ sx: { ...containedColorSx, ...options?.sx || {} },
353
355
  children: status === "error" ? t("payment.subscription.overdue.retry") : t("payment.subscription.overdue.payNow")
354
356
  }
355
357
  );
package/es/libs/util.d.ts CHANGED
@@ -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/es/libs/util.js CHANGED
@@ -1372,3 +1372,6 @@ export function formatLinkWithLocale(url, locale) {
1372
1372
  return `${url}${separator}locale=${locale}`;
1373
1373
  }
1374
1374
  }
1375
+ export function primaryContrastColor(theme) {
1376
+ return theme.palette.getContrastText(theme.palette.primary.main);
1377
+ }
@@ -54,7 +54,7 @@ function CrossSellCard({
54
54
  fontWeight: 900,
55
55
  letterSpacing: "0.12em",
56
56
  bgcolor: "primary.main",
57
- color: "#fff",
57
+ color: theme => (0, _format.primaryContrastColor)(theme),
58
58
  boxShadow: "0 4px 12px rgba(45,124,243,0.2)",
59
59
  "& .MuiChip-label": {
60
60
  px: 1.5
@@ -236,7 +236,7 @@ function CrossSellCard({
236
236
  transition: "all 0.2s",
237
237
  "&:hover": {
238
238
  bgcolor: "primary.main",
239
- color: "#fff",
239
+ color: theme => (0, _format.primaryContrastColor)(theme),
240
240
  borderColor: "primary.main"
241
241
  },
242
242
  "&:active": {
@@ -246,7 +246,7 @@ function ProductItemCard({
246
246
  fontWeight: 900,
247
247
  letterSpacing: "0.12em",
248
248
  bgcolor: "primary.main",
249
- color: "#fff",
249
+ color: th => (0, _format.primaryContrastColor)(th),
250
250
  boxShadow: "0 4px 12px rgba(45,124,243,0.2)",
251
251
  "& .MuiChip-label": {
252
252
  px: 1.5
@@ -513,7 +513,14 @@ function ProductItemCard({
513
513
  sx: {
514
514
  mt: 1.5
515
515
  },
516
- children: /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Chip, {
516
+ children: isRateLoading ? /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Skeleton, {
517
+ variant: "rounded",
518
+ width: 160,
519
+ height: 22,
520
+ sx: {
521
+ borderRadius: "6px"
522
+ }
523
+ }) : /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Chip, {
517
524
  icon: /* @__PURE__ */(0, _jsxRuntime.jsx)(_LocalOffer.default, {
518
525
  sx: {
519
526
  color: "warning.main",
@@ -14,6 +14,8 @@ interface PromotionInputProps {
14
14
  discountAmount: string | null;
15
15
  /** Start with input field visible (skip the "Add promotion code" button) */
16
16
  initialShowInput?: boolean;
17
+ /** Show skeleton for the discount amount while switching */
18
+ isAmountLoading?: boolean;
17
19
  }
18
- export default function PromotionInput({ promotion, discounts, discountAmount, initialShowInput, }: PromotionInputProps): import("react").JSX.Element | null;
20
+ export default function PromotionInput({ promotion, discounts, discountAmount, initialShowInput, isAmountLoading, }: PromotionInputProps): import("react").JSX.Element | null;
19
21
  export {};
@@ -16,7 +16,8 @@ function PromotionInput({
16
16
  promotion,
17
17
  discounts,
18
18
  discountAmount,
19
- initialShowInput = false
19
+ initialShowInput = false,
20
+ isAmountLoading = false
20
21
  }) {
21
22
  const {
22
23
  t
@@ -116,7 +117,11 @@ function PromotionInput({
116
117
  }
117
118
  })
118
119
  })]
119
- }), /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
120
+ }), isAmountLoading ? /* @__PURE__ */(0, _jsxRuntime.jsx)(_material.Skeleton, {
121
+ variant: "text",
122
+ width: 80,
123
+ height: 22
124
+ }) : /* @__PURE__ */(0, _jsxRuntime.jsxs)(_material.Typography, {
120
125
  sx: {
121
126
  color: "text.primary",
122
127
  fontWeight: 600,
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", {
6
6
  module.exports = SubmitButton;
7
7
  var _jsxRuntime = require("react/jsx-runtime");
8
8
  var _material = require("@mui/material");
9
+ var _format = require("../../utils/format");
9
10
  function SubmitButton({
10
11
  canSubmit,
11
12
  isProcessing,
@@ -28,7 +29,8 @@ function SubmitButton({
28
29
  py: 1.5,
29
30
  fontSize: "1.3rem",
30
31
  fontWeight: 600,
31
- textTransform: "none"
32
+ textTransform: "none",
33
+ color: theme => (0, _format.primaryContrastColor)(theme)
32
34
  },
33
35
  children: isProcessing ? processingLabel : label
34
36
  });
@@ -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: {
@@ -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",
@@ -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.1",
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.1",
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.1",
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": "1ba42f376f040b1214d992420cda37053fc14288"
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 } };
@@ -292,7 +298,7 @@ export default function ProductItemCard({
292
298
  fontWeight: 900,
293
299
  letterSpacing: '0.12em',
294
300
  bgcolor: 'primary.main',
295
- color: '#fff',
301
+ color: (th: any) => primaryContrastColor(th),
296
302
  boxShadow: '0 4px 12px rgba(45,124,243,0.2)',
297
303
  '& .MuiChip-label': { px: 1.5 },
298
304
  }}
@@ -467,12 +473,16 @@ export default function ProductItemCard({
467
473
  {/* Discount chip */}
468
474
  {discountCode && perItemDiscount && (
469
475
  <Box sx={{ mt: 1.5 }}>
470
- <Chip
471
- icon={<LocalOfferIcon sx={{ color: 'warning.main', fontSize: 'small' }} />}
472
- label={`${discountCode} (-${perItemDiscount})`}
473
- size="small"
474
- sx={{ height: 22, borderRadius: '6px', '& .MuiChip-label': { fontSize: 12 } }}
475
- />
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
+ )}
476
486
  </Box>
477
487
  )}
478
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>
@@ -39,10 +39,10 @@ import {
39
39
  import { joinURL } from 'ufo';
40
40
  import { usePaymentContext } from '../../../contexts/payment';
41
41
  import { useMobile } from '../../../hooks/mobile';
42
- import { getPrefix } from '../../../libs/util';
42
+ import { getPrefix, getStatementDescriptor } from '../../../libs/util';
43
43
  import OverdueInvoicePayment from '../../../components/over-due-invoice-payment';
44
44
 
45
- import { tSafe, whiteTooltipSx } from '../../utils/format';
45
+ import { tSafe, whiteTooltipSx, primaryContrastColor } from '../../utils/format';
46
46
  import CustomerInfoCard from '../../components/right/customer-info-card';
47
47
  import SubscriptionDisclaimer from '../../components/right/subscription-disclaimer';
48
48
  import StatusFeedback from '../../components/right/status-feedback';
@@ -459,6 +459,7 @@ export default function PaymentPanel() {
459
459
  }}
460
460
  discounts={discounts}
461
461
  discountAmount={pricing.discount}
462
+ isAmountLoading={isAmountLoading}
462
463
  />
463
464
  </>
464
465
  )}
@@ -564,15 +565,39 @@ export default function PaymentPanel() {
564
565
  disabled={!canSubmit || submit.status === 'waiting_stripe'}
565
566
  onClick={handleAction}
566
567
  startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : null}
567
- endIcon={!isProcessing ? <ArrowForwardIcon /> : undefined}
568
568
  sx={{
569
569
  py: 1.5,
570
570
  fontSize: '1.1rem',
571
571
  fontWeight: 600,
572
572
  textTransform: 'none',
573
573
  borderRadius: '12px',
574
+ color: (theme) => primaryContrastColor(theme),
575
+ position: 'relative',
576
+ overflow: 'hidden',
577
+ '&:hover': { bgcolor: 'primary.main' },
578
+ '&:hover .arrow-icon': { transform: 'translateX(4px)' },
579
+ '&:hover .shine-layer': { transform: 'translateX(100%)' },
574
580
  }}>
575
- {isProcessing ? `${t('payment.checkout.processing')}...` : buttonLabel}
581
+ <Box component="span" sx={{ position: 'relative', zIndex: 1 }}>
582
+ {isProcessing ? `${t('payment.checkout.processing')}...` : buttonLabel}
583
+ </Box>
584
+ {!isProcessing && (
585
+ <ArrowForwardIcon
586
+ className="arrow-icon"
587
+ sx={{ ml: 1, position: 'relative', zIndex: 1, transition: 'transform 0.2s ease' }}
588
+ />
589
+ )}
590
+ <Box
591
+ className="shine-layer"
592
+ sx={{
593
+ position: 'absolute',
594
+ inset: 0,
595
+ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent)',
596
+ transform: 'translateX(-100%)',
597
+ transition: 'transform 0.7s ease',
598
+ pointerEvents: 'none',
599
+ }}
600
+ />
576
601
  </Button>
577
602
 
578
603
  {/* Mobile: SSL footer inside fixed bar, below button */}
@@ -642,7 +667,7 @@ export default function PaymentPanel() {
642
667
  mode={mode}
643
668
  subscription={subscription}
644
669
  staking={pricing.staking}
645
- appName={(session?.metadata as any)?.app_name || 'New Payment Kit'}
670
+ appName={getStatementDescriptor(session?.line_items || [])}
646
671
  />
647
672
 
648
673
  {!isMobile && (
@@ -1,6 +1,8 @@
1
1
  import { fromUnitToToken } from '@ocap/util';
2
2
  import type { TPaymentCurrency } from '@blocklet/payment-types';
3
3
 
4
+ export { primaryContrastColor } from '../../libs/util';
5
+
4
6
  // Interval key → locale key mapping
5
7
  export const INTERVAL_LOCALE_KEY: Record<string, string> = {
6
8
  day: 'common.daily',
@@ -3,6 +3,7 @@ import { alpha, useTheme } from '@mui/material/styles';
3
3
  import ArrowBackIcon from '@mui/icons-material/ArrowBack';
4
4
  import Header from '@blocklet/ui-react/lib/Header';
5
5
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
6
+ import { primaryContrastColor } from '../utils/format';
6
7
 
7
8
  interface ErrorViewProps {
8
9
  error: string;
@@ -191,6 +192,7 @@ function ErrorContent({ error, errorCode = undefined }: { error: string; errorCo
191
192
  fontWeight: 600,
192
193
  fontSize: 16,
193
194
  letterSpacing: '0.02em',
195
+ color: (th) => primaryContrastColor(th),
194
196
  boxShadow: `0 8px 32px -4px ${alpha(primaryColor, 0.3)}`,
195
197
  '&:hover': {
196
198
  boxShadow: `0 12px 40px -4px ${alpha(primaryColor, 0.4)}`,
@@ -23,7 +23,7 @@ import type { TCheckoutSessionExpanded } from '@blocklet/payment-types';
23
23
  import { usePaymentMethodContext } from '@blocklet/payment-react-headless';
24
24
 
25
25
  import { getPrefix } from '../../libs/util';
26
- import { formatTokenAmount } from '../utils/format';
26
+ import { formatTokenAmount, primaryContrastColor } from '../utils/format';
27
27
 
28
28
  // ── Animations ──
29
29
 
@@ -573,6 +573,7 @@ function SubscriptionLinks({
573
573
  fontWeight: 700,
574
574
  fontSize: { xs: 16, md: 17 },
575
575
  letterSpacing: '0.02em',
576
+ color: (theme) => primaryContrastColor(theme),
576
577
  boxShadow: '0 8px 24px -4px rgba(59,130,246,0.25)',
577
578
  '&:hover': {
578
579
  boxShadow: '0 12px 28px -4px rgba(59,130,246,0.35)',
@@ -616,6 +617,7 @@ function InvoiceLink({
616
617
  fontWeight: 700,
617
618
  fontSize: { xs: 16, md: 17 },
618
619
  letterSpacing: '0.02em',
620
+ color: (theme) => primaryContrastColor(theme),
619
621
  boxShadow: '0 8px 24px -4px rgba(59,130,246,0.25)',
620
622
  '&:hover': {
621
623
  boxShadow: '0 12px 28px -4px rgba(59,130,246,0.35)',
@@ -19,7 +19,7 @@ import Dialog from '@arcblock/ux/lib/Dialog/dialog';
19
19
  import { CheckCircle as CheckCircleIcon } from '@mui/icons-material';
20
20
  import debounce from 'lodash/debounce';
21
21
  import { usePaymentContext } from '../contexts/payment';
22
- import { formatAmount, formatError, getPrefix, isCrossOrigin } from '../libs/util';
22
+ import { formatAmount, formatError, getPrefix, isCrossOrigin, primaryContrastColor } from '../libs/util';
23
23
  import { useSubscription } from '../hooks/subscription';
24
24
  import api from '../libs/api';
25
25
  import LoadingButton from './loading-button';
@@ -397,6 +397,8 @@ function OverdueInvoicePayment({
397
397
  const { currency } = item;
398
398
  const inProcess = payLoading && selectCurrencyId === currency.id;
399
399
  const status = paymentStatus[currency.id] || 'idle';
400
+ const containedColorSx =
401
+ (options?.variant || 'contained') === 'contained' ? { color: (th: any) => primaryContrastColor(th) } : {};
400
402
 
401
403
  if (status === 'success') {
402
404
  return (
@@ -404,6 +406,7 @@ function OverdueInvoicePayment({
404
406
  variant={options?.variant || 'contained'}
405
407
  size="small"
406
408
  onClick={() => checkAndHandleInvoicePaid(currency.id)}
409
+ sx={containedColorSx}
407
410
  {...(primaryButton
408
411
  ? {}
409
412
  : {
@@ -442,7 +445,7 @@ function OverdueInvoicePayment({
442
445
  disabled={paying || status === 'processing'}
443
446
  loading={paying || status === 'processing'}
444
447
  onClick={onPay}
445
- sx={options?.sx}>
448
+ sx={{ ...containedColorSx, ...((options?.sx || {}) as any) }}>
446
449
  {buttonText}
447
450
  </LoadingButton>
448
451
  )}
@@ -456,7 +459,7 @@ function OverdueInvoicePayment({
456
459
  disabled={inProcess}
457
460
  loading={inProcess}
458
461
  onClick={() => handlePay(item)}
459
- sx={options?.sx}>
462
+ sx={{ ...containedColorSx, ...((options?.sx || {}) as any) }}>
460
463
  {status === 'error' ? t('payment.subscription.overdue.retry') : t('payment.subscription.overdue.payNow')}
461
464
  </LoadingButton>
462
465
  );
package/src/libs/util.ts CHANGED
@@ -1823,3 +1823,10 @@ export function formatLinkWithLocale(url: string, locale?: string) {
1823
1823
  return `${url}${separator}locale=${locale}`;
1824
1824
  }
1825
1825
  }
1826
+
1827
+ // Compute text color that contrasts with primary.main, works in both light and dark mode
1828
+ export function primaryContrastColor(theme: {
1829
+ palette: { primary: { main: string }; getContrastText: (bg: string) => string };
1830
+ }): string {
1831
+ return theme.palette.getContrastText(theme.palette.primary.main);
1832
+ }