@blocklet/payment-react 1.26.1 → 1.26.3

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 (73) hide show
  1. package/es/checkout-v2/components/dialogs/checkout-dialogs.js +2 -0
  2. package/es/checkout-v2/components/left/cross-sell-card.js +3 -3
  3. package/es/checkout-v2/components/left/product-item-card.js +9 -3
  4. package/es/checkout-v2/components/left/promotion-input.d.ts +4 -1
  5. package/es/checkout-v2/components/left/promotion-input.js +8 -13
  6. package/es/checkout-v2/components/right/customer-info-card.d.ts +2 -0
  7. package/es/checkout-v2/components/right/customer-info-card.js +22 -14
  8. package/es/checkout-v2/components/right/status-feedback.js +1 -1
  9. package/es/checkout-v2/components/right/submit-button.js +3 -1
  10. package/es/checkout-v2/layouts/checkout-layout.js +13 -3
  11. package/es/checkout-v2/panels/left/composite-panel.js +27 -6
  12. package/es/checkout-v2/panels/right/payment-panel.js +40 -9
  13. package/es/checkout-v2/utils/format.d.ts +1 -1
  14. package/es/checkout-v2/utils/format.js +1 -0
  15. package/es/checkout-v2/views/error-view.d.ts +1 -1
  16. package/es/checkout-v2/views/error-view.js +9 -0
  17. package/es/checkout-v2/views/success-view.js +3 -1
  18. package/es/components/over-due-invoice-payment.js +5 -3
  19. package/es/components/service-suspended-dialog.d.ts +4 -0
  20. package/es/components/service-suspended-dialog.js +61 -0
  21. package/es/libs/util.d.ts +8 -0
  22. package/es/libs/util.js +3 -0
  23. package/es/locales/en.js +4 -0
  24. package/es/locales/zh.js +4 -0
  25. package/es/payment/form/index.js +17 -0
  26. package/es/payment/index.js +15 -4
  27. package/lib/checkout-v2/components/dialogs/checkout-dialogs.js +4 -0
  28. package/lib/checkout-v2/components/left/cross-sell-card.js +2 -2
  29. package/lib/checkout-v2/components/left/product-item-card.js +9 -2
  30. package/lib/checkout-v2/components/left/promotion-input.d.ts +4 -1
  31. package/lib/checkout-v2/components/left/promotion-input.js +12 -19
  32. package/lib/checkout-v2/components/right/customer-info-card.d.ts +2 -0
  33. package/lib/checkout-v2/components/right/customer-info-card.js +19 -13
  34. package/lib/checkout-v2/components/right/status-feedback.js +1 -1
  35. package/lib/checkout-v2/components/right/submit-button.js +3 -1
  36. package/lib/checkout-v2/layouts/checkout-layout.js +28 -5
  37. package/lib/checkout-v2/panels/left/composite-panel.js +20 -5
  38. package/lib/checkout-v2/panels/right/payment-panel.js +46 -7
  39. package/lib/checkout-v2/utils/format.d.ts +1 -1
  40. package/lib/checkout-v2/utils/format.js +7 -0
  41. package/lib/checkout-v2/views/error-view.d.ts +1 -1
  42. package/lib/checkout-v2/views/error-view.js +9 -0
  43. package/lib/checkout-v2/views/success-view.js +2 -0
  44. package/lib/components/over-due-invoice-payment.js +12 -2
  45. package/lib/components/service-suspended-dialog.d.ts +4 -0
  46. package/lib/components/service-suspended-dialog.js +97 -0
  47. package/lib/libs/util.d.ts +8 -0
  48. package/lib/libs/util.js +4 -0
  49. package/lib/locales/en.js +4 -0
  50. package/lib/locales/zh.js +4 -0
  51. package/lib/payment/form/index.js +23 -0
  52. package/lib/payment/index.js +15 -4
  53. package/package.json +4 -4
  54. package/src/checkout-v2/components/dialogs/checkout-dialogs.tsx +4 -0
  55. package/src/checkout-v2/components/left/cross-sell-card.tsx +3 -3
  56. package/src/checkout-v2/components/left/product-item-card.tsx +18 -8
  57. package/src/checkout-v2/components/left/promotion-input.tsx +17 -17
  58. package/src/checkout-v2/components/right/customer-info-card.tsx +29 -16
  59. package/src/checkout-v2/components/right/status-feedback.tsx +2 -2
  60. package/src/checkout-v2/components/right/submit-button.tsx +2 -0
  61. package/src/checkout-v2/layouts/checkout-layout.tsx +25 -10
  62. package/src/checkout-v2/panels/left/composite-panel.tsx +28 -6
  63. package/src/checkout-v2/panels/right/payment-panel.tsx +32 -5
  64. package/src/checkout-v2/utils/format.ts +2 -0
  65. package/src/checkout-v2/views/error-view.tsx +11 -1
  66. package/src/checkout-v2/views/success-view.tsx +3 -1
  67. package/src/components/over-due-invoice-payment.tsx +6 -3
  68. package/src/components/service-suspended-dialog.tsx +64 -0
  69. package/src/libs/util.ts +7 -0
  70. package/src/locales/en.tsx +4 -0
  71. package/src/locales/zh.tsx +4 -0
  72. package/src/payment/form/index.tsx +20 -0
  73. package/src/payment/index.tsx +26 -4
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from 'react';
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { Box, Button, InputBase, InputAdornment, Stack, Typography } from '@mui/material';
3
3
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
4
  import CountrySelect from '../../../components/country-select';
@@ -17,7 +17,9 @@ interface CustomerInfoCardProps {
17
17
  values: Record<string, any>;
18
18
  onChange: (field: string, value: string | boolean | Record<string, string>) => void;
19
19
  errors: Partial<Record<string, string>>;
20
+ checkValid: () => Promise<boolean>;
20
21
  validateField: (field: string) => Promise<void>;
22
+ prefetched: boolean;
21
23
  };
22
24
  isLoggedIn: boolean;
23
25
  }
@@ -39,20 +41,31 @@ export default function CustomerInfoCard({ form, isLoggedIn }: CustomerInfoCardP
39
41
  const { t } = useLocaleContext();
40
42
  const labels = fieldLabelMap(t);
41
43
 
42
- // Default to confirmed view if required fields have values
43
- const hasRequiredData = !!(form.values.customer_name && form.values.customer_email);
44
- const [showEditForm, setShowEditForm] = useState(!hasRequiredData);
45
- const autoConfirmedRef = useRef(false);
44
+ // Don't render until first validation completes avoids flash.
45
+ // Valid show collapsed summary; invalid → show expanded form.
46
+ // After first check, only manual "Edit"/"Confirm" toggles.
47
+ const [showEditForm, setShowEditForm] = useState(false);
48
+ const [ready, setReady] = useState(false);
49
+ const checkedRef = useRef(false);
46
50
 
47
- // When data arrives (e.g. prefetch), auto-switch to confirmed if valid
48
51
  useEffect(() => {
49
- if (!autoConfirmedRef.current && form.values.customer_name && form.values.customer_email) {
50
- autoConfirmedRef.current = true;
51
- setShowEditForm(false);
52
- }
53
- }, [form.values.customer_name, form.values.customer_email]);
52
+ if (checkedRef.current) return;
53
+ // Wait for data to arrive before making a decision
54
+ if (!form.prefetched && !form.values.customer_name && !form.values.customer_email) return;
55
+ checkedRef.current = true;
56
+ form.checkValid().then((valid) => {
57
+ setShowEditForm(!valid);
58
+ setReady(true);
59
+ });
60
+ }, [form.prefetched]); // eslint-disable-line react-hooks/exhaustive-deps
54
61
 
55
- if (!isLoggedIn) return null;
62
+ // Wrap onChange to delegate to parent form
63
+ const handleChange: typeof form.onChange = useCallback(
64
+ (field, value) => form.onChange(field, value),
65
+ [form.onChange] // eslint-disable-line react-hooks/exhaustive-deps
66
+ );
67
+
68
+ if (!isLoggedIn || !ready) return null;
56
69
 
57
70
  // Summary view
58
71
  if (!showEditForm) {
@@ -162,8 +175,8 @@ export default function CustomerInfoCard({ form, isLoggedIn }: CustomerInfoCardP
162
175
  key={name}
163
176
  value={value || ''}
164
177
  country={form.values.billing_address?.country || ''}
165
- onChange={(phone) => form.onChange('customer_phone', phone)}
166
- onCountryChange={(c) => form.onChange('billing_address.country', c)}
178
+ onChange={(phone) => handleChange('customer_phone', phone)}
179
+ onCountryChange={(c) => handleChange('billing_address.country', c)}
167
180
  onBlur={() => form.validateField(name)}
168
181
  label={label}
169
182
  error={form.errors[name]}
@@ -177,14 +190,14 @@ export default function CustomerInfoCard({ form, isLoggedIn }: CustomerInfoCardP
177
190
  <InputBase
178
191
  fullWidth
179
192
  value={value || ''}
180
- onChange={(e) => form.onChange(name, e.target.value)}
193
+ onChange={(e) => handleChange(name, e.target.value)}
181
194
  onBlur={() => form.validateField(name)}
182
195
  startAdornment={
183
196
  isPostalCode ? (
184
197
  <InputAdornment position="start" sx={{ mr: 0.5, ml: -0.5 }}>
185
198
  <CountrySelect
186
199
  value={form.values.billing_address?.country || ''}
187
- onChange={(v) => form.onChange('billing_address.country', v)}
200
+ onChange={(v) => handleChange('billing_address.country', v)}
188
201
  sx={{
189
202
  '.MuiOutlinedInput-notchedOutline': { borderColor: 'transparent !important' },
190
203
  '& .MuiSelect-select': { py: 0, pr: '20px !important' },
@@ -19,8 +19,8 @@ export default function StatusFeedback({ status, context, onReset }: StatusFeedb
19
19
  prevStatusRef.current = status;
20
20
 
21
21
  if (status === 'failed' && context?.type === 'error') {
22
- // CUSTOMER_LIMITED is handled by parent (overdue invoice dialog)
23
- if (context.code === 'CUSTOMER_LIMITED') return;
22
+ // CUSTOMER_LIMITED / STOP_ACCEPTING_ORDERS are handled by CheckoutDialogs
23
+ if (context.code === 'CUSTOMER_LIMITED' || context.code === 'STOP_ACCEPTING_ORDERS') return;
24
24
 
25
25
  Toast.error(context.message || 'Payment failed');
26
26
  onReset();
@@ -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>
@@ -27,14 +27,26 @@ const fadeIn = {
27
27
  animation: { xs: 'none', md: 'fadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.15s both' },
28
28
  } as const;
29
29
 
30
- // Desktop: right panel slides in from the right
31
- const slideInFromRight = {
32
- '@keyframes slideInRight': {
33
- from: { transform: 'translateX(100%)' },
34
- to: { transform: 'translateX(0)' },
35
- },
36
- animation: { xs: 'none', md: 'slideInRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards' },
37
- } as const;
30
+ // Desktop: right panel slides in from right (non-Safari) or fades in (Safari)
31
+ // Safari has flexbox reflow bugs with translateX(100%), so we use a subtle fade instead
32
+ const isSafari =
33
+ typeof navigator !== 'undefined' && /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
34
+
35
+ const slideInFromRight = isSafari
36
+ ? ({
37
+ '@keyframes panelFadeIn': {
38
+ from: { opacity: 0, transform: 'translateX(24px)' },
39
+ to: { opacity: 1, transform: 'none' },
40
+ },
41
+ animation: { xs: 'none', md: 'panelFadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' },
42
+ } as const)
43
+ : ({
44
+ '@keyframes slideInRight': {
45
+ from: { transform: 'translateX(100%)' },
46
+ to: { transform: 'translateX(0)' },
47
+ },
48
+ animation: { xs: 'none', md: 'slideInRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards' },
49
+ } as const);
38
50
 
39
51
  interface CheckoutLayoutProps {
40
52
  left: React.ReactNode;
@@ -94,6 +106,7 @@ export default function CheckoutLayout({ left, right, mode = 'inline' }: Checkou
94
106
  <Box
95
107
  sx={{
96
108
  flex: 1,
109
+ minWidth: 0,
97
110
  bgcolor: (t) => (t.palette.mode === 'dark' ? 'background.default' : '#f8faff'),
98
111
  p: { xs: 3, md: 5 },
99
112
  pt: { xs: 3, md: 4 },
@@ -170,7 +183,8 @@ export default function CheckoutLayout({ left, right, mode = 'inline' }: Checkou
170
183
  {!hideLeft && (
171
184
  <Box
172
185
  sx={{
173
- width: { xs: '100%', md: '50%' },
186
+ flex: { xs: 'none', md: '0 0 50%' },
187
+ width: { xs: '100%' },
174
188
  height: { xs: 'auto', md: '100vh' },
175
189
  display: 'flex',
176
190
  justifyContent: { md: 'center' },
@@ -196,7 +210,8 @@ export default function CheckoutLayout({ left, right, mode = 'inline' }: Checkou
196
210
  {/* Right panel — full width when left is hidden */}
197
211
  <Box
198
212
  sx={{
199
- width: hideLeft ? '100%' : { xs: '100%', md: '50%' },
213
+ flex: hideLeft ? 'none' : { xs: 'none', md: '0 0 50%' },
214
+ width: hideLeft ? '100%' : { xs: '100%' },
200
215
  height: hideLeft ? '100vh' : { xs: 'auto', md: '100vh' },
201
216
  bgcolor: 'background.paper',
202
217
  boxShadow: hideLeft ? 'none' : { md: '-4px 0 16px rgba(0,0,0,0.04)' },
@@ -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,8 @@ export default function PaymentPanel() {
459
459
  }}
460
460
  discounts={discounts}
461
461
  discountAmount={pricing.discount}
462
+ currency={currency}
463
+ isAmountLoading={isAmountLoading}
462
464
  />
463
465
  </>
464
466
  )}
@@ -564,15 +566,39 @@ export default function PaymentPanel() {
564
566
  disabled={!canSubmit || submit.status === 'waiting_stripe'}
565
567
  onClick={handleAction}
566
568
  startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : null}
567
- endIcon={!isProcessing ? <ArrowForwardIcon /> : undefined}
568
569
  sx={{
569
570
  py: 1.5,
570
571
  fontSize: '1.1rem',
571
572
  fontWeight: 600,
572
573
  textTransform: 'none',
573
574
  borderRadius: '12px',
575
+ color: (theme) => primaryContrastColor(theme),
576
+ position: 'relative',
577
+ overflow: 'hidden',
578
+ '&:hover': { bgcolor: 'primary.main' },
579
+ '&:hover .arrow-icon': { transform: 'translateX(4px)' },
580
+ '&:hover .shine-layer': { transform: 'translateX(100%)' },
574
581
  }}>
575
- {isProcessing ? `${t('payment.checkout.processing')}...` : buttonLabel}
582
+ <Box component="span" sx={{ position: 'relative', zIndex: 1 }}>
583
+ {isProcessing ? `${t('payment.checkout.processing')}...` : buttonLabel}
584
+ </Box>
585
+ {!isProcessing && (
586
+ <ArrowForwardIcon
587
+ className="arrow-icon"
588
+ sx={{ ml: 1, position: 'relative', zIndex: 1, transition: 'transform 0.2s ease' }}
589
+ />
590
+ )}
591
+ <Box
592
+ className="shine-layer"
593
+ sx={{
594
+ position: 'absolute',
595
+ inset: 0,
596
+ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent)',
597
+ transform: 'translateX(-100%)',
598
+ transition: 'transform 0.7s ease',
599
+ pointerEvents: 'none',
600
+ }}
601
+ />
576
602
  </Button>
577
603
 
578
604
  {/* Mobile: SSL footer inside fixed bar, below button */}
@@ -632,6 +658,7 @@ export default function PaymentPanel() {
632
658
  }}
633
659
  discounts={discounts}
634
660
  discountAmount={pricing.discount}
661
+ currency={currency}
635
662
  />
636
663
  </Drawer>
637
664
  )}
@@ -642,7 +669,7 @@ export default function PaymentPanel() {
642
669
  mode={mode}
643
670
  subscription={subscription}
644
671
  staking={pricing.staking}
645
- appName={(session?.metadata as any)?.app_name || 'New Payment Kit'}
672
+ appName={getStatementDescriptor(session?.line_items || [])}
646
673
  />
647
674
 
648
675
  {!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,10 +3,11 @@ 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;
9
- errorCode?: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | null;
10
+ errorCode?: 'SESSION_EXPIRED' | 'EMPTY_LINE_ITEMS' | 'STOP_ACCEPTING_ORDERS' | null;
10
11
  mode?: string;
11
12
  }
12
13
 
@@ -127,6 +128,14 @@ function getErrorConfig(
127
128
  };
128
129
  }
129
130
 
131
+ if (errorCode === 'STOP_ACCEPTING_ORDERS') {
132
+ return {
133
+ title: t('payment.checkout.stopAcceptingOrders.title'),
134
+ description: t('payment.checkout.stopAcceptingOrders.description'),
135
+ color: '#f59e0b',
136
+ };
137
+ }
138
+
130
139
  return {
131
140
  title: t('payment.checkout.error.title'),
132
141
  description: error,
@@ -191,6 +200,7 @@ function ErrorContent({ error, errorCode = undefined }: { error: string; errorCo
191
200
  fontWeight: 600,
192
201
  fontSize: 16,
193
202
  letterSpacing: '0.02em',
203
+ color: (th) => primaryContrastColor(th),
194
204
  boxShadow: `0 8px 32px -4px ${alpha(primaryColor, 0.3)}`,
195
205
  '&:hover': {
196
206
  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
  );
@@ -0,0 +1,64 @@
1
+ import { Box, Button, Dialog, DialogContent, Stack, Typography } from '@mui/material';
2
+ import { alpha } from '@mui/material/styles';
3
+ import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline';
4
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
5
+
6
+ export default function ServiceSuspendedDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
7
+ const { t } = useLocaleContext();
8
+
9
+ return (
10
+ <Dialog
11
+ open={open}
12
+ onClose={onClose}
13
+ PaperProps={{
14
+ sx: {
15
+ borderRadius: 3,
16
+ maxWidth: 400,
17
+ mx: 'auto',
18
+ overflow: 'hidden',
19
+ },
20
+ }}>
21
+ <DialogContent sx={{ p: 0 }}>
22
+ <Stack alignItems="center" sx={{ pt: 4, pb: 3, px: 4, textAlign: 'center' }}>
23
+ <Box
24
+ sx={{
25
+ width: 64,
26
+ height: 64,
27
+ borderRadius: '50%',
28
+ display: 'flex',
29
+ alignItems: 'center',
30
+ justifyContent: 'center',
31
+ bgcolor: (theme) => alpha(theme.palette.warning.main, 0.1),
32
+ mb: 2.5,
33
+ }}>
34
+ <PauseCircleOutlineIcon sx={{ fontSize: 36, color: 'warning.main' }} />
35
+ </Box>
36
+
37
+ <Typography sx={{ fontWeight: 700, fontSize: 18, mb: 1, color: 'text.primary' }}>
38
+ {t('payment.checkout.stopAcceptingOrders.title')}
39
+ </Typography>
40
+
41
+ <Typography sx={{ color: 'text.secondary', fontSize: 14, lineHeight: 1.6 }}>
42
+ {t('payment.checkout.stopAcceptingOrders.description')}
43
+ </Typography>
44
+ </Stack>
45
+
46
+ <Box sx={{ px: 4, pb: 3 }}>
47
+ <Button
48
+ fullWidth
49
+ variant="contained"
50
+ disableElevation
51
+ onClick={onClose}
52
+ sx={{
53
+ borderRadius: 2,
54
+ textTransform: 'none',
55
+ fontWeight: 600,
56
+ py: 1,
57
+ }}>
58
+ {t('common.know')}
59
+ </Button>
60
+ </Box>
61
+ </DialogContent>
62
+ </Dialog>
63
+ );
64
+ }
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
+ }
@@ -396,6 +396,10 @@ export default flat({
396
396
  title: 'Nothing to show here',
397
397
  description: 'It seems this checkout session is not configured properly',
398
398
  },
399
+ stopAcceptingOrders: {
400
+ title: 'Service Suspended',
401
+ description: 'New order placement is temporarily unavailable due to a system-level service suspension.',
402
+ },
399
403
  error: {
400
404
  title: 'Something went wrong',
401
405
  },
@@ -416,6 +416,10 @@ export default flat({
416
416
  title: '没有任何购买项目',
417
417
  description: '可能这个付款链接没有正确配置',
418
418
  },
419
+ stopAcceptingOrders: {
420
+ title: '暂停服务',
421
+ description: '因系统策略调整,当前已暂停新订单服务。',
422
+ },
419
423
  error: {
420
424
  title: '出了点问题',
421
425
  },
@@ -61,6 +61,7 @@ import LoadingButton from '../../components/loading-button';
61
61
  import OverdueInvoicePayment from '../../components/over-due-invoice-payment';
62
62
  import { saveCurrencyPreference } from '../../libs/currency';
63
63
  import ConfirmDialog from '../../components/confirm';
64
+ import ServiceSuspendedDialog from '../../components/service-suspended-dialog';
64
65
  import PriceChangeConfirm from '../../components/price-change-confirm';
65
66
  import { getFieldValidation, validatePostalCode } from '../../libs/validator';
66
67
 
@@ -267,6 +268,7 @@ export default function PaymentForm({
267
268
  };
268
269
  customer?: TCustomer;
269
270
  customerLimited?: boolean;
271
+ serviceSuspended?: boolean;
270
272
  stripePaying: boolean;
271
273
  fastCheckoutInfo: FastCheckoutInfo | null;
272
274
  creditInsufficientInfo: {
@@ -289,6 +291,7 @@ export default function PaymentForm({
289
291
  stripeContext: undefined,
290
292
  customer,
291
293
  customerLimited: false,
294
+ serviceSuspended: false,
292
295
  stripePaying: false,
293
296
  fastCheckoutInfo: null,
294
297
  creditInsufficientInfo: null,
@@ -1198,6 +1201,11 @@ export default function PaymentForm({
1198
1201
  shouldToast = false;
1199
1202
  setState({ customerLimited: true });
1200
1203
  }
1204
+
1205
+ if (errorCode === 'STOP_ACCEPTING_ORDERS') {
1206
+ shouldToast = false;
1207
+ setState({ serviceSuspended: true });
1208
+ }
1201
1209
  }
1202
1210
  if (shouldToast) {
1203
1211
  Toast.error(formatError(err));
@@ -1474,6 +1482,9 @@ export default function PaymentForm({
1474
1482
  }}
1475
1483
  />
1476
1484
  )}
1485
+ {state.serviceSuspended && (
1486
+ <ServiceSuspendedDialog open onClose={() => setState({ serviceSuspended: false })} />
1487
+ )}
1477
1488
  {FastCheckoutConfirmDialog}
1478
1489
  {CreditInsufficientDialog}
1479
1490
  {PriceUpdatedDialog}
@@ -1748,6 +1759,15 @@ export default function PaymentForm({
1748
1759
  }}
1749
1760
  />
1750
1761
  )}
1762
+ {state.serviceSuspended && (
1763
+ <ConfirmDialog
1764
+ onConfirm={() => setState({ serviceSuspended: false })}
1765
+ onCancel={() => setState({ serviceSuspended: false })}
1766
+ title={t('payment.checkout.stopAcceptingOrders.title')}
1767
+ message={t('payment.checkout.stopAcceptingOrders.description')}
1768
+ confirm={t('common.confirm')}
1769
+ />
1770
+ )}
1751
1771
  {FastCheckoutConfirmDialog}
1752
1772
  {CreditInsufficientDialog}
1753
1773
  {PriceUpdatedDialog}