Skip to content

Commit f3771c8

Browse files
mmioanaclaudecoderabbitai[bot]
authored
feat: add dust modal and banner components (#2704)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent ef77635 commit f3771c8

56 files changed

Lines changed: 4042 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/api/dust-quote/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { NextRequest } from 'next/server';
2+
import { NextResponse } from 'next/server';
3+
import { makeLifiComposerClient } from '@/app/lib/lifi-composer-client';
4+
5+
export const POST = async (request: NextRequest) => {
6+
try {
7+
const body = await request.json();
8+
const client = makeLifiComposerClient();
9+
const result = await client.compose(body);
10+
return NextResponse.json(result);
11+
} catch (error) {
12+
return NextResponse.json(
13+
{
14+
success: false,
15+
error: {
16+
message: error instanceof Error ? error.message : String(error),
17+
kind: 'internal',
18+
},
19+
},
20+
{ status: 500 },
21+
);
22+
}
23+
};
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import 'server-only';
2+
import config from '@/config/env-config';
3+
4+
interface ERC20Resource {
5+
kind: 'erc20';
6+
token: string;
7+
chainId: number;
8+
}
9+
10+
interface NativeResource {
11+
kind: 'native';
12+
chainId: number;
13+
}
14+
15+
type Resource = ERC20Resource | NativeResource;
16+
17+
interface FlowInput {
18+
name: string;
19+
resource: Resource;
20+
}
21+
22+
interface SwapNode {
23+
id: string;
24+
op: 'lifi.swap';
25+
bind: {
26+
amountIn: { $ref: string };
27+
};
28+
config: {
29+
resourceOut: Resource;
30+
slippage: number;
31+
};
32+
}
33+
34+
interface Flow {
35+
version: number;
36+
id: string;
37+
chainId: number;
38+
inputs: FlowInput[];
39+
nodes: SwapNode[];
40+
}
41+
42+
interface DirectDepositInput {
43+
kind: 'directDeposit';
44+
amount: string;
45+
}
46+
47+
interface Run {
48+
inputs: Record<string, DirectDepositInput>;
49+
signer: string;
50+
sweepTo: string;
51+
simulationPolicy?: string;
52+
checkOnChainAllowances?: boolean;
53+
maxPriceImpactBps?: number;
54+
}
55+
56+
interface ComposeRequestBody {
57+
flow: Flow;
58+
run: Run;
59+
}
60+
61+
interface TransactionRequest {
62+
to: string;
63+
data: string;
64+
value: string;
65+
}
66+
67+
interface ApprovalItem {
68+
token: string;
69+
spender: string;
70+
amount: string;
71+
transactionRequest: TransactionRequest;
72+
}
73+
74+
export interface ProducedResourceSimulated {
75+
amountOut: string;
76+
amountOutMin: string;
77+
}
78+
79+
export interface ProducedResource {
80+
kind: 'native' | 'erc20';
81+
chainId: number;
82+
availability: string;
83+
owner: string;
84+
simulated: ProducedResourceSimulated;
85+
}
86+
87+
export interface PriceImpact {
88+
inputValueUsd: number;
89+
outputValueUsd: number;
90+
impactBps: number;
91+
unpricedInputs: string[];
92+
unpricedOutputs: string[];
93+
}
94+
95+
export interface ComposeResponseData {
96+
producedResources: Record<string, ProducedResource>;
97+
transactionRequest: TransactionRequest;
98+
userProxy: string;
99+
approvals: ApprovalItem[];
100+
priceImpact: PriceImpact;
101+
}
102+
103+
interface ComposeResponse {
104+
data: ComposeResponseData;
105+
success: boolean;
106+
error?: {
107+
message: string;
108+
kind: string;
109+
};
110+
}
111+
112+
class LifiComposerClient {
113+
private baseUrl: string;
114+
private apiKey: string;
115+
116+
constructor(baseUrl: string, apiKey: string) {
117+
this.baseUrl = baseUrl;
118+
this.apiKey = apiKey;
119+
}
120+
121+
async compose(body: ComposeRequestBody): Promise<ComposeResponse> {
122+
const response = await fetch(`${this.baseUrl}/compose`, {
123+
method: 'POST',
124+
headers: {
125+
'Content-Type': 'application/json',
126+
'x-lifi-api-key': this.apiKey,
127+
},
128+
body: JSON.stringify(body),
129+
});
130+
131+
if (!response.ok) {
132+
throw new Error(
133+
`LiFi Composer failed: ${response.status} ${response.statusText}`,
134+
);
135+
}
136+
137+
return response.json();
138+
}
139+
}
140+
141+
export const makeLifiComposerClient = (): LifiComposerClient => {
142+
return new LifiComposerClient(
143+
config.NEXT_PUBLIC_LIFI_COMPOSER_BACKEND_URL,
144+
config.LIFI_COMPOSER_API_KEY,
145+
);
146+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client';
2+
import { DustFlow } from '@/components/composite/DustFlow/DustFlow';
3+
import { AB_TEST_NAME } from '@/const/abtests';
4+
import { useABTest } from '@/hooks/useABTest';
5+
import { useAccount } from '@lifi/wallet-management';
6+
7+
export const PortfolioDustSection = () => {
8+
const { account } = useAccount();
9+
const dustConversionFeatureFlag = useABTest({
10+
feature: AB_TEST_NAME.DUST_CONVERSION,
11+
address: account?.address ?? '',
12+
});
13+
14+
if (
15+
dustConversionFeatureFlag.isLoading ||
16+
!dustConversionFeatureFlag.isEnabled ||
17+
!dustConversionFeatureFlag.value
18+
) {
19+
return null;
20+
}
21+
return <DustFlow />;
22+
};

src/app/ui/portfolio/PortfolioPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { PortfolioAssetsSection } from './PortfolioAssetsSection';
2+
import { PortfolioDustSection } from './PortfolioDustSection';
23
import { PortfolioHeaderSection } from './PortfolioHeaderSection';
34

45
export const PortfolioPage = () => {
56
return (
67
<>
78
<PortfolioHeaderSection />
9+
<PortfolioDustSection />
810
<PortfolioAssetsSection />
911
</>
1012
);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Button } from '@/components/core/buttons/Button/Button';
2+
import Typography from '@mui/material/Typography';
3+
import { Trans, useTranslation } from 'react-i18next';
4+
import { EntityStack } from '../EntityStack/EntityStack';
5+
import { AvatarSize } from '@/components/core/AvatarStack/AvatarStack.types';
6+
import { useDustBalances } from './hooks/useDustBalances';
7+
import { usePortfolioFormatters } from '@/hooks/tokens/usePortfolioFormatters';
8+
import { type FC, useMemo } from 'react';
9+
import { orderBy, uniqBy } from 'lodash';
10+
import {
11+
DustBannerContainer,
12+
DustBannerContentContainer,
13+
} from './DustFlow.styles';
14+
import { INITIAL_MAX_THRESHOLD_USD } from './constants';
15+
import useMediaQuery from '@mui/material/useMediaQuery';
16+
import { checkBalanceWithinRange, getChainMinUsdThreshold } from './utils';
17+
18+
interface DustBannerProps {
19+
forceDisplay?: boolean;
20+
onClick: () => void;
21+
}
22+
23+
export const DustBanner: FC<DustBannerProps> = ({
24+
forceDisplay = false,
25+
onClick,
26+
}) => {
27+
const { t } = useTranslation();
28+
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
29+
const { nonNativeBalances } = useDustBalances();
30+
const { toDisplayAggregatedAmountUSD } = usePortfolioFormatters();
31+
32+
const filteredBalances = useMemo(
33+
() =>
34+
orderBy(
35+
nonNativeBalances.filter((balance) =>
36+
checkBalanceWithinRange(
37+
balance,
38+
INITIAL_MAX_THRESHOLD_USD,
39+
getChainMinUsdThreshold(balance.token.chainId),
40+
),
41+
),
42+
'amountUSD',
43+
'desc',
44+
),
45+
[nonNativeBalances],
46+
);
47+
48+
const tokens = useMemo(() => {
49+
return uniqBy(
50+
filteredBalances.map((balance) => balance.token),
51+
'symbol',
52+
);
53+
}, [filteredBalances]);
54+
55+
const totalUSD = toDisplayAggregatedAmountUSD(filteredBalances);
56+
57+
if (!tokens.length && !forceDisplay) {
58+
return null;
59+
}
60+
61+
return (
62+
<DustBannerContainer>
63+
<DustBannerContentContainer>
64+
<EntityStack
65+
entities={tokens}
66+
direction="row-reverse"
67+
size={AvatarSize.MD}
68+
limit={3}
69+
useAvatarOverflow
70+
disableBorder
71+
avatarSx={(theme) => ({
72+
border: `2px solid ${(theme.vars || theme).palette.surface1.main}`,
73+
})}
74+
/>
75+
<Typography variant="bodyXSmall">
76+
<Trans
77+
i18nKey="portfolio.dustConversion.banner"
78+
values={{ value: totalUSD }}
79+
/>
80+
</Typography>
81+
</DustBannerContentContainer>
82+
<Button onClick={onClick} fullWidth={isMobile}>
83+
{t('buttons.convertDust')}
84+
</Button>
85+
</DustBannerContainer>
86+
);
87+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Meta, StoryObj } from '@storybook/nextjs-vite';
2+
import { action } from 'storybook/actions';
3+
import { useState } from 'react';
4+
import { DustModal } from './DustModal';
5+
import { ConnectButton } from '@/components/ConnectButton';
6+
import { PortfolioProvider } from '@/providers/PortfolioProvider/PortfolioProvider';
7+
import { useIsDisconnected } from '@/components/Navbar/hooks';
8+
import { WalletMenuToggle } from '@/components/Navbar/components/Buttons/WalletMenuToggle';
9+
import { DustFlow } from './DustFlow';
10+
import { DustBanner } from './DustBanner';
11+
import Stack from '@mui/material/Stack';
12+
13+
const meta = {
14+
component: DustFlow,
15+
title: 'components/composite/Dust Flow',
16+
} satisfies Meta<typeof DustFlow>;
17+
18+
export default meta;
19+
type Story = StoryObj<typeof meta>;
20+
21+
export const Default: Story = {
22+
render: () => {
23+
const isDisconnected = useIsDisconnected();
24+
25+
return (
26+
<PortfolioProvider>
27+
<Stack sx={{ gap: 2 }}>
28+
{isDisconnected ? <ConnectButton /> : <WalletMenuToggle />}
29+
<DustFlow />
30+
</Stack>
31+
</PortfolioProvider>
32+
);
33+
},
34+
args: {},
35+
};
36+
37+
export const OnlyBanner: Story = {
38+
render: () => {
39+
const isDisconnected = useIsDisconnected();
40+
41+
return (
42+
<PortfolioProvider>
43+
<Stack sx={{ gap: 2 }}>
44+
{isDisconnected ? <ConnectButton /> : <WalletMenuToggle />}
45+
<DustBanner forceDisplay onClick={action('click-dust-banner-btn')} />
46+
</Stack>
47+
</PortfolioProvider>
48+
);
49+
},
50+
args: {},
51+
};
52+
53+
export const OnlyModal: Story = {
54+
render: () => {
55+
const isDisconnected = useIsDisconnected();
56+
const [isModalOpen, setIsModalOpen] = useState(true);
57+
58+
return (
59+
<PortfolioProvider>
60+
<Stack sx={{ gap: 2 }}>
61+
{isDisconnected ? <ConnectButton /> : <WalletMenuToggle />}
62+
<DustModal
63+
isOpen={isModalOpen}
64+
onClose={() => setIsModalOpen(false)}
65+
/>
66+
</Stack>
67+
</PortfolioProvider>
68+
);
69+
},
70+
args: {},
71+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Box from '@mui/material/Box';
2+
import { styled } from '@mui/material/styles';
3+
4+
export const DustBannerContainer = styled(Box)(({ theme }) => ({
5+
backgroundColor: (theme.vars || theme).palette.accent1.main,
6+
color: (theme.vars || theme).palette.textPrimaryInverted,
7+
padding: theme.spacing(1, 1.5),
8+
borderRadius: theme.shape.radius8,
9+
display: 'flex',
10+
flexDirection: 'column',
11+
alignItems: 'center',
12+
[theme.breakpoints.up('sm')]: {
13+
flexDirection: 'row',
14+
borderRadius: theme.shape.radius32,
15+
},
16+
}));
17+
18+
export const DustBannerContentContainer = styled(Box)(({ theme }) => ({
19+
padding: theme.spacing(1, 1.5),
20+
borderRadius: theme.shape.radius32,
21+
flex: 1,
22+
display: 'flex',
23+
flexDirection: 'row',
24+
alignItems: 'center',
25+
gap: theme.spacing(1.25),
26+
}));

0 commit comments

Comments
 (0)