mirror of
https://github.com/lux-tel/websites.git
synced 2026-10-30 20:31:36 +00:00
426 lines
13 KiB
TypeScript
426 lines
13 KiB
TypeScript
import { AddressLink } from '@/components/AddressLink';
|
|
import { ExitRequestorIndicator, NodeOperatorIndicator } from '@/components/StakedNodeCard';
|
|
import { StakedNodeDataTestId } from '@/testing/data-test-ids';
|
|
import { formatSENTBigInt } from '@session/contracts/hooks/Token';
|
|
import {
|
|
type ContributionContractContributor,
|
|
type StakeContributor,
|
|
isContributionContractContributor,
|
|
} from '@session/staking-api-js/schema';
|
|
import { Loading } from '@session/ui/components/loading';
|
|
import { ArrowDownIcon } from '@session/ui/icons/ArrowDownIcon';
|
|
import { HumanIcon } from '@session/ui/icons/HumanIcon';
|
|
import { KeyRoundIcon } from '@session/ui/icons/KeyRoundIcon';
|
|
import { cn } from '@session/ui/lib/utils';
|
|
import { Tooltip } from '@session/ui/ui/tooltip';
|
|
import { areHexesEqual } from '@session/util-crypto/string';
|
|
import { PubkeyWithEns } from '@session/wallet/components/PubkeyWithEns';
|
|
import { type VariantProps, cva } from 'class-variance-authority';
|
|
import { useTranslations } from 'next-intl';
|
|
import { type HTMLAttributes, type ReactNode, forwardRef, useMemo, useState } from 'react';
|
|
import type { Address } from 'viem';
|
|
|
|
export const outerNodeCardVariants = cva(
|
|
'rounded-xl bg-module-outline p-px bg-blend-lighten shadow-md transition-all ease-in-out',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: '',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'default',
|
|
},
|
|
}
|
|
);
|
|
|
|
const innerNodeCardVariants = cva(
|
|
'flex h-full w-full flex-col rounded-xl bg-module px-5 py-4 align-middle md:px-6 md:py-5',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: '',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'default',
|
|
},
|
|
}
|
|
);
|
|
|
|
export interface StakeCardProps
|
|
extends HTMLAttributes<HTMLDivElement>,
|
|
VariantProps<typeof innerNodeCardVariants> {
|
|
loading?: boolean;
|
|
}
|
|
|
|
const NodeCard = forwardRef<HTMLDivElement, StakeCardProps>(
|
|
({ className, variant, loading, children, ...props }, ref) => {
|
|
return (
|
|
<div className={cn(outerNodeCardVariants({ variant }))}>
|
|
<div className={cn(innerNodeCardVariants({ variant, className }))} ref={ref} {...props}>
|
|
{loading ? <Loading /> : children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
NodeCard.displayName = 'NodeCard';
|
|
|
|
const nodeCardHeaderVariants = cva('flex w-full flex-row', {
|
|
variants: {
|
|
variant: {
|
|
default: '',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'default',
|
|
},
|
|
});
|
|
|
|
export interface NodeCardHeaderProps
|
|
extends HTMLAttributes<HTMLDivElement>,
|
|
VariantProps<typeof nodeCardHeaderVariants> {
|
|
loading?: boolean;
|
|
}
|
|
|
|
const NodeCardHeader = forwardRef<HTMLDivElement, NodeCardHeaderProps>(
|
|
({ className, variant, loading, children, ...props }, ref) => {
|
|
return (
|
|
<div className={cn(nodeCardHeaderVariants({ variant, className }))} ref={ref} {...props}>
|
|
{loading ? <Loading /> : children}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
NodeCardHeader.displayName = 'NodeCardHeader';
|
|
|
|
const NodeCardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<h4
|
|
ref={ref}
|
|
className={cn('font-medium text-gradient-white text-lg leading-none md:text-xl', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
NodeCardTitle.displayName = 'NodeCardTitle';
|
|
|
|
const NodeCardText = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<span
|
|
ref={ref}
|
|
className={cn('font-light text-gradient-white text-sm md:text-base', className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
NodeCardText.displayName = 'NodeCardText';
|
|
|
|
type ContributorIconProps = {
|
|
className?: string;
|
|
contributor?: StakeContributor | ContributionContractContributor;
|
|
isUser?: boolean;
|
|
isOperator?: boolean;
|
|
isRequestingExit?: boolean;
|
|
};
|
|
|
|
const humanIconClassName = (
|
|
contributor?: StakeContributor | ContributionContractContributor,
|
|
isUser?: boolean,
|
|
className?: string
|
|
) =>
|
|
cn(
|
|
'h-4 w-4',
|
|
contributor
|
|
? contributor.amount
|
|
? isUser
|
|
? 'fill-session-green'
|
|
: 'fill-text-primary'
|
|
: isContributionContractContributor(contributor) && contributor.reserved
|
|
? 'fill-warning'
|
|
: 'fill-text-primary'
|
|
: 'fill-text-primary',
|
|
className
|
|
);
|
|
|
|
function ContributorTooltipCard({
|
|
contributor,
|
|
isUser,
|
|
isOperator,
|
|
isRequestingExit,
|
|
}: ContributorIconProps & Required<Pick<ContributorIconProps, 'contributor'>>) {
|
|
const dictionary = useTranslations('general');
|
|
const dictionaryStakedNode = useTranslations('nodeCard.staked');
|
|
return (
|
|
<div className="flex flex-row gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex flex-row gap-2">
|
|
<ContributorIcon
|
|
className="mt-0.5"
|
|
contributor={contributor}
|
|
isUser={isUser}
|
|
isOperator={isOperator}
|
|
isRequestingExit={isRequestingExit}
|
|
/>
|
|
<PubkeyWithEns pubKey={contributor.address} />
|
|
<AddressLink address={contributor.address} />
|
|
</div>
|
|
<span className="flex flex-row gap-2">
|
|
{isOperator ? <NodeOperatorIndicator /> : null}
|
|
{`${formatSENTBigInt(contributor.amount)} ${dictionary('staked')}`}
|
|
</span>
|
|
{isContributionContractContributor(contributor) && contributor.reserved
|
|
? `${formatSENTBigInt(contributor.reserved)} ${dictionary('reserved')}`
|
|
: ''}
|
|
{isRequestingExit ? (
|
|
<span className="flex flex-row gap-2">
|
|
<ExitRequestorIndicator isConnectedWallet={isUser} />
|
|
{dictionaryStakedNode('exitRequestor')}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ContributorIcon = forwardRef<
|
|
HTMLDivElement,
|
|
ContributorIconProps & HTMLAttributes<HTMLDivElement>
|
|
>(({ className, contributor, isUser, isOperator, isRequestingExit, ...props }, ref) => (
|
|
<div ref={ref} {...props} className={cn('contrib-icon relative h-max', className)}>
|
|
<HumanIcon
|
|
className={humanIconClassName(contributor, isUser, className)}
|
|
full={!!contributor?.amount}
|
|
/>
|
|
{isRequestingExit ? (
|
|
<KeyRoundIcon className="-bottom-0.5 -right-0.5 absolute h-3 w-3 fill-warning stroke-session-black" />
|
|
) : null}
|
|
</div>
|
|
));
|
|
|
|
const ContributorIconWithTooltip = ({
|
|
className,
|
|
contributor,
|
|
isUser,
|
|
isOperator,
|
|
isRequestingExit,
|
|
}: ContributorIconProps) => {
|
|
const dictionary = useTranslations('general');
|
|
return (
|
|
<Tooltip
|
|
tooltipContent={
|
|
contributor ? (
|
|
<ContributorTooltipCard
|
|
contributor={contributor}
|
|
isUser={isUser}
|
|
isOperator={isOperator}
|
|
isRequestingExit={isRequestingExit}
|
|
/>
|
|
) : (
|
|
dictionary('emptySlot')
|
|
)
|
|
}
|
|
>
|
|
<ContributorIcon
|
|
className={className}
|
|
contributor={contributor}
|
|
isUser={isUser}
|
|
isOperator={isOperator}
|
|
isRequestingExit={isRequestingExit}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
type StakedNodeContributorListProps = HTMLAttributes<HTMLDivElement> & {
|
|
contributors: Array<StakeContributor | ContributionContractContributor>;
|
|
userAddress?: Address;
|
|
operatorAddress: Address;
|
|
exitRequestAddress?: Address;
|
|
showEmptySlots?: boolean;
|
|
forceExpand?: boolean;
|
|
};
|
|
|
|
const NodeContributorList = forwardRef<HTMLDivElement, StakedNodeContributorListProps>(
|
|
(
|
|
{
|
|
className,
|
|
contributors = [],
|
|
operatorAddress,
|
|
exitRequestAddress,
|
|
userAddress,
|
|
showEmptySlots,
|
|
forceExpand,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const dictionary = useTranslations('maths');
|
|
|
|
const userContributor = useMemo(
|
|
() => contributors.find(({ address }) => areHexesEqual(address, userAddress)),
|
|
[contributors, userAddress]
|
|
);
|
|
|
|
const emptyContributorSlots = useMemo(
|
|
() =>
|
|
showEmptySlots
|
|
? Array.from(
|
|
{
|
|
length: 10 - contributors.length,
|
|
},
|
|
(_, index) => `empty-slot-${index}`
|
|
)
|
|
: [],
|
|
[showEmptySlots, contributors.length]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{!forceExpand ? (
|
|
<ContributorIconWithTooltip
|
|
className={cn('fill-text-primary peer-checked:hidden peer-checked:opacity-0')}
|
|
contributor={userContributor}
|
|
isOperator={areHexesEqual(userContributor?.address, operatorAddress)}
|
|
isRequestingExit={
|
|
exitRequestAddress && areHexesEqual(userContributor?.address, exitRequestAddress)
|
|
}
|
|
isUser
|
|
/>
|
|
) : null}
|
|
<div
|
|
className={cn(
|
|
'flex w-max flex-row items-center align-middle',
|
|
forceExpand
|
|
? 'gap-0.5 md:gap-1 [&>.contrib-icon]:w-4'
|
|
: 'peer-checked:gap-0.5 md:peer-checked:gap-1 [&>.contrib-icon>svg]:w-0 peer-checked:[&>.contrib-icon>svg]:w-4 [&>.contrib-icon]:w-0 [&>.contrib-icon]:opacity-0 [&>.contrib-icon]:transition-all [&>.contrib-icon]:duration-300 peer-checked:[&>.contrib-icon]:w-4 peer-checked:[&>.contrib-icon]:opacity-100 [&>.contrib-icon]:motion-reduce:transition-none',
|
|
className
|
|
)}
|
|
ref={ref}
|
|
{...props}
|
|
>
|
|
{contributors.map((contributor) => (
|
|
<ContributorIconWithTooltip
|
|
key={contributor.address}
|
|
contributor={contributor}
|
|
isUser={areHexesEqual(contributor.address, userAddress)}
|
|
isOperator={areHexesEqual(contributor.address, operatorAddress)}
|
|
isRequestingExit={
|
|
exitRequestAddress && areHexesEqual(contributor.address, exitRequestAddress)
|
|
}
|
|
/>
|
|
))}
|
|
{showEmptySlots
|
|
? emptyContributorSlots.map((key) => (
|
|
<ContributorIconWithTooltip key={key} className="h-4" />
|
|
))
|
|
: null}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'letter -ms-2 mt-0.5 block text-lg tracking-widest transition-all duration-300 ease-in-out',
|
|
forceExpand
|
|
? 'w-0 opacity-0'
|
|
: 'w-max opacity-100 peer-checked:w-0 peer-checked:opacity-0'
|
|
)}
|
|
>
|
|
{showEmptySlots
|
|
? dictionary('outOf', { count: contributors.length, max: 10 })
|
|
: contributors.length}
|
|
</span>
|
|
</>
|
|
);
|
|
}
|
|
);
|
|
|
|
type ToggleCardExpansionButtonProps = HTMLAttributes<HTMLLabelElement> & {
|
|
htmlFor: string;
|
|
};
|
|
|
|
export const ToggleCardExpansionButton = forwardRef<
|
|
HTMLLabelElement,
|
|
ToggleCardExpansionButtonProps
|
|
>(({ htmlFor, className, ...props }, ref) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const dictionary = useTranslations('nodeCard.staked');
|
|
return (
|
|
<label
|
|
ref={ref}
|
|
htmlFor={htmlFor}
|
|
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole lint/a11y/useSemanticElements: This is a toggle button
|
|
role="button"
|
|
tabIndex={-1}
|
|
onClick={() => setExpanded((prev) => !prev)}
|
|
aria-label={expanded ? dictionary('ariaCollapse') : dictionary('ariaExpand')}
|
|
data-testid={
|
|
expanded ? StakedNodeDataTestId.Collapse_Button : StakedNodeDataTestId.Expand_Button
|
|
}
|
|
className={cn(
|
|
'ms-auto flex w-max cursor-pointer select-none items-center align-middle peer-checked:[&>svg]:rotate-180',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<span className="hidden font-medium text-gradient-white lg:inline-block">
|
|
{expanded ? dictionary('labelCollapse') : dictionary('labelExpand')}
|
|
</span>
|
|
<ArrowDownIcon
|
|
className={cn(
|
|
'ms-2 h-4 w-4 transform fill-session-text stroke-session-text transition-all duration-300 ease-in-out motion-reduce:transition-none'
|
|
)}
|
|
/>
|
|
</label>
|
|
);
|
|
});
|
|
|
|
export const RowLabel = ({ children, className }: { children: ReactNode; className?: string }) => (
|
|
<span className={cn('font-semibold', className)}>{children} </span>
|
|
);
|
|
|
|
const collapsableContentVariants = cva(
|
|
'inline-flex h-full max-h-0 select-none flex-wrap gap-1 overflow-y-hidden transition-all duration-300 ease-in-out peer-checked:select-auto motion-reduce:transition-none',
|
|
{
|
|
variants: {
|
|
size: {
|
|
xs: 'text-xs peer-checked:max-h-4 md:text-xs',
|
|
base: cn('text-sm peer-checked:max-h-5', 'md:text-base md:peer-checked:max-h-6'),
|
|
buttonMd: cn('peer-checked:max-h-11'),
|
|
buttonSm: cn('peer-checked:max-h-9'),
|
|
},
|
|
width: {
|
|
'w-full': 'w-full',
|
|
'w-max': 'w-max',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
size: 'base',
|
|
width: 'w-full',
|
|
},
|
|
}
|
|
);
|
|
|
|
type CollapsableContentProps = HTMLAttributes<HTMLSpanElement> &
|
|
VariantProps<typeof collapsableContentVariants>;
|
|
|
|
export const CollapsableContent = forwardRef<HTMLSpanElement, CollapsableContentProps>(
|
|
({ className, size, width, ...props }, ref) => (
|
|
<NodeCardText
|
|
ref={ref}
|
|
className={cn(collapsableContentVariants({ size, width, className }))}
|
|
{...props}
|
|
/>
|
|
)
|
|
);
|
|
|
|
export {
|
|
ContributorIconWithTooltip,
|
|
NodeCard,
|
|
NodeCardHeader,
|
|
NodeCardText,
|
|
NodeCardTitle,
|
|
NodeContributorList,
|
|
innerNodeCardVariants,
|
|
};
|