diff --git a/packages/browser-wallet/.storybook/main.js b/packages/browser-wallet/.storybook/main.js index 81c79a823..26b73cd75 100644 --- a/packages/browser-wallet/.storybook/main.js +++ b/packages/browser-wallet/.storybook/main.js @@ -7,6 +7,7 @@ const pathToSvgAssets = path.resolve(__dirname, '../src/assets/svg'); module.exports = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + staticDirs: [{ from: '../src/assets', to: '/assets' }], addons: [ getAbsolutePath('@storybook/addon-links'), @@ -14,6 +15,7 @@ module.exports = { getAbsolutePath('@storybook/addon-interactions'), getAbsolutePath('@storybook/preset-scss'), getAbsolutePath('@storybook/addon-webpack5-compiler-swc'), + getAbsolutePath('@storybook/addon-viewport'), ], framework: { @@ -34,7 +36,21 @@ module.exports = { rules.push({ test: /\.svg$/, include: pathToSvgAssets, - use: ['@svgr/webpack'], + use: [ + { + loader: '@svgr/webpack', + options: { + svgoConfig: { + plugins: [ + { + name: 'removeViewBox', + active: false, + }, + ], + }, + }, + }, + ], }); config.resolve.plugins = [new TsconfigPathsPlugin()]; diff --git a/packages/browser-wallet/.storybook/preview.jsx b/packages/browser-wallet/.storybook/preview.jsx index a5d399975..20f9bcfd5 100644 --- a/packages/browser-wallet/.storybook/preview.jsx +++ b/packages/browser-wallet/.storybook/preview.jsx @@ -3,6 +3,14 @@ import React, { useEffect } from 'react'; import '../src/popup/index.scss'; import '../src/popup/shell/i18n'; +// Workaround for bigint serialization error: https://github.com/storybookjs/storybook/issues/22452 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line no-extend-native, func-names +BigInt.prototype.toJSON = function () { + return this.toString(); +}; + document.getElementsByTagName('html').item(0)?.classList.add('ui-scale-large'); /** @@ -30,14 +38,46 @@ const themeDecorator = (Story, { globals, parameters }) => { return ; }; +/** + * Adds custom styling to the HTML surrounding individual stories. + */ +const customStyleDecorator = (Story) => { + return ( +
+ +
+ ); +}; + export const parameters = { + viewport: { + viewports: { + Small: { + name: 'Small', + styles: { + width: '312px', + height: '528px', + }, + }, + Normal: { + name: 'Normal', + styles: { + width: '375px', + height: '600px', + }, + }, + }, + // Optionally, you can set default viewports + defaultViewport: 'Normal', + }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, + backgrounds: { default: 'dark' }, }; -export const decorators = [themeDecorator]; +export const decorators = [themeDecorator, customStyleDecorator]; export const tags = ['autodocs']; diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index 493b7f5b6..34a1ac9b9 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -64,6 +64,7 @@ "@storybook/addon-essentials": "^8.3.5", "@storybook/addon-interactions": "^8.3.5", "@storybook/addon-links": "^8.3.5", + "@storybook/addon-viewport": "^8.3.5", "@storybook/addon-webpack5-compiler-swc": "^1.0.5", "@storybook/preset-scss": "^1.0.3", "@storybook/react": "^8.3.5", diff --git a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx index 47f10acfd..de05f0d55 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/MainPage/MainPage.tsx @@ -21,6 +21,7 @@ import FileText from '@assets/svgX/file-text.svg'; import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; import Plant from '@assets/svgX/plant.svg'; import Gear from '@assets/svgX/gear.svg'; +import { formatTokenAmount } from '@popup/popupX/shared/utils/helpers'; /** Hook loading every fungible token added to the account. */ function useAccountFungibleTokens(account: WalletCredential) { @@ -34,22 +35,11 @@ function useAccountTokenBalance(accountAddress: string, contractAddress: string, return balance; } -/** Display a token balance with a number of decimals. Localized. */ -function formatBalance(balance: bigint, decimals: number = 0) { - const padded = balance.toString().padStart(decimals + 1, '0'); - const integer = padded.slice(0, -decimals); - const fraction = padded.slice(-decimals); - const balanceFormatter = new Intl.NumberFormat(undefined, { minimumFractionDigits: decimals }); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore format below supports strings, TypeScript is just not aware. - return balanceFormatter.format(`${integer}.${fraction}`); -} - type TokenBalanceProps = { decimals?: number; tokenId: string; contractAddress: string; accountAddress: string }; /** Component for fetching and displaying a CIS-2 token balance of an account. */ function AccountTokenBalance({ decimals, tokenId, contractAddress, accountAddress }: TokenBalanceProps) { const balanceRaw = useAccountTokenBalance(accountAddress, contractAddress, tokenId) ?? 0n; - const balance = useMemo(() => formatBalance(balanceRaw, decimals), [balanceRaw]); + const balance = useMemo(() => formatTokenAmount(balanceRaw, decimals), [balanceRaw]); return {balance}; } diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss index ed557a006..39ba6da5d 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Form.scss @@ -24,8 +24,10 @@ margin-bottom: rem(8px); width: 100%; outline: none; - font-size: rem(14px); /* override base style, remove after release */ - transform: unset; /* override base style, remove after release */ + font-size: rem(14px); + /* override base style, remove after release */ + transform: unset; + /* override base style, remove after release */ @include transition(border-color); &:focus { @@ -76,6 +78,7 @@ transform: unset; svg { + g, path { fill: $color-mineral-3; @@ -110,11 +113,11 @@ $handle-size: rem(20px); .form-toggle-x { &__root { - input:checked + .form-toggle-x__slider { + input:checked+.form-toggle-x__slider { background-color: $color-green-toggle; } - input:checked + .form-toggle-x__slider::before { + input:checked+.form-toggle-x__slider::before { transform: translateX(rem(24px)); } } @@ -191,11 +194,11 @@ $handle-size: rem(20px); } } - &:hover input ~ .checkmark { + &:hover input~.checkmark { background-color: $color-grey-3; } - input:checked ~ .checkmark::after { + input:checked~.checkmark::after { display: block; } @@ -206,3 +209,7 @@ $handle-size: rem(20px); } } } + +.form-error-message { + color: $color-red-attention !important; +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx index 852840ec3..ee99d1a20 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/Password/Password.tsx @@ -32,7 +32,7 @@ type Props = Pick, 'className' | 'autoFocu * Password input with reveal button and optional strength check. */ export function Password({ showStrength = false, value, className, autoFocus, ...props }: Props) { - const { t } = useTranslation(); + const { t } = useTranslation('x', { keyPrefix: 'sharedX' }); const [reveal, setReveal] = useState(false); const strength = useMemo( diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss new file mode 100644 index 000000000..1ad87a397 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.scss @@ -0,0 +1,140 @@ +.token-amount { + display: flex; + flex-direction: column; + border-radius: rem(16px); + background: $gradient-card-bg; + margin-top: rem(16px); + padding: rem(20px) rem(16px); + + .text__main_medium, + .text__main { + color: $color-black; + } + + .capture__main_small { + color: $color-grey-3 + } + + .capture__additional_small { + padding: rem(4px); + border-radius: rem(4px); + color: $color-black; + background: $secondary-button-bg; + } + + &_token, + &_amount, + &_receiver { + display: flex; + flex-direction: column; + } + + &_token { + .token-selector-container { + flex-wrap: wrap; + display: flex; + align-items: center; + margin-top: rem(12px); + padding-bottom: rem(12px); + border-bottom: 1px solid rgba($color-black, 0.1); + + .token-selector { + display: flex; + cursor: pointer; + position: relative; + } + + .text__additional_small { + margin-left: auto; + text-wrap: nowrap; + } + + .token-icon { + display: flex; + padding: rem(5px); + margin-right: rem(8px); + border-radius: rem(6px); + background: $color-grey-1; + + svg, + img { + width: rem(14px); + height: rem(14px); + } + } + + select { + appearance: none; + background: none; + border: none; + position: absolute; + z-index: 0; + color: transparent; + width: 100%; + height: 100%; + + &::selection { + background-color: transparent; + } + } + } + } + + &_amount { + position: relative; + margin-top: rem(24px); + + &_selector { + display: flex; + align-items: center; + justify-content: space-between; + padding: rem(8px) 0; + margin-bottom: rem(4px); + border-bottom: 1px solid rgba($color-black, 0.1); + + .heading_medium { + color: $color-black; + } + } + + &_field { + flex: 1; + min-width: 0; + margin-right: rem(4px); + } + + &_max { + flex: 0; + text-wrap: nowrap; + } + } + + &_field { + border: none; + width: 100%; + background: none; + + &--invalid { + color: $color-red-attention !important; + } + } + + button.capture__additional_small { + border: unset; + cursor: pointer; + } + + &_receiver { + margin-top: rem(24px); + + .address-selector { + display: flex; + justify-content: space-between; + margin-top: rem(12px); + } + + textarea { + resize: none; + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx new file mode 100644 index 000000000..ca72059ef --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.stories.tsx @@ -0,0 +1,95 @@ +/* eslint-disable no-console */ +/* eslint-disable prefer-destructuring */ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { PropsOf } from 'wallet-common-helpers'; + +import TokenAmountView, { AmountReceiveForm } from './View'; +import Form, { useForm } from '..'; + +function Wrapper(props: PropsOf) { + const form = useForm({ + mode: 'onTouched', + defaultValues: { + amount: '1,000.00', + receiver: '3ybJ66spZ2xdWF3avgxQb2meouYa7mpvMWNPmUnczU8FoF8cGB', + }, + }); + return ( +
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {() => } + + ); +} + +export default { + title: 'X/Shared/Form/TokenAmount', + component: TokenAmountView, + render: (props) => , + beforeEach: () => { + const body = document.getElementsByTagName('body').item(0); + body?.classList.add('popup-x'); + + return () => { + body?.classList.remove('popup-x'); + }; + }, +} as Meta; + +type Story = StoryObj; + +const tokens = [ + { + id: '', + contract: ContractAddress.create(123, 0), + metadata: { + symbol: 'wETH', + name: 'Wrapped Ether', + decimals: 18, + thumbnail: { url: 'https://s2.coinmarketcap.com/static/img/coins/64x64/2396.png' }, + }, + }, + { + id: '', + contract: ContractAddress.create(432, 0), + metadata: { symbol: 'wCCD', name: 'Wrapped CCD', decimals: 6 }, + }, +]; + +export const OnlyAmount: Story = { + args: { + tokenType: 'ccd', + fee: CcdAmount.fromCcd(0.032), + buttonMaxLabel: 'Stake max.', + receiver: false, + tokens, + balance: 17004000000n, + onSelectToken: console.log, + }, +}; + +export const WithReceiver: Story = { + args: { + buttonMaxLabel: 'Send max.', + fee: CcdAmount.fromCcd(0.032), + receiver: true, + tokens, + balance: 17004000000n, + onSelectToken: console.log, + }, +}; + +export const TokenWithReceiver: Story = { + args: { + tokenType: 'cis2', + tokenAddress: { id: '', contract: ContractAddress.create(123, 0) }, + buttonMaxLabel: 'Send max.', + fee: CcdAmount.fromCcd(0.132), + receiver: true, + tokens, + balance: 17004000000n, + onSelectToken: console.log, + }, +}; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx new file mode 100644 index 000000000..79ed84ea9 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/TokenAmount.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import { atomFamily, selectAtom, useAtomValue } from 'jotai/utils'; +import { AccountAddress, AccountInfo, ContractAddress, CIS2 } from '@concordium/web-sdk'; +import { atom } from 'jotai'; + +import { contractBalancesFamily } from '@popup/store/token'; +import TokenAmountView, { TokenAmountViewProps } from './View'; +import { useTokenInfo } from './util'; + +const tokenAddressEq = (a: CIS2.TokenAddress | null, b: CIS2.TokenAddress | null) => { + if (a !== null && b !== null) { + return a.id === b.id && ContractAddress.equals(a.contract, b.contract); + } + + return a === b; +}; + +const balanceAtomFamily = atomFamily( + ([account, tokenAddress]: [AccountInfo, CIS2.TokenAddress | null]) => { + if (tokenAddress === null) { + return atom(account.accountAvailableBalance.microCcdAmount); + } + const tokens = contractBalancesFamily(account.accountAddress.address, tokenAddress.contract.index.toString()); + return selectAtom(tokens, (ts) => ts[tokenAddress.id]); + }, + ([aa, ta], [ab, tb]) => AccountAddress.equals(aa.accountAddress, ab.accountAddress) && tokenAddressEq(ta, tb) +); + +type Props = Omit & { + /** The account info of the account to take the amount from */ + accountInfo: AccountInfo; +}; + +/** + * TokenAmount component renders a form for transferring tokens with an amount field and optionally a receiver field. + * + * @example + * const formMethods = useForm(); + * const tokens = [{ + * id: '', + * contract: ContractAddress.create(1), + * metadata: { symbol: 'wETH', name: 'Wrapped Ether', decimals: 18 }, + * }]; + * + * // Usage with token picker & receiver + * + * + * // Usage with CCD token + * const formMethods = useForm(); + * + * + * // Usage with CIS2 token + receiver + * const formMethods = useForm(); + * + */ +export default function TokenAmount({ accountInfo, ...props }: Props) { + const tokenInfo = useTokenInfo(accountInfo.accountAddress); + const [tokenAddress, setTokenAddress] = useState(null); + const tokenBalance = useAtomValue(balanceAtomFamily([accountInfo, tokenAddress])); + + if (tokenInfo.loading) { + return null; + } + + return ( + + ); +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx new file mode 100644 index 000000000..20618d4fe --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/TokenAmount/View.tsx @@ -0,0 +1,420 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable react/destructuring-assignment */ +import React, { InputHTMLAttributes, ReactNode, forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { UseFormReturn, Validate } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import clsx from 'clsx'; +import { CIS2, CcdAmount, ContractAddress } from '@concordium/web-sdk'; +import { CCD_METADATA } from '@shared/constants/token-metadata'; +import { ensureDefined } from '@shared/utils/basic-helpers'; +import SideArrow from '@assets/svgX/side-arrow.svg'; +import ConcordiumLogo from '@assets/svgX/concordium-logo.svg'; +import { validateAccountAddress, validateTransferAmount } from '@popup/shared/utils/transaction-helpers'; +import Img, { DEFAULT_FAILED } from '@popup/shared/Img'; +import { displayAsCcd } from 'wallet-common-helpers'; +import { RequiredUncontrolledFieldProps } from '../common/types'; +import { makeUncontrolled } from '../common/utils'; +import Button from '../../Button'; +import { formatTokenAmount, parseTokenAmount, removeNumberGrouping } from '../../utils/helpers'; +import ErrorMessage from '../ErrorMessage'; +import { TokenInfo } from './util'; + +type AmountInputProps = Pick< + InputHTMLAttributes, + 'className' | 'type' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' +> & + RequiredUncontrolledFieldProps; + +/** + * @description + * Use as a normal \. Should NOT be used for checkbox or radio. + */ +const InputClear = forwardRef( + ({ error, className, type = 'text', ...props }, ref) => { + return ( + + ); + } +); + +const FormInputClear = makeUncontrolled(InputClear); + +type ReceiverInputProps = Pick< + InputHTMLAttributes, + 'className' | 'value' | 'onChange' | 'onBlur' | 'autoFocus' +> & + RequiredUncontrolledFieldProps; + +/** + * @description + * Use as a normal \