@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.
- package/es/components/auto-topup/modal.d.ts +2 -0
- package/es/components/auto-topup/modal.js +48 -6
- package/es/components/auto-topup/product-card.d.ts +16 -1
- package/es/components/auto-topup/product-card.js +97 -15
- package/es/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/es/components/dynamic-pricing-unavailable.js +58 -0
- package/es/components/loading-amount.d.ts +17 -0
- package/es/components/loading-amount.js +46 -0
- package/es/components/price-change-confirm.d.ts +18 -0
- package/es/components/price-change-confirm.js +107 -0
- package/es/components/quote-details-panel.d.ts +21 -0
- package/es/components/quote-details-panel.js +170 -0
- package/es/components/quote-lock-banner.d.ts +7 -0
- package/es/components/quote-lock-banner.js +79 -0
- package/es/components/slippage-config.d.ts +20 -0
- package/es/components/slippage-config.js +261 -0
- package/es/history/invoice/list.js +125 -15
- package/es/hooks/dynamic-pricing.d.ts +102 -0
- package/es/hooks/dynamic-pricing.js +393 -0
- package/es/index.d.ts +6 -1
- package/es/index.js +9 -1
- package/es/libs/util.d.ts +42 -5
- package/es/libs/util.js +345 -57
- package/es/locales/en.js +114 -3
- package/es/locales/zh.js +114 -3
- package/es/payment/form/index.d.ts +4 -1
- package/es/payment/form/index.js +454 -22
- package/es/payment/index.d.ts +1 -1
- package/es/payment/index.js +279 -16
- package/es/payment/product-item.d.ts +26 -1
- package/es/payment/product-item.js +330 -51
- package/es/payment/summary-section/promotion-section.d.ts +32 -0
- package/es/payment/summary-section/promotion-section.js +143 -0
- package/es/payment/summary-section/total-section.d.ts +39 -0
- package/es/payment/summary-section/total-section.js +83 -0
- package/es/payment/summary.d.ts +17 -2
- package/es/payment/summary.js +300 -253
- package/es/types/index.d.ts +11 -0
- package/lib/components/auto-topup/modal.d.ts +2 -0
- package/lib/components/auto-topup/modal.js +54 -6
- package/lib/components/auto-topup/product-card.d.ts +16 -1
- package/lib/components/auto-topup/product-card.js +75 -7
- package/lib/components/dynamic-pricing-unavailable.d.ts +9 -0
- package/lib/components/dynamic-pricing-unavailable.js +81 -0
- package/lib/components/loading-amount.d.ts +17 -0
- package/lib/components/loading-amount.js +53 -0
- package/lib/components/price-change-confirm.d.ts +18 -0
- package/lib/components/price-change-confirm.js +157 -0
- package/lib/components/quote-details-panel.d.ts +21 -0
- package/lib/components/quote-details-panel.js +226 -0
- package/lib/components/quote-lock-banner.d.ts +7 -0
- package/lib/components/quote-lock-banner.js +93 -0
- package/lib/components/slippage-config.d.ts +20 -0
- package/lib/components/slippage-config.js +316 -0
- package/lib/history/invoice/list.js +167 -27
- package/lib/hooks/dynamic-pricing.d.ts +102 -0
- package/lib/hooks/dynamic-pricing.js +390 -0
- package/lib/index.d.ts +6 -1
- package/lib/index.js +32 -0
- package/lib/libs/util.d.ts +42 -5
- package/lib/libs/util.js +367 -49
- package/lib/locales/en.js +114 -3
- package/lib/locales/zh.js +114 -3
- package/lib/payment/form/index.d.ts +4 -1
- package/lib/payment/form/index.js +476 -20
- package/lib/payment/index.d.ts +1 -1
- package/lib/payment/index.js +308 -14
- package/lib/payment/product-item.d.ts +26 -1
- package/lib/payment/product-item.js +270 -35
- package/lib/payment/summary-section/promotion-section.d.ts +32 -0
- package/lib/payment/summary-section/promotion-section.js +133 -0
- package/lib/payment/summary-section/total-section.d.ts +39 -0
- package/lib/payment/summary-section/total-section.js +117 -0
- package/lib/payment/summary.d.ts +17 -2
- package/lib/payment/summary.js +205 -127
- package/lib/types/index.d.ts +11 -0
- package/package.json +3 -3
- package/src/components/auto-topup/modal.tsx +59 -6
- package/src/components/auto-topup/product-card.tsx +118 -11
- package/src/components/dynamic-pricing-unavailable.tsx +69 -0
- package/src/components/loading-amount.tsx +66 -0
- package/src/components/price-change-confirm.tsx +136 -0
- package/src/components/quote-details-panel.tsx +218 -0
- package/src/components/quote-lock-banner.tsx +99 -0
- package/src/components/slippage-config.tsx +336 -0
- package/src/history/invoice/list.tsx +143 -9
- package/src/hooks/dynamic-pricing.ts +617 -0
- package/src/index.ts +9 -0
- package/src/libs/util.ts +473 -58
- package/src/locales/en.tsx +117 -0
- package/src/locales/zh.tsx +111 -0
- package/src/payment/form/index.tsx +561 -19
- package/src/payment/index.tsx +349 -10
- package/src/payment/product-item.tsx +451 -37
- package/src/payment/summary-section/promotion-section.tsx +172 -0
- package/src/payment/summary-section/total-section.tsx +141 -0
- package/src/payment/summary.tsx +334 -192
- 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
|
+
}
|