Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
423 changes: 48 additions & 375 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@
"papaparse": "^5.4.1",
"posthog-js": "^1.258.5",
"qrcode-generator": "^1.4.4",
"react": "^19.2.1",
"react": "^19.2.3",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.2.1",
"react-dom": "^19.2.3",
"react-ga4": "^2.1.0",
"react-intersection-observer": "^10.0.0",
"react-json-view": "^1.21.3",
Expand Down
10 changes: 10 additions & 0 deletions app/public/dapp-icons/cross-chain-swap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
183 changes: 183 additions & 0 deletions app/src/apps/cross-chain-swap/SlippageSettingDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright 2023-2025 dev.mimir authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { SlippagePreset, SlippageState } from './types';

import {
Button,
Divider,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Tooltip,
} from '@mimir-wallet/ui';
import { useState } from 'react';

import IconQuestion from '@/assets/svg/icon-question-fill.svg?react';

const SLIPPAGE_PRESETS: SlippagePreset[] = ['0.1', '1', '5'];

interface SlippageSettingDialogProps {
open: boolean;
value: SlippageState;
onChange: (slippage: SlippageState) => void;
onClose: () => void;
}

/**
* Slippage preset/custom item component
* Follows exact same pattern as add-proxy DelayItem
*/
function SlippageItem({
preset,
isSelected,
isCustom,
customValue,
onSelect,
onCustomChange,
}: {
preset?: SlippagePreset;
isSelected: boolean;
isCustom?: boolean;
customValue?: string;
onSelect: () => void;
onCustomChange?: (value: string) => void;
}) {
const isCustomSelected = isSelected && isCustom;

return (
<div
data-selected={isSelected}
data-custom={isCustomSelected}
className="bg-secondary text-secondary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground flex h-[43px] w-[25%] cursor-pointer items-center justify-center gap-[5px] rounded-full px-2.5 text-sm transition-all data-[custom=true]:w-[50%] data-[custom=true]:flex-[0_0_auto] data-[custom=true]:shrink-0 data-[custom=true]:grow-0"
onClick={onSelect}
>
{isCustomSelected ? (
<>
<input
autoFocus
type="number"
min={0}
max={50}
value={customValue}
onChange={onCustomChange && ((e) => onCustomChange(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="text-foreground border-divider bg-primary-foreground h-[27px] shrink grow rounded-full border px-2.5 outline-none"
placeholder="0"
/>
<span className="text-nowrap">%</span>
</>
) : isCustom ? (
'Customize'
) : (
`${preset}%`
)}
</div>
);
}

function SlippageSettingDialog({
open,
value,
onChange,
onClose,
}: SlippageSettingDialogProps) {
// Local state - synced from props when value changes externally
const [localValue, setLocalValue] = useState<SlippageState>(value);

const valueKey = `${value.type}:${value.value}`;
const localKey = `${localValue.type}:${localValue.value}`;

if (valueKey !== localKey && !open) {
setLocalValue(value);
}

// Handlers for local state
const handlePresetSelect = (preset: SlippagePreset) => {
setLocalValue({ type: 'preset', value: preset });
};

const handleCustomSelect = () => {
// Switch to custom mode, keep current custom value or default to '1'
setLocalValue((prev) => ({
type: 'custom',
value: prev.type === 'custom' ? prev.value : '1',
}));
};

const handleCustomChange = (inputValue: string) => {
// Validate numeric input
const numValue = parseFloat(inputValue);

if (inputValue && (isNaN(numValue) || numValue < 0 || numValue > 50)) {
return;
}

setLocalValue({ type: 'custom', value: inputValue });
};

// Handle confirm - call onChange with local value
const handleConfirm = () => {
// If custom is selected but empty, default to 1%
if (localValue.type === 'custom' && !localValue.value) {
onChange({ type: 'preset', value: '1' });
} else {
onChange(localValue);
}

onClose();
};

// Derived state from local value
const isPresetSelected = (preset: SlippagePreset) =>
localValue.type === 'preset' && localValue.value === preset;

const isCustomSelected = localValue.type === 'custom';

return (
<Modal size="sm" onClose={onClose} isOpen={open}>
<ModalContent>
<ModalHeader className="justify-center">Max Slippage</ModalHeader>
<Divider />
<ModalBody className="gap-y-4">
{/* Slippage label with tooltip */}
<div className="flex items-center gap-[5px]">
<span className="text-sm font-bold">Slippage</span>
<Tooltip content="Your transaction will revert if the price changes unfavorably by more than this percentage.">
<IconQuestion className="size-3 cursor-help text-foreground/50" />
</Tooltip>
</div>

{/* Slippage options row */}
<div className="flex gap-2.5">
{SLIPPAGE_PRESETS.map((preset) => (
<SlippageItem
key={preset}
preset={preset}
isSelected={isPresetSelected(preset)}
onSelect={() => handlePresetSelect(preset)}
/>
))}
<SlippageItem
isCustom
isSelected={isCustomSelected}
customValue={localValue.type === 'custom' ? localValue.value : ''}
onSelect={handleCustomSelect}
onCustomChange={handleCustomChange}
/>
</div>
</ModalBody>
<Divider />
<ModalFooter>
<Button color="primary" fullWidth onClick={handleConfirm}>
Confirm
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

export default SlippageSettingDialog;
133 changes: 133 additions & 0 deletions app/src/apps/cross-chain-swap/SwapFeeInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright 2023-2025 dev.mimir authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { SwapRouteStep } from './types';
import type { XcmFeeAsset } from '@mimir-wallet/polkadot-core';

import { Avatar, cn, Tooltip } from '@mimir-wallet/ui';
import React from 'react';

import IconQuestion from '@/assets/svg/icon-question-fill.svg?react';
import FormatBalance from '@/components/FormatBalance';

interface SwapFeeInfoProps {
time?: string;
originFee?: XcmFeeAsset;
destFee?: XcmFeeAsset;
route?: SwapRouteStep[];
exchangeRate?: string;
}

function FeeRow({
icon,
label,
value,
tooltip,
valueClassName,
}: {
icon: string;
label: string;
value?: React.ReactNode;
tooltip?: string;
valueClassName?: string;
}) {
return (
<div className="flex gap-[5px] items-center justify-between w-full">
<div className="flex items-center gap-[5px]">
<span className="text-sm">{icon}</span>
<span className="text-sm">{label}</span>
{tooltip && (
<Tooltip content={tooltip}>
<IconQuestion className="size-4 text-foreground/50 cursor-help" />
</Tooltip>
)}
</div>
<span className={cn('text-sm', valueClassName)}>{value ?? '--'}</span>
</div>
);
}

function RouteDisplay({ route }: { route: SwapRouteStep[] }) {
if (!route || route.length === 0) return <span>--</span>;

return (
<div className="flex items-center gap-1">
{route.map((step, index) => (
<React.Fragment key={index}>
<Avatar
alt={step.network.name}
fallback={step.network.name?.slice(0, 1) || '?'}
src={step.network.icon}
style={{ width: 16, height: 16 }}
/>
{index < route.length - 1 && (
<span className="text-foreground/50">→</span>
)}
</React.Fragment>
))}
</div>
);
}

function SwapFeeInfo({
time,
originFee,
destFee,
route,
exchangeRate,
}: SwapFeeInfoProps) {
return (
<div className="bg-primary/5 rounded-[10px] p-2.5 flex flex-col gap-2.5">
<FeeRow
icon="⏰"
label="Time"
value={time}
tooltip="Estimated time for the cross-chain swap to complete"
/>
<FeeRow
icon="💰"
label="Origin Fee"
value={
originFee ? (
<FormatBalance
value={originFee.fee}
format={[originFee.decimals, originFee.symbol]}
withCurrency
/>
) : undefined
}
tooltip="Fee paid on the source chain"
/>
<FeeRow
icon="💰"
label="Destination Fee"
value={
destFee ? (
<FormatBalance
value={destFee.fee}
format={[destFee.decimals, destFee.symbol]}
withCurrency
/>
) : undefined
}
tooltip="Fee paid on the destination chain"
/>
<FeeRow
icon="🗺️"
label="Route"
value={<RouteDisplay route={route || []} />}
tooltip="The path your tokens will take across chains"
/>
{exchangeRate && (
<FeeRow
icon="💱"
label="Rate"
value={exchangeRate}
tooltip="Exchange rate for this swap"
/>
)}
</div>
);
}

export default SwapFeeInfo;
Loading
Loading