Skip to content

Commit

Permalink
feat(tangle-dapp): Restake Delegation (#2414)
Browse files Browse the repository at this point in the history
  • Loading branch information
AtelyPham authored Jul 6, 2024
1 parent eb40c79 commit 874b3b1
Show file tree
Hide file tree
Showing 42 changed files with 2,043 additions and 537 deletions.
74 changes: 74 additions & 0 deletions apps/tangle-dapp/app/restake/ActionButtonBase.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useConnectWallet } from '@webb-tools/api-provider-environment/ConnectWallet';
import { useWebContext } from '@webb-tools/api-provider-environment/webb-context';
import Button from '@webb-tools/webb-ui-components/components/buttons/Button';
import { ConnectWalletMobileButton } from '@webb-tools/webb-ui-components/components/ConnectWalletMobileButton';
import { useCheckMobile } from '@webb-tools/webb-ui-components/hooks/useCheckMobile';
import { Typography } from '@webb-tools/webb-ui-components/typography/Typography';
import Link from 'next/link';
import { type ReactNode, useMemo } from 'react';

type Props = {
targetTypedChainId?: number;
children: (isLoading: boolean, loadingText?: string) => ReactNode;
};

export default function ActionButtonBase({
targetTypedChainId,
children,
}: Props) {
const { isMobile } = useCheckMobile();
const { loading, isConnecting, activeWallet } = useWebContext();
const { toggleModal } = useConnectWallet();

const { isLoading, loadingText } = useMemo(() => {
if (loading)
return {
isLoading: true,
loadingText: 'Loading...',
};

if (isConnecting)
return {
isLoading: true,
loadingText: 'Connecting...',
};

return {
isLoading: false,
};
}, [isConnecting, loading]);

if (isMobile) {
return (
<ConnectWalletMobileButton title="Try Hubble on Desktop" isFullWidth>
<Typography variant="body1">
A complete mobile experience for Hubble Bridge is in the works. For
now, enjoy all features on a desktop device.
</Typography>
<Typography variant="body1">
Visit the link on desktop below to start transacting privately!
</Typography>
<Button as={Link} href="deposit" variant="link">
{window.location.href}
</Button>
</ConnectWalletMobileButton>
);
}

// If the user is not connected to a wallet, show the connect wallet button
if (activeWallet === undefined) {
return (
<Button
type="button"
isFullWidth
isLoading={isLoading}
loadingText={loadingText}
onClick={() => toggleModal(true, targetTypedChainId)}
>
Connect Wallet
</Button>
);
}

return children(isLoading, loadingText);
}
59 changes: 59 additions & 0 deletions apps/tangle-dapp/app/restake/AvatarWithText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { isEthereumAddress } from '@polkadot/util-crypto';
import { getFlexBasic } from '@webb-tools/icons/utils';
import { Avatar } from '@webb-tools/webb-ui-components/components/Avatar';
import { Typography } from '@webb-tools/webb-ui-components/typography/Typography';
import { shortenHex } from '@webb-tools/webb-ui-components/utils/shortenHex';
import { shortenString } from '@webb-tools/webb-ui-components/utils/shortenString';
import isEqual from 'lodash/isEqual';
import { type ComponentProps, memo } from 'react';
import { twMerge } from 'tailwind-merge';
import { isHex } from 'viem';

type Props = ComponentProps<'div'> & {
accountAddress: string;
overrideAvatarProps?: Partial<ComponentProps<typeof Avatar>>;
overrideTypographyProps?: Partial<ComponentProps<typeof Typography>>;
};

const AvatarWithText = ({
accountAddress,
overrideAvatarProps,
overrideTypographyProps,
className,
...props
}: Props) => {
return (
<div
{...props}
className={twMerge(
'flex items-center max-w-xs space-x-2 grow',
className,
)}
>
<Avatar
// TODO: Determine the theme instead of hardcoding it
theme={isEthereumAddress(accountAddress) ? 'ethereum' : 'substrate'}
value={accountAddress}
{...overrideAvatarProps}
className={twMerge(
`${getFlexBasic()} shrink-0`,
overrideAvatarProps?.className,
)}
/>

<Typography
variant="body2"
{...overrideTypographyProps}
className={twMerge('truncate', overrideTypographyProps?.className)}
>
{isHex(accountAddress)
? shortenHex(accountAddress)
: shortenString(accountAddress)}
</Typography>
</div>
);
};

export default memo(AvatarWithText, (prevProps, nextProps) =>
isEqual(prevProps, nextProps),
);
84 changes: 84 additions & 0 deletions apps/tangle-dapp/app/restake/ChainList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import { useWebContext } from '@webb-tools/api-provider-environment/webb-context';
import { calculateTypedChainId } from '@webb-tools/sdk-core/typed-chain-id';
import ChainListCard from '@webb-tools/webb-ui-components/components/ListCard/ChainListCard';
import { type ComponentProps, useMemo } from 'react';
import { twMerge } from 'tailwind-merge';

import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../constants/restake';

type Props = Partial<ComponentProps<typeof ChainListCard>> & {
selectedTypedChainId?: number | null;
};

const ChainList = ({
className,
onClose,
selectedTypedChainId,
...props
}: Props) => {
const { activeChain, loading, apiConfig } = useWebContext();

const selectedChain = useMemo(
() =>
typeof selectedTypedChainId === 'number'
? apiConfig.chains[selectedTypedChainId]
: null,
[apiConfig.chains, selectedTypedChainId],
);

const chains = useMemo(
() =>
SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS.map(
(typedChainId) =>
[typedChainId, apiConfig.chains[typedChainId]] as const,
)
.filter(([, chain]) => Boolean(chain))
.map(([typedChainId, chain]) => ({
typedChainId,
name: chain.name,
tag: chain.tag,
needSwitchWallet:
activeChain?.id !== chain.id &&
activeChain?.chainType !== chain.chainType,
})),
[activeChain?.chainType, activeChain?.id, apiConfig.chains],
);

const defaultCategory = useMemo<
ComponentProps<typeof ChainListCard>['defaultCategory']
>(() => {
return selectedChain?.tag ?? activeChain?.tag ?? 'test';
}, [activeChain?.tag, selectedChain?.tag]);

return (
<ChainListCard
chainType="source"
overrideTitleProps={{
variant: 'h4',
}}
chains={chains}
activeTypedChainId={
activeChain
? calculateTypedChainId(activeChain.chainType, activeChain.id)
: undefined
}
defaultCategory={defaultCategory}
isConnectingToChain={loading}
overrideScrollAreaProps={{
className: 'h-[320px]',
}}
{...props}
onClose={onClose}
className={twMerge(
'p-0 dark:bg-[var(--restake-card-bg-dark)]',
className,
)}
/>
);
};

ChainList.displayName = 'ChainList';

export default ChainList;
45 changes: 45 additions & 0 deletions apps/tangle-dapp/app/restake/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import isDefined from '@webb-tools/dapp-types/utils/isDefined';
import { InformationLine } from '@webb-tools/icons';
import type { PropsOf } from '@webb-tools/webb-ui-components/types';
import { Typography } from '@webb-tools/webb-ui-components/typography/Typography';
import { ComponentProps } from 'react';
import { twMerge } from 'tailwind-merge';

type Props = PropsOf<'p'> & {
typographyProps?: Partial<ComponentProps<typeof Typography>>;
};

export default function ErrorMessage({
children,
className,
typographyProps: {
variant = 'body4',
className: typoClassName,
...typographyProps
} = {},
...props
}: Props) {
return (
<p
{...props}
className={twMerge(
'flex items-center gap-1 text-red-70 dark:text-red-50 h-4 mt-2',
className,
)}
>
{isDefined(children) ? (
<InformationLine size="md" className="!fill-current" />
) : null}

<Typography
component="span"
fw="bold"
{...typographyProps}
variant={variant}
className={twMerge('!text-inherit', typoClassName)}
>
{children}
</Typography>
</p>
);
}
37 changes: 37 additions & 0 deletions apps/tangle-dapp/app/restake/SlideAnimation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Transition, TransitionRootProps } from '@headlessui/react';
import type { PropsWithChildren } from 'react';
import { twMerge } from 'tailwind-merge';

type Props = Partial<TransitionRootProps<'div'>> &
Pick<TransitionRootProps<'div'>, 'show'> &
Omit<TransitionRootProps<'div'>, 'as'> & {
className?: string;
};

export default function SlideAnimation({
children,
className,
enter,
enterFrom,
enterTo,
leave,
leaveFrom,
leaveTo,
...restProps
}: PropsWithChildren<Props>) {
return (
<Transition
{...restProps}
as="div"
className={twMerge('h-full w-full', className)}
enter={twMerge('transition-[top,_opacity] duration-150', enter)}
enterFrom={twMerge('opacity-0 top-[250px]', enterFrom)}
enterTo={twMerge('opacity-100 top-0', enterTo)}
leave={twMerge('transition-[top,_opacity] duration-150', leave)}
leaveFrom={twMerge('opacity-100 top-0', leaveFrom)}
leaveTo={twMerge('opacity-0 top-[250px]', leaveTo)}
>
{children}
</Transition>
);
}
85 changes: 85 additions & 0 deletions apps/tangle-dapp/app/restake/delegate/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import isDefined from '@webb-tools/dapp-types/utils/isDefined';
import type { Noop } from '@webb-tools/dapp-types/utils/types';
import Button from '@webb-tools/webb-ui-components/components/buttons/Button';
import { useMemo } from 'react';
import type { FieldErrors, UseFormWatch } from 'react-hook-form';

import { SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS } from '../../../constants/restake';
import useActiveTypedChainId from '../../../hooks/useActiveTypedChainId';
import { DelegationFormFields } from '../../../types/restake';
import ActionButtonBase from '../ActionButtonBase';

type Props = {
openChainModal: Noop;
isValid: boolean;
isSubmitting: boolean;
errors: FieldErrors<DelegationFormFields>;
watch: UseFormWatch<DelegationFormFields>;
};

export default function ActionButton({
openChainModal,
isValid,
isSubmitting,
errors,
watch,
}: Props) {
const activeTypedChainId = useActiveTypedChainId();
const operatorAccountId = watch('operatorAccountId');
const assetId = watch('assetId');
const amount = watch('amount');

const displayError = useMemo(
() => {
return errors.operatorAccountId !== undefined || !operatorAccountId
? 'Select an operator'
: errors.assetId !== undefined || !assetId
? 'Select an asset'
: !amount
? 'Enter an amount'
: errors.amount !== undefined
? 'Invalid amount'
: undefined;
},
// prettier-ignore
[errors.operatorAccountId, errors.assetId, errors.amount, operatorAccountId, assetId, amount],
);

return (
<ActionButtonBase>
{(isLoading, loadingText) => {
const activeChainSupported =
isDefined(activeTypedChainId) &&
SUPPORTED_RESTAKE_DEPOSIT_TYPED_CHAIN_IDS.includes(
activeTypedChainId,
);

if (!activeChainSupported) {
return (
<Button
isFullWidth
type="button"
isLoading={isLoading}
loadingText={loadingText}
onClick={openChainModal}
>
Switch to supported chain
</Button>
);
}

return (
<Button
isDisabled={!isValid || isDefined(displayError)}
type="submit"
isFullWidth
isLoading={isSubmitting || isLoading}
loadingText={isSubmitting ? 'Delegating...' : loadingText}
>
{displayError ?? 'Delegate'}
</Button>
);
}}
</ActionButtonBase>
);
}
Loading

0 comments on commit 874b3b1

Please sign in to comment.