diff --git a/apps/web-stratos/codegen.yml b/apps/web-stratos/codegen.yml index 5c948b9d83..02dde92358 100644 --- a/apps/web-stratos/codegen.yml +++ b/apps/web-stratos/codegen.yml @@ -3,7 +3,7 @@ generates: ./src/graphql/types/general_types.ts: documents: - 'src/graphql/general/*' - schema: https://hasura-dev.thestratos.org/v1/graphql + schema: https://hasura.thestratos.org/v1/graphql config: # omitOperationSuffix: true skipTypeNameForRoot: true diff --git a/apps/web-stratos/src/recoil/profiles/atom.ts b/apps/web-stratos/src/recoil/profiles/atom.ts new file mode 100644 index 0000000000..4a057f3990 --- /dev/null +++ b/apps/web-stratos/src/recoil/profiles/atom.ts @@ -0,0 +1,9 @@ +import { atomFamily } from 'recoil'; +import type { AtomState } from '@/recoil/profiles/types'; + +const initialState: AtomState = null; + +export const atomFamilyState = atomFamily({ + key: 'profile', + default: initialState, +}); diff --git a/apps/web-stratos/src/recoil/profiles/hooks.ts b/apps/web-stratos/src/recoil/profiles/hooks.ts new file mode 100644 index 0000000000..169b3b0253 --- /dev/null +++ b/apps/web-stratos/src/recoil/profiles/hooks.ts @@ -0,0 +1,53 @@ +import { useRecoilValue } from 'recoil'; +import chainConfig from '@/chainConfig'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import { useDesmosProfile } from '@/hooks/use_desmos_profile'; +import { + readDelegatorAddress, + readDelegatorAddresses, + readProfile, + readProfiles, +} from '@/recoil/profiles/selectors'; + +const { extra } = chainConfig(); + +/** + * Accepts a delegator address and returns the appropriate profile + * @param address + */ +export const useProfileRecoil = (address: string): AvatarName => { + const profiles = useRecoilValue(readProfile(address)); + const delegatorAddress = useRecoilValue(readDelegatorAddress(address)); + + // ========================== + // Desmos Profile + // ========================== + useDesmosProfile({ + addresses: delegatorAddress ? [delegatorAddress] : [], + skip: true, + }); + + return profiles; +}; + +/** + * Accepts a list of addresses and returns the appropriate profiles + * @param address + */ +export const useProfilesRecoil = ( + addresses: string[] +): { profiles: AvatarName[]; loading: boolean; error: unknown } => { + const profiles = useRecoilValue(readProfiles(addresses)); + const delegatorAddresses = useRecoilValue(readDelegatorAddresses(addresses)); + const delegatorAddressMemo = useShallowMemo(delegatorAddresses); + + // ========================== + // Desmos Profile + // ========================== + const { loading, error } = useDesmosProfile({ + addresses: delegatorAddressMemo, + skip: true, + }); + + return { profiles, loading, error }; +}; diff --git a/apps/web-stratos/src/recoil/profiles/index.ts b/apps/web-stratos/src/recoil/profiles/index.ts new file mode 100644 index 0000000000..bf4df2b0a1 --- /dev/null +++ b/apps/web-stratos/src/recoil/profiles/index.ts @@ -0,0 +1,12 @@ +export { atomFamilyState } from '@/recoil/profiles/atom'; +export { useProfileRecoil, useProfilesRecoil } from '@/recoil/profiles/hooks'; +export { + readDelegatorAddress, + readDelegatorAddresses, + readProfile, + readProfileExist, + readProfiles, + readProfilesExist, + validatorToDelegatorAddress, + writeProfile, +} from '@/recoil/profiles/selectors'; diff --git a/apps/web-stratos/src/recoil/profiles/selectors.ts b/apps/web-stratos/src/recoil/profiles/selectors.ts new file mode 100644 index 0000000000..9e11359572 --- /dev/null +++ b/apps/web-stratos/src/recoil/profiles/selectors.ts @@ -0,0 +1,228 @@ +import { bech32 } from 'bech32'; +import { GetRecoilValue, selectorFamily } from 'recoil'; +import chainConfig from '@/chainConfig'; +import { atomFamilyState } from '@/recoil/profiles/atom'; +import type { AtomState as ProfileAtomState } from '@/recoil/profiles/types'; +import { readValidator } from '@/recoil/validators'; + +const { prefix } = chainConfig(); +const consensusRegex = new RegExp(`^(${prefix.consensus})`); +const validatorRegex = new RegExp(`^(${prefix.validator})`); +const delegatorRegex = new RegExp(`^(${prefix.account})`); + +/** + * It takes an address and returns the delegator address + * @param - `address` - consensus address or validator address or delegator address + * @returns The address of the delegator. + */ +const getDelegatorAddress = ({ + address, + get, +}: { + address: string; + get: GetRecoilValue; +}): string => { + let selectedAddress = ''; + if (consensusRegex.test(address)) { + // address given is a consensus + const validator = get(readValidator(address)); + if (validator) { + selectedAddress = validator.delegator; + } + } else if (validatorRegex.test(address)) { + // address given is a validator + const decode = bech32.decode(address).words; + selectedAddress = bech32.encode(prefix.account, decode); + } else if (delegatorRegex.test(address)) { + // address given is a delegator + selectedAddress = address; + } + return selectedAddress; +}; + +/** + * It takes a validator address and returns the delegator address + * @param {string} address - The address of the validator to be converted to the delegator address. + * @returns The address of the validator + */ +export const validatorToDelegatorAddress = (address: string) => { + const decode = bech32.decode(address).words; + return bech32.encode(prefix.account, decode); +}; + +/** + * Returns a validator address if the given address is a consensus address. + * Returns address otherwise + */ +const getReturnAddress = ({ address, get }: { address: string; get: GetRecoilValue }): string => { + let selectedAddress = address; + if (consensusRegex.test(address)) { + // address given is a consensus + const validator = get(readValidator(address)); + if (validator) { + selectedAddress = validator.validator; + } + } + return selectedAddress; +}; + +/** + * Takes a address and returns the profile + * Returns null if no record found + * ex - cosmosvalcon1... returns cosmosvaloper1... + * @param address string + * @returns string | null + */ +const getProfile = + (address: string) => + ({ get }: { get: GetRecoilValue }): AvatarName => { + const returnAddress = getReturnAddress({ + address, + get, + }); + const delegatorAddress = getDelegatorAddress({ + address, + get, + }); + const state = get(atomFamilyState(delegatorAddress)); + const name = state && state !== true ? (state.moniker ?? address) : address; + const imageUrl = state && state !== true ? state.imageUrl || undefined : undefined; + return { + address: returnAddress, + name: name || address || '', + imageUrl, + }; + }; + +/** + * It takes an array of addresses and returns an array of AvatarName objects + * @param {string[]} addresses - string[] - an array of addresses to get the profile for + * @returns An array of objects with the following properties: + * address: string + * name: string + * imageUrl: string + */ +const getProfiles = + (addresses: string[]) => + ({ get }: { get: GetRecoilValue }): AvatarName[] => { + const profiles = addresses.map((x) => { + const returnAddress = getReturnAddress({ + address: x, + get, + }); + const delegatorAddress = getDelegatorAddress({ + address: x, + get, + }); + const state = get(atomFamilyState(delegatorAddress)); + const name = state && state !== true ? (state?.moniker ?? x) : x; + const imageUrl = state && state !== true ? state?.imageUrl || undefined : undefined; + return { + address: returnAddress, + name: name || x || '', + imageUrl, + }; + }); + return profiles; + }; + +/* A selector family that takes an address and returns a profile. */ +export const writeProfile = selectorFamily({ + key: 'profile.write.profile', + get: getProfile, + set: + (address: string) => + ({ set, get }, profile) => { + const delegatorAddress = getDelegatorAddress({ + address, + get, + }); + if (delegatorAddress) { + if (!isAvatarName(profile)) { + set(atomFamilyState(delegatorAddress), false); + } else { + set(atomFamilyState(delegatorAddress), { + moniker: profile.name, + imageUrl: profile.imageUrl || undefined, + }); + } + } + + function isAvatarName(x: typeof profile): x is AvatarName { + if (!x) return false; + return 'name' in x && 'imageUrl' in x; + } + }, +}); + +/* Creating a selector family that takes an address and returns a profile. */ +export const readProfile = selectorFamily({ + key: 'profile.read.profile', + get: getProfile, +}); + +/* Creating a selector family that takes an array of addresses and returns an array of AvatarName +objects. */ +export const readProfiles = selectorFamily({ + key: 'profile.read.profiles', + get: getProfiles, +}); + +/* A selector family that takes an address and returns a delegator address. */ +export const readDelegatorAddress = selectorFamily({ + key: 'profile.read.delegatorAddress', + get: + (address: string) => + ({ get }): string => + getDelegatorAddress({ + address, + get, + }), +}); + +/* A selector family that takes an array of addresses and returns an array of delegator addresses. */ +export const readDelegatorAddresses = selectorFamily({ + key: 'profile.read.delegatorAddresses', + get: + (addresses: string[]) => + ({ get }): string[] => + addresses.map((x) => + getDelegatorAddress({ + address: x, + get, + }) + ), +}); + +/* A selector family that takes an address and returns a profile. */ +export const readProfileExist = selectorFamily({ + key: 'profile.read.profileExist', + get: + (address: string) => + ({ get }): ProfileAtomState => { + const delegatorAddress = getDelegatorAddress({ + address, + get, + }); + const state = get(atomFamilyState(delegatorAddress)); + return state; + }, +}); + +/* A selector family that takes an array of addresses and returns an array of profile states. */ +export const readProfilesExist = selectorFamily({ + key: 'profile.read.profilesExist', + get: + (addresses: string[]) => + ({ get }) => { + const profiles: ProfileAtomState[] = addresses.map((x) => { + const delegatorAddress = getDelegatorAddress({ + address: x, + get, + }); + const state = get(atomFamilyState(delegatorAddress)); + return state; + }); + return profiles; + }, +}); diff --git a/apps/web-stratos/src/recoil/profiles/types.ts b/apps/web-stratos/src/recoil/profiles/types.ts new file mode 100644 index 0000000000..d4c2386f05 --- /dev/null +++ b/apps/web-stratos/src/recoil/profiles/types.ts @@ -0,0 +1,12 @@ +export type AtomState = + | { + moniker: string; + imageUrl?: string; + } + | null + | boolean; + +export interface Profile { + moniker: string; + imageUrl?: string; +} diff --git a/apps/web-stratos/src/screens/account_details/components/balance/index.tsx b/apps/web-stratos/src/screens/account_details/components/balance/index.tsx new file mode 100644 index 0000000000..197fccacfd --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/balance/index.tsx @@ -0,0 +1,129 @@ +import chainConfig from '@/chainConfig'; +import Box from '@/components/box'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import { readMarket } from '@/recoil/market'; +import useStyles from '@/screens/account_details/components/balance/styles'; +import { formatBalanceData } from '@/screens/account_details/components/balance/utils'; +import { formatNumber } from '@/utils/format_token'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import Big from 'big.js'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import dynamic from 'next/dynamic'; +import numeral from 'numeral'; +import { FC } from 'react'; +import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts'; +import { useRecoilValue } from 'recoil'; + +const DynamicPieChart = dynamic(() => Promise.resolve(PieChart), { ssr: false }); +const { primaryTokenUnit, tokenUnits } = chainConfig(); + +type BalanceProps = Parameters[0] & { + className?: string; + total: TokenUnit; +}; + +const Balance: FC = (props) => { + const { t } = useAppTranslation('accounts'); + const { classes, cx, theme } = useStyles(); + const market = useRecoilValue(readMarket); + const formattedChartData = formatBalanceData(props); + const empty = { + key: 'empty', + value: 2400, + background: theme.palette.custom.charts.zero, + display: '', + }; + const backgrounds = [ + theme.palette.custom.charts.one, + theme.palette.custom.charts.two, + theme.palette.custom.charts.three, + theme.palette.custom.charts.four, + theme.palette.custom.charts.five, + ]; + const formatData = formattedChartData.map((x, i) => ({ + ...x, + value: numeral(x.value).value(), + background: backgrounds[i], + })); + const notEmpty = formatData.some((x) => x.value && Big(x.value).gt(0)); + const dataMemo = useShallowMemo(notEmpty ? formatData : [...formatData, empty]); + + const dataCount = formatData.filter((x) => x.value && Big(x.value).gt(0)).length; + const totalAmount = `$${numeral( + Big(market.price || 0) + ?.times(props.total.value) + .toPrecision() + ).format('0,0.00')}`; + + // format + const totalDisplay = formatNumber(props.total.value, props.total.exponent); + + return ( + + {t('balance')} +
+
+ + + 1 ? 5 : 0} + fill="#82ca9d" + stroke="none" + > + {dataMemo.map((entry) => ( + + ))} + + + +
+
+ {dataMemo.map((x) => { + if (x.key.toLowerCase() === 'empty') { + return null; + } + + return ( +
+
+
+ {t(x.key)} +
+ {x.display} +
+ ); + })} +
+
+
+ +
+
+ + {t('total', { + unit: props.total.displayDenom.toUpperCase(), + })} + + {totalDisplay} +
+
+ + ${numeral(market.price).format('0,0.[00]', Math.floor)} /{' '} + {(tokenUnits?.[primaryTokenUnit]?.display ?? '').toUpperCase()} + + {totalAmount} +
+
+
+ + ); +}; + +export default Balance; diff --git a/apps/web-stratos/src/screens/account_details/components/balance/styles.ts b/apps/web-stratos/src/screens/account_details/components/balance/styles.ts new file mode 100644 index 0000000000..b853f5ff60 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/balance/styles.ts @@ -0,0 +1,86 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + '& .MuiTypography-h2': { + marginBottom: theme.spacing(2), + }, + [theme.breakpoints.up('lg')]: { + display: 'flex', + flexDirection: 'column', + }, + }, + chart: { + height: '300px', + [theme.breakpoints.up('md')]: { + height: '200px', + width: '200px', + }, + [theme.breakpoints.up('lg')]: { + height: '150px', + width: '150px', + }, + }, + chartWrapper: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + [theme.breakpoints.up('md')]: { + flexDirection: 'row', + alignItems: 'center', + }, + }, + legends: { + color: theme.palette.custom.fonts.fontTwo, + '& .legends__single--container': { + marginBottom: theme.spacing(1), + [theme.breakpoints.up('md')]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + }, + '& .single__label--container': { + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(0.5), + }, + '& .legend-color': { + width: theme.spacing(1.75), + height: theme.spacing(1.75), + borderRadius: '2px', + marginRight: theme.spacing(1), + }, + [theme.breakpoints.up('md')]: { + flex: 1, + marginLeft: theme.spacing(3), + }, + }, + divider: { + margin: theme.spacing(2, 0), + }, + total: { + '& .total__single--container': { + marginBottom: theme.spacing(1), + '& .label': { + marginBottom: theme.spacing(0.5), + color: theme.palette.custom.fonts.fontTwo, + [theme.breakpoints.up('md')]: { + color: theme.palette.custom.fonts.fontOne, + }, + }, + [theme.breakpoints.up('md')]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + }, + '& .total__secondary--container': { + [theme.breakpoints.up('md')]: { + color: theme.palette.custom.fonts.fontTwo, + }, + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/balance/utils.tsx b/apps/web-stratos/src/screens/account_details/components/balance/utils.tsx new file mode 100644 index 0000000000..d8e4f1e529 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/balance/utils.tsx @@ -0,0 +1,61 @@ +import Big from 'big.js'; +import { formatNumber } from '@/utils/format_token'; + +export const formatBalanceData = (data: { + available: TokenUnit; + delegate: TokenUnit; + unbonding: TokenUnit; + reward: TokenUnit; + commission?: TokenUnit; + total?: TokenUnit; +}) => { + const balanceChart = [ + { + key: 'balanceAvailable', + display: `${formatNumber( + data.available.value, + data.available.exponent + )} ${data.available.displayDenom.toUpperCase()}`, + value: data.available.value, + }, + { + key: 'balanceDelegate', + display: `${formatNumber( + data.delegate.value, + data.delegate.exponent + )} ${data.delegate.displayDenom.toUpperCase()}`, + value: data.delegate.value, + }, + { + key: 'balanceUnbonding', + display: `${formatNumber( + data.unbonding.value, + data.unbonding.exponent + )} ${data.unbonding.displayDenom.toUpperCase()}`, + value: data.unbonding.value, + }, + { + key: 'balanceReward', + display: data.reward + ? `${formatNumber( + data.reward.value, + data.reward.exponent + )} ${data.reward.displayDenom.toUpperCase()}` + : '', + value: data.reward?.value, + }, + ]; + + if (data.commission && Big(data.commission.value).gt(0)) { + balanceChart.push({ + key: 'balanceCommission', + display: `${formatNumber( + data.commission.value, + data.commission.exponent + )} ${data.commission.displayDenom.toUpperCase()}`, + value: data.commission.value, + }); + } + + return balanceChart; +}; diff --git a/apps/web-stratos/src/screens/account_details/components/index.ts b/apps/web-stratos/src/screens/account_details/components/index.ts new file mode 100644 index 0000000000..6947bf7a0a --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/index.ts @@ -0,0 +1,5 @@ +export { default as Balance } from '@/screens/account_details/components/balance'; +export { default as OtherTokens } from '@/screens/account_details/components/other_tokens'; +export { default as Overview } from '@/screens/account_details/components/overview'; +export { default as Staking } from '@/screens/account_details/components/staking'; +export { default as Transactions } from '@/screens/account_details/components/transactions'; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/index.tsx b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/index.tsx new file mode 100644 index 0000000000..7bee641658 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/index.tsx @@ -0,0 +1,64 @@ +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { formatNumber } from '@/utils/format_token'; +import type { OtherTokenType } from '@/screens/account_details/types'; +import { columns } from '@/screens/account_details/components/other_tokens/components/desktop/utils'; + +type DesktopProps = { + className?: string; + items?: OtherTokenType[]; +}; + +const Desktop: FC = ({ className, items }) => { + const { t } = useAppTranslation('accounts'); + + const formattedItems = items?.map((x, i) => ({ + key: i, + token: x.denom.toUpperCase(), + commission: formatNumber(x.commission.value, x.commission.exponent), + available: formatNumber(x.available.value, x.available.exponent), + reward: x.reward ? formatNumber(x.reward.value, x.reward.exponent) : '', + })); + + return ( +
+ + + + {columns.map((column) => ( + + {t(column.key)} + + ))} + + + + {formattedItems?.map((row) => ( + + {columns.map((column) => ( + + {row[column.key as keyof typeof row]} + + ))} + + ))} + +
+
+ ); +}; + +export default Desktop; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/utils.tsx b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/utils.tsx new file mode 100644 index 0000000000..f4994b3b67 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/desktop/utils.tsx @@ -0,0 +1,25 @@ +export const columns: { + key: string; + align?: 'left' | 'center' | 'right' | 'justify' | 'inherit'; + width: number; +}[] = [ + { + key: 'token', + width: 25, + }, + { + key: 'available', + width: 25, + align: 'right', + }, + { + key: 'reward', + width: 25, + align: 'right', + }, + { + key: 'commission', + width: 25, + align: 'right', + }, +]; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/components/index.ts b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/index.ts new file mode 100644 index 0000000000..3440e69f49 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/index.ts @@ -0,0 +1,4 @@ +import Desktop from '@/screens/account_details/components/other_tokens/components/desktop'; +import Mobile from '@/screens/account_details/components/other_tokens/components/mobile'; + +export { Desktop, Mobile }; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/index.tsx b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/index.tsx new file mode 100644 index 0000000000..a77d21331d --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/index.tsx @@ -0,0 +1,69 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC, Fragment } from 'react'; +import { formatNumber } from '@/utils/format_token'; +import type { OtherTokenType } from '@/screens/account_details/types'; +import useStyles from '@/screens/account_details/components/other_tokens/components/mobile/styles'; + +type MobileProps = { + className?: string; + items?: OtherTokenType[]; +}; + +const Mobile: FC = ({ className, items }) => { + const { classes } = useStyles(); + const { t } = useAppTranslation('accounts'); + return ( +
+ {items?.map((x, i) => { + const available = formatNumber(x.available.value, x.available.exponent); + const reward = x.reward ? formatNumber(x.reward.value, x.reward.exponent) : ''; + const commission = formatNumber(x.commission.value, x.commission.exponent); + const isLast = !items || i === items.length - 1; + return ( + // eslint-disable-next-line react/no-array-index-key + +
+
+ + {t('token')} + + + {x.denom.toUpperCase()} + +
+
+ + {t('available')} + + + {available} + +
+
+ + {t('reward')} + + + {reward} + +
+
+ + {t('commission')} + + + {commission} + +
+
+ {!isLast && } +
+ ); + })} +
+ ); +}; + +export default Mobile; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/styles.ts b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/styles.ts new file mode 100644 index 0000000000..f6446cf8b9 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/components/mobile/styles.ts @@ -0,0 +1,32 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + list: { + margin: theme.spacing(2, 0), + width: '100%', + }, + item: { + marginBottom: theme.spacing(2), + '& .label': { + marginBottom: theme.spacing(1), + color: theme.palette.custom.fonts.fontThree, + }, + '& p.value': { + color: theme.palette.custom.fonts.fontTwo, + wordBreak: 'break-all', + }, + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + '& > div': { + width: '50%', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/index.tsx b/apps/web-stratos/src/screens/account_details/components/other_tokens/index.tsx new file mode 100644 index 0000000000..7821e3c59e --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/index.tsx @@ -0,0 +1,54 @@ +import Box from '@/components/box'; +import Pagination from '@/components/pagination'; +import { usePagination } from '@/hooks/use_pagination'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import Desktop from '@/screens/account_details/components/other_tokens/components/desktop'; +import Mobile from '@/screens/account_details/components/other_tokens/components/mobile'; +import useStyles from '@/screens/account_details/components/other_tokens/styles'; +import type { OtherTokenType } from '@/screens/account_details/types'; +import { useDisplayStyles } from '@/styles/useSharedStyles'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC, useMemo } from 'react'; + +type OtherTokensProps = { + className?: string; + otherTokens: { + data: OtherTokenType[]; + count: number; + }; +}; + +const OtherTokens: FC = ({ className, otherTokens }) => { + const { t } = useAppTranslation('accounts'); + const { classes } = useStyles(); + const display = useDisplayStyles().classes; + const { page, rowsPerPage, handlePageChange, handleRowsPerPageChange, sliceItems } = + usePagination({}); + const dataMemo = useShallowMemo(otherTokens.data); + const items = useMemo(() => sliceItems(dataMemo), [dataMemo, sliceItems]); + + const count = dataMemo.length; + if (!dataMemo.length) { + return null; + } + + return ( + + {t('otherTokens')} + + + + + ); +}; + +export default OtherTokens; diff --git a/apps/web-stratos/src/screens/account_details/components/other_tokens/styles.ts b/apps/web-stratos/src/screens/account_details/components/other_tokens/styles.ts new file mode 100644 index 0000000000..8c91344178 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/other_tokens/styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + paginate: { + marginTop: theme.spacing(3), + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/overview/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/account_details/components/overview/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..27b9776566 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/__snapshots__/index.test.tsx.snap @@ -0,0 +1,170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: AccountDetails/Overview matches snapshot 1`] = ` +@media (min-width:768px) { + .emotion-0 { + display: grid; + grid-template-columns: repeat(2,1fr); + } +} + +.emotion-1 { + padding: 16px 0px; + color: #414141; +} + +.emotion-1 .detail { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; + -webkit-box-pack: end; + -ms-flex-pack: end; + -webkit-justify-content: flex-end; + justify-content: flex-end; +} + +.emotion-1 .detail svg { + width: 1rem; + margin-left: 8px; +} + +.emotion-1:first-of-type { + padding-top: 0; +} + +.emotion-1:last-child { + padding-bottom: 0; +} + +.emotion-1:not(:last-child) { + border-bottom: solid 1px #E8E8E8; +} + +.emotion-1 .label { + margin-bottom: 8px; +} + +.emotion-1 .detail.MuiTypography-body1 { + word-wrap: break-word; +} + +@media (min-width:768px) { + .emotion-1 { + padding: 0; + } + + .emotion-1:not(:last-child) { + border-bottom: none; + } + + .emotion-1 .label { + margin-bottom: 0; + } +} + +.emotion-2 { + margin: 0; + font-size: 1rem; + white-space: pre-wrap; + letter-spacing: 0.5px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.5; +} + +.emotion-3:hover { + cursor: pointer; +} + +@media (max-width:1279.95px) { + .emotion-6 { + display: none; + } +} + +@media (min-width:1280px) { + .emotion-7 { + display: none; + } +} + +
+
+

+ address +

+
+ + +

+ + desmos1jrld5g998gqm4yx26l6cvhxz7y5adgxquy94nz + + + desmos1jrld5g99...y94nz + +

+
+
+
+

+ rewardAddress +

+
+ +

+ + desmos1jrld5g998gqm4yx26l6cvhxz7y5adgxquy94nz + + + desmos1jrld5g99...y94nz + +

+
+
+
+`; diff --git a/apps/web-stratos/src/screens/account_details/components/overview/hooks.test.tsx b/apps/web-stratos/src/screens/account_details/components/overview/hooks.test.tsx new file mode 100644 index 0000000000..633dbc417c --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/hooks.test.tsx @@ -0,0 +1,36 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { useOverview } from '@/screens/account_details/components/overview/hooks'; + +jest.mock('react-toastify', () => ({ + toast: jest.fn(), +})); + +jest.mock('copy-to-clipboard', () => ({ + copy: jest.fn(), +})); + +describe('hook: useOverview', () => { + test('handles open correctly', () => { + const { result } = renderHook(() => useOverview()); + expect(result.current.open).toBe(false); + + act(() => result.current.handleOpen()); + expect(result.current.open).toBe(true); + }); + + test('handles close correctly', () => { + const { result } = renderHook(() => useOverview()); + expect(result.current.open).toBe(false); + + act(() => result.current.handleOpen()); + expect(result.current.open).toBe(true); + + act(() => result.current.handleClose()); + expect(result.current.open).toBe(false); + }); +}); + +afterEach(() => { + cleanup(); + jest.clearAllMocks(); +}); diff --git a/apps/web-stratos/src/screens/account_details/components/overview/hooks.ts b/apps/web-stratos/src/screens/account_details/components/overview/hooks.ts new file mode 100644 index 0000000000..a10bfdf87c --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/hooks.ts @@ -0,0 +1,28 @@ +import copy from 'copy-to-clipboard'; +import type { TFunction } from '@/hooks/useAppTranslation'; +import { useState } from 'react'; +import { toast } from 'react-toastify'; + +export const useOverview = (t?: TFunction) => { + const [open, setOpen] = useState(false); + + const handleClose = () => { + setOpen(false); + }; + + const handleOpen = () => { + setOpen(true); + }; + + const handleCopyToClipboard = (value: string) => { + copy(value); + toast(t ? t('common:copied') : 'copied'); + }; + + return { + open, + handleClose, + handleOpen, + handleCopyToClipboard, + }; +}; diff --git a/apps/web-stratos/src/screens/account_details/components/overview/index.test.tsx b/apps/web-stratos/src/screens/account_details/components/overview/index.test.tsx new file mode 100644 index 0000000000..5c53872896 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/index.test.tsx @@ -0,0 +1,37 @@ +import renderer from 'react-test-renderer'; +import Overview from '@/screens/account_details/components/overview'; +import MockTheme from '@/tests/mocks/MockTheme'; + +let component: renderer.ReactTestRenderer; + +// ================================== +// mocks +// ================================== +jest.mock('@/components/box_details', () => (props: JSX.IntrinsicElements['div']) => ( +
+)); +jest.mock('@/components/box', () => (props: JSX.IntrinsicElements['div']) => ( +
+)); + +// ================================== +// unit tests +// ================================== +describe('screen: AccountDetails/Overview', () => { + it('matches snapshot', () => { + component = renderer.create( + + + + ); + const tree = component?.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/apps/web-stratos/src/screens/account_details/components/overview/index.tsx b/apps/web-stratos/src/screens/account_details/components/overview/index.tsx new file mode 100644 index 0000000000..1ffe7ca732 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/index.tsx @@ -0,0 +1,143 @@ +import Box from '@/components/box'; +import { useWindowOrigin } from '@/hooks/use_window'; +import { useOverview } from '@/screens/account_details/components/overview/hooks'; +import useStyles from '@/screens/account_details/components/overview/styles'; +import { useDisplayStyles } from '@/styles/useSharedStyles'; +import { getMiddleEllipsis } from '@/utils/get_middle_ellipsis'; +import Dialog from '@mui/material/Dialog'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { QRCodeSVG } from 'qrcode.react'; +import { FC } from 'react'; +import { + EmailIcon, + EmailShareButton, + FacebookIcon, + FacebookShareButton, + TelegramIcon, + TelegramShareButton, + TwitterIcon, + TwitterShareButton, + WhatsappIcon, + WhatsappShareButton, +} from 'react-share'; +import CopyIcon from 'shared-utils/assets/icon-copy.svg'; +import ShareIcon from 'shared-utils/assets/icon-share.svg'; + +type OverviewProps = { + className?: string; + withdrawalAddress: string; + address: string; +}; + +const Overview: FC = ({ className, address, withdrawalAddress }) => { + const { location } = useWindowOrigin(); + const { classes, cx } = useStyles(); + const display = useDisplayStyles().classes; + const { t } = useAppTranslation('accounts'); + const { open, handleClose, handleOpen, handleCopyToClipboard } = useOverview(t); + + const url = `${location}/accounts/${address}`; + const hashTags = ['bigdipperexplorer', 'bigdipper']; + return ( + <> + + + + {t('scanForAddress')} + + +
+ {t('shareTo')} +
+ + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + {t('address')} + +
+ handleCopyToClipboard(address)} + className={classes.actionIcons} + /> + + + {address} + + {getMiddleEllipsis(address, { + beginning: 15, + ending: 5, + })} + + +
+
+ +
+ + {t('rewardAddress')} + +
+ handleCopyToClipboard(withdrawalAddress)} + /> + + {withdrawalAddress} + + {getMiddleEllipsis(withdrawalAddress, { + beginning: 15, + ending: 5, + })} + + +
+
+
+ + ); +}; + +export default Overview; diff --git a/apps/web-stratos/src/screens/account_details/components/overview/styles.ts b/apps/web-stratos/src/screens/account_details/components/overview/styles.ts new file mode 100644 index 0000000000..2c7d16c7cf --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/overview/styles.ts @@ -0,0 +1,87 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + [theme.breakpoints.up('md')]: { + display: 'grid', + gridTemplateColumns: 'repeat(2,1fr)', + }, + }, + dialog: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'column', + '& .MuiTypography-body1': { + marginBottom: theme.spacing(2), + }, + '& .dialog__share--wrapper': { + marginTop: theme.spacing(2), + }, + '& .share-buttons': { + '&:not(:last-child)': { + marginRight: theme.spacing(1), + }, + '&.email': { + '& circle': { + fill: theme.palette.primary.main, + }, + }, + }, + }, + actionIcons: { + '&:hover': { + cursor: 'pointer', + }, + }, + icons: { + '& svg': { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + }, + }, + item: { + padding: theme.spacing(2, 0), + color: theme.palette.custom.fonts.fontTwo, + '&:first-of-type': { + paddingTop: 0, + }, + '&:last-child': { + paddingBottom: 0, + }, + '&:not(:last-child)': { + borderBottom: `solid 1px ${theme.palette.divider}`, + }, + '& .label': { + marginBottom: theme.spacing(1), + }, + '& .detail': { + '&.MuiTypography-body1': { + wordWrap: 'break-word', + }, + }, + [theme.breakpoints.up('md')]: { + padding: 0, + '&:not(:last-child)': { + borderBottom: 'none', + }, + '& .label': { + marginBottom: 0, + }, + }, + }, + copyText: { + '& .detail': { + display: 'flex', + alignItems: 'center', + flexDirection: 'row-reverse', + justifyContent: 'flex-end', + '& svg': { + width: '1rem', + marginLeft: theme.spacing(1), + }, + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/index.tsx new file mode 100644 index 0000000000..44989de2bc --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/index.tsx @@ -0,0 +1,77 @@ +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { formatNumber } from '@/utils/format_token'; +import type { ItemType } from '@/screens/account_details/components/staking/components/delegations/types'; +import { columns } from '@/screens/account_details/components/staking/components/delegations/components/desktop/utils'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import AvatarName from '@/components/avatar_name'; + +type DelegationsRowProps = { + item: ItemType; + i: number; +}; + +const DelegationsRow: FC = ({ item, i }) => { + const { name, address, imageUrl } = useProfileRecoil(item.validator); + const amount = item.amount ? formatNumber(item.amount.value, item.amount.exponent) : ''; + const commission = item.commission ?? 0; + const reward = item.reward ? formatNumber(item.reward.value, item.reward.exponent) : ''; + const formattedItem = { + identifier: i, + validator: , + amount: `${amount} ${item.amount?.displayDenom.toUpperCase()}`, + commission: `${commission} %`, + reward: `${reward} ${item.reward?.displayDenom.toUpperCase()}`, + }; + return ( + + {columns.map((column) => ( + + {formattedItem[column.key as keyof typeof formattedItem]} + + ))} + + ); +}; + +type DesktopProps = { + className?: string; + items?: ItemType[]; +}; + +const Desktop: FC = ({ className, items }) => { + const { t } = useAppTranslation('accounts'); + + return ( +
+ + + + {columns.map((column) => ( + + {t(column.key)} + + ))} + + + + {items?.map((x, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +
+
+ ); +}; + +export default Desktop; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/utils.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/utils.tsx new file mode 100644 index 0000000000..ccb6bde0c2 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/desktop/utils.tsx @@ -0,0 +1,25 @@ +export const columns: { + key: string; + align?: 'left' | 'center' | 'right' | 'justify' | 'inherit'; + width: number; +}[] = [ + { + key: 'validator', + width: 25, + }, + { + key: 'amount', + width: 25, + align: 'right', + }, + { + key: 'commission', + width: 25, + align: 'right', + }, + { + key: 'reward', + width: 25, + align: 'right', + }, +]; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/index.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/index.ts new file mode 100644 index 0000000000..111746f2a2 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/index.ts @@ -0,0 +1,4 @@ +import Desktop from '@/screens/account_details/components/staking/components/delegations/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/delegations/components/mobile'; + +export { Desktop, Mobile }; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/index.tsx new file mode 100644 index 0000000000..97eac7d5fe --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/index.tsx @@ -0,0 +1,77 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import AvatarName from '@/components/avatar_name'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import useStyles from '@/screens/account_details/components/staking/components/delegations/components/mobile/styles'; +import type { ItemType } from '@/screens/account_details/components/staking/components/delegations/types'; +import { formatNumber } from '@/utils/format_token'; + +type DelegationsItemProps = { + item: ItemType; + isLast: boolean; +}; + +const DelegationsItem: FC = ({ item, isLast }) => { + const { name, address, imageUrl } = useProfileRecoil(item.validator); + const { classes } = useStyles(); + const { t } = useAppTranslation('accounts'); + return ( + <> +
+
+ + {t('validator')} + + +
+
+
+ + {t('amount')} + + + {item.amount ? formatNumber(item.amount.value, item.amount.exponent) : ''}{' '} + {item.amount?.displayDenom.toUpperCase()} + +
+
+ + {t('commission')} + + + {item.commission ? `${item.commission} %` : ''} + +
+
+ + {t('reward')} + + + {item.reward ? formatNumber(item.reward.value, item.reward.exponent) : ''}{' '} + {item.reward?.displayDenom.toUpperCase()} + +
+
+
+ {!isLast && } + + ); +}; + +type MobileProps = { + className?: string; + items?: ItemType[]; +}; + +const Mobile: FC = ({ className, items }) => ( +
+ {items?.map((x, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+); + +export default Mobile; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/styles.ts new file mode 100644 index 0000000000..f4d18a6386 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/components/mobile/styles.ts @@ -0,0 +1,45 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + list: { + margin: theme.spacing(2, 0), + }, + item: { + marginBottom: theme.spacing(2), + '& .label': { + marginBottom: theme.spacing(1), + color: theme.palette.custom.fonts.fontThree, + }, + '& p.value': { + color: theme.palette.custom.fonts.fontTwo, + '&.unknown': { + color: theme.palette.custom.condition.zero, + }, + '&.unbonded': { + color: theme.palette.custom.condition.zero, + }, + '&.active': { + color: theme.palette.custom.condition.one, + }, + '&.jailed': { + color: theme.palette.custom.condition.two, + }, + '&.unbonding': { + color: theme.palette.custom.condition.three, + }, + }, + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + '& > div': { + width: '50%', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/index.tsx new file mode 100644 index 0000000000..affb39f326 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/index.tsx @@ -0,0 +1,74 @@ +import Loading from '@/components/loading'; +import NoData from '@/components/no_data'; +import Pagination from '@/components/pagination'; +import { usePagination } from '@/hooks/use_pagination'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import Desktop from '@/screens/account_details/components/staking/components/delegations/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/delegations/components/mobile'; +import useStyles from '@/screens/account_details/components/staking/components/delegations/styles'; +import type { DelegationsType } from '@/screens/account_details/components/staking/types'; +import { useDisplayStyles } from '@/styles/useSharedStyles'; +import { FC, useCallback } from 'react'; + +type DelegationsProps = { + className?: string; + delegations: DelegationsType; + setPage: (page: number) => void; +}; + +const Delegations: FC = (props) => { + const { classes } = useStyles(); + const display = useDisplayStyles().classes; + const { page, rowsPerPage, handlePageChange, handleRowsPerPageChange } = usePagination({}); + const handlePageChangeCallback = useCallback( + (event: Parameters[0], newPage: number) => { + props.setPage?.(newPage); + handlePageChange?.(event, newPage); + }, + [handlePageChange, props] + ); + const itemsMemo = useShallowMemo(props?.delegations?.data); + + let component = null; + + if (props.delegations.error) { + component =
{props.delegations.error.message}
; + } else if (props.delegations.loading && !itemsMemo?.length) { + component = ; + } else if (!itemsMemo?.length) { + component = ; + } else { + component = ( + <> + + + + ); + } + + let total = props.delegations.count; + if (total === undefined && props.delegations.data?.length !== undefined) { + if (props.delegations.data.length === rowsPerPage) { + total = page * rowsPerPage + props.delegations.data.length + 1; + } else { + total = page * rowsPerPage + props.delegations.data.length; + } + } + + return ( +
+ {component} + +
+ ); +}; + +export default Delegations; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/styles.ts new file mode 100644 index 0000000000..8c91344178 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + paginate: { + marginTop: theme.spacing(3), + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/types.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/types.ts new file mode 100644 index 0000000000..a5e3360a26 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/delegations/types.ts @@ -0,0 +1,3 @@ +import type { DelegationType } from '@/screens/account_details/components/staking/types'; + +export type ItemType = DelegationType; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/index.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/index.ts new file mode 100644 index 0000000000..0ed71f141c --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/index.ts @@ -0,0 +1,6 @@ +import Delegations from '@/screens/account_details/components/staking/components/delegations'; +import Redelgations from '@/screens/account_details/components/staking/components/redelegations'; +import Tabs from '@/screens/account_details/components/staking/components/tabs'; +import Unbondings from '@/screens/account_details/components/staking/components/unbondings'; + +export { Tabs, Unbondings, Redelgations, Delegations }; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/index.tsx new file mode 100644 index 0000000000..fd786f7097 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/index.tsx @@ -0,0 +1,92 @@ +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import { formatNumber } from '@/utils/format_token'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import type { ItemType } from '@/screens/account_details/components/staking/components/redelegations/types'; +import { columns } from '@/screens/account_details/components/staking/components/redelegations/components/desktop/utils'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import AvatarName from '@/components/avatar_name'; + +type RedelegationsRowProps = { + item: ItemType; + i: number; +}; + +const RedelegationsRow: FC = ({ item, i }) => { + const { + address: fromAddress, + imageUrl: fromImageUrl, + name: fromName, + } = useProfileRecoil(item.from); + const { address: toAddress, imageUrl: toImageUrl, name: toName } = useProfileRecoil(item.to); + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + const formattedItem = { + identifier: i, + to: , + from: , + amount: item.amount + ? `${formatNumber( + item.amount.value, + item.amount.exponent + )} ${item.amount.displayDenom.toUpperCase()}` + : '', + completionTime: formatDayJs(dayjs.utc(item.completionTime), dateFormat, timeFormat), + }; + return ( + + {columns.map((column) => ( + + {formattedItem[column.key as keyof typeof formattedItem]} + + ))} + + ); +}; + +type DesktopProps = { + className?: string; + items: ItemType[]; +}; + +const Desktop: FC = ({ className, items }) => { + const { t } = useAppTranslation('accounts'); + return ( +
+ + + + {columns.map((column) => ( + + {t(column.key)} + + ))} + + + + {items?.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +
+
+ ); +}; + +export default Desktop; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/utils.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/utils.tsx new file mode 100644 index 0000000000..996e01ec04 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/desktop/utils.tsx @@ -0,0 +1,24 @@ +export const columns: { + key: string; + align?: 'left' | 'center' | 'right' | 'justify' | 'inherit'; + width: number; +}[] = [ + { + key: 'from', + width: 25, + }, + { + key: 'to', + width: 25, + }, + { + key: 'amount', + align: 'right', + width: 25, + }, + { + key: 'completionTime', + align: 'right', + width: 25, + }, +]; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/index.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/index.ts new file mode 100644 index 0000000000..a641b73a8f --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/index.ts @@ -0,0 +1,4 @@ +import Desktop from '@/screens/account_details/components/staking/components/redelegations/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/redelegations/components/mobile'; + +export { Desktop, Mobile }; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/index.tsx new file mode 100644 index 0000000000..f807361050 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/index.tsx @@ -0,0 +1,86 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import AvatarName from '@/components/avatar_name'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import useStyles from '@/screens/account_details/components/staking/components/redelegations/components/mobile/styles'; +import type { ItemType } from '@/screens/account_details/components/staking/components/redelegations/types'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import { formatNumber } from '@/utils/format_token'; + +type RedelegationsItemProps = { + item: ItemType; + isLast: boolean; +}; + +const RedelegationsItem: FC = ({ item, isLast }) => { + const { + address: fromAddress, + imageUrl: fromImageUrl, + name: fromName, + } = useProfileRecoil(item.from); + const { address: toAddress, imageUrl: toImageUrl, name: toName } = useProfileRecoil(item.to); + const { classes } = useStyles(); + const { t } = useAppTranslation('accounts'); + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + return ( + <> +
+
+ + {t('from')} + + +
+
+ + {t('to')} + + +
+
+ + {t('completionTime')} + + {formatDayJs(dayjs.utc(item.completionTime), dateFormat, timeFormat)} +
+
+ + {t('amount')} + + {item.amount + ? `${formatNumber( + item.amount.value, + item.amount.exponent + )} ${item.amount.displayDenom.toUpperCase()}` + : ''} +
+
+ {!isLast && } + + ); +}; + +type MobileProps = { + className?: string; + items?: ItemType[]; +}; + +const Mobile: FC = ({ className, items }) => ( +
+ {items?.map((x, i) => ( + + ))} +
+); + +export default Mobile; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/styles.ts new file mode 100644 index 0000000000..e64a0f745f --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/components/mobile/styles.ts @@ -0,0 +1,30 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + list: { + margin: theme.spacing(2, 0), + }, + item: { + marginBottom: theme.spacing(2), + '& .label': { + marginBottom: theme.spacing(1), + color: theme.palette.custom.fonts.fontThree, + }, + '& p.value': { + color: theme.palette.custom.fonts.fontTwo, + }, + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + '& > div': { + width: '50%', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/index.tsx new file mode 100644 index 0000000000..86feca9776 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/index.tsx @@ -0,0 +1,74 @@ +import Loading from '@/components/loading'; +import NoData from '@/components/no_data'; +import Pagination from '@/components/pagination'; +import { usePagination } from '@/hooks/use_pagination'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import Desktop from '@/screens/account_details/components/staking/components/redelegations/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/redelegations/components/mobile'; +import useStyles from '@/screens/account_details/components/staking/components/redelegations/styles'; +import type { RedelegationsType } from '@/screens/account_details/components/staking/types'; +import { useDisplayStyles } from '@/styles/useSharedStyles'; +import { FC, useCallback } from 'react'; + +type RedelegationsProps = { + className?: string; + redelegations: RedelegationsType; + setPage: (page: number) => void; +}; + +const Redelegations: FC = (props) => { + const { classes } = useStyles(); + const display = useDisplayStyles().classes; + const { page, rowsPerPage, handlePageChange, handleRowsPerPageChange } = usePagination({}); + const handlePageChangeCallback = useCallback( + (event: Parameters[0], newPage: number) => { + props.setPage?.(newPage); + handlePageChange?.(event, newPage); + }, + [handlePageChange, props] + ); + const itemsMemo = useShallowMemo(props?.redelegations?.data); + + let component = null; + + if (props.redelegations.error) { + component =
{props.redelegations.error.message}
; + } else if (props.redelegations.loading && !itemsMemo?.length) { + component = ; + } else if (!itemsMemo?.length) { + component = ; + } else { + component = ( + <> + + + + ); + } + + let total = props.redelegations.count; + if (total === undefined && props.redelegations.data?.length !== undefined) { + if (props.redelegations.data.length === rowsPerPage) { + total = page * rowsPerPage + props.redelegations.data.length + 1; + } else { + total = page * rowsPerPage + props.redelegations.data.length; + } + } + + return ( +
+ {component} + +
+ ); +}; + +export default Redelegations; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/styles.ts new file mode 100644 index 0000000000..8c91344178 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + paginate: { + marginTop: theme.spacing(3), + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/types.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/types.ts new file mode 100644 index 0000000000..916fec9a1b --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/redelegations/types.ts @@ -0,0 +1,3 @@ +import type { RedelegationType } from '@/screens/account_details/components/staking/types'; + +export type ItemType = RedelegationType; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..fce0d762d5 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/__snapshots__/index.test.tsx.snap @@ -0,0 +1,243 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: Validators/Tabs matches snapshot 1`] = ` +.emotion-0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + justify-content: space-between; +} + +.emotion-1 { + overflow: hidden; + min-height: 48px; + -webkit-overflow-scrolling: touch; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +@media (max-width:374.95px) { + .emotion-1 .MuiTabs-scrollButtons { + display: none; + } +} + +.emotion-1 .MuiTab-textColorInherit { + color: #777777; + opacity: 1; + font-size: 1rem; +} + +.emotion-1 .MuiTab-textColorInherit.Mui-selected { + color: #000000; +} + +.emotion-1 .MuiTabs-indicator { + background-color: #000000; +} + +.emotion-1.MuiTabs-root, +.emotion-1 .MuiTab-root { + min-height: 40px; +} + +.emotion-2 { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.emotion-2::-webkit-scrollbar { + display: none; +} + +.emotion-3 { + position: relative; + display: inline-block; + -webkit-flex: 1 1 auto; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + white-space: nowrap; + scrollbar-width: none; + overflow-x: auto; + overflow-y: hidden; +} + +.emotion-3::-webkit-scrollbar { + display: none; +} + +.emotion-4 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; +} + +.emotion-5 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + position: relative; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + background-color: transparent; + outline: 0; + border: 0; + margin: 0; + border-radius: 0; + padding: 0; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + vertical-align: middle; + -moz-appearance: none; + -webkit-appearance: none; + -webkit-text-decoration: none; + text-decoration: none; + color: inherit; + font-size: 0.875rem; + letter-spacing: 1.25px; + text-transform: none; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + line-height: 1.25; + max-width: 360px; + min-width: 90px; + position: relative; + min-height: 48px; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + padding: 12px 16px; + overflow: hidden; + white-space: normal; + text-align: center; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + color: #414141; +} + +.emotion-5::-moz-focus-inner { + border-style: none; +} + +.emotion-5.Mui-disabled { + pointer-events: none; + cursor: default; +} + +@media print { + .emotion-5 { + -webkit-print-color-adjust: exact; + color-adjust: exact; + } +} + +.emotion-5.Mui-selected { + color: #FF835B; +} + +.emotion-5.Mui-disabled { + color: rgba(0, 0, 0, 0.38); +} + +.emotion-6 { + position: absolute; + height: 2px; + bottom: 0; + width: 100%; + -webkit-transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + background-color: #FF835B; +} + +
+
+
+
+
+ +
+
+
+
+`; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.test.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.test.tsx new file mode 100644 index 0000000000..85148db7ae --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.test.tsx @@ -0,0 +1,33 @@ +import renderer from 'react-test-renderer'; +import TabsHeader from '@/screens/account_details/components/staking/components/tabs'; +import MockTheme from '@/tests/mocks/MockTheme'; + +// ================================== +// unit tests +// ================================== +describe('screen: Validators/Tabs', () => { + it('matches snapshot', () => { + const component = renderer.create( + + , + }, + ]} + /> + + ); + const tree = component?.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.tsx new file mode 100644 index 0000000000..c61496c5b7 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/index.tsx @@ -0,0 +1,41 @@ +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { ComponentProps, FC, ReactNode } from 'react'; +import { a11yProps } from '@/utils/a11yProps'; +import useStyles from '@/screens/account_details/components/staking/components/tabs/styles'; + +interface TabsHeaderProps { + className?: string; + tab: number; + handleTabChange: ComponentProps['onChange']; + tabs: { + id: number; + key: string; + count: string; + component?: ReactNode; + }[]; +} + +const TabsHeader: FC = ({ className, tab, handleTabChange, tabs }) => { + const { classes, cx } = useStyles(); + const { t } = useAppTranslation('accounts'); + + return ( +
+ + {tabs.map((x) => ( + + ))} + +
+ ); +}; + +export default TabsHeader; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/styles.ts new file mode 100644 index 0000000000..1297a0aa23 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/tabs/styles.ts @@ -0,0 +1,30 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + searchBar: { + display: 'none', + [theme.breakpoints.up('lg')]: { + display: 'block', + width: '300px', + '& .MuiInputBase-root': { + width: '100%', + background: theme.palette.custom.general.surfaceTwo, + padding: theme.spacing(0.4, 1.2), + borderRadius: theme.shape.borderRadius, + }, + '& .MuiInputBase-input': { + textOverflow: 'ellipsis', + '&::placeholder': { + color: theme.palette.custom.fonts.fontThree, + }, + }, + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/index.tsx new file mode 100644 index 0000000000..26c4b68dec --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/index.tsx @@ -0,0 +1,87 @@ +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import { formatNumber } from '@/utils/format_token'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import type { ItemType } from '@/screens/account_details/components/staking/components/unbondings/types'; +import { columns } from '@/screens/account_details/components/staking/components/unbondings/components/desktop/utils'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import AvatarName from '@/components/avatar_name'; + +type UnbondingsRowProps = { + item: ItemType; +}; + +const UnbondingsRow: FC = ({ item }) => { + const { name, address, imageUrl } = useProfileRecoil(item.validator); + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + const formattedItem = { + validator: , + amount: item.amount + ? `${formatNumber( + item.amount.value, + item.amount.exponent + )} ${item.amount.displayDenom.toUpperCase()}` + : '', + completionTime: formatDayJs(dayjs.utc(item.completionTime), dateFormat, timeFormat), + }; + return ( + + {columns.map((column) => { + const selected = formattedItem[column.key as keyof typeof formattedItem]; + return ( + + {selected} + + ); + })} + + ); +}; + +type DesktopProps = { + className?: string; + items: ItemType[]; +}; + +const Desktop: FC = ({ className, items }) => { + const { t } = useAppTranslation('accounts'); + return ( +
+ + + + {columns.map((column) => ( + + {t(column.key)} + + ))} + + + + {items?.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +
+
+ ); +}; + +export default Desktop; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/utils.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/utils.tsx new file mode 100644 index 0000000000..01cc3ca1c0 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/desktop/utils.tsx @@ -0,0 +1,20 @@ +export const columns: { + key: string; + align?: 'left' | 'center' | 'right' | 'justify' | 'inherit'; + width: number; +}[] = [ + { + key: 'validator', + width: 40, + }, + { + key: 'amount', + align: 'right', + width: 30, + }, + { + key: 'completionTime', + align: 'right', + width: 30, + }, +]; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/index.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/index.ts new file mode 100644 index 0000000000..b081d832ab --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/index.ts @@ -0,0 +1,4 @@ +import Desktop from '@/screens/account_details/components/staking/components/unbondings/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/unbondings/components/mobile'; + +export { Desktop, Mobile }; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/index.tsx new file mode 100644 index 0000000000..25c6566d34 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/index.tsx @@ -0,0 +1,71 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import AvatarName from '@/components/avatar_name'; +import { useProfileRecoil } from '@/recoil/profiles/hooks'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import useStyles from '@/screens/account_details/components/staking/components/unbondings/components/mobile/styles'; +import type { ItemType } from '@/screens/account_details/components/staking/components/unbondings/types'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import { formatNumber } from '@/utils/format_token'; + +type UnbondingsItemProps = { + item: ItemType; + isLast: boolean; +}; + +const UnbondingsItem: FC = ({ item, isLast }) => { + const { name, address, imageUrl } = useProfileRecoil(item.validator); + const { classes } = useStyles(); + const { t } = useAppTranslation('accounts'); + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + return ( + <> +
+
+ + {t('validator')} + + +
+
+ + {t('completionTime')} + + {formatDayJs(dayjs.utc(item.completionTime), dateFormat, timeFormat)} +
+
+ + {t('amount')} + + {item.amount + ? `${formatNumber( + item.amount.value, + item.amount.exponent + )} ${item.amount.displayDenom.toUpperCase()}` + : ''} +
+
+ {!isLast && } + + ); +}; + +type MobileProps = { + className?: string; + items: ItemType[]; +}; + +const Mobile: FC = ({ className, items }) => ( +
+ {items?.map((x, i) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+); + +export default Mobile; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/styles.ts new file mode 100644 index 0000000000..e64a0f745f --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/components/mobile/styles.ts @@ -0,0 +1,30 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + list: { + margin: theme.spacing(2, 0), + }, + item: { + marginBottom: theme.spacing(2), + '& .label': { + marginBottom: theme.spacing(1), + color: theme.palette.custom.fonts.fontThree, + }, + '& p.value': { + color: theme.palette.custom.fonts.fontTwo, + }, + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + '& > div': { + width: '50%', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/index.tsx new file mode 100644 index 0000000000..42a1212bff --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/index.tsx @@ -0,0 +1,74 @@ +import Loading from '@/components/loading'; +import NoData from '@/components/no_data'; +import Pagination from '@/components/pagination'; +import { usePagination } from '@/hooks/use_pagination'; +import useShallowMemo from '@/hooks/useShallowMemo'; +import Desktop from '@/screens/account_details/components/staking/components/unbondings/components/desktop'; +import Mobile from '@/screens/account_details/components/staking/components/unbondings/components/mobile'; +import useStyles from '@/screens/account_details/components/staking/components/unbondings/styles'; +import type { UnbondingsType } from '@/screens/account_details/components/staking/types'; +import { useDisplayStyles } from '@/styles/useSharedStyles'; +import { FC, useCallback } from 'react'; + +type UnbondingsProps = { + className?: string; + unbondings: UnbondingsType; + setPage: (page: number) => void; +}; + +const Unbondings: FC = (props) => { + const { classes } = useStyles(); + const display = useDisplayStyles().classes; + const { page, rowsPerPage, handlePageChange, handleRowsPerPageChange } = usePagination({}); + const handlePageChangeCallback = useCallback( + (event: Parameters[0], newPage: number) => { + props.setPage?.(newPage); + handlePageChange?.(event, newPage); + }, + [handlePageChange, props] + ); + const itemsMemo = useShallowMemo(props?.unbondings?.data); + + let component = null; + + if (props.unbondings.error) { + component =
{props.unbondings.error.message}
; + } else if (props.unbondings.loading && !itemsMemo?.length) { + component = ; + } else if (!itemsMemo?.length) { + component = ; + } else { + component = ( + <> + + + + ); + } + + let total = props.unbondings.count; + if (total === undefined && props.unbondings.data?.length !== undefined) { + if (props.unbondings.data.length === rowsPerPage) { + total = page * rowsPerPage + props.unbondings.data.length + 1; + } else { + total = page * rowsPerPage + props.unbondings.data.length; + } + } + + return ( +
+ {component} + +
+ ); +}; + +export default Unbondings; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/styles.ts new file mode 100644 index 0000000000..8c91344178 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + paginate: { + marginTop: theme.spacing(3), + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/types.ts b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/types.ts new file mode 100644 index 0000000000..4d349bf39a --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/components/unbondings/types.ts @@ -0,0 +1,3 @@ +import type { UnbondingType } from '@/screens/account_details/components/staking/types'; + +export type ItemType = UnbondingType; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/hooks.ts b/apps/web-stratos/src/screens/account_details/components/staking/hooks.ts new file mode 100644 index 0000000000..0ea1ae23e3 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/hooks.ts @@ -0,0 +1,334 @@ +import Big from 'big.js'; +import { useRouter } from 'next/router'; +import numeral from 'numeral'; +import * as R from 'ramda'; +import { SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import chainConfig from '@/chainConfig'; +import { + useAccountDelegationsQuery, + useAccountRedelegationsQuery, + useAccountUndelegationsQuery, + useValidatorsQuery, + ValidatorsQuery, +} from '@/graphql/types/general_types'; +import type { + DelegationType, + RedelegationType, + StakingState, +} from '@/screens/account_details/components/staking/types'; +import type { RewardsType } from '@/screens/account_details/types'; +import { ValidatorType } from '@/screens/validators/components/list/types'; +import { formatToken } from '@/utils/format_token'; +import { getDenom } from '@/utils/get_denom'; + +const { primaryTokenUnit } = chainConfig(); + +export const ROWS_PER_PAGE = 10; + +export const formatDelegations = ( + data: Array<{ + validator_address?: string; + coins?: MsgCoin[]; + }>, + validatorsCommission: Pick[], + rewards: RewardsType +) => + data + .map((x): DelegationType => { + const validator = x?.validator_address ?? ''; + const delegation = getDenom(x.coins, primaryTokenUnit); + return { + validator, + commission: + numeral( + validatorsCommission.find((val) => val.validator === validator)?.commission?.toFixed(3) + ).value() ?? 0, + amount: formatToken(delegation.amount, delegation.denom), + reward: rewards[validator], + }; + }) + .sort((a, b) => (Big(a.amount?.value).gt(b.amount?.value) ? -1 : 1)); + +export const formatRedelegations = ( + data: Array<{ + entries?: Array<{ balance: string | number; completion_time?: string }>; + validator_src_address?: string; + validator_dst_address?: string; + }> +) => { + const results: RedelegationType[] = []; + data.forEach((x) => { + x.entries?.forEach((y) => { + results.push({ + from: x?.validator_src_address ?? '', + to: x?.validator_dst_address ?? '', + amount: formatToken(y.balance, primaryTokenUnit), + completionTime: y?.completion_time ?? '', + }); + }); + }); + + results.sort((a, b) => (a.completionTime < b.completionTime ? -1 : 1)); + + return results; +}; + +export const formatUnbondings = ( + data: Array<{ + entries?: Array<{ balance: string | number; completion_time?: string }>; + validator_address?: string; + }> +) => { + const results: Array<{ validator: string; amount: TokenUnit; completionTime: string }> = []; + data.forEach((x) => { + x?.entries?.forEach((y) => { + results.push({ + validator: x?.validator_address ?? '', + amount: formatToken(y.balance, primaryTokenUnit), + completionTime: y?.completion_time ?? '', + }); + }); + }); + + results.sort((a, b) => (a.completionTime < b.completionTime ? -1 : 1)); + + return results; +}; + +export const useStaking = ( + rewards: RewardsType, + delegationsPage: number, + redelegationsPage: number, + unbondingsPage: number +) => { + const router = useRouter(); + const [state, setState] = useState({ + tab: 0, + }); + + const [validatorsCommission, setValidatorsCommission] = useState< + Pick[] + >([]); + + // ========================== + // Fetch Data + // ========================== + useValidatorsQuery({ + onCompleted: (data) => { + formatValidators(data); + }, + }); + + // return a list of all validators with their address and commission rate + const formatValidators = useCallback( + (data: ValidatorsQuery): { items: Pick[] } => { + const formattedItems: Pick[] = data.validator + .filter((x) => x.validatorInfo) + .map((x) => ({ + validator: x.validatorInfo?.operatorAddress ?? '', + commission: (x?.validatorCommissions?.[0]?.commission ?? 0) * 100, + })); + + setValidatorsCommission(formattedItems); + + return { + items: formattedItems, + }; + }, + [] + ); + + const address = Array.isArray(router?.query?.address) + ? router.query.address[0] + : (router?.query?.address ?? ''); + + // ===================================== + // delegations + // ===================================== + const { + data: delegationsData, + loading: delegationsLoading, + error: delegationsError, + refetch: delegationsRefetch, + } = useAccountDelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: delegationsPage * ROWS_PER_PAGE, + }, + }); + useEffect(() => { + if (delegationsLoading) return; + if (delegationsError) { + delegationsRefetch({ pagination: false }); + } + }, [delegationsError, delegationsLoading, delegationsRefetch]); + useAccountDelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: (delegationsPage + 1) * ROWS_PER_PAGE, + }, + }); + + const [delegationsPagination, setDelegationsPagination] = useState(); + const { + data: dData, + error: dError, + refetch: dRefetch, + } = useAccountDelegationsQuery({ + variables: { + address, + limit: 0, + offset: 0, + pagination: true, + }, + skip: delegationsPagination !== undefined, + }); + useEffect(() => { + if (dError) { + dRefetch(); + } else if (dData) { + setDelegationsPagination(dData?.delegations?.pagination?.total ?? 0); + } + }, [dData, dError, dRefetch]); + + // ===================================== + // redelegations + // ===================================== + const { + data: redelegationsData, + loading: redelegationsLoading, + error: redelegationsError, + refetch: redelegationsRefetch, + } = useAccountRedelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: redelegationsPage * ROWS_PER_PAGE, + }, + }); + useEffect(() => { + if (redelegationsLoading) return; + if (redelegationsError) { + redelegationsRefetch({ pagination: false }); + } + }, [redelegationsError, redelegationsLoading, redelegationsRefetch]); + useAccountRedelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: (redelegationsPage + 1) * ROWS_PER_PAGE, + }, + }); + + const [redelegationsPagination, setRedelegationsPagination] = useState(); + const { + data: rData, + error: rError, + refetch: rRefetch, + } = useAccountRedelegationsQuery({ + variables: { + address, + limit: 0, + offset: 0, + pagination: true, + }, + skip: redelegationsPagination !== undefined, + }); + useEffect(() => { + if (rError) { + rRefetch(); + } else if (rData) { + setRedelegationsPagination(rData?.redelegations?.pagination?.total ?? 0); + } + }, [rData, rError, rRefetch]); + + // ===================================== + // unbondings + // ===================================== + const { + data: undelegationsData, + loading: undelegationsLoading, + error: undelegationsError, + refetch: undelegationsRefetch, + } = useAccountUndelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: unbondingsPage * ROWS_PER_PAGE, + }, + }); + useEffect(() => { + if (undelegationsLoading) return; + if (undelegationsError) { + undelegationsRefetch({ pagination: false }); + } + }, [undelegationsError, undelegationsLoading, undelegationsRefetch]); + useAccountUndelegationsQuery({ + variables: { + address, + limit: ROWS_PER_PAGE, + offset: (unbondingsPage + 1) * ROWS_PER_PAGE, + }, + }); + + const [undelegationsPagination, setUndelegationsPagination] = useState(); + const { + data: uData, + error: uError, + refetch: uRefetch, + } = useAccountUndelegationsQuery({ + variables: { + address, + limit: 0, + offset: 0, + pagination: true, + }, + skip: undelegationsPagination !== undefined, + }); + useEffect(() => { + if (uError) { + uRefetch(); + } else if (uData) { + setUndelegationsPagination(uData?.undelegations?.pagination?.total ?? 0); + } + }, [uData, uError, uRefetch]); + + const handleTabChange = useCallback( + (_event: SyntheticEvent, newValue: number) => { + setState((prevState) => { + const newState = { ...prevState, tab: newValue }; + return R.equals(prevState, newState) ? prevState : newState; + }); + }, + [] + ); + + return { + state, + delegations: { + loading: delegationsLoading, + count: delegationsPagination, + data: formatDelegations( + delegationsData?.delegations?.delegations ?? [], + validatorsCommission, + rewards + ), + error: delegationsError, + }, + redelegations: { + loading: redelegationsLoading, + count: redelegationsPagination, + data: formatRedelegations(redelegationsData?.redelegations?.redelegations ?? []), + error: redelegationsError, + }, + unbondings: { + loading: undelegationsLoading, + count: undelegationsPagination, + data: formatUnbondings(undelegationsData?.undelegations?.undelegations ?? []), + error: undelegationsError, + }, + handleTabChange, + }; +}; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/index.tsx b/apps/web-stratos/src/screens/account_details/components/staking/index.tsx new file mode 100644 index 0000000000..ce8eaa0ebb --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/index.tsx @@ -0,0 +1,63 @@ +import { FC, useState } from 'react'; +import Box from '@/components/box'; +import TabPanel from '@/components/tab_panel'; +import Delegations from '@/screens/account_details/components/staking/components/delegations'; +import Redelgations from '@/screens/account_details/components/staking/components/redelegations'; +import Tabs from '@/screens/account_details/components/staking/components/tabs'; +import Unbondings from '@/screens/account_details/components/staking/components/unbondings'; +import { useStaking } from '@/screens/account_details/components/staking/hooks'; +import useStyles from '@/screens/account_details/components/staking/styles'; +import type { RewardsType } from '@/screens/account_details/types'; +import { formatCount } from '@/screens/validator_details/components/staking'; + +type StakingProps = { + className?: string; + rewards: RewardsType; +}; + +const Staking: FC = ({ rewards, className }) => { + const { classes, cx } = useStyles(); + const [delegationsPage, setDelegationsPage] = useState(0); + const [redelegationsPage, setRedelegationsPage] = useState(0); + const [unbondingsPage, setUnbondingsPage] = useState(0); + const { state, delegations, redelegations, unbondings, handleTabChange } = useStaking( + rewards, + delegationsPage, + redelegationsPage, + unbondingsPage + ); + + const tabs = [ + { + id: 0, + key: 'delegations', + component: , + count: formatCount(delegationsPage, delegations), + }, + { + id: 1, + key: 'redelegations', + component: , + count: formatCount(redelegationsPage, redelegations), + }, + { + id: 2, + key: 'unbondings', + component: , + count: formatCount(unbondingsPage, unbondings), + }, + ]; + + return ( + + + {tabs.map((x) => ( + + {x.component} + + ))} + + ); +}; + +export default Staking; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/styles.ts b/apps/web-stratos/src/screens/account_details/components/staking/styles.ts new file mode 100644 index 0000000000..9bf1bab5d9 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + overflow: 'hidden', + [theme.breakpoints.up('md')]: { + // display: 'flex', + // flexDirection: 'column', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/staking/types.ts b/apps/web-stratos/src/screens/account_details/components/staking/types.ts new file mode 100644 index 0000000000..8087079e55 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/staking/types.ts @@ -0,0 +1,36 @@ +import { ApolloError } from '@apollo/client'; + +export type StakingType = { + data: T[]; + count: number | undefined; + loading: boolean; + error: ApolloError | undefined; +}; + +export interface DelegationType { + validator: string; + amount: TokenUnit; + reward: TokenUnit; + commission?: number; +} + +export interface RedelegationType { + from: string; + to: string; + amount: TokenUnit; + completionTime: string; +} + +export interface UnbondingType { + validator: string; + amount: TokenUnit; + completionTime: string; +} + +export type DelegationsType = StakingType; +export type RedelegationsType = StakingType; +export type UnbondingsType = StakingType; + +export interface StakingState { + tab: number; +} diff --git a/apps/web-stratos/src/screens/account_details/components/transactions/hooks.ts b/apps/web-stratos/src/screens/account_details/components/transactions/hooks.ts new file mode 100644 index 0000000000..00577a7827 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/transactions/hooks.ts @@ -0,0 +1,122 @@ +import { useRouter } from 'next/router'; +import * as R from 'ramda'; +import { useEffect, useRef, useState } from 'react'; +import { convertMsgsToModels } from '@/components/msg/utils'; +import { + GetMessagesByAddressQuery, + useGetMessagesByAddressQuery, +} from '@/graphql/types/general_types'; +import type { TransactionState } from '@/screens/account_details/components/transactions/types'; +import { convertMsgType } from '@/utils/convert_msg_type'; + +const LIMIT = 2; + +const formatTransactions = (data: GetMessagesByAddressQuery): Transactions[] => { + let formattedData = data.messagesByAddress; + if (data.messagesByAddress.length === LIMIT + 1) { + formattedData = data.messagesByAddress.slice(0, LIMIT + 1); + } + return formattedData.map((x) => { + const { transaction } = x; + + // ============================= + // messages + // ============================= + const messages = convertMsgsToModels(transaction); + const msgType = messages.map((eachMsg) => { + const eachMsgType = eachMsg?.type ?? 'none type'; + return eachMsgType ?? ''; + }); + const convertedMsgType = convertMsgType(msgType); + return { + height: transaction?.height, + hash: transaction?.hash ?? '', + type: convertedMsgType, + messages: { + count: messages.length, + items: messages, + }, + success: transaction?.success ?? false, + timestamp: transaction?.block.timestamp, + }; + }); +}; + +export function useTransactions() { + const router = useRouter(); + const [state, setState] = useState({ + data: [], + hasNextPage: false, + isNextPageLoading: true, + offsetCount: 0, + }); + const isFirst = useRef(true); + + // reset state when address changes + useEffect(() => { + if (isFirst.current) { + isFirst.current = false; + } else { + setState((prevState) => ({ + ...prevState, + data: [], + hasNextPage: false, + isNextPageLoading: true, + offsetCount: 0, + })); + } + }, [router?.query?.address]); + + const handleSetState = (stateChange: (prevState: TransactionState) => TransactionState) => { + setState((prevState) => { + const newState = stateChange(prevState); + return R.equals(prevState, newState) ? prevState : newState; + }); + }; + + const { fetchMore } = useGetMessagesByAddressQuery({ + variables: { + limit: LIMIT + 1, // to check if more exist + offset: 0, + address: `{${router?.query?.address ?? ''}}`, + }, + onCompleted: (data) => { + const itemsLength = data.messagesByAddress.length; + const newItems = R.uniq([...state.data, ...formatTransactions(data)]); + const stateChange: TransactionState = { + data: newItems, + hasNextPage: itemsLength === LIMIT + 1, + isNextPageLoading: false, + offsetCount: state.offsetCount + LIMIT, + }; + + handleSetState((prevState) => ({ ...prevState, ...stateChange })); + }, + }); + + const loadNextPage = async () => { + handleSetState((prevState) => ({ ...prevState, isNextPageLoading: true })); + // refetch query + await fetchMore({ + variables: { + offset: state.offsetCount, + limit: LIMIT + 1, + }, + }).then(({ data }) => { + const itemsLength = data.messagesByAddress.length; + const newItems = R.uniq([...state.data, ...formatTransactions(data)]); + const stateChange: TransactionState = { + data: newItems, + hasNextPage: itemsLength === LIMIT + 1, + isNextPageLoading: false, + offsetCount: state.offsetCount + LIMIT, + }; + handleSetState((prevState) => ({ ...prevState, ...stateChange })); + }); + }; + + return { + state, + loadNextPage, + }; +} diff --git a/apps/web-stratos/src/screens/account_details/components/transactions/index.tsx b/apps/web-stratos/src/screens/account_details/components/transactions/index.tsx new file mode 100644 index 0000000000..4779314a14 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/transactions/index.tsx @@ -0,0 +1,53 @@ +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import { FC } from 'react'; +import { useRecoilValue } from 'recoil'; +import Box from '@/components/box'; +import TransactionsList from '@/components/transactions_list'; +import TransactionsListDetails from '@/components/transactions_list_details'; +import { readTx } from '@/recoil/settings'; +import { useTransactions } from '@/screens/account_details/components/transactions/hooks'; +import useStyles from '@/screens/account_details/components/transactions/styles'; + +const Transactions: FC = (props) => { + const txListFormat = useRecoilValue(readTx); + const { classes, cx } = useStyles(); + const { t } = useAppTranslation('validators'); + + const { state, loadNextPage } = useTransactions(); + + const loadMoreItems = state.isNextPageLoading ? () => null : loadNextPage; + const isItemLoaded = (index: number) => !state.hasNextPage || index < state.data.length; + const itemCount = state.hasNextPage ? state.data.length + 1 : state.data.length; + + return ( + + {t('transactions')} +
+ {txListFormat === 'compact' ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default Transactions; diff --git a/apps/web-stratos/src/screens/account_details/components/transactions/styles.ts b/apps/web-stratos/src/screens/account_details/components/transactions/styles.ts new file mode 100644 index 0000000000..97932d962d --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/transactions/styles.ts @@ -0,0 +1,18 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + '& .MuiTypography-h2': { + marginBottom: theme.spacing(2), + }, + }, + list: { + minHeight: '500px', + height: '50vh', + [theme.breakpoints.up('lg')]: { + minHeight: '65vh', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/components/transactions/types.ts b/apps/web-stratos/src/screens/account_details/components/transactions/types.ts new file mode 100644 index 0000000000..9704b125d3 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/components/transactions/types.ts @@ -0,0 +1,6 @@ +export interface TransactionState { + hasNextPage: boolean; + isNextPageLoading: boolean; + offsetCount: number; + data: Transactions[]; +} diff --git a/apps/web-stratos/src/screens/account_details/hooks.ts b/apps/web-stratos/src/screens/account_details/hooks.ts new file mode 100644 index 0000000000..a6f6c78d26 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/hooks.ts @@ -0,0 +1,285 @@ +import Big from 'big.js'; +import { useRouter } from 'next/router'; +import * as R from 'ramda'; +import { useCallback, useEffect, useState } from 'react'; +import chainConfig from '@/chainConfig'; +import { useDesmosProfile } from '@/hooks/use_desmos_profile'; +import type { + AccountDetailState, + BalanceType, + OtherTokenType, +} from '@/screens/account_details/types'; +import { + useAccountWithdrawalAddress, + useAvailableBalances, + useCommission, + useDelegationBalance, + useRewards, + useUnbondingBalance, +} from '@/screens/account_details/utils'; +import { formatToken } from '@/utils/format_token'; +import { getDenom } from '@/utils/get_denom'; + +const { extra, primaryTokenUnit, tokenUnits } = chainConfig(); + +const defaultTokenUnit: TokenUnit = { + value: '0', + baseDenom: '', + displayDenom: '', + exponent: 0, +}; + +const initialState: AccountDetailState = { + loading: true, + exists: true, + desmosProfile: null, + overview: { + address: '', + withdrawalAddress: '', + }, + otherTokens: { + count: 0, + data: [], + }, + balance: { + available: defaultTokenUnit, + delegate: defaultTokenUnit, + unbonding: defaultTokenUnit, + reward: defaultTokenUnit, + commission: defaultTokenUnit, + total: defaultTokenUnit, + }, + rewards: {}, +}; + +type Data = { + delegationRewards?: ReturnType['delegationRewards']; + accountBalances?: ReturnType['accountBalances']; + delegationBalance?: ReturnType['delegationBalance']; + unbondingBalance?: ReturnType['unbondingBalance']; + commission?: ReturnType['commission']; +}; + +// ============================ +// rewards +// ============================ +const formatRewards = (data: Data) => { + const rewardsDict: { [key: string]: TokenUnit } = {}; + // log all the rewards + data?.delegationRewards?.forEach((x) => { + if (!x) return; + const coins = x.coins ?? []; + const denomAmount = getDenom(coins, primaryTokenUnit); + const denomFormat = formatToken(denomAmount.amount, primaryTokenUnit); + rewardsDict[x.validatorAddress ?? ''] = denomFormat; + }); + return rewardsDict; +}; + +// ============================ +// balance +// ============================ +const formatBalance = (data: Data): BalanceType => { + const available = getDenom(R.pathOr([], ['accountBalances', 'coins'], data), primaryTokenUnit); + const availableAmount = formatToken(available.amount, primaryTokenUnit); + const delegate = getDenom(R.pathOr([], ['delegationBalance', 'coins'], data), primaryTokenUnit); + const delegateAmount = formatToken(delegate.amount, primaryTokenUnit); + + const unbonding = getDenom(R.pathOr([], ['unbondingBalance', 'coins'], data), primaryTokenUnit); + const unbondingAmount = formatToken(unbonding.amount, primaryTokenUnit); + + const rewards = + data.delegationRewards?.reduce((a, b) => { + if (!b) return a; + const coins = R.pathOr([], ['coins'], b); + const dsmCoins = getDenom(coins, primaryTokenUnit); + + return Big(a).plus(dsmCoins.amount).toPrecision(); + }, '0') ?? '0'; + const rewardsAmount = formatToken(rewards, primaryTokenUnit); + + const commission = getDenom( + R.pathOr['coins']>>( + [], + ['commission', 'coins'], + data + ), + primaryTokenUnit + ); + const commissionAmount = formatToken(commission.amount, primaryTokenUnit); + + const total = Big(availableAmount.value) + .plus(delegateAmount.value) + .plus(unbondingAmount.value) + .plus(rewardsAmount.value) + .plus(commissionAmount.value) + .toFixed(tokenUnits?.[primaryTokenUnit].exponent); + + const balance: BalanceType = { + available: availableAmount, + delegate: delegateAmount, + unbonding: unbondingAmount, + reward: rewardsAmount, + commission: commissionAmount, + total: { + value: total, + displayDenom: availableAmount.displayDenom, + baseDenom: availableAmount.baseDenom, + exponent: availableAmount.exponent, + }, + }; + + return balance; +}; + +// ============================ +// other tokens +// ============================ +const formatOtherTokens = (data: Data) => { + // Loop through balance and delegation to figure out what the other tokens are + const otherTokenUnits = new Set(); + const otherTokens: OtherTokenType[] = []; + // available tokens + const available = R.pathOr([], ['accountBalances', 'coins'], data); + + available.forEach((x) => { + otherTokenUnits.add(x.denom); + }); + + // rewards tokens + const rewards = R.pathOr }>>([], ['delegationRewards'], data); + + rewards.forEach((x) => { + x.coins?.forEach((y) => { + otherTokenUnits.add(y.denom); + }); + }); + + // commission tokens + const commission = R.pathOr([], ['commission', 'coins'], data); + + commission.forEach((x) => { + otherTokenUnits.add(x.denom); + }); + + // remove the primary token unit thats being shown in balance + otherTokenUnits.delete(primaryTokenUnit); + + otherTokenUnits.forEach((x: string) => { + const availableRawAmount = getDenom(available, x); + const availableAmount = formatToken(availableRawAmount.amount, x); + const rewardsRawAmount = rewards.reduce((a, b) => { + const coins = R.pathOr>([], ['coins'], b); + const denom = getDenom(coins, x); + return Big(a).plus(denom.amount).toPrecision(); + }, '0'); + const rewardAmount = formatToken(rewardsRawAmount, x); + const commissionRawAmount = getDenom(commission, x); + const commissionAmount = formatToken(commissionRawAmount.amount, x); + + otherTokens.push({ + denom: tokenUnits?.[x]?.display ?? x, + available: availableAmount, + reward: rewardAmount, + commission: commissionAmount, + }); + }); + + return { + data: otherTokens, + count: otherTokens.length, + }; +}; + +// ========================== +// Format Data +// ========================== +const formatAllBalance = (data: Data) => { + const stateChange: Partial = { + loading: false, + }; + + stateChange.rewards = formatRewards(data); + + stateChange.balance = formatBalance(data); + + formatOtherTokens(data); + + stateChange.otherTokens = formatOtherTokens(data); + + return stateChange; +}; + +export const useAccountDetails = () => { + const router = useRouter(); + const [state, setState] = useState(initialState); + + const handleSetState = useCallback( + (stateChange: (prevState: AccountDetailState) => AccountDetailState) => { + setState((prevState) => { + const newState = stateChange(prevState); + return R.equals(prevState, newState) ? prevState : newState; + }); + }, + [] + ); + const address = Array.isArray(router.query.address) + ? router.query.address[0] + : (router.query.address ?? ''); + + // ========================== + // Desmos Profile + // ========================== + const { data: dataDesmosProfile, loading: loadingDesmosProfile } = useDesmosProfile({ + addresses: [address], + skip: true, + }); + useEffect( + () => setState((prevState) => ({ ...prevState, desmosProfile: dataDesmosProfile?.[0] })), + [dataDesmosProfile] + ); + + const commission = useCommission(address); + const available = useAvailableBalances(address); + const delegation = useDelegationBalance(address); + const unbonding = useUnbondingBalance(address); + const rewards = useRewards(address); + + useEffect(() => { + const formattedRawData: { + commission?: (typeof commission)['commission']; + accountBalances?: (typeof available)['accountBalances']; + delegationBalance?: (typeof delegation)['delegationBalance']; + unbondingBalance?: (typeof unbonding)['unbondingBalance']; + delegationRewards?: (typeof rewards)['delegationRewards']; + } = {}; + formattedRawData.commission = R.pathOr({ coins: [] }, ['commission'], commission); + formattedRawData.accountBalances = R.pathOr({ coins: [] }, ['accountBalances'], available); + formattedRawData.delegationBalance = R.pathOr({ coins: [] }, ['delegationBalance'], delegation); + formattedRawData.unbondingBalance = R.pathOr({ coins: [] }, ['unbondingBalance'], unbonding); + formattedRawData.delegationRewards = R.pathOr([], ['delegationRewards'], rewards); + + handleSetState((prevState) => ({ ...prevState, ...formatAllBalance(formattedRawData) })); + }, [commission, available, delegation, unbonding, rewards, handleSetState]); + + // ========================== + // Fetch Data + // ========================== + const withdrawalAddress = useAccountWithdrawalAddress(address); + useEffect(() => { + handleSetState((prevState) => ({ + ...prevState, + overview: { + address: address ?? '', + withdrawalAddress: withdrawalAddress.withdrawalAddress?.address ?? '', + }, + })); + }, [handleSetState, address, withdrawalAddress.withdrawalAddress?.address]); + + if (loadingDesmosProfile) state.loading = true; + + return { state }; +}; +// function useBoundingBalance(_address?: string) { +// throw new Error('Function not implemented.'); +// } diff --git a/apps/web-stratos/src/screens/account_details/index.tsx b/apps/web-stratos/src/screens/account_details/index.tsx new file mode 100644 index 0000000000..5a68e826dd --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/index.tsx @@ -0,0 +1,64 @@ +import { NextSeo } from 'next-seo'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import DesmosProfile from '@/components/desmos_profile'; +import Layout from '@/components/layout'; +import LoadAndExist from '@/components/load_and_exist'; +import Balance from '@/screens/account_details/components/balance'; +import OtherTokens from '@/screens/account_details/components/other_tokens'; +import Overview from '@/screens/account_details/components/overview'; +import Staking from '@/screens/account_details/components/staking'; +import Transactions from '@/screens/account_details/components/transactions'; +import { useAccountDetails } from '@/screens/account_details/hooks'; +import useStyles from '@/screens/account_details/styles'; + +const AccountDetails = () => { + const { t } = useAppTranslation('accounts'); + const { classes } = useStyles(); + const { state } = useAccountDetails(); + + return ( + <> + + + + + {!!state.desmosProfile && ( + + )} + + + + + + + + + + ); +}; + +export default AccountDetails; diff --git a/apps/web-stratos/src/screens/account_details/styles.ts b/apps/web-stratos/src/screens/account_details/styles.ts new file mode 100644 index 0000000000..1ed8ae9dc1 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/styles.ts @@ -0,0 +1,45 @@ +import { CSSObject } from '@emotion/react'; +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + root: { + ...(theme.mixins.layout as CSSObject), + display: 'grid', + gridTemplateRows: 'auto', + gap: theme.spacing(1), + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + [theme.breakpoints.up('lg')]: { + gap: theme.spacing(2), + // gridTemplateColumns: 'repeat(2, 1fr)', + }, + }, + balance: { + [theme.breakpoints.up('lg')]: { + // gridColumn: '1 / 3', + }, + }, + otherTokens: { + [theme.breakpoints.up('lg')]: { + // gridColumn: '1 / 3', + }, + }, + overview: { + [theme.breakpoints.up('lg')]: { + // gridColumn: '1 / 3', + }, + }, + staking: { + [theme.breakpoints.up('lg')]: { + // gridColumn: '1 / 3', + }, + }, + transactions: { + [theme.breakpoints.up('lg')]: { + // gridColumn: '1 / 3', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/account_details/types.ts b/apps/web-stratos/src/screens/account_details/types.ts new file mode 100644 index 0000000000..ab96035783 --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/types.ts @@ -0,0 +1,37 @@ +export interface OverviewType { + address: string; + withdrawalAddress: string; +} + +export interface BalanceType { + available: TokenUnit; + delegate: TokenUnit; + unbonding: TokenUnit; + reward: TokenUnit; + commission?: TokenUnit; + total: TokenUnit; +} + +export interface OtherTokenType { + denom: string; + available: TokenUnit; + reward: TokenUnit; + commission: TokenUnit; +} + +export interface RewardsType { + [value: string]: TokenUnit; +} + +export interface AccountDetailState { + loading: boolean; + exists: boolean; + desmosProfile: DesmosProfile | null; + overview: OverviewType; + balance: BalanceType; + otherTokens: { + data: OtherTokenType[]; + count: number; + }; + rewards: RewardsType; +} diff --git a/apps/web-stratos/src/screens/account_details/utils.tsx b/apps/web-stratos/src/screens/account_details/utils.tsx new file mode 100644 index 0000000000..feb4fa12aa --- /dev/null +++ b/apps/web-stratos/src/screens/account_details/utils.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo } from 'react'; +import { + useAccountBalancesQuery, + useAccountCommissionQuery, + useAccountDelegationBalanceQuery, + useAccountDelegationRewardsQuery, + useAccountUnbondingBalanceQuery, + useAccountWithdrawalAddressQuery, +} from '@/graphql/types/general_types'; +import { toValidatorAddress } from '@/utils/prefix_convert'; + +export const useCommission = (address?: string) => { + /* Converting the address to a validator address. */ + let validatorAddress = ''; + try { + if (address) validatorAddress = toValidatorAddress(address); + } catch (e) { + console.error(e); + } + + const defaultReturnValue = useMemo( + () => ({ + commission: { + coins: [], + }, + }), + [] + ); + const { data, error, refetch } = useAccountCommissionQuery({ + variables: { + validatorAddress, + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; + +export const useAccountWithdrawalAddress = (address?: string) => { + const defaultReturnValue = useMemo( + () => ({ + withdrawalAddress: { + address, + }, + }), + [address] + ); + const { data, error, refetch } = useAccountWithdrawalAddressQuery({ + variables: { + address: address ?? '', + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; + +export const useAvailableBalances = (address?: string) => { + const defaultReturnValue = useMemo( + () => ({ + accountBalances: { + coins: [], + }, + }), + [] + ); + const { data, error, refetch } = useAccountBalancesQuery({ + variables: { + address: address ?? '', + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; + +export const useDelegationBalance = (address?: string) => { + const defaultReturnValue = useMemo( + () => ({ + delegationBalance: { + coins: [], + }, + }), + [] + ); + const { data, error, refetch } = useAccountDelegationBalanceQuery({ + variables: { + address: address ?? '', + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; + +export const useUnbondingBalance = (address?: string) => { + const defaultReturnValue = useMemo( + () => ({ + unbondingBalance: { + coins: [], + }, + }), + [] + ); + const { data, error, refetch } = useAccountUnbondingBalanceQuery({ + variables: { + address: address ?? '', + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; + +export const useRewards = (address?: string) => { + const defaultReturnValue = useMemo(() => ({ delegationRewards: [] }), []); + const { data, error, refetch } = useAccountDelegationRewardsQuery({ + variables: { + address: address ?? '', + }, + skip: !address, + }); + useEffect(() => { + if (error) refetch(); + }, [error, refetch]); + return data ?? defaultReturnValue; +}; diff --git a/apps/web-stratos/src/screens/profile_details/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/profile_details/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..9161a76f5b --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/__snapshots__/index.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: ProfileDetails matches snapshot 1`] = ` +.emotion-0 { + padding: 16px; + display: grid; + grid-template-rows: auto; + gap: 8px; +} + +@media (min-width: 1280px) { + .emotion-0 { + padding: 16px 24px; + } +} + +.emotion-0 a { + color: #4092CD; +} + +@media (min-width:1280px) { + .emotion-0 { + gap: 16px; + } +} + +
+
+ +
+
+ +
+
+`; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/profile_details/components/connections/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..7fc745cfca --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/__snapshots__/index.test.tsx.snap @@ -0,0 +1,510 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: ProfileDetails/Connections matches snapshot 1`] = ` +.emotion-0 { + margin: 0; + font-size: 1.5rem; + letter-spacing: 0; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 300; + line-height: 1.2; +} + +@media (max-width:1279.95px) { + .emotion-1 { + display: none; + } +} + +.emotion-1.emotion-1 { + white-space: nowrap; +} + +.emotion-2 { + display: table; + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +.emotion-2 caption { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + padding: 16px; + color: #414141; + text-align: left; + caption-side: bottom; +} + +.emotion-3 { + display: table-header-group; +} + +.emotion-3 { + background-color: initial; +} + +.emotion-4 { + color: inherit; + display: table-row; + vertical-align: middle; + outline: 0; +} + +.emotion-4.MuiTableRow-hover:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.emotion-4.Mui-selected { + background-color: rgba(255, 131, 91, 0.08); +} + +.emotion-4.Mui-selected:hover { + background-color: rgba(255, 131, 91, 0.12); +} + +.emotion-5 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + line-height: 1.5rem; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: left; + padding: 16px; + color: #000000; +} + +.emotion-5 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-7 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + line-height: 1.5rem; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: right; + padding: 16px; + color: #000000; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.emotion-7 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-8 { + display: table-row-group; +} + +.emotion-8 .MuiTableRow-root:nth-of-type(odd) { + background-color: #F8F8F8; +} + +.emotion-8 .MuiTableCell-root { + color: #414141; +} + +.emotion-10 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: left; + padding: 16px; + color: #000000; +} + +.emotion-10 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-12 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: right; + padding: 16px; + color: #000000; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.emotion-12 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +@media (min-width:1280px) { + .emotion-17 { + display: none; + } +} + +.emotion-18 { + margin: 16px 0px; +} + +.emotion-19 { + margin-bottom: 16px; +} + +.emotion-19 .label { + margin-bottom: 8px; + color: #777777; +} + +.emotion-19 p.value { + color: #414141; + word-break: break-all; +} + +.emotion-19 a { + color: #4092CD; +} + +.emotion-20 { + margin: 0; + font-size: 1rem; + letter-spacing: 0.15px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.235; +} + +.emotion-21 { + margin: 0; + font-size: 1rem; + white-space: pre-wrap; + letter-spacing: 0.5px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.5; +} + +.emotion-28 { + margin: 0; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + border-width: 0; + border-style: solid; + border-color: #E8E8E8; + border-bottom-width: thin; +} + +.emotion-39 { + margin-top: 16px; +} + +
+

+ connectionsTitle +

+
+ + + + + + + + + + + + + + + + + + + + +
+ network + + identifier + + creationTime +
+ NATIVE + + + desmos1rzhewpmmdl72lhnxj6zmxr4v94f522s4ff2psv + + + 2020-08-10 12:00:00 +
+ EMONEY + + emoney1wke3ev9ja6rxsngld75r3vppcpet94xxnh63ry + + 2020-08-10 12:00:00 +
+
+
+
+
+

+ network +

+

+ NATIVE +

+
+ +
+

+ creationTime +

+

+ 2020-08-10 12:00:00 +

+
+
+
+
+
+

+ network +

+

+ EMONEY +

+
+
+

+ identifier +

+

+ emoney1wke3ev9ja6rxsngld75r3vppcpet94xxnh63ry +

+
+
+

+ creationTime +

+

+ 2020-08-10 12:00:00 +

+
+
+
+ +`; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..19f1d349bd --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/__snapshots__/index.test.tsx.snap @@ -0,0 +1,294 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: ProfileDetails/Connections/Desktop matches snapshot 1`] = ` +.emotion-0 { + display: table; + width: 100%; + border-collapse: collapse; + border-spacing: 0; +} + +.emotion-0 caption { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + padding: 16px; + color: #414141; + text-align: left; + caption-side: bottom; +} + +.emotion-1 { + display: table-header-group; +} + +.emotion-1 { + background-color: initial; +} + +.emotion-2 { + color: inherit; + display: table-row; + vertical-align: middle; + outline: 0; +} + +.emotion-2.MuiTableRow-hover:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.emotion-2.Mui-selected { + background-color: rgba(255, 131, 91, 0.08); +} + +.emotion-2.Mui-selected:hover { + background-color: rgba(255, 131, 91, 0.12); +} + +.emotion-3 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + line-height: 1.5rem; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: left; + padding: 16px; + color: #000000; +} + +.emotion-3 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-5 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + line-height: 1.5rem; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: right; + padding: 16px; + color: #000000; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.emotion-5 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-6 { + display: table-row-group; +} + +.emotion-6 .MuiTableRow-root:nth-of-type(odd) { + background-color: #F8F8F8; +} + +.emotion-6 .MuiTableCell-root { + color: #414141; +} + +.emotion-8 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: left; + padding: 16px; + color: #000000; +} + +.emotion-8 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +.emotion-10 { + font-size: 0.875rem; + letter-spacing: 0.25px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.43; + display: table-cell; + vertical-align: inherit; + border-bottom: 1px solid rgba(252, 252, 252, 1); + text-align: right; + padding: 16px; + color: #000000; + -webkit-flex-direction: row-reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.emotion-10 { + border-bottom: none; + padding: 0 16px; + height: 50px; + font-size: 1rem; +} + +
+ + + + + + + + + + + + + + + + + + + + +
+ network + + identifier + + creationTime +
+ NATIVE + + + desmos1rzhewpmmdl72lhnxj6zmxr4v94f522s4ff2psv + + + 2020-08-10 12:00:00 +
+ EMONEY + + emoney1wke3ev9ja6rxsngld75r3vppcpet94xxnh63ry + + 2020-08-10 12:00:00 +
+
+`; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.test.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.test.tsx new file mode 100644 index 0000000000..07650ee2d8 --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.test.tsx @@ -0,0 +1,50 @@ +import Desktop from '@/screens/profile_details/components/connections/components/desktop'; +import MockTheme from '@/tests/mocks/MockTheme'; +import { ComponentProps } from 'react'; +import renderer from 'react-test-renderer'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +// ================================== +// mocks +// ================================== +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: ComponentProps) => + children({ + height: 600, + width: 600, + }) +); + +// ================================== +// unit tests +// ================================== +describe('screen: ProfileDetails/Connections/Desktop', () => { + it('matches snapshot', () => { + const component = renderer.create( + + + + ); + const tree = component?.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.tsx new file mode 100644 index 0000000000..b5c9a1b576 --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/index.tsx @@ -0,0 +1,82 @@ +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import Link from 'next/link'; +import { FC, ReactNode } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ACCOUNT_DETAILS } from '@/utils/go_to_page'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import { columns } from '@/screens/profile_details/components/connections/components/desktop/utils'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import chainConfig from '@/chainConfig'; + +const { prefix } = chainConfig(); + +type DesktopProps = { + className?: string; + items?: ProfileConnectionType[]; +}; + +const Desktop: FC = ({ className, items }) => { + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + const { t } = useAppTranslation('accounts'); + + const formattedItems = items?.map((x) => { + let identity: ReactNode = x.identifier; + if (new RegExp(`^(${prefix.account})`).test(x.identifier)) { + identity = ( + + {x.identifier} + + ); + } + + return { + key: `${x.identifier}-${x.creationTime}`, + network: x.network.toUpperCase(), + identifier: identity, + creationTime: formatDayJs(dayjs.utc(x.creationTime), dateFormat, timeFormat), + }; + }); + + return ( +
+ + + + {columns.map((column) => ( + + {t(column.key)} + + ))} + + + + {formattedItems?.map((row) => ( + + {columns.map((column) => ( + + {row[column.key as keyof typeof row]} + + ))} + + ))} + +
+
+ ); +}; + +export default Desktop; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/utils.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/utils.tsx new file mode 100644 index 0000000000..06d220f6c3 --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/desktop/utils.tsx @@ -0,0 +1,20 @@ +export const columns: { + key: string; + align?: 'left' | 'center' | 'right' | 'justify' | 'inherit'; + width: number; +}[] = [ + { + key: 'network', + width: 25, + }, + { + key: 'identifier', + width: 50, + align: 'left', + }, + { + key: 'creationTime', + width: 25, + align: 'right', + }, +]; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/index.ts b/apps/web-stratos/src/screens/profile_details/components/connections/components/index.ts new file mode 100644 index 0000000000..ff46e00d85 --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/index.ts @@ -0,0 +1,4 @@ +import Desktop from '@/screens/profile_details/components/connections/components/desktop'; +import Mobile from '@/screens/profile_details/components/connections/components/mobile'; + +export { Desktop, Mobile }; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/__snapshots__/index.test.tsx.snap b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..eda2f3ef4a --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/__snapshots__/index.test.tsx.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screen: ProfileDetails/Connections/Mobile matches snapshot 1`] = ` +.emotion-0 { + margin: 16px 0px; +} + +.emotion-1 { + margin-bottom: 16px; +} + +.emotion-1 .label { + margin-bottom: 8px; + color: #777777; +} + +.emotion-1 p.value { + color: #414141; + word-break: break-all; +} + +.emotion-1 a { + color: #4092CD; +} + +.emotion-2 { + margin: 0; + font-size: 1rem; + letter-spacing: 0.15px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.235; +} + +.emotion-3 { + margin: 0; + font-size: 1rem; + white-space: pre-wrap; + letter-spacing: 0.5px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + line-height: 1.5; +} + +.emotion-10 { + margin: 0; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + border-width: 0; + border-style: solid; + border-color: #E8E8E8; + border-bottom-width: thin; +} + +
+
+
+

+ network +

+

+ NATIVE +

+
+ +
+

+ creationTime +

+

+ 2020-08-10 12:00:00 +

+
+
+
+
+
+

+ network +

+

+ EMONEY +

+
+
+

+ identifier +

+

+ emoney1wke3ev9ja6rxsngld75r3vppcpet94xxnh63ry +

+
+
+

+ creationTime +

+

+ 2020-08-10 12:00:00 +

+
+
+
+`; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.test.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.test.tsx new file mode 100644 index 0000000000..cfd8e7e36a --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.test.tsx @@ -0,0 +1,50 @@ +import Mobile from '@/screens/profile_details/components/connections/components/mobile'; +import MockTheme from '@/tests/mocks/MockTheme'; +import { ComponentProps } from 'react'; +import renderer from 'react-test-renderer'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +// ================================== +// mocks +// ================================== +jest.mock( + 'react-virtualized-auto-sizer', + () => + ({ children }: ComponentProps) => + children({ + height: 600, + width: 600, + }) +); + +// ================================== +// unit tests +// ================================== +describe('screen: ProfileDetails/Connections/Mobile', () => { + it('matches snapshot', () => { + const component = renderer.create( + + + + ); + const tree = component?.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +}); diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.tsx new file mode 100644 index 0000000000..6f530ac9ed --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/index.tsx @@ -0,0 +1,78 @@ +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import useAppTranslation from '@/hooks/useAppTranslation'; +import Link from 'next/link'; +import { FC, Fragment } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ACCOUNT_DETAILS } from '@/utils/go_to_page'; +import dayjs, { formatDayJs } from '@/utils/dayjs'; +import useStyles from '@/screens/profile_details/components/connections/components/mobile/styles'; +import { readDate, readTimeFormat } from '@/recoil/settings'; +import chainConfig from '@/chainConfig'; + +const { prefix } = chainConfig(); + +type MobileProps = { + className?: string; + items?: ProfileConnectionType[]; +}; + +const Mobile: FC = ({ className, items }) => { + const dateFormat = useRecoilValue(readDate); + const timeFormat = useRecoilValue(readTimeFormat); + const { classes } = useStyles(); + const { t } = useAppTranslation('accounts'); + const itemCount = items?.length; + return ( +
+ {items?.map((x, i) => { + const checkIdentifier = new RegExp(`^(${prefix.account})`).test(x.identifier); + const isLast = !itemCount || i === itemCount - 1; + return ( + // eslint-disable-next-line react/no-array-index-key + +
+
+ + {t('network')} + + + {x.network.toUpperCase()} + +
+
+ + {t('identifier')} + + + {checkIdentifier && ( + + {x.identifier} + + )} + {new RegExp(`^(${prefix.account})`).test(x.identifier) === false && x.identifier} + +
+
+ + {t('creationTime')} + + + {formatDayJs(dayjs.utc(x.creationTime), dateFormat, timeFormat)} + +
+
+ {!isLast && } +
+ ); + })} +
+ ); +}; + +export default Mobile; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/styles.ts b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/styles.ts new file mode 100644 index 0000000000..b38a603493 --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/components/mobile/styles.ts @@ -0,0 +1,31 @@ +import { makeStyles } from 'tss-react/mui'; + +const useStyles = makeStyles()((theme) => ({ + list: { + margin: theme.spacing(2, 0), + }, + item: { + marginBottom: theme.spacing(2), + '& .label': { + marginBottom: theme.spacing(1), + color: theme.palette.custom.fonts.fontThree, + }, + '& p.value': { + color: theme.palette.custom.fonts.fontTwo, + wordBreak: 'break-all', + }, + '& a': { + color: theme.palette.custom.fonts.highlight, + }, + }, + flex: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + '& > div': { + width: '50%', + }, + }, +})); + +export default useStyles; diff --git a/apps/web-stratos/src/screens/profile_details/components/connections/index.test.tsx b/apps/web-stratos/src/screens/profile_details/components/connections/index.test.tsx new file mode 100644 index 0000000000..3998ce324d --- /dev/null +++ b/apps/web-stratos/src/screens/profile_details/components/connections/index.test.tsx @@ -0,0 +1,45 @@ +import renderer from 'react-test-renderer'; +import Connections from '@/screens/profile_details/components/connections'; +import MockTheme from '@/tests/mocks/MockTheme'; + +// ================================== +// mocks +// ================================== +jest.mock('@/components/box', () => (props: JSX.IntrinsicElements['div']) => ( +
+)); +jest.mock('@/components/pagination', () => (props: JSX.IntrinsicElements['div']) => ( +