@blocklet/payment-react 1.24.4 → 1.25.1

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 (98) hide show
  1. package/es/components/auto-topup/modal.d.ts +2 -0
  2. package/es/components/auto-topup/modal.js +48 -6
  3. package/es/components/auto-topup/product-card.d.ts +16 -1
  4. package/es/components/auto-topup/product-card.js +97 -15
  5. package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
  6. package/es/components/dynamic-pricing-unavailable.js +58 -0
  7. package/es/components/loading-amount.d.ts +17 -0
  8. package/es/components/loading-amount.js +46 -0
  9. package/es/components/price-change-confirm.d.ts +18 -0
  10. package/es/components/price-change-confirm.js +107 -0
  11. package/es/components/quote-details-panel.d.ts +21 -0
  12. package/es/components/quote-details-panel.js +170 -0
  13. package/es/components/quote-lock-banner.d.ts +7 -0
  14. package/es/components/quote-lock-banner.js +79 -0
  15. package/es/components/slippage-config.d.ts +20 -0
  16. package/es/components/slippage-config.js +261 -0
  17. package/es/history/invoice/list.js +125 -15
  18. package/es/hooks/dynamic-pricing.d.ts +102 -0
  19. package/es/hooks/dynamic-pricing.js +393 -0
  20. package/es/index.d.ts +6 -1
  21. package/es/index.js +9 -1
  22. package/es/libs/util.d.ts +42 -5
  23. package/es/libs/util.js +345 -57
  24. package/es/locales/en.js +114 -3
  25. package/es/locales/zh.js +114 -3
  26. package/es/payment/form/index.d.ts +4 -1
  27. package/es/payment/form/index.js +454 -22
  28. package/es/payment/index.d.ts +1 -1
  29. package/es/payment/index.js +279 -16
  30. package/es/payment/product-item.d.ts +26 -1
  31. package/es/payment/product-item.js +330 -51
  32. package/es/payment/summary-section/promotion-section.d.ts +32 -0
  33. package/es/payment/summary-section/promotion-section.js +143 -0
  34. package/es/payment/summary-section/total-section.d.ts +39 -0
  35. package/es/payment/summary-section/total-section.js +83 -0
  36. package/es/payment/summary.d.ts +17 -2
  37. package/es/payment/summary.js +300 -253
  38. package/es/types/index.d.ts +11 -0
  39. package/lib/components/auto-topup/modal.d.ts +2 -0
  40. package/lib/components/auto-topup/modal.js +54 -6
  41. package/lib/components/auto-topup/product-card.d.ts +16 -1
  42. package/lib/components/auto-topup/product-card.js +75 -7
  43. package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
  44. package/lib/components/dynamic-pricing-unavailable.js +81 -0
  45. package/lib/components/loading-amount.d.ts +17 -0
  46. package/lib/components/loading-amount.js +53 -0
  47. package/lib/components/price-change-confirm.d.ts +18 -0
  48. package/lib/components/price-change-confirm.js +157 -0
  49. package/lib/components/quote-details-panel.d.ts +21 -0
  50. package/lib/components/quote-details-panel.js +226 -0
  51. package/lib/components/quote-lock-banner.d.ts +7 -0
  52. package/lib/components/quote-lock-banner.js +93 -0
  53. package/lib/components/slippage-config.d.ts +20 -0
  54. package/lib/components/slippage-config.js +316 -0
  55. package/lib/history/invoice/list.js +167 -27
  56. package/lib/hooks/dynamic-pricing.d.ts +102 -0
  57. package/lib/hooks/dynamic-pricing.js +390 -0
  58. package/lib/index.d.ts +6 -1
  59. package/lib/index.js +32 -0
  60. package/lib/libs/util.d.ts +42 -5
  61. package/lib/libs/util.js +367 -49
  62. package/lib/locales/en.js +114 -3
  63. package/lib/locales/zh.js +114 -3
  64. package/lib/payment/form/index.d.ts +4 -1
  65. package/lib/payment/form/index.js +476 -20
  66. package/lib/payment/index.d.ts +1 -1
  67. package/lib/payment/index.js +308 -14
  68. package/lib/payment/product-item.d.ts +26 -1
  69. package/lib/payment/product-item.js +270 -35
  70. package/lib/payment/summary-section/promotion-section.d.ts +32 -0
  71. package/lib/payment/summary-section/promotion-section.js +133 -0
  72. package/lib/payment/summary-section/total-section.d.ts +39 -0
  73. package/lib/payment/summary-section/total-section.js +117 -0
  74. package/lib/payment/summary.d.ts +17 -2
  75. package/lib/payment/summary.js +205 -127
  76. package/lib/types/index.d.ts +11 -0
  77. package/package.json +3 -3
  78. package/src/components/auto-topup/modal.tsx +59 -6
  79. package/src/components/auto-topup/product-card.tsx +118 -11
  80. package/src/components/dynamic-pricing-unavailable.tsx +69 -0
  81. package/src/components/loading-amount.tsx +66 -0
  82. package/src/components/price-change-confirm.tsx +136 -0
  83. package/src/components/quote-details-panel.tsx +218 -0
  84. package/src/components/quote-lock-banner.tsx +99 -0
  85. package/src/components/slippage-config.tsx +336 -0
  86. package/src/history/invoice/list.tsx +143 -9
  87. package/src/hooks/dynamic-pricing.ts +617 -0
  88. package/src/index.ts +9 -0
  89. package/src/libs/util.ts +473 -58
  90. package/src/locales/en.tsx +117 -0
  91. package/src/locales/zh.tsx +111 -0
  92. package/src/payment/form/index.tsx +561 -19
  93. package/src/payment/index.tsx +349 -10
  94. package/src/payment/product-item.tsx +451 -37
  95. package/src/payment/summary-section/promotion-section.tsx +172 -0
  96. package/src/payment/summary-section/total-section.tsx +141 -0
  97. package/src/payment/summary.tsx +334 -192
  98. package/src/types/index.ts +15 -0
@@ -0,0 +1,218 @@
1
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
2
+ import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
3
+ import SettingsIcon from '@mui/icons-material/Settings';
4
+ import {
5
+ Box,
6
+ Collapse,
7
+ Dialog,
8
+ DialogTitle,
9
+ DialogContent,
10
+ Fade,
11
+ IconButton,
12
+ Stack,
13
+ Tooltip,
14
+ Typography,
15
+ } from '@mui/material';
16
+ import type { ReactNode } from 'react';
17
+ import { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
19
+ import SlippageConfig from './slippage-config';
20
+ import type { SlippageConfigValue } from './slippage-config';
21
+
22
+ type QuoteDetailRow = {
23
+ label: string;
24
+ value: ReactNode;
25
+ isSlippage?: boolean;
26
+ tooltip?: string;
27
+ };
28
+
29
+ type QuoteDetailsPanelProps = {
30
+ rateLine: string;
31
+ rows: QuoteDetailRow[];
32
+ isSubscription?: boolean;
33
+ slippageValue?: number;
34
+ onSlippageChange?: (value: SlippageConfigValue) => void | Promise<void>;
35
+ slippageConfig?: SlippageConfigValue;
36
+ exchangeRate?: string | null;
37
+ baseCurrency?: string;
38
+ disabled?: boolean;
39
+ };
40
+
41
+ export default function QuoteDetailsPanel({
42
+ rateLine,
43
+ rows,
44
+ isSubscription = false,
45
+ slippageValue = 0.5,
46
+ onSlippageChange = undefined,
47
+ slippageConfig = undefined,
48
+ exchangeRate = null,
49
+ baseCurrency = 'USD',
50
+ disabled = false,
51
+ }: QuoteDetailsPanelProps) {
52
+ const { t } = useLocaleContext();
53
+ const [open, setOpen] = useState(false);
54
+ const [showContent, setShowContent] = useState(true);
55
+ const [dialogOpen, setDialogOpen] = useState(false);
56
+ const [pendingConfig, setPendingConfig] = useState<SlippageConfigValue | null>(null);
57
+ const [submitting, setSubmitting] = useState(false);
58
+ const hasRows = rows.length > 0;
59
+
60
+ const handleOpenDialog = useCallback(() => {
61
+ setPendingConfig(slippageConfig || { mode: 'percent', percent: slippageValue });
62
+ setDialogOpen(true);
63
+ }, [slippageValue, slippageConfig]);
64
+
65
+ const handleCloseDialog = () => {
66
+ setDialogOpen(false);
67
+ setPendingConfig(null);
68
+ };
69
+
70
+ const handleSlippageChange = (value: number) => {
71
+ setPendingConfig((prev) => (prev ? { ...prev, percent: value } : { mode: 'percent', percent: value }));
72
+ };
73
+
74
+ const handleConfigChange = (config: SlippageConfigValue) => {
75
+ setPendingConfig(config);
76
+ };
77
+
78
+ const handleSubmit = async () => {
79
+ if (!pendingConfig || !onSlippageChange) return;
80
+
81
+ setSubmitting(true);
82
+ try {
83
+ const configToSave: SlippageConfigValue = {
84
+ ...pendingConfig,
85
+ ...(baseCurrency ? { base_currency: baseCurrency } : {}),
86
+ };
87
+ await onSlippageChange(configToSave);
88
+ setDialogOpen(false);
89
+ setPendingConfig(null);
90
+ } catch (err) {
91
+ console.error('Failed to update slippage', err);
92
+ } finally {
93
+ setSubmitting(false);
94
+ }
95
+ };
96
+
97
+ // Trigger fade effect when rateLine changes
98
+ useEffect(() => {
99
+ if (!rateLine) return undefined;
100
+ setShowContent(false);
101
+ const timer = setTimeout(() => {
102
+ setShowContent(true);
103
+ }, 150);
104
+ return () => clearTimeout(timer);
105
+ }, [rateLine]);
106
+
107
+ const renderedRows = useMemo(
108
+ () =>
109
+ rows.map((row) => {
110
+ const isSlippageRow = row.isSlippage && isSubscription && onSlippageChange;
111
+ return (
112
+ <Stack
113
+ key={row.label}
114
+ direction="row"
115
+ sx={{
116
+ alignItems: 'center',
117
+ justifyContent: 'space-between',
118
+ gap: 2,
119
+ }}>
120
+ <Stack direction="row" spacing={0.5} alignItems="center">
121
+ <Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{row.label}</Typography>
122
+ {row.tooltip && (
123
+ <Tooltip title={row.tooltip} placement="top">
124
+ <InfoOutlinedIcon sx={{ fontSize: '0.75rem', color: 'text.lighter' }} />
125
+ </Tooltip>
126
+ )}
127
+ </Stack>
128
+ <Stack direction="row" alignItems="center" spacing={0.5}>
129
+ <Typography sx={{ fontSize: '0.75rem', color: 'text.primary' }}>{row.value}</Typography>
130
+ {isSlippageRow && !disabled && (
131
+ <IconButton size="small" onClick={handleOpenDialog} sx={{ p: 0.25 }}>
132
+ <SettingsIcon sx={{ fontSize: '0.875rem', color: 'text.secondary' }} />
133
+ </IconButton>
134
+ )}
135
+ </Stack>
136
+ </Stack>
137
+ );
138
+ }),
139
+ [rows, isSubscription, onSlippageChange, disabled, handleOpenDialog]
140
+ );
141
+
142
+ // For subscriptions with slippage settings, show panel even without rateLine
143
+ // This allows metered subscriptions to configure slippage before first usage
144
+ const showSlippageOnly = !rateLine && isSubscription && onSlippageChange && rows.some((r) => r.isSlippage);
145
+
146
+ if (!rateLine && !showSlippageOnly) {
147
+ return null;
148
+ }
149
+
150
+ return (
151
+ <>
152
+ <Box sx={{ width: '100%', mt: 0.5 }}>
153
+ <Stack
154
+ direction="row"
155
+ sx={{
156
+ alignItems: 'center',
157
+ justifyContent: 'space-between',
158
+ gap: 1,
159
+ }}>
160
+ {rateLine ? (
161
+ <Fade in={showContent} timeout={300}>
162
+ <Typography sx={{ fontSize: '0.7875rem', color: 'text.lighter' }}>{rateLine}</Typography>
163
+ </Fade>
164
+ ) : (
165
+ <Box /> // Empty placeholder when no rate line
166
+ )}
167
+ {hasRows && (
168
+ <IconButton size="small" onClick={() => setOpen((prev) => !prev)} aria-label={open ? 'collapse' : 'expand'}>
169
+ <ExpandMoreIcon
170
+ sx={{
171
+ fontSize: '1rem',
172
+ transition: 'transform 0.2s ease',
173
+ transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
174
+ }}
175
+ />
176
+ </IconButton>
177
+ )}
178
+ </Stack>
179
+ {hasRows && (
180
+ <Collapse in={open} timeout="auto" unmountOnExit>
181
+ <Fade in={showContent} timeout={300}>
182
+ <Stack
183
+ sx={{
184
+ mt: 1,
185
+ p: 1.25,
186
+ borderRadius: 1,
187
+ border: '1px solid',
188
+ borderColor: 'divider',
189
+ bgcolor: 'action.hover',
190
+ }}
191
+ spacing={1}>
192
+ {renderedRows}
193
+ </Stack>
194
+ </Fade>
195
+ </Collapse>
196
+ )}
197
+ </Box>
198
+
199
+ <Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
200
+ <DialogTitle>{t('payment.checkout.quote.slippage.title')}</DialogTitle>
201
+ <DialogContent>
202
+ <SlippageConfig
203
+ value={pendingConfig?.percent ?? slippageValue}
204
+ onChange={handleSlippageChange}
205
+ config={pendingConfig || slippageConfig}
206
+ onConfigChange={handleConfigChange}
207
+ exchangeRate={exchangeRate}
208
+ baseCurrency={baseCurrency}
209
+ disabled={disabled || submitting}
210
+ sx={{ mt: 1 }}
211
+ onCancel={handleCloseDialog}
212
+ onSave={handleSubmit}
213
+ />
214
+ </DialogContent>
215
+ </Dialog>
216
+ </>
217
+ );
218
+ }
@@ -0,0 +1,99 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { LockOutlined } from '@mui/icons-material';
3
+ import { Box, Typography } from '@mui/material';
4
+ import type { TLineItemExpanded, TPaymentCurrency } from '@blocklet/payment-types';
5
+ import { useMemo } from 'react';
6
+ import { formatExchangeRate } from '../libs/util';
7
+
8
+ interface QuoteLockInfo {
9
+ exchangeRate: string | null;
10
+ tokenSymbol: string;
11
+ baseCurrency: string;
12
+ expiresAt: number | null;
13
+ }
14
+
15
+ interface QuoteLockBannerProps {
16
+ items: TLineItemExpanded[];
17
+ currency: TPaymentCurrency;
18
+ }
19
+
20
+ function getQuoteLockInfo(items: TLineItemExpanded[], currency: TPaymentCurrency): QuoteLockInfo | null {
21
+ if (!items?.length || !currency) {
22
+ return null;
23
+ }
24
+
25
+ const dynamicItems = items.filter((item) => {
26
+ const price = (item.upsell_price || item.price) as any;
27
+ return price?.pricing_type === 'dynamic' && (item as any)?.quoted_amount;
28
+ });
29
+ if (!dynamicItems.length) {
30
+ return null;
31
+ }
32
+
33
+ let expiresAt: number | null = null;
34
+ let exchangeRate: string | null = null;
35
+
36
+ dynamicItems.forEach((item) => {
37
+ if ((item as any)?.expires_at) {
38
+ expiresAt = expiresAt === null ? (item as any)?.expires_at : Math.min(expiresAt, (item as any)?.expires_at);
39
+ }
40
+
41
+ if (!exchangeRate) {
42
+ exchangeRate = (item as any)?.exchange_rate || null;
43
+ }
44
+ });
45
+
46
+ return {
47
+ exchangeRate,
48
+ tokenSymbol: currency.symbol,
49
+ baseCurrency: ((dynamicItems[0]?.upsell_price || dynamicItems[0]?.price) as any)?.base_currency || 'USD',
50
+ expiresAt,
51
+ };
52
+ }
53
+
54
+ export default function QuoteLockBanner({ items, currency }: QuoteLockBannerProps) {
55
+ const { t } = useLocaleContext();
56
+ const quoteLockInfo = useMemo(() => getQuoteLockInfo(items, currency), [items, currency]);
57
+
58
+ if (!quoteLockInfo || !quoteLockInfo.exchangeRate) {
59
+ return null;
60
+ }
61
+
62
+ const formattedRateValue = formatExchangeRate(quoteLockInfo.exchangeRate);
63
+ let formattedRate = '';
64
+ if (formattedRateValue) {
65
+ formattedRate =
66
+ quoteLockInfo.baseCurrency === 'USD'
67
+ ? `$${formattedRateValue}`
68
+ : `${formattedRateValue} ${quoteLockInfo.baseCurrency}`;
69
+ }
70
+ return (
71
+ <Box
72
+ sx={{
73
+ bgcolor: 'action.hover',
74
+ border: '1px solid',
75
+ borderColor: 'divider',
76
+ borderRadius: 1,
77
+ px: 2,
78
+ py: 1.5,
79
+ mb: 2,
80
+ }}>
81
+ <Box
82
+ sx={{
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ gap: 1.5,
86
+ flexWrap: 'wrap',
87
+ }}>
88
+ <LockOutlined sx={{ fontSize: '1rem', color: 'success.main' }} />
89
+ <Typography sx={{ fontSize: '0.875rem', color: 'text.primary', flex: 1 }}>
90
+ {t('payment.checkout.quote.dynamicPricingInfo', {
91
+ symbol: quoteLockInfo.tokenSymbol,
92
+ rate: formattedRate,
93
+ currency: '',
94
+ })}
95
+ </Typography>
96
+ </Box>
97
+ </Box>
98
+ );
99
+ }
@@ -0,0 +1,336 @@
1
+ import { useState, useMemo, useEffect } from 'react';
2
+ import { Box, Stack, Typography, TextField, ToggleButton, ToggleButtonGroup, Button } from '@mui/material';
3
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
+
5
+ export type SlippageConfigValue = {
6
+ mode: 'percent' | 'rate';
7
+ percent: number;
8
+ min_acceptable_rate?: string;
9
+ base_currency?: string;
10
+ updated_at_ms?: number;
11
+ };
12
+
13
+ export interface SlippageConfigProps {
14
+ value: number;
15
+ onChange: (value: number) => void;
16
+ config?: SlippageConfigValue;
17
+ onConfigChange?: (value: SlippageConfigValue) => void;
18
+ exchangeRate?: string | null;
19
+ baseCurrency?: string;
20
+ disabled?: boolean;
21
+ sx?: any;
22
+ onCancel?: () => void;
23
+ onSave?: () => void;
24
+ }
25
+
26
+ const PRESET_SLIPPAGE = [0.5, 1, 3, 5, 10];
27
+
28
+ export default function SlippageConfig({
29
+ value,
30
+ onChange,
31
+ config = undefined,
32
+ onConfigChange = undefined,
33
+ exchangeRate = null,
34
+ baseCurrency = 'USD',
35
+ disabled = false,
36
+ sx = {},
37
+ onCancel = undefined,
38
+ onSave = undefined,
39
+ }: SlippageConfigProps) {
40
+ const { t } = useLocaleContext();
41
+ const [inputValue, setInputValue] = useState('');
42
+ const [inputMode, setInputMode] = useState<'percent' | 'rate'>(config?.mode || 'percent');
43
+ const [error, setError] = useState<string | null>(null);
44
+ const [isEditing, setIsEditing] = useState(false); // Track if user is actively editing input
45
+ const percentValue = config?.percent ?? value;
46
+
47
+ // Round exchange rate to 2 decimal places for simpler display and calculation
48
+ const roundedRate = useMemo(() => {
49
+ if (!exchangeRate) return null;
50
+ const rateNum = Number(exchangeRate);
51
+ if (Number.isNaN(rateNum) || rateNum <= 0) return null;
52
+ return Math.round(rateNum * 100) / 100;
53
+ }, [exchangeRate]);
54
+
55
+ // Compute min rate from percent using rounded rate
56
+ const computeMinRateFromPercent = (percent: number) => {
57
+ if (!roundedRate) return '';
58
+ const slippageMultiplier = 1 + percent / 100;
59
+ return (roundedRate / slippageMultiplier).toFixed(2);
60
+ };
61
+
62
+ // Sync mode from config
63
+ useEffect(() => {
64
+ if (config?.mode && config.mode !== inputMode) {
65
+ setInputMode(config.mode);
66
+ }
67
+ }, [config?.mode, inputMode]);
68
+
69
+ // Sync input value based on mode - skip when user is actively editing
70
+ useEffect(() => {
71
+ // Skip sync when user is typing to prevent overwriting their input
72
+ if (isEditing) {
73
+ return;
74
+ }
75
+
76
+ if (inputMode === 'percent') {
77
+ setInputValue(percentValue.toFixed(2));
78
+ return;
79
+ }
80
+
81
+ if (config?.min_acceptable_rate) {
82
+ setInputValue(String(config.min_acceptable_rate));
83
+ return;
84
+ }
85
+
86
+ const minRate = computeMinRateFromPercent(percentValue);
87
+ setInputValue(minRate);
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ }, [percentValue, inputMode, exchangeRate, config?.min_acceptable_rate, isEditing]);
90
+
91
+ const handlePresetClick = (preset: number) => {
92
+ if (disabled) return;
93
+ setInputValue(preset.toFixed(2));
94
+ setInputMode('percent');
95
+ setError(null);
96
+ onChange(preset);
97
+ // Always pass min_acceptable_rate so backend saves the exact value user sees
98
+ const minRate = computeMinRateFromPercent(preset);
99
+ onConfigChange?.({
100
+ mode: 'percent',
101
+ percent: preset,
102
+ ...(minRate ? { min_acceptable_rate: minRate } : {}),
103
+ });
104
+ };
105
+
106
+ const handleInputChange = (newValue: string) => {
107
+ setInputValue(newValue);
108
+ setError(null);
109
+
110
+ const numValue = Number(newValue);
111
+ if (!newValue || Number.isNaN(numValue)) {
112
+ setError(t('payment.checkout.quote.slippage.invalid'));
113
+ return;
114
+ }
115
+
116
+ if (numValue <= 0) {
117
+ setError(t('payment.checkout.quote.slippage.invalidPositive'));
118
+ return;
119
+ }
120
+
121
+ if (inputMode === 'percent') {
122
+ onChange(numValue);
123
+ // Always pass min_acceptable_rate so backend saves the exact value user sees
124
+ const minRate = computeMinRateFromPercent(numValue);
125
+ onConfigChange?.({
126
+ mode: 'percent',
127
+ percent: numValue,
128
+ ...(minRate ? { min_acceptable_rate: minRate } : {}),
129
+ });
130
+ } else {
131
+ // Rate mode: compute equivalent percent from input rate using rounded rate
132
+ if (!roundedRate) {
133
+ setError(t('payment.checkout.quote.slippage.rateRequired'));
134
+ return;
135
+ }
136
+ // No range restriction - accept any positive rate
137
+ const percent = ((roundedRate - numValue) / numValue) * 100;
138
+ onChange(Math.max(0, percent));
139
+ onConfigChange?.({ mode: 'rate', percent: Math.max(0, percent), min_acceptable_rate: newValue });
140
+ }
141
+ };
142
+
143
+ const handleModeChange = (_: any, newMode: 'percent' | 'rate' | null) => {
144
+ if (disabled || !newMode) return;
145
+ setInputMode(newMode);
146
+ setError(null);
147
+
148
+ if (newMode === 'rate') {
149
+ if (!roundedRate) {
150
+ setError(t('payment.checkout.quote.slippage.rateRequired'));
151
+ return;
152
+ }
153
+ const minRate = config?.min_acceptable_rate || computeMinRateFromPercent(percentValue);
154
+ setInputValue(minRate);
155
+ onConfigChange?.({
156
+ mode: 'rate',
157
+ percent: percentValue,
158
+ min_acceptable_rate: minRate,
159
+ });
160
+ } else {
161
+ setInputValue(percentValue.toFixed(2));
162
+ // Always pass min_acceptable_rate so backend saves the exact value user sees
163
+ const minRate = computeMinRateFromPercent(percentValue);
164
+ onConfigChange?.({
165
+ mode: 'percent',
166
+ percent: percentValue,
167
+ ...(minRate ? { min_acceptable_rate: minRate } : {}),
168
+ });
169
+ }
170
+ };
171
+
172
+ const minAcceptableRate = useMemo(() => {
173
+ if (!roundedRate) return null;
174
+ const slippageMultiplier = 1 + percentValue / 100;
175
+ return (roundedRate / slippageMultiplier).toFixed(2);
176
+ }, [roundedRate, percentValue]);
177
+
178
+ const currentRateLabel = useMemo(() => {
179
+ if (!roundedRate) {
180
+ return '—';
181
+ }
182
+ return roundedRate.toFixed(2);
183
+ }, [roundedRate]);
184
+
185
+ const handleCancel = () => {
186
+ onCancel?.();
187
+ };
188
+
189
+ const handleSave = () => {
190
+ onSave?.();
191
+ };
192
+
193
+ return (
194
+ <Stack spacing={2.5} sx={sx}>
195
+ {/* Description */}
196
+ <Typography variant="body2" color="text.secondary">
197
+ {t('payment.checkout.quote.slippageLimit.description')}
198
+ </Typography>
199
+
200
+ {/* Mode toggle */}
201
+ <ToggleButtonGroup
202
+ value={inputMode}
203
+ exclusive
204
+ onChange={handleModeChange}
205
+ size="small"
206
+ fullWidth
207
+ disabled={disabled}>
208
+ <ToggleButton value="percent">{t('payment.checkout.quote.slippageLimit.configTogglePercent')}</ToggleButton>
209
+ <ToggleButton value="rate" disabled={!roundedRate}>
210
+ {t('payment.checkout.quote.slippageLimit.configToggleRate')}
211
+ </ToggleButton>
212
+ </ToggleButtonGroup>
213
+
214
+ {/* Percent mode */}
215
+ {inputMode === 'percent' && (
216
+ <Stack spacing={1.5}>
217
+ <Stack direction="row" spacing={1}>
218
+ {PRESET_SLIPPAGE.map((preset) => (
219
+ <ToggleButton
220
+ key={preset}
221
+ value={preset}
222
+ selected={Math.abs(percentValue - preset) < 0.01}
223
+ onClick={() => handlePresetClick(preset)}
224
+ size="small"
225
+ disabled={disabled}
226
+ sx={{
227
+ flex: 1,
228
+ py: 1,
229
+ borderRadius: 1,
230
+ border: '1px solid',
231
+ borderColor: 'divider',
232
+ '&.Mui-selected': {
233
+ bgcolor: 'primary.main',
234
+ color: 'primary.contrastText',
235
+ borderColor: 'primary.main',
236
+ '&:hover': {
237
+ bgcolor: 'primary.dark',
238
+ },
239
+ },
240
+ }}>
241
+ <Typography variant="body2">{preset}%</Typography>
242
+ </ToggleButton>
243
+ ))}
244
+ </Stack>
245
+
246
+ {/* Custom input */}
247
+ <TextField
248
+ size="small"
249
+ fullWidth
250
+ value={inputValue}
251
+ onChange={(e) => handleInputChange(e.target.value)}
252
+ onFocus={() => setIsEditing(true)}
253
+ onBlur={() => setIsEditing(false)}
254
+ error={!!error}
255
+ helperText={error}
256
+ disabled={disabled}
257
+ label={t('common.custom')}
258
+ InputProps={{
259
+ endAdornment: (
260
+ <Typography variant="body2" sx={{ color: 'text.secondary', mr: 1 }}>
261
+ %
262
+ </Typography>
263
+ ),
264
+ }}
265
+ placeholder="0.50"
266
+ />
267
+ </Stack>
268
+ )}
269
+
270
+ {/* Rate mode */}
271
+ {inputMode === 'rate' && (
272
+ <Stack spacing={1.5}>
273
+ <TextField
274
+ size="small"
275
+ fullWidth
276
+ value={inputValue}
277
+ onChange={(e) => handleInputChange(e.target.value)}
278
+ onFocus={() => setIsEditing(true)}
279
+ onBlur={() => setIsEditing(false)}
280
+ error={!!error}
281
+ helperText={error}
282
+ disabled={disabled || !roundedRate}
283
+ label={t('payment.checkout.quote.slippage.rateInputLabel', { currency: baseCurrency })}
284
+ InputProps={{
285
+ endAdornment: (
286
+ <Typography variant="body2" sx={{ color: 'text.secondary', mr: 1 }}>
287
+ {baseCurrency}
288
+ </Typography>
289
+ ),
290
+ }}
291
+ placeholder={roundedRate?.toFixed(2) || '0.00'}
292
+ />
293
+ </Stack>
294
+ )}
295
+
296
+ {/* Rate info - only show when exchange rate is available */}
297
+ {roundedRate && (
298
+ <Box
299
+ sx={{
300
+ borderRadius: 1,
301
+ p: 1.5,
302
+ bgcolor: 'action.hover',
303
+ }}>
304
+ <Stack spacing={0.5}>
305
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
306
+ <Typography variant="body2" color="text.secondary">
307
+ {t('payment.checkout.quote.slippageLimit.derivedCurrentRate')}
308
+ </Typography>
309
+ <Typography variant="body2" fontWeight={500}>
310
+ {currentRateLabel} {baseCurrency}
311
+ </Typography>
312
+ </Stack>
313
+ <Stack direction="row" justifyContent="space-between" alignItems="center">
314
+ <Typography variant="body2" color="text.secondary">
315
+ {t('payment.checkout.quote.slippageLimit.derivedMinRate')}
316
+ </Typography>
317
+ <Typography variant="body2" fontWeight={500} color="primary.main">
318
+ {inputMode === 'rate' ? inputValue : minAcceptableRate || '—'} {baseCurrency}
319
+ </Typography>
320
+ </Stack>
321
+ </Stack>
322
+ </Box>
323
+ )}
324
+
325
+ {/* Actions */}
326
+ <Stack direction="row" spacing={1} justifyContent="flex-end">
327
+ <Button onClick={handleCancel} disabled={disabled} color="inherit">
328
+ {t('common.cancel')}
329
+ </Button>
330
+ <Button variant="contained" onClick={handleSave} disabled={disabled || !!error}>
331
+ {t('common.save')}
332
+ </Button>
333
+ </Stack>
334
+ </Stack>
335
+ );
336
+ }