Skip to content

Commit

Permalink
feat(wallet-dashboard): style send entry screen (#3807)
Browse files Browse the repository at this point in the history
* feat(wallet-dashboard): style send entry screen WIP

* feat(wallet-dashboard): style send entry screen WIP

* fix(wallet-dashboard): sort the dependencies

* feat(wallet-dashboard): includes icon coin in coin selector

* fix(wallet-dashboard): prettier

* fix(wallet-dashboard): update schema validation and share gas budget estimation logic

* fix(wallet-dashboard): some fixes

* fix(wallet-dashboard): some build errors

* fix(wallet-dashboard): fix change amount in send token input

* fix(wallet-dashboard): linter

* fix(wallet-dashboard): linter

* fix(wallet-dashboard): core prettier

* fix(wallet-dashboard): include interface with props and some fixes

* fix(wallet-dashboard): fixes

* fix(wallet-dashboard): fixes

* fix(wallet-dashboard): move FormInputs to a standalone component

* fix(wallet-dashboard): improve AddressInputs props

* fix(wallet-dashboard): linter

* fix(wallet-dashboard): format core

* fix(wallet-dashboard): clean debris

* fix(wallet-dashboard): bring back the validation field

* fix(wallet-dashboard): bad merge removing duplicated image components

* fix(wallet-dashboard): remove unnecesary InputForm component

* fix(wallet-dashboard): adjust to full height the dialog body

* fix(wallet-dashboard): prettier

* fix(wallet-dashboard): max button disabled

* feat(wallet-dashboard): improvements

* fix(wallet-dashboard): improve formik props

* fix(wallet-dashboard): improvements

* refactor: Simplify SendTokenFormInput

* refactor: prettier:fix

* refactor: prettier:fix on apps/core

* refactor: Add missing license header to token.ts

* fix: linter

* fix(wallet-dashboard): linter

* fix(wallet-dashboard): linter

* feat: Improve validation flow of sent screen

* fmt

* fix(wallet-dashboard): fixes

* fix(wallet-dashboard): linter

* fix(wallet-dashboard): error to click max button

* fix(wallet-dashboard): add setFieldValue in useEffect

* fix(wallet-dashboard): remove default exports

* fix(wallet-dashboard): lint

* fix(wallet-dashboard): build

---------

Co-authored-by: marc2332 <[email protected]>
Co-authored-by: Bran <[email protected]>
  • Loading branch information
3 people authored Nov 20, 2024
1 parent 47fd999 commit bacfda4
Show file tree
Hide file tree
Showing 49 changed files with 903 additions and 807 deletions.
4 changes: 4 additions & 0 deletions apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@
"@amplitude/analytics-types": "^0.20.0",
"@growthbook/growthbook-react": "^1.0.0",
"@hookform/resolvers": "^3.9.0",
"@iota/apps-ui-kit": "workspace:*",
"@iota/dapp-kit": "workspace:*",
"@iota/iota-sdk": "workspace:*",
"@iota/kiosk": "workspace:*",
"@iota/ui-icons": "workspace:*",
"@sentry/react": "^7.59.2",
"@tanstack/react-query": "^5.50.1",
"bignumber.js": "^9.1.1",
"clsx": "^2.1.1",
"formik": "^2.4.2",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
67 changes: 67 additions & 0 deletions apps/core/src/components/Inputs/AddressInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Mysten Labs, Inc.
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { Input, InputType } from '@iota/apps-ui-kit';
import { Close } from '@iota/ui-icons';
import { useIotaAddressValidation } from '../../hooks';
import React, { useCallback } from 'react';
import { useField, useFormikContext } from 'formik';

export interface AddressInputProps {
name: string;
disabled?: boolean;
placeholder?: string;
label?: string;
}

export function AddressInput({
name,
disabled,
placeholder = '0x...',
label = 'Enter Recipient Address',
}: AddressInputProps) {
const { validateField } = useFormikContext();
const [field, meta, helpers] = useField<string>(name);
const iotaAddressValidation = useIotaAddressValidation();

const formattedValue = iotaAddressValidation.cast(field.value);

const handleOnChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const address = e.currentTarget.value;
await helpers.setValue(iotaAddressValidation.cast(address));
validateField(name);
},
[name, iotaAddressValidation],
);

const clearAddress = () => {
helpers.setValue('');
};

return (
<Input
type={InputType.Text}
disabled={disabled}
placeholder={placeholder}
value={formattedValue}
name={field.name}
onBlur={field.onBlur}
label={label}
onChange={handleOnChange}
errorMessage={meta.error}
trailingElement={
formattedValue ? (
<button
onClick={clearAddress}
type="button"
className="flex items-center justify-center"
>
<Close />
</button>
) : undefined
}
/>
);
}
80 changes: 80 additions & 0 deletions apps/core/src/components/Inputs/SendTokenFormInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { ButtonPill, Input, InputType } from '@iota/apps-ui-kit';
import { CoinStruct } from '@iota/iota-sdk/client';
import { useGasBudgetEstimation } from '../../hooks';
import { useEffect } from 'react';
import { useField, useFormikContext } from 'formik';
import { TokenForm } from '../../forms';

export interface SendTokenInputProps {
coins: CoinStruct[];
symbol: string;
coinDecimals: number;
activeAddress: string;
to: string;
onActionClick: () => Promise<void>;
isMaxActionDisabled?: boolean;
name: string;
}

export function SendTokenFormInput({
coins,
to,
symbol,
coinDecimals,
activeAddress,
onActionClick,
isMaxActionDisabled,
name,
}: SendTokenInputProps) {
const { values, setFieldValue, isSubmitting, validateField } = useFormikContext<TokenForm>();
const gasBudgetEstimation = useGasBudgetEstimation({
coinDecimals,
coins: coins ?? [],
activeAddress,
to: to,
amount: values.amount,
isPayAllIota: values.isPayAllIota,
});

const [field, meta, helpers] = useField<string>(name);
const errorMessage = meta.error;
const isActionButtonDisabled = isSubmitting || isMaxActionDisabled;

const renderAction = () => (
<ButtonPill disabled={isActionButtonDisabled} onClick={onActionClick}>
Max
</ButtonPill>
);

// gasBudgetEstimation should change when the amount above changes
useEffect(() => {
setFieldValue('gasBudgetEst', gasBudgetEstimation, false);
}, [gasBudgetEstimation, setFieldValue, values.amount]);

return (
<Input
type={InputType.NumericFormat}
name={field.name}
onBlur={field.onBlur}
value={field.value}
caption="Est. Gas Fees:"
placeholder="0.00"
label="Send Amount"
suffix={` ${symbol}`}
prefix={values.isPayAllIota ? '~ ' : undefined}
allowNegative={false}
errorMessage={errorMessage}
amountCounter={!errorMessage ? (coins ? gasBudgetEstimation : '--') : undefined}
trailingElement={renderAction()}
decimalScale={coinDecimals ? undefined : 0}
thousandSeparator
onValueChange={async (values) => {
await helpers.setValue(values.value);
validateField(name);
}}
/>
);
}
5 changes: 5 additions & 0 deletions apps/core/src/components/Inputs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './AddressInput';
export * from './SendTokenFormInput';
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// Modifications Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { ImageIcon, ImageIconSize } from '_app/shared/image-icon';
import { useCoinMetadata } from '@iota/core';
import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils';
import React from 'react';
import { useCoinMetadata } from '../../hooks';
import { IotaLogoMark } from '@iota/ui-icons';
import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils';
import { ImageIcon, ImageIconSize } from '../icon';
import cx from 'clsx';

interface NonIotaCoinProps {
Expand All @@ -28,7 +29,6 @@ function NonIotaCoin({ coinType, size = ImageIconSize.Full, rounded }: NonIotaCo
</div>
);
}

export interface CoinIconProps {
coinType: string;
size?: ImageIconSize;
Expand All @@ -37,7 +37,7 @@ export interface CoinIconProps {

export function CoinIcon({ coinType, size = ImageIconSize.Full, rounded }: CoinIconProps) {
return coinType === IOTA_TYPE_ARG ? (
<div className={cx(size)}>
<div className={cx(size, 'text-neutral-10')}>
<IotaLogoMark className="h-full w-full" />
</div>
) : (
Expand Down
61 changes: 61 additions & 0 deletions apps/core/src/components/coin/CoinSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils';
import { Select, SelectOption } from '@iota/apps-ui-kit';
import { CoinBalance } from '@iota/iota-sdk/client';
import { useFormatCoin } from '../../hooks';
import { CoinIcon } from './CoinIcon';
import { ImageIconSize } from '../icon';

interface CoinSelectorProps {
activeCoinType: string;
coins: CoinBalance[];
onClick: (coinType: string) => void;
}

export function CoinSelector({
activeCoinType = IOTA_TYPE_ARG,
coins,
onClick,
}: CoinSelectorProps) {
const activeCoin = coins?.find(({ coinType }) => coinType === activeCoinType) ?? coins?.[0];
const initialValue = activeCoin?.coinType;
const coinsOptions: SelectOption[] =
coins?.map((coin) => ({
id: coin.coinType,
renderLabel: () => <CoinSelectOption coin={coin} />,
})) || [];

return (
<Select
label="Select Coins"
value={initialValue}
options={coinsOptions}
onValueChange={(coinType) => {
onClick(coinType);
}}
/>
);
}

function CoinSelectOption({ coin: { coinType, totalBalance } }: { coin: CoinBalance }) {
const [formatted, symbol, { data: coinMeta }] = useFormatCoin(totalBalance, coinType);
const isIota = coinType === IOTA_TYPE_ARG;

return (
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-md">
<div className="flex h-6 w-6 items-center justify-center">
<CoinIcon size={ImageIconSize.Small} coinType={coinType} rounded />
</div>
<span className="text-body-lg text-neutral-10">
{isIota ? (coinMeta?.name || '').toUpperCase() : coinMeta?.name || symbol}
</span>
</div>
<span className="text-label-lg text-neutral-60">
{formatted} {symbol}
</span>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export { default as SendCoinPopup } from './SendCoinPopup';
export * from './CoinIcon';
export * from './CoinSelector';
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { useState } from 'react';
import Image from 'next/image';
import React, { useState } from 'react';
import cn from 'clsx';

export enum ImageIconSize {
Expand All @@ -12,67 +11,60 @@ export enum ImageIconSize {
Full = 'w-full h-full',
}

export interface ImageIconProps {
src: string | null | undefined;
label: string;
fallback: string;
alt?: string;
rounded?: boolean;
size?: ImageIconSize;
}

interface FallBackAvatarProps {
text: string;
str: string;
rounded?: boolean;
size?: ImageIconSize;
}
function FallBackAvatar({ text, rounded, size = ImageIconSize.Large }: FallBackAvatarProps) {
const textSize = (() => {

function FallBackAvatar({ str, rounded, size = ImageIconSize.Large }: FallBackAvatarProps) {
function generateTextSize(size: ImageIconSize) {
switch (size) {
case ImageIconSize.Small:
return 'text-label-sm';
case ImageIconSize.Medium:
return 'text-label-md';
case ImageIconSize.Large:
return 'text-title-md';
case ImageIconSize.Full:
return 'text-title-lg';
case ImageIconSize.Full:
return 'text-display-lg';
}
})();

}
return (
<div
className={cn(
'flex h-full w-full items-center justify-center bg-neutral-96 bg-gradient-to-r capitalize dark:bg-neutral-20',
{ 'rounded-full': rounded },
textSize,
'flex items-center justify-center bg-neutral-96 bg-gradient-to-r capitalize text-neutral-10 dark:bg-neutral-92 dark:text-primary-100',
{ 'rounded-full': rounded, 'rounded-lg': !rounded },
size,
generateTextSize(size),
)}
>
{text.slice(0, 2)}
{str?.slice(0, 2)}
</div>
);
}
export interface ImageIconProps {
src: string | null | undefined;
label: string;
fallbackText: string;
alt?: string;
rounded?: boolean;
size?: ImageIconSize;
}

export function ImageIcon({
src,
label,
alt = label,
fallbackText,
rounded,
size,
}: ImageIconProps) {
export function ImageIcon({ src, label, alt = label, fallback, rounded, size }: ImageIconProps) {
const [error, setError] = useState(false);
return (
<div role="img" aria-label={label} className={size}>
{error || !src ? (
<FallBackAvatar rounded={rounded} text={fallbackText} size={size} />
<FallBackAvatar rounded={rounded} str={fallback} size={size} />
) : (
<Image
<img
src={src}
alt={alt}
className="flex h-full w-full items-center justify-center rounded-full object-cover"
onError={() => setError(true)}
layout="fill"
objectFit="cover"
/>
)}
</div>
Expand Down
4 changes: 4 additions & 0 deletions apps/core/src/components/icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './ImageIcon';
4 changes: 4 additions & 0 deletions apps/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@
// SPDX-License-Identifier: Apache-2.0

export * from './KioskClientProvider';

export * from './coin';
export * from './icon';
export * from './Inputs';
export * from './QR';
4 changes: 4 additions & 0 deletions apps/core/src/forms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

export * from './token';
Loading

0 comments on commit bacfda4

Please sign in to comment.