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 (
+
+ );
+}
+
+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 \